mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
480
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34c4102fb | ||
|
|
72358bd8e6 | ||
|
|
a1cd36c2e1 | ||
|
|
60468ef090 | ||
|
|
b8ab9f88a3 | ||
|
|
b497de4773 | ||
|
|
ccb9bd06f6 | ||
|
|
be45c9bdcb | ||
|
|
1ead734255 | ||
|
|
b92890c9de | ||
|
|
6cc879f0d8 | ||
|
|
a9932bdba1 | ||
|
|
c22beae2ad | ||
|
|
fb14d12753 | ||
|
|
463e6f0a16 | ||
|
|
a42c6f9f97 | ||
|
|
584c3260e3 | ||
|
|
7335d3d3d1 | ||
|
|
d8e912738b | ||
|
|
cbea22fbb6 | ||
|
|
c7aca8489f | ||
|
|
319876a186 | ||
|
|
9093960719 | ||
|
|
68e57e86c5 | ||
|
|
46ac49ad05 | ||
|
|
d23ab5ae50 | ||
|
|
9ad4c9fb8b | ||
|
|
c7065027b1 | ||
|
|
b97e39e074 | ||
|
|
1deaac7263 | ||
|
|
ef66d5b892 | ||
|
|
b732e582b9 | ||
|
|
f145c903b6 | ||
|
|
d009ebd140 | ||
|
|
0c49146fc8 | ||
|
|
cbbdf773e2 | ||
|
|
e1207bb017 | ||
|
|
94a737918a | ||
|
|
7ed5855e4a | ||
|
|
011515f715 | ||
|
|
e3cbe2688f | ||
|
|
7dc72d6c33 | ||
|
|
f4e1a98127 | ||
|
|
e5087bdb5a | ||
|
|
0e7918fb88 | ||
|
|
e5076cd6ab | ||
|
|
3d4cade73b | ||
|
|
1332c90868 | ||
|
|
1b38710e92 | ||
|
|
aaa301b2e5 | ||
|
|
3052227936 | ||
|
|
95a366fe89 | ||
|
|
fc0e275414 | ||
|
|
5dc0515248 | ||
|
|
20a3c3e399 | ||
|
|
0a6020695b | ||
|
|
dfde0ac091 | ||
|
|
dc274d681c | ||
|
|
246a071dbc | ||
|
|
1e965ec5a8 | ||
|
|
a7ff35cf7b | ||
|
|
3e31a55950 | ||
|
|
4e910746ce | ||
|
|
06254caea3 | ||
|
|
7b78be8d4e | ||
|
|
73602536bb | ||
|
|
d601fc2e54 | ||
|
|
9ca1b032ce | ||
|
|
5b8898d018 | ||
|
|
28a4e5443f | ||
|
|
dc05ba0cc7 | ||
|
|
2c8b7ee172 | ||
|
|
74cf4f8c53 | ||
|
|
335d11e35e | ||
|
|
cc7bcbf4e5 | ||
|
|
182b9a1473 | ||
|
|
cb3f4387db | ||
|
|
db0d65ee3c | ||
|
|
b328f4d660 | ||
|
|
37f8b6a525 | ||
|
|
3a9d71b2db | ||
|
|
0a1a6908c0 | ||
|
|
95c234cc1e | ||
|
|
201ae4d255 | ||
|
|
918ff50c7a | ||
|
|
e2ce8e42a6 | ||
|
|
5815bf4f11 | ||
|
|
9c21a95409 | ||
|
|
91ae68828a | ||
|
|
a6bcea674c | ||
|
|
938ae3ca47 | ||
|
|
5b87045fee | ||
|
|
25776d1a4c | ||
|
|
247b67a77d | ||
|
|
47e8375fd1 | ||
|
|
e4a131f5c7 | ||
|
|
4f792eb7de | ||
|
|
e431a41ab1 | ||
|
|
dfabf62567 | ||
|
|
7dd05db9be | ||
|
|
d0f6223a90 | ||
|
|
c457259ae2 | ||
|
|
088107b407 | ||
|
|
ec86a343c5 | ||
|
|
21fdd33cdf | ||
|
|
ee6cf6acb8 | ||
|
|
b0831e54c8 | ||
|
|
f1341139ff | ||
|
|
4f9cc17e91 | ||
|
|
8913deaa76 | ||
|
|
66d731cbf0 | ||
|
|
acb209dbf7 | ||
|
|
74f0265db1 | ||
|
|
fe47804f28 | ||
|
|
e8214a02aa | ||
|
|
49f4359fcf | ||
|
|
f12d87b5d9 | ||
|
|
0b63d040ea | ||
|
|
b19d8659ff | ||
|
|
f9b002f804 | ||
|
|
64e298952d | ||
|
|
cad2a29d2a | ||
|
|
a7fa9ad7bf | ||
|
|
c9cfa4a192 | ||
|
|
8d4196a29b | ||
|
|
7c681e0ed2 | ||
|
|
a11e54e7b8 | ||
|
|
42e7d94082 | ||
|
|
5f7341487f | ||
|
|
a5fba2ddac | ||
|
|
f93b7cd0fa | ||
|
|
bda07e1a84 | ||
|
|
f065bab2e4 | ||
|
|
cefa6153d1 | ||
|
|
ae175cc968 | ||
|
|
42ad9192e3 | ||
|
|
8065b2830a | ||
|
|
0b3cf8d4f0 | ||
|
|
93921eccc4 | ||
|
|
d1f4cd22ce | ||
|
|
29c7ce8c7c | ||
|
|
869ffec2a1 | ||
|
|
adfff67c88 | ||
|
|
732f6eff1b | ||
|
|
15469c5501 | ||
|
|
0ac347d5b0 | ||
|
|
80405878b5 | ||
|
|
52cc53a4f9 | ||
|
|
1cb3aa5af6 | ||
|
|
7db3ecb67f | ||
|
|
32967a2f0f | ||
|
|
62a23ba025 | ||
|
|
fff16ae2ed | ||
|
|
2cacd5561c | ||
|
|
8fdbad2d4b | ||
|
|
f5811c797a | ||
|
|
34c5dce056 | ||
|
|
5e6bed2e42 | ||
|
|
8bb23aa1f7 | ||
|
|
a491181961 | ||
|
|
4f69f45167 | ||
|
|
2f9d889dcc | ||
|
|
a459a00584 | ||
|
|
ede6d6dfbd | ||
|
|
2cf38dd095 | ||
|
|
89f4b34c1f | ||
|
|
929e75181b | ||
|
|
647e7c2aca | ||
|
|
db34a746d1 | ||
|
|
84c5e0b63b | ||
|
|
77a6cf7182 | ||
|
|
7e42cc8ece | ||
|
|
bb6305da25 | ||
|
|
0a95d1c047 | ||
|
|
dd7414486b | ||
|
|
d385d188f3 | ||
|
|
d81f99ba8f | ||
|
|
0dea3a89e2 | ||
|
|
2286d42b7f | ||
|
|
b869bdcacd | ||
|
|
ba6575314c | ||
|
|
f383fdf74c | ||
|
|
1582cc638f | ||
|
|
5c66b27c9a | ||
|
|
1a01f5ef59 | ||
|
|
a717d6d583 | ||
|
|
1ffe686350 | ||
|
|
217216e263 | ||
|
|
825f8f8845 | ||
|
|
0771f59bca | ||
|
|
c455bb6ec7 | ||
|
|
17864b867d | ||
|
|
09146bfecf | ||
|
|
f2337d0a73 | ||
|
|
e852db5e60 | ||
|
|
f781769177 | ||
|
|
3e83f8a48a | ||
|
|
f9a97f264f | ||
|
|
894848c17b | ||
|
|
4c1f8f24bd | ||
|
|
f4f9897c9e | ||
|
|
201ea8b393 | ||
|
|
9cbde2b525 | ||
|
|
35eb60e2f1 | ||
|
|
8537e29c92 | ||
|
|
191b66303a | ||
|
|
92b8883039 | ||
|
|
c784e572d7 | ||
|
|
479d10e794 | ||
|
|
4812518327 | ||
|
|
7daca92c4a | ||
|
|
0c06082dfa | ||
|
|
e2de446128 | ||
|
|
8d93530931 | ||
|
|
f7efb0d710 | ||
|
|
75600ca046 | ||
|
|
9da77d9d4b | ||
|
|
01791aba51 | ||
|
|
b5b762b840 | ||
|
|
0a770a0e14 | ||
|
|
4808d801fe | ||
|
|
d27a7cbce3 | ||
|
|
9039a268f6 | ||
|
|
c566e69c6c | ||
|
|
461a8757e3 | ||
|
|
d9e70d5ca7 | ||
|
|
3b261d1081 | ||
|
|
c487bbce9e | ||
|
|
9c5377a571 | ||
|
|
63b8018505 | ||
|
|
9c6325c1f8 | ||
|
|
dc8f01ea19 | ||
|
|
b65c319b2a | ||
|
|
8fb51b0c4a | ||
|
|
0a00df7640 | ||
|
|
675298eac2 | ||
|
|
8b8ab7fd5d | ||
|
|
2f3f8d7a7f | ||
|
|
7e459e5fb1 | ||
|
|
0dcc9a15fd | ||
|
|
17e0143745 | ||
|
|
8469dcc953 | ||
|
|
dffd3973bc | ||
|
|
84185641bc | ||
|
|
e60455d12c | ||
|
|
043f9c7b7b | ||
|
|
9f28fe8903 | ||
|
|
88a001b7fa | ||
|
|
e932a2da61 | ||
|
|
edd11b6103 | ||
|
|
73ae9f6cf1 | ||
|
|
69fc342a1a | ||
|
|
ad7904946e | ||
|
|
2677a397e5 | ||
|
|
2445108f10 | ||
|
|
e0f11804f2 | ||
|
|
88ee948bc2 | ||
|
|
e7e5413667 | ||
|
|
ca1b200c79 | ||
|
|
b603db6176 | ||
|
|
779b773530 | ||
|
|
7fb1f77870 | ||
|
|
8a9db2dc7b | ||
|
|
4d13a4ebcc | ||
|
|
1d01a2d696 | ||
|
|
ea01f563b4 | ||
|
|
49cf77a312 | ||
|
|
d026f1eb37 | ||
|
|
5ff61e1c42 | ||
|
|
f44042e0eb | ||
|
|
4a2a5b9977 | ||
|
|
6534db4f6d | ||
|
|
479f53624c | ||
|
|
69689b3fdd | ||
|
|
5410e79811 | ||
|
|
c6dfae6dd7 | ||
|
|
d7662a97da | ||
|
|
4623d2e59f | ||
|
|
04cd6ece23 | ||
|
|
9218dc3e7d | ||
|
|
5b66bd0ec0 | ||
|
|
f75896ec45 | ||
|
|
ae72e1f6ca | ||
|
|
164cfd6b11 | ||
|
|
a2e111a461 | ||
|
|
e4568abf6e | ||
|
|
64981e95b7 | ||
|
|
7d0e5aa953 | ||
|
|
09a9735630 | ||
|
|
a80973335c | ||
|
|
fe867a6132 | ||
|
|
d32e7ff887 | ||
|
|
ace27c11a9 | ||
|
|
8742e1768e | ||
|
|
c91ca07115 | ||
|
|
c001f56d0f | ||
|
|
e086b2cfdd | ||
|
|
b36a63fb39 | ||
|
|
f86c946a3c | ||
|
|
0f4880bacd | ||
|
|
3451e4e29a | ||
|
|
bd59c0bab2 | ||
|
|
d3cb2f5c29 | ||
|
|
81445f5218 | ||
|
|
ef2ec4c0dc | ||
|
|
e2146572a6 | ||
|
|
d94e4cb7a4 | ||
|
|
2b32b9142f | ||
|
|
a0964a8010 | ||
|
|
d91bd61281 | ||
|
|
289c2df527 | ||
|
|
5134f8459e | ||
|
|
6b96bb96b6 | ||
|
|
5485e189f8 | ||
|
|
bc33470d5c | ||
|
|
c48c2f2167 | ||
|
|
8ce20ba5bd | ||
|
|
74d986ca44 | ||
|
|
bdac04d6ca | ||
|
|
cd1d9ba23b | ||
|
|
44d9bddebd | ||
|
|
984273f5c8 | ||
|
|
9f666edc44 | ||
|
|
48e731483a | ||
|
|
cd70f518e9 | ||
|
|
cc243fabd8 | ||
|
|
f44af70080 | ||
|
|
f7300bd96b | ||
|
|
8202f02f4e | ||
|
|
b6cd09442e | ||
|
|
8428ff0f59 | ||
|
|
e3b1ae512f | ||
|
|
5b4dd8b72e | ||
|
|
5f269772d9 | ||
|
|
955e7fe48d | ||
|
|
7211d029ab | ||
|
|
48342a7ef3 | ||
|
|
25e00a0ecf | ||
|
|
1d2d495686 | ||
|
|
3ea82790ea | ||
|
|
fb0454d001 | ||
|
|
a849545f63 | ||
|
|
b0e65dec06 | ||
|
|
d1ef7b4fb0 | ||
|
|
25662576ff | ||
|
|
31ead52d21 | ||
|
|
ee90b3b34b | ||
|
|
7ef092fabc | ||
|
|
8740d3897c | ||
|
|
a72f06cfab | ||
|
|
fab9c37413 | ||
|
|
dc418c7623 | ||
|
|
57a6f4e9aa | ||
|
|
ac7e491f3b | ||
|
|
d3559e2a5f | ||
|
|
c90893c29c | ||
|
|
b85a9e8350 | ||
|
|
99207f16b1 | ||
|
|
e7f746782f | ||
|
|
cb1c3499bf | ||
|
|
4a094bee90 | ||
|
|
66e1fc5079 | ||
|
|
c404ef5e2a | ||
|
|
85bfbc7d46 | ||
|
|
f983223354 | ||
|
|
3d39bbdaf1 | ||
|
|
2aca6fe06c | ||
|
|
598668e4bc | ||
|
|
a1a249e2f6 | ||
|
|
eb5e0bb914 | ||
|
|
d93c3cdd82 | ||
|
|
81b3803017 | ||
|
|
91ed783e8d | ||
|
|
bc3a724c01 | ||
|
|
51244a64f6 | ||
|
|
1d25065363 | ||
|
|
22f0e30d07 | ||
|
|
c8f484e040 | ||
|
|
d4a1ebaa33 | ||
|
|
5a75089f61 | ||
|
|
483ad5209c | ||
|
|
91369d11c5 | ||
|
|
82eeba7753 | ||
|
|
de25583ee5 | ||
|
|
8170b8742b | ||
|
|
2ac85c50eb | ||
|
|
24e316855e | ||
|
|
e41443c64c | ||
|
|
d46bed5c36 | ||
|
|
9b9d045150 | ||
|
|
1e5cc9031f | ||
|
|
ee351caed8 | ||
|
|
8ebcf6c13d | ||
|
|
08b9765278 | ||
|
|
380d5ebe9f | ||
|
|
70e3fe006d | ||
|
|
18c23a6df3 | ||
|
|
43f2497f1f | ||
|
|
445183a7df | ||
|
|
380d71d8a8 | ||
|
|
85f53198ce | ||
|
|
5479b0362f | ||
|
|
0543cf0345 | ||
|
|
13e441e235 | ||
|
|
c9a8361493 | ||
|
|
345f64e47c | ||
|
|
8f84c2cd48 | ||
|
|
e0a48b3f66 | ||
|
|
d35efa25ec | ||
|
|
b8b15b1682 | ||
|
|
05f8ec650d | ||
|
|
2702542c73 | ||
|
|
f049a08e48 | ||
|
|
3e930fc74b | ||
|
|
04ec03745d | ||
|
|
6a6b5957eb | ||
|
|
8c18d9429f | ||
|
|
44448d941f | ||
|
|
2ee238e5f1 | ||
|
|
8237c15741 | ||
|
|
7711bfcdc5 | ||
|
|
2ac08efcb2 | ||
|
|
559b83626f | ||
|
|
c2915208fd | ||
|
|
04bc30606d | ||
|
|
6893bcff88 | ||
|
|
aae98d3db5 | ||
|
|
6bb648db1e | ||
|
|
b4de9dc23c | ||
|
|
4a4cad6864 | ||
|
|
fece4c6a76 | ||
|
|
a0b19a4820 | ||
|
|
02b1d1f430 | ||
|
|
75c149afde | ||
|
|
6f785d1a28 | ||
|
|
e1d825312f | ||
|
|
5ea61ed3d8 | ||
|
|
231b309634 | ||
|
|
052dac817a | ||
|
|
3dc958611c | ||
|
|
33c15a6d79 | ||
|
|
797482183c | ||
|
|
f6209003de | ||
|
|
694568a89c | ||
|
|
20d7d111e5 | ||
|
|
fb769e55e0 | ||
|
|
01d0170821 | ||
|
|
e71f98adde | ||
|
|
80df20837b | ||
|
|
2092c5a2e8 | ||
|
|
e319dd578f | ||
|
|
0f596d9e3c | ||
|
|
7671219f1a | ||
|
|
b901415c80 | ||
|
|
da877fe1db | ||
|
|
6143e258ab | ||
|
|
690033b71b | ||
|
|
f9ca0a76cf | ||
|
|
4fea5fafe5 | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
66969d8a93 | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
ed01a93df6 | ||
|
|
1a56e74324 | ||
|
|
25b84dfc2e | ||
|
|
a2098c2ffa | ||
|
|
d98dc8edc9 | ||
|
|
bf05176692 | ||
|
|
4a9bb0ce17 | ||
|
|
cd5216e012 | ||
|
|
786c2482a1 | ||
|
|
0cac3a9153 | ||
|
|
9a84148376 | ||
|
|
5678030da2 | ||
|
|
a3489d33b4 | ||
|
|
9458e6e99e | ||
|
|
9c16d40db1 | ||
|
|
730184c64f |
No files matched your search
@@ -1,3 +1,5 @@
|
||||
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
@@ -74,7 +76,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -167,17 +169,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
Generated
+3
@@ -1527,6 +1527,9 @@ name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
|
||||
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -266,6 +269,18 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
|
||||
/// entity under the crosshair (`Entity.getStringUUID`), if any.
|
||||
pub selected_entities: Vec<String>,
|
||||
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
|
||||
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
|
||||
pub target_point: Option<[String; 3]>,
|
||||
|
||||
@@ -1563,3 +1563,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
|
||||
/// name gets the `<id>.item` message, with the item's name as a third
|
||||
/// argument.
|
||||
pub fn death_message_args(
|
||||
key: &str,
|
||||
victim: &Text,
|
||||
cause: &Text,
|
||||
item: Option<&Text>,
|
||||
) -> (String, Vec<Text>) {
|
||||
match item {
|
||||
Some(item) => (
|
||||
format!("{key}.item"),
|
||||
vec![victim.clone(), cause.clone(), item.clone()],
|
||||
),
|
||||
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_message_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
|
||||
let victim = Text::literal("Steve");
|
||||
let cause = Text::literal("Zombie");
|
||||
// DamageSource.getLocalizedDeathMessage: the item variant, with the
|
||||
// item's name as the third argument.
|
||||
let (key, args) = death_message_args(
|
||||
"death.attack.arrow",
|
||||
&victim,
|
||||
&cause,
|
||||
Some(&Text::literal("My Sword")),
|
||||
);
|
||||
assert_eq!(key, "death.attack.arrow.item");
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0].plain(), "Steve");
|
||||
assert_eq!(args[1].plain(), "Zombie");
|
||||
assert_eq!(args[2].plain(), "My Sword");
|
||||
// Without a custom name the message keeps its two arguments.
|
||||
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
|
||||
assert_eq!(key, "death.attack.arrow");
|
||||
assert_eq!(args.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
|
||||
/// in front of the right Alt.
|
||||
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
|
||||
|
||||
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
|
||||
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
|
||||
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
|
||||
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
|
||||
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
|
||||
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
|
||||
!edit_modifier || (altgr_is_ctrl_alt && alt)
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altgr_types_text_where_it_is_ctrl_alt() {
|
||||
// Plain keys and Shift type.
|
||||
assert!(types_text(false, false, true));
|
||||
assert!(types_text(false, false, false));
|
||||
// Ctrl (Cmd) shortcuts do not.
|
||||
assert!(!types_text(true, false, true));
|
||||
assert!(!types_text(true, false, false));
|
||||
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
|
||||
assert!(types_text(true, true, true));
|
||||
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
|
||||
assert!(!types_text(true, true, false));
|
||||
// Alt alone (macOS Option, Linux AltGr) types.
|
||||
assert!(types_text(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Two offscreen frames presented in one window, without game resources or player data.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
|
||||
use horizon_client_render::gfx::compositor::Compositor;
|
||||
use std::sync::Arc;
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
struct Frames {
|
||||
window: Arc<Window>,
|
||||
compositor: Compositor,
|
||||
textures: [wgpu::Texture; 2],
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("Compositor: red and blue views")
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
|
||||
)?,
|
||||
);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
|
||||
Box::new(window.clone()),
|
||||
));
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
|
||||
let size = wgpu::Extent3d {
|
||||
width: 320,
|
||||
height: 360,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Offscreen view"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&pixel.repeat(320 * 360),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(320 * 4),
|
||||
rows_per_image: Some(360),
|
||||
},
|
||||
size,
|
||||
);
|
||||
texture
|
||||
});
|
||||
let compositor = Compositor::new(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&queue,
|
||||
window.inner_size().into(),
|
||||
)?;
|
||||
Ok(Self {
|
||||
window,
|
||||
compositor,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Demo {
|
||||
frames: Option<Frames>,
|
||||
error: Option<String>,
|
||||
presented: usize,
|
||||
smoke: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Demo {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.frames.is_none() {
|
||||
match Frames::new(event_loop) {
|
||||
Ok(frames) => self.frames = Some(frames),
|
||||
Err(error) => {
|
||||
self.error = Some(error.to_string());
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
let Some(f) = &mut self.frames else { return };
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
|
||||
// Uncovered pixels are black; resize smaller to exercise clipping.
|
||||
WindowEvent::RedrawRequested
|
||||
if f.compositor
|
||||
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
|
||||
{
|
||||
self.presented += 1;
|
||||
if self.smoke && self.presented == 10 {
|
||||
f.compositor.set_vsync(false);
|
||||
f.compositor.resize(0, 0);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(u32::MAX, u32::MAX);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(400, 240);
|
||||
let _ = f
|
||||
.window
|
||||
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
|
||||
}
|
||||
if self.smoke && self.presented == 20 {
|
||||
println!("compositor: 20 frames, resize, suspension and clipping passed");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
|
||||
if let Some(f) = &self.frames {
|
||||
f.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client_render::Result<()> {
|
||||
let mut demo = Demo {
|
||||
smoke: std::env::args().any(|a| a == "--smoke"),
|
||||
..Default::default()
|
||||
};
|
||||
EventLoop::new()?.run_app(&mut demo)?;
|
||||
if let Some(error) = demo.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if demo.smoke && demo.presented < 20 {
|
||||
return Err("smoke test closed early".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// The output device asked for (`soundDevice`), empty for the system's default; the
|
||||
/// default plays when that one can't be opened, as in vanilla.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,7 +598,7 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
@@ -581,6 +615,7 @@ impl Audio {
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -602,10 +637,61 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open(mixer)
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
|
||||
self.device_name = name.to_owned();
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
|
||||
@@ -256,6 +256,10 @@ impl Biomes {
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Whether the game has the biome `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
|
||||
@@ -50,20 +50,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
|
||||
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +716,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +787,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +842,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +892,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -1002,7 +1087,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1100,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1138,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1171,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1238,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1303,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1374,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1384,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1409,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1418,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1434,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -486,6 +486,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
thread_local! {
|
||||
/// How many `contain` calls this thread is inside.
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
/// it end the game. For work that is not the game's own and whose failure the caller recovers
|
||||
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
|
||||
/// to the log with its stack, and no crash report is made for it.
|
||||
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
CONTAINED.with(|c| c.set(c.get() + 1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
CONTAINED.with(|c| c.set(c.get() - 1));
|
||||
result.map_err(|payload| {
|
||||
CAUGHT
|
||||
.with(|c| c.borrow_mut().take())
|
||||
.unwrap_or_else(|| payload_text(&*payload))
|
||||
})
|
||||
}
|
||||
|
||||
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
|
||||
/// return. False for any other panic.
|
||||
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
|
||||
if CONTAINED.with(Cell::get) == 0 {
|
||||
return false;
|
||||
}
|
||||
let message = panic_message(info);
|
||||
let stack = stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
);
|
||||
log::error!(
|
||||
"Recovered from an error: {message}\n{}",
|
||||
stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -308,13 +354,35 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into())
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
@@ -417,10 +485,13 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if contained_panic(info) {
|
||||
return;
|
||||
}
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
@@ -609,6 +680,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contained_panics_come_back_as_errors() {
|
||||
assert_eq!(contain(|| 7), Ok(7));
|
||||
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
|
||||
assert!(error.contains("a pack's program"), "{error}");
|
||||
// Nested, and the thread is no longer inside one after.
|
||||
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
|
||||
assert!(outer.unwrap().unwrap_err().contains("inner"));
|
||||
assert_eq!(CONTAINED.with(Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::overrides::{self, PackFile, WorldgenOverrides},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
|
||||
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
|
||||
/// order, then every pack it lists neither way, in name order.
|
||||
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let list = |key: &str| -> Vec<String> {
|
||||
saved
|
||||
.as_ref()
|
||||
.and_then(|v| v[key].as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let disabled = list("disabled");
|
||||
let mut enabled: Vec<String> = list("enabled")
|
||||
.into_iter()
|
||||
.filter(|id| packs.iter().any(|p| &p.id == id))
|
||||
.collect();
|
||||
for p in packs {
|
||||
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
|
||||
enabled.push(p.id.clone());
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
/// left out: the prepared bundles are it) for the chunk generator, and what
|
||||
/// it leaves out, for the log. `biomes` says which biomes the game has: a
|
||||
/// pack's new biome is left out (the game has no colours, sounds or id for it).
|
||||
pub fn worldgen_overrides(
|
||||
packs: &[&DataPack],
|
||||
biomes: impl Fn(&str) -> bool,
|
||||
) -> (WorldgenOverrides, Vec<String>) {
|
||||
let mut out = WorldgenOverrides::default();
|
||||
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings.extend(out.finish(biomes));
|
||||
for (kind, files) in not_read {
|
||||
warnings.push(format!("{kind}: {files} files the generator does not read"));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join("datapacks").join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
"{\"values\":[]}",
|
||||
);
|
||||
}
|
||||
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
|
||||
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
|
||||
write(
|
||||
"a",
|
||||
"data/minecraft/worldgen/world_preset/normal.json",
|
||||
"{}",
|
||||
);
|
||||
write(
|
||||
"b",
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
"{\"min_y\":-64,\"height\":2096}",
|
||||
);
|
||||
// `b` is enabled first, so `a` (enabled after it) wins.
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
|
||||
)
|
||||
.unwrap();
|
||||
let packs = discover(&dir);
|
||||
let enabled = enabled_ids(&dir, &packs);
|
||||
assert_eq!(enabled, ["file/b", "file/a"]);
|
||||
let enabled: Vec<&DataPack> = enabled
|
||||
.iter()
|
||||
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
|
||||
.collect();
|
||||
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
|
||||
assert_eq!(
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
@@ -566,4 +806,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -137,6 +139,7 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
own_name_tag: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -211,6 +214,7 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -225,7 +229,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -251,6 +255,7 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -378,6 +383,7 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -396,6 +402,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +418,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -426,7 +435,7 @@ impl Item {
|
||||
Some(match self {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -439,6 +448,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -471,7 +483,9 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +494,7 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -506,6 +521,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
@@ -534,7 +550,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -558,6 +574,8 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use entity_batch::{
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -69,6 +70,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
|
||||
mod animate;
|
||||
mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
@@ -92,12 +94,14 @@ pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
@@ -353,6 +357,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -380,6 +392,11 @@ pub struct Renderer {
|
||||
pub last_wait: std::time::Duration,
|
||||
/// CPU time of the last `render`, waits excluded.
|
||||
pub last_encode: std::time::Duration,
|
||||
/// What the system says about the window's display, watched only while HDR output
|
||||
/// is on; before `window`, so it's dropped first.
|
||||
display: DisplayWatch,
|
||||
/// The watch was just made: the settings' Auto values take what it reported.
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
@@ -566,6 +583,8 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
pub drawn_sections: usize,
|
||||
@@ -575,8 +594,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -1168,6 +1187,7 @@ impl Renderer {
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
let hdr_output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(hdr_output.is_some());
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
@@ -2580,6 +2600,8 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
@@ -2722,6 +2744,7 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
drawn_sections: 0,
|
||||
@@ -3241,7 +3264,7 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3256,7 +3279,7 @@ impl Renderer {
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3286,7 +3309,15 @@ impl Renderer {
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
pub fn set_hdr(
|
||||
&mut self,
|
||||
on: bool,
|
||||
paper_white: f32,
|
||||
peak: f32,
|
||||
black: f32,
|
||||
ui: f32,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
@@ -3297,6 +3328,16 @@ impl Renderer {
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
// Only HDR output asks the system about the display, so with HDR off the
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) = DisplayWatch::new(&self.window);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
} else {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
@@ -3309,16 +3350,35 @@ impl Renderer {
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = ui;
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
hdr.black = black;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
true
|
||||
}
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
@@ -3518,14 +3578,14 @@ impl Renderer {
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
|
||||
self.projection(
|
||||
/// The views `render` draws `frame` from.
|
||||
pub fn views(&self, frame: &Frame) -> ViewSet {
|
||||
ViewSet::single(self.screen_view(
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
))
|
||||
}
|
||||
/// The view frustum `render` will cull with, for culling geometry built
|
||||
/// before the frame (entities, block entities) the same way.
|
||||
@@ -3537,13 +3597,14 @@ impl Renderer {
|
||||
bob: Mat4,
|
||||
depth_far: f32,
|
||||
) -> Frustum {
|
||||
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
|
||||
Frustum {
|
||||
planes: frustum(self.projection(fov_degrees, yaw, pitch, bob, depth_far)),
|
||||
planes: frustum(views.cull().view_proj(bob)),
|
||||
}
|
||||
}
|
||||
fn projection(&self, fov_degrees: f32, yaw: f32, pitch: f32, bob: Mat4, far: f32) -> Mat4 {
|
||||
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
world_projection(fov_degrees, aspect, far) * bob * crate::camera::view_matrix(yaw, pitch)
|
||||
View::screen(yaw, pitch, fov_degrees, aspect, far)
|
||||
}
|
||||
fn update_lightmap(
|
||||
&mut self,
|
||||
@@ -3675,7 +3736,12 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
(Some(f), None) => f.texture.clone(),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
(Some(f), None) => self
|
||||
.readback
|
||||
.target(&self.device, &f.texture, self.screenshot.is_some())
|
||||
.unwrap_or_else(|| f.texture.clone()),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
// One command buffer for the frame, after the section copies staged
|
||||
@@ -3703,7 +3769,9 @@ impl Renderer {
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
let views = self.views(frame);
|
||||
let pose = frame.bob * frame.spin;
|
||||
let view_proj = views.primary().view_proj(pose);
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -3723,8 +3791,7 @@ impl Renderer {
|
||||
frame.pitch,
|
||||
);
|
||||
}
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let view = views.primary().view;
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
// Celestial bodies rotate around the X axis; angle 0 = noon.
|
||||
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
|
||||
@@ -3777,8 +3844,8 @@ impl Renderer {
|
||||
misc: [
|
||||
1.0 - frame.sky.sky_light_factor,
|
||||
frame.game_time,
|
||||
aspect,
|
||||
(frame.fov_degrees.to_radians() / 2.0).tan(),
|
||||
views.primary().aspect,
|
||||
views.primary().tan_half_fov,
|
||||
],
|
||||
inverse_view: view.inverse().to_cols_array_2d(),
|
||||
sunrise: frame.sky.sunrise_color,
|
||||
@@ -3841,14 +3908,15 @@ impl Renderer {
|
||||
bytemuck::bytes_of(&crosshair_globals),
|
||||
);
|
||||
// Frustum culling and camera-relative origins.
|
||||
let cull_proj = views.cull().view_proj(pose);
|
||||
let view = Frustum {
|
||||
planes: frustum(view_proj),
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(view_proj).filter(|_| self.sides_on);
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
@@ -5195,6 +5263,10 @@ impl Renderer {
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
@@ -5203,6 +5275,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5306,24 +5385,20 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
self.section_copy_bytes = 0;
|
||||
self.section_uploads.finish(&encoder);
|
||||
}
|
||||
if let Some(f) = &surface_frame {
|
||||
self.readback
|
||||
.present(&self.device, &mut encoder, &f.texture);
|
||||
}
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
@@ -5344,9 +5419,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5396,43 +5471,68 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
[width, height]: [u32; 2],
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(padded_row(width)) * u64::from(height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded_row(width)),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(self.padded_row()) * u64::from(self.config.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.padded_row()),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.config.width,
|
||||
height: self.config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5447,7 +5547,7 @@ impl Renderer {
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.padded_row() as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
@@ -5467,8 +5567,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5478,6 +5588,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -6008,3 +6154,101 @@ mod sprite_upload_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
/// compositor's preferred image description of the surface.
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
}
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
|
||||
let watch = Self {
|
||||
wayland: Some(wayland),
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(|now| now.map(reported));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The log of what the system reports about the display: one line per change. The
|
||||
/// renderer is made anew a few times while the game starts and each asks again, and a
|
||||
/// slider dragged in the desktop's settings changes the values many times a second, so
|
||||
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
|
||||
/// held that long, and only the one they settled on is logged.
|
||||
struct DisplayLog {
|
||||
last: String,
|
||||
at: Option<std::time::Instant>,
|
||||
pending: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
|
||||
last: String::new(),
|
||||
at: None,
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if line == log.last {
|
||||
log.pending = None;
|
||||
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
|
||||
log::info!("{line}");
|
||||
(log.last, log.at, log.pending) = (line, Some(now), None);
|
||||
} else {
|
||||
log.pending = Some((line, now));
|
||||
}
|
||||
}
|
||||
/// Logs a change [`log_display`] held back, once the values have held for
|
||||
/// [`DISPLAY_LOG_QUIET`].
|
||||
fn flush_display_log() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, since)) = &log.pending
|
||||
&& now - *since >= DISPLAY_LOG_QUIET
|
||||
&& let Some((line, _)) = log.pending.take()
|
||||
{
|
||||
log::info!("{line}");
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
|
||||
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
|
||||
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
|
||||
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
|
||||
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
|
||||
/// PQ's top, so the metadata never ends below the black level.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
|
||||
log_display(format!(
|
||||
"Horizon renderer: the compositor shows this window with reference white {} nits, \
|
||||
{}..{} nits: peak {peak} nits in HDR output's scale",
|
||||
l.reference, l.target_min, l.target_max,
|
||||
));
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Splitscreen: several players' renderers draw offscreen on one device, and the
|
||||
//! compositor copies each frame into its place on a window's surface. A window holds views but
|
||||
//! never owns them, so a view can later move to another window (another monitor, detached).
|
||||
use super::Renderer;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
pub struct Compositor {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
|
||||
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
|
||||
// The surface this renderer set aside for its successor (`release_surface`), else a new
|
||||
// one: a window can't keep two.
|
||||
let spare = self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(window.clone())?,
|
||||
};
|
||||
Compositor::new(
|
||||
surface,
|
||||
&self.adapter,
|
||||
&self.device,
|
||||
&self.queue,
|
||||
window.inner_size().into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The last offscreen frame (`--offscreen`), for the compositor.
|
||||
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
|
||||
self.offscreen.as_ref()
|
||||
}
|
||||
|
||||
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
|
||||
/// the window again (it takes it, as after `release_surface`).
|
||||
pub fn take_back(&self, compositor: Compositor) {
|
||||
*self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Present offscreen BGRA frames on a compatible surface using their existing device.
|
||||
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
|
||||
pub fn new(
|
||||
surface: wgpu::Surface<'static>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let caps = surface.get_capabilities(adapter);
|
||||
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
|
||||
// take any surface format (Rgba8Unorm on some Linux drivers).
|
||||
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
return Err("surface does not support copying offscreen frames".into());
|
||||
}
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
if !caps.formats.contains(&format) {
|
||||
return Err(format!(
|
||||
"splitscreen needs a {format:?} surface; this one offers {:?}",
|
||||
caps.formats
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size[0].clamp(1, max),
|
||||
height: size[1].clamp(1, max),
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
surface.configure(device, &config);
|
||||
Ok(Compositor {
|
||||
surface,
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
config,
|
||||
present_modes: caps.present_modes,
|
||||
suspended: !super::drawable_size(size[0], size[1], max),
|
||||
})
|
||||
}
|
||||
|
||||
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
|
||||
pub fn set_vsync(&mut self, vsync: bool) {
|
||||
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
|
||||
.into_iter()
|
||||
.find(|m| !vsync && self.present_modes.contains(m))
|
||||
.unwrap_or(wgpu::PresentMode::Fifo);
|
||||
if mode != self.config.present_mode {
|
||||
self.config.present_mode = mode;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended =
|
||||
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
|
||||
if !self.suspended {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy each view's frame to its top-left corner on the window and present. Frames that
|
||||
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
|
||||
/// one still at its old size after a resize) are black. Nothing when the window has no frame
|
||||
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
|
||||
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
|
||||
if self.suspended {
|
||||
return false;
|
||||
}
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return false;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Splitscreen compositor"),
|
||||
});
|
||||
let target = frame.texture.create_view(&Default::default());
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Splitscreen compositor clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
for &(texture, [x, y]) in views {
|
||||
let [width, height] = clipped_size(
|
||||
[texture.width(), texture.height()],
|
||||
[x, y],
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
origin: wgpu::Origin3d { x, y, z: 0 },
|
||||
..frame.texture.as_image_copy()
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
self.queue.present(frame);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies never reach past the surface, including while a resized view still has its old frame.
|
||||
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
|
||||
[
|
||||
view[0].min(surface[0].saturating_sub(at[0])),
|
||||
view[1].min(surface[1].saturating_sub(at[1])),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_clipped_to_the_window_during_resize() {
|
||||
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [1000, 600], [1280, 720]),
|
||||
[280, 120]
|
||||
);
|
||||
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
|
||||
[0, 480]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,12 @@ struct Targets {
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
clear_alpha: wgpu::RenderPipeline,
|
||||
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
@@ -73,6 +79,10 @@ pub struct Hdr {
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -138,6 +148,48 @@ impl Hdr {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&device.create_pipeline_layout(&Default::default())),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_clear_alpha"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
|
||||
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
|
||||
// stays 0.
|
||||
let scale_alpha = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Constant,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let hand_mark = alpha_pass(
|
||||
"Horizon HDR hand mark",
|
||||
Some(wgpu::BlendState {
|
||||
color: scale_alpha,
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
@@ -147,6 +199,8 @@ impl Hdr {
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
@@ -154,6 +208,8 @@ impl Hdr {
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +266,9 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -220,6 +278,56 @@ impl Hdr {
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.clear_alpha);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.hand_mark);
|
||||
let a = 1.0 / 255.0;
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: a,
|
||||
g: a,
|
||||
b: a,
|
||||
a,
|
||||
});
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
@@ -240,8 +348,8 @@ impl Hdr {
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
self.black.clamp(0.0, paper),
|
||||
self.ui.clamp(0.25, 1.0),
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
|
||||
@@ -15,10 +15,19 @@ pub struct Bolt {
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
|
||||
/// retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
|
||||
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
|
||||
/// is on the sprite's border, where the atlas's nearest sampling picks the
|
||||
/// neighbouring, transparent texels for most fragments.
|
||||
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
|
||||
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
|
||||
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
fn white_sprite() -> crate::hud::Rect {
|
||||
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
|
||||
crate::hud::Rect {
|
||||
u0: 40.0 / w,
|
||||
v0: 12.0 / h,
|
||||
u1: 44.0 / w,
|
||||
v1: 16.0 / h,
|
||||
w: 4,
|
||||
h: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The white sprite's corner is on its border with the next atlas texels,
|
||||
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
|
||||
/// point sampled is at least a texel inside the sprite.
|
||||
#[test]
|
||||
fn white_uv_is_inside_the_white_sprite() {
|
||||
let [u, v] = white_uv(&white_sprite());
|
||||
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
|
||||
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
|
||||
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
|
||||
}
|
||||
|
||||
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
|
||||
/// sampling, only the white sprite opaque) draws the same pixels as through a
|
||||
/// texture that is white everywhere (#3171: the sprite's corner left a few
|
||||
/// scattered pixels). Run explicitly, like the test above.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
// The lightning pipeline (gfx.rs): additive, into RGBA8.
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_lightning"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: additive,
|
||||
alpha: additive,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
|
||||
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
|
||||
.to_cols_array_2d(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |(width, height): (u32, u32), data: &[u8]| {
|
||||
device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let white = texture((1, 1), &[255; 4]);
|
||||
// Transparent but for the white sprite.
|
||||
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
|
||||
for y in 12..16 {
|
||||
for x in 40..44 {
|
||||
let i = ((y * ATLAS.0 + x) * 4) as usize;
|
||||
texels[i..i + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
let atlas = texture(ATLAS, &texels);
|
||||
// The atlas sampler (gfx.rs): wgpu's default, nearest.
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
|
||||
let size = 256;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [0.0, -1.62, 14.0],
|
||||
}];
|
||||
let input = Input { bolts: &bolts, uv };
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
{
|
||||
let target = color.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size * size * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
|
||||
let expected = draw(&white, [0.5; 2]);
|
||||
let drawn = draw(&atlas, white_uv(&white_sprite()));
|
||||
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
|
||||
assert_eq!(
|
||||
lit(&drawn),
|
||||
lit(&expected),
|
||||
"pixels drawn through the atlas"
|
||||
);
|
||||
assert!(drawn == expected, "the bolt's colours through the atlas");
|
||||
}
|
||||
@@ -476,10 +476,12 @@ fn sampler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
|
||||
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
|
||||
/// pipeline (#3525), which a pack's program must not turn into a crash.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
let value = crate::crash::contain(create);
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's description or its sources.
|
||||
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2631,38 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let failed = checked(&device, || -> wgpu::Buffer {
|
||||
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
|
||||
});
|
||||
let error = failed.expect_err("the panic comes back as an error");
|
||||
assert!(error.contains("Expression [89] is not cached!"), "{error}");
|
||||
let buffer = |usage| {
|
||||
checked(&device, || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
};
|
||||
// The device still makes things, and its errors still come back.
|
||||
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
|
||||
{
|
||||
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot frame"),
|
||||
size: surface.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
}
|
||||
self.drawn = true;
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 70,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
|
||||
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
let pixels = read(device, buffer);
|
||||
let row = 70usize * 4;
|
||||
let last = row.next_multiple_of(256) * 2 + row - 4;
|
||||
pixels[last..last + 4].to_vec()
|
||||
}
|
||||
|
||||
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
|
||||
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
|
||||
/// the swapchain, with no validation error (the copy used to need the
|
||||
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
|
||||
#[test]
|
||||
fn screenshot_of_a_surface_without_copy_src() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Sampled only so the test can look at what reached it.
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
// A second screenshot reuses the texture.
|
||||
(0.0, [255, 0, 0, 255]),
|
||||
];
|
||||
for (r, bgra) in colors {
|
||||
let color = wgpu::Color {
|
||||
r,
|
||||
g: 0.0,
|
||||
b: 1.0 - r,
|
||||
a: 1.0,
|
||||
};
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
}
|
||||
assert!(!readback.drawn);
|
||||
}
|
||||
|
||||
/// A surface that can be copied from, or a frame nobody reads back,
|
||||
/// draws straight into the swapchain image.
|
||||
#[test]
|
||||
fn frames_draw_straight_to_a_readable_surface() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut readback = Readback::default();
|
||||
let readable = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The views a frame is drawn from. A flat screen has one; a headset has
|
||||
//! one per eye. Every view shares the frame's camera position, so the
|
||||
//! camera-relative world (section origins, entity offsets) is built once and
|
||||
//! only each view's rotation and lens differ.
|
||||
|
||||
use glam::Mat4;
|
||||
|
||||
/// The most views one frame draws.
|
||||
const MAX_VIEWS: usize = 2;
|
||||
|
||||
/// One eye's view of the camera-relative world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
/// Camera-relative world to eye: a rotation, plus the eye's offset from
|
||||
/// the camera on a headset.
|
||||
pub view: Mat4,
|
||||
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
|
||||
pub projection: Mat4,
|
||||
/// Width over height of the image this view fills.
|
||||
pub aspect: f32,
|
||||
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
|
||||
pub tan_half_fov: f32,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The flat-screen view: vanilla's camera rotation and a symmetric
|
||||
/// vertical field of view.
|
||||
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
|
||||
Self {
|
||||
view: crate::camera::view_matrix(yaw, pitch),
|
||||
projection: super::world_projection(fov_degrees, aspect, far),
|
||||
aspect,
|
||||
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip space from the camera-relative world, with `pose` (view bobbing,
|
||||
/// nausea spin) between the lens and the rotation.
|
||||
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
|
||||
self.projection * pose * self.view
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view of a frame, and the one geometry is culled with.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ViewSet {
|
||||
views: [View; MAX_VIEWS],
|
||||
len: usize,
|
||||
cull: View,
|
||||
}
|
||||
|
||||
impl ViewSet {
|
||||
/// A frame drawn from `view` alone, culled with it.
|
||||
pub fn single(view: View) -> Self {
|
||||
Self {
|
||||
views: [view; MAX_VIEWS],
|
||||
len: 1,
|
||||
cull: view,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the HUD, hand and screen-space effects follow.
|
||||
pub fn primary(&self) -> &View {
|
||||
&self.views[0]
|
||||
}
|
||||
|
||||
pub fn views(&self) -> &[View] {
|
||||
&self.views[..self.len]
|
||||
}
|
||||
|
||||
/// The view whose frustum covers every other view's.
|
||||
pub fn cull(&self) -> &View {
|
||||
&self.cull
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The formulas `Renderer` used before views existed, kept to pin the
|
||||
/// screen view to them bit for bit.
|
||||
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
|
||||
super::super::world_projection(fov, aspect, far)
|
||||
* pose
|
||||
* crate::camera::view_matrix(yaw, pitch)
|
||||
}
|
||||
|
||||
fn poses() -> [Mat4; 3] {
|
||||
[
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
|
||||
* Mat4::from_rotation_z(0.021),
|
||||
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
|
||||
for (fov, yaw, pitch, aspect, far) in [
|
||||
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
|
||||
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
|
||||
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
|
||||
(82.333, 12.5, 7.75, 1.0, 1024.0),
|
||||
] {
|
||||
let view = View::screen(yaw, pitch, fov, aspect, far);
|
||||
for pose in poses() {
|
||||
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
|
||||
let new = view.view_proj(pose);
|
||||
assert_eq!(
|
||||
old.to_cols_array().map(f32::to_bits),
|
||||
new.to_cols_array().map(f32::to_bits),
|
||||
"fov {fov} yaw {yaw} pitch {pitch}"
|
||||
);
|
||||
}
|
||||
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
|
||||
assert_eq!(
|
||||
view.tan_half_fov.to_bits(),
|
||||
(fov.to_radians() / 2.0).tan().to_bits()
|
||||
);
|
||||
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_view_set_draws_and_culls_with_its_view() {
|
||||
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
|
||||
let set = ViewSet::single(view);
|
||||
assert_eq!(set.views(), &[view]);
|
||||
assert_eq!(*set.primary(), view);
|
||||
assert_eq!(*set.cull(), view);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ struct TaaUniform {
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
/// TAA's GPU resources, absent when the device cannot compile the shader
|
||||
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
|
||||
/// from depth textures).
|
||||
struct TaaResources {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -57,9 +66,7 @@ pub struct Post {
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
taa_resources: Option<TaaResources>,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
@@ -105,40 +112,11 @@ impl Post {
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
@@ -166,6 +144,88 @@ impl Post {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The OpenGL backend's GLSL translator does not support textureLoad
|
||||
// from depth textures: on devices running through OpenGL (such as older
|
||||
// Intel GPUs on Linux), TAA cannot compile its pipeline.
|
||||
let taa_resources = {
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let taa_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: Some(&taa_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_taa"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: HISTORY_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Some(TaaResources {
|
||||
layout: taa_layout,
|
||||
pipeline: taa_pipeline,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
@@ -175,15 +235,7 @@ impl Post {
|
||||
Self {
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
@@ -192,12 +244,18 @@ impl Post {
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_resources,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether TAA is supported on this GPU/backend.
|
||||
pub fn supports_taa(&self) -> bool {
|
||||
self.taa_resources.is_some()
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
@@ -212,7 +270,7 @@ impl Post {
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
self.taa_on = on && self.taa && self.taa_resources.is_some();
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
@@ -290,7 +348,8 @@ impl Post {
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let taa = history_size.and_then(|output| {
|
||||
let res = self.taa_resources.as_ref()?;
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
@@ -298,7 +357,7 @@ impl Post {
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
layout: &res.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -318,18 +377,18 @@ impl Post {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
resource: res.uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
Some(History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
@@ -381,12 +440,12 @@ impl Post {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
@@ -401,7 +460,7 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -485,4 +544,21 @@ mod tests {
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_post_pipelines_compile_or_fall_back() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
|
||||
let _ = post.supports_taa();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -343,10 +379,18 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -381,8 +425,10 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -445,8 +491,22 @@ impl Graphics {
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -464,7 +524,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -494,6 +554,8 @@ impl Graphics {
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -502,6 +564,22 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
match self.hdr_paper_white {
|
||||
0 => reported().map_or(203, |r| r.paper_white),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system reports, else 1000.
|
||||
pub fn peak_nits(&self) -> u32 {
|
||||
match self.hdr_peak {
|
||||
0 => reported().map_or(1000, |r| r.peak),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -611,12 +689,19 @@ mod tests {
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -130,8 +130,9 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -38,8 +40,12 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -120,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright highlights get. Auto: the peak your system reports.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -230,20 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -351,8 +375,18 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -403,8 +437,23 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -427,8 +476,20 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +565,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -605,6 +685,7 @@ pub enum Action {
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -624,15 +705,20 @@ struct Widget {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
@@ -657,7 +743,7 @@ impl GraphicsUi {
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut y = top + 4.0 - self.scroll.amount;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level, s) {
|
||||
@@ -683,6 +769,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -702,7 +799,7 @@ impl GraphicsUi {
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
@@ -744,12 +841,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -759,6 +854,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -799,15 +895,16 @@ impl GraphicsUi {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
Self::scrollbar_x(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
self.scroll.amount,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -825,9 +922,14 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, top, bottom, content);
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -841,6 +943,13 @@ impl GraphicsUi {
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
|
||||
if self
|
||||
.scroll
|
||||
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
@@ -851,7 +960,10 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -866,14 +978,16 @@ impl GraphicsUi {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +995,53 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Hdr, peak),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
let on = |level, item| {
|
||||
extra_rows(level, &s)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
|
||||
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
@@ -960,29 +1121,68 @@ mod tests {
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
assert_eq!(ui.scroll.amount, max);
|
||||
}
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
assert_eq!(ui.scroll.amount, max - 4.5);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
assert_eq!(ui.scroll.amount, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
// column, mouseDragged moves the list with the pointer, onRelease lets go.
|
||||
let mut s = Settings::default();
|
||||
let size = (480.0, 2.0 * STYLE.band + 60.0);
|
||||
let (top, bottom) = GraphicsUi::bounds(size.1);
|
||||
// On the scrollbar column, as `draw` paints it.
|
||||
let x =
|
||||
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
|
||||
let first_row = |ui: &mut GraphicsUi| {
|
||||
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
|
||||
.0[0]
|
||||
.y
|
||||
};
|
||||
let mut ui = GraphicsUi::default();
|
||||
let start = first_row(&mut ui);
|
||||
let at = |y| (x, y);
|
||||
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
|
||||
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
|
||||
};
|
||||
// The press itself makes no sound and changes nothing.
|
||||
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
|
||||
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
|
||||
let moved = first_row(&mut ui);
|
||||
assert!(moved < start, "dragging down scrolls the list down");
|
||||
// Past the bottom of the list: the last row.
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
let content = GraphicsUi::content_height(Level::Advanced, &s);
|
||||
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
|
||||
// Above the list: the first row again.
|
||||
press(&mut ui, &mut s, top - 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
// Released: drags no longer scroll, and no slider click plays.
|
||||
assert!(!ui.release());
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
assert!(ui.scroll.amount > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
@@ -992,7 +1192,7 @@ mod tests {
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ struct Params {
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// The display's black level in nits; 0 leaves the picture as it is.
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -56,20 +60,51 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
// ST 2084's inverse: the PQ signal to nits.
|
||||
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
|
||||
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
|
||||
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
|
||||
}
|
||||
|
||||
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
|
||||
// `e`: black moves up to the display's black level, the peak stays, and the lift
|
||||
// fades out by the fourth power on the way up, so only the shadows move. A display
|
||||
// that can't go darker than its black level would crush everything below it.
|
||||
fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
|
||||
let n = min(e / top, vec3(1.0));
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
|
||||
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, and only where
|
||||
// nothing was drawn over them since: grey text over the white sun still looks
|
||||
// like the sun darkened, but it raised the frame's alpha.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
@@ -84,9 +119,15 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -435,6 +435,28 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
fn replaces(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
filled: impl Fn(TileKey) -> bool,
|
||||
) -> bool {
|
||||
let Some(key) = (0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: (x * 16).div_euclid(16 << level),
|
||||
z: (z * 16).div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| self.nodes.contains(key))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
// A split node's child there is out of range: nothing is drawn.
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
@@ -534,6 +556,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -571,8 +595,17 @@ pub struct Lod {
|
||||
active: FxHashSet<TileKey>,
|
||||
dirty: FxHashSet<TileKey>,
|
||||
removed: Vec<TileKey>,
|
||||
/// The columns left out: `full` and `retiring`.
|
||||
covered: FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
/// The full-detail columns `update` was last given.
|
||||
full: FxHashSet<(i32, i32)>,
|
||||
/// Columns that left the full-detail area, still left out (their
|
||||
/// full-detail meshes still drawn) until the tile planned there is ready
|
||||
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
|
||||
/// render distance shrinks, their coarsest ancestors would otherwise
|
||||
/// stand in for it for minutes.
|
||||
retiring: FxHashSet<(i32, i32)>,
|
||||
/// The covered columns the active meshes leave out, when a mesh job
|
||||
/// has finished since the last `take_cutout`.
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -589,6 +622,7 @@ impl Lod {
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -633,6 +667,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -695,6 +732,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -770,6 +808,8 @@ impl Lod {
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
full: FxHashSet::default(),
|
||||
retiring: FxHashSet::default(),
|
||||
cutout: None,
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
@@ -991,9 +1031,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
@@ -1029,7 +1071,8 @@ impl Lod {
|
||||
(ox + (index as i32 % 16) * stride).div_euclid(16),
|
||||
(oz + (index as i32 / 16) * stride).div_euclid(16),
|
||||
);
|
||||
if self.covered.contains(&chunk) {
|
||||
// A retiring column's tile is drawn soon: it needs its detail.
|
||||
if self.full.contains(&chunk) {
|
||||
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
|
||||
} else {
|
||||
chunks.insert(chunk);
|
||||
@@ -1151,31 +1194,13 @@ impl Lod {
|
||||
}
|
||||
/// Takes the new full-detail columns, when they changed (`None`: not).
|
||||
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
|
||||
if let Some(covered) = covered
|
||||
&& self.covered != covered
|
||||
if let Some(full) = covered
|
||||
&& self.full != full
|
||||
{
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
self.covered = covered;
|
||||
self.replanned = true;
|
||||
self.reselect = true;
|
||||
self.retiring.extend(self.full.difference(&full).copied());
|
||||
self.retiring.retain(|column| !full.contains(column));
|
||||
self.full = full;
|
||||
self.leave_out();
|
||||
}
|
||||
// Complete resident columns need no duplicate noise work. Their real
|
||||
// summaries are retained when the full-detail chunks leave the view.
|
||||
@@ -1189,12 +1214,55 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Leaves out the full-detail and retiring columns: the tiles touching
|
||||
/// one that changed are meshed again.
|
||||
fn leave_out(&mut self) {
|
||||
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
|
||||
if self.covered == covered {
|
||||
return;
|
||||
}
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
// Only new covered columns need stand-ins.
|
||||
self.replanned |= !covered.is_subset(&self.covered);
|
||||
self.covered = covered;
|
||||
self.reselect = true;
|
||||
}
|
||||
/// Chooses the tiles to draw and meshes those that changed, when the
|
||||
/// plan, the samples, the covered columns or the active tiles changed.
|
||||
fn select(&mut self) {
|
||||
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.retiring.is_empty() {
|
||||
let (plan, ready, saved, masks) =
|
||||
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
|
||||
// A level-0 tile there is an empty stand-in (`cover`) until the
|
||||
// chunk's summary or generated detail fills it (`apply_fine`).
|
||||
self.retiring.retain(|&column| {
|
||||
!plan.replaces(column, ready, |key| {
|
||||
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
|
||||
})
|
||||
});
|
||||
self.leave_out();
|
||||
}
|
||||
{
|
||||
let mut selected = Vec::new();
|
||||
for &key in &self.plan.roots {
|
||||
@@ -1611,6 +1679,48 @@ mod tests {
|
||||
assert_eq!(selected, parent.children());
|
||||
}
|
||||
#[test]
|
||||
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
|
||||
// Render distance 64 under LOD 64: every tile inside it is a level-0
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| key.level == 0 || before.roots.contains(key))
|
||||
.map(|&key| (key, ()))
|
||||
.collect();
|
||||
ready.retain(|key, _| after.nodes.contains(key));
|
||||
let contains = |key: &TileKey, (x, z): (i32, i32)| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 1,
|
||||
};
|
||||
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
|
||||
ready.insert(planned, ());
|
||||
assert!(after.replaces(column, &ready, |_| true));
|
||||
// 12 chunks out, its level-0 tile is the stand-in until filled.
|
||||
assert!(!after.replaces((12, 0), &ready, |_| false));
|
||||
assert!(after.replaces((12, 0), &ready, |_| true));
|
||||
// Past the far terrain nothing waits.
|
||||
assert!(after.replaces((100, 0), &ready, |_| false));
|
||||
}
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
|
||||
@@ -513,6 +513,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
|
||||
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -120,6 +141,11 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -144,6 +170,10 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::SoundDevice => "soundDevice",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
@@ -167,6 +197,7 @@ impl Opt {
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::SoundDevice => "options.audioDevice",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
Opt::Gamma => "options.gamma",
|
||||
@@ -194,7 +225,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -217,7 +254,7 @@ impl Opt {
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
Opt::ChatDelay => "Chat Delay",
|
||||
Opt::ChatDelay => "horizon.options.chat_delay",
|
||||
Opt::DistortionEffects => "options.screenEffectScale",
|
||||
Opt::FovEffects => "options.fovEffectScale",
|
||||
Opt::DarknessPulsing => "options.darknessEffectScale",
|
||||
@@ -278,7 +315,11 @@ impl Opt {
|
||||
| Opt::GlintStrength
|
||||
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
|
||||
Opt::RenderDistance => unit(
|
||||
s.render_distance as f32,
|
||||
2.0,
|
||||
crate::options_file::MAX_RENDER_DISTANCE as f32,
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
@@ -308,6 +349,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -356,6 +398,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -573,7 +623,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -586,6 +649,13 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::SoundDevice => {
|
||||
if s.sound_device.is_empty() {
|
||||
tr("options.audioDevice.default")
|
||||
} else {
|
||||
Text::literal(&s.sound_device)
|
||||
}
|
||||
}
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
@@ -603,25 +673,25 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "Health",
|
||||
gamepad::Lightbar::Dimension => "Dimension",
|
||||
gamepad::Lightbar::Off => "Off",
|
||||
Opt::PadLightbar => tr(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
|
||||
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
|
||||
gamepad::Lightbar::Off => "options.off",
|
||||
}),
|
||||
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "Auto",
|
||||
GlyphStyle::Xbox => "Xbox",
|
||||
GlyphStyle::PlayStation => "PlayStation",
|
||||
GlyphStyle::Nintendo => "Nintendo",
|
||||
Opt::PadGlyphs => tr(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "horizon.options.glyphs.auto",
|
||||
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "Focus",
|
||||
gamepad::MenuNav::Pointer => "Pointer",
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "Auto",
|
||||
gamepad::TextKeyboard::Horizon => "Horizon",
|
||||
gamepad::TextKeyboard::Steam => "Steam",
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -647,7 +717,10 @@ impl Opt {
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::RenderDistance => {
|
||||
s.render_distance =
|
||||
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
@@ -674,6 +747,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -770,7 +844,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -779,6 +860,23 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::SoundDevice => {
|
||||
let devices = crate::audio::output_devices();
|
||||
let mut all = vec![String::new()];
|
||||
for d in devices {
|
||||
if !all.contains(&d) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
|
||||
let count = all.len();
|
||||
let next_idx = if back {
|
||||
(current_idx + count - 1) % count
|
||||
} else {
|
||||
(current_idx + 1) % count
|
||||
};
|
||||
s.sound_device = all[next_idx].clone();
|
||||
}
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
@@ -921,6 +1019,7 @@ pub enum Page {
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -1012,7 +1111,11 @@ pub enum Event {
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
Packs(Vec<String>),
|
||||
/// Create World's Data Packs screen closed with a new selection.
|
||||
DataPacks(Vec<String>),
|
||||
OpenPackFolder,
|
||||
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
|
||||
OpenDataPackFolder(std::path::PathBuf),
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
@@ -1078,6 +1181,13 @@ pub struct OptionsUi {
|
||||
pub pack_selection: Vec<String>,
|
||||
/// The open pack screen, made at its first frame.
|
||||
pub pack_screen: Option<crate::pack_screen::PackScreen>,
|
||||
/// The Packs page is Create World's Data Packs screen on this folder.
|
||||
data_pack_dir: Option<std::path::PathBuf>,
|
||||
/// The game's resources, for the built-in data pack's icon.
|
||||
data_pack_resources: std::path::PathBuf,
|
||||
/// The folder's entries when its packs were listed, and when they were
|
||||
/// last compared with it.
|
||||
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
|
||||
/// A reload failure, dismissed before returning to the current page.
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
@@ -1112,6 +1222,9 @@ impl Default for OptionsUi {
|
||||
pack_infos: Vec::new(),
|
||||
pack_selection: Vec::new(),
|
||||
pack_screen: None,
|
||||
data_pack_dir: None,
|
||||
data_pack_resources: Default::default(),
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
in_world: false,
|
||||
@@ -1164,22 +1277,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1219,9 +1324,48 @@ impl OptionsUi {
|
||||
pub fn open(&mut self, in_world: bool) {
|
||||
self.open_at(in_world, Page::Main);
|
||||
}
|
||||
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
|
||||
/// page on the new world's pack folder `dir`, `selected` chosen;
|
||||
/// `resources` holds the built-in pack's icon.
|
||||
pub fn open_data_packs(
|
||||
&mut self,
|
||||
dir: std::path::PathBuf,
|
||||
selected: Vec<String>,
|
||||
resources: std::path::PathBuf,
|
||||
) {
|
||||
self.open_at(false, Page::Packs);
|
||||
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
|
||||
self.data_pack_resources = resources;
|
||||
self.pack_selection = selected;
|
||||
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
|
||||
self.data_pack_dir = Some(dir);
|
||||
}
|
||||
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
|
||||
/// folder's packs are listed again if it changed, the selection kept
|
||||
/// (the search text and scroll start over).
|
||||
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
|
||||
let Some(dir) = &self.data_pack_dir else {
|
||||
return;
|
||||
};
|
||||
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
|
||||
return;
|
||||
}
|
||||
let stamp = crate::datapack::folder_stamp(dir);
|
||||
let changed = stamp != self.data_pack_stamp.0;
|
||||
self.data_pack_stamp = (stamp, Instant::now());
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
|
||||
if let Some(p) = self.pack_screen.take() {
|
||||
self.pack_selection = p.commit();
|
||||
self.packs(atlas, size).changed = p.changed;
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
@@ -1242,7 +1386,10 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
if matches!(
|
||||
page,
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
@@ -1251,6 +1398,7 @@ impl OptionsUi {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1269,16 +1417,16 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1416,21 +1564,23 @@ impl OptionsUi {
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens a page built from vanilla's widgets.
|
||||
let open = |label: &str, page: Page| {
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
|
||||
};
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
open("horizon.pages.open.graphics", Page::Graphics),
|
||||
Some(open(
|
||||
"horizon.pages.open.graphics_advanced",
|
||||
Page::GraphicsAdvanced,
|
||||
)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
|
||||
Some(open("horizon.pages.open.controller", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -1438,7 +1588,7 @@ impl OptionsUi {
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -1457,7 +1607,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
tr("horizon.agent.copy_details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
@@ -1471,14 +1621,14 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
tr("horizon.pages.open.horizon_graphics"),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
tr("horizon.pages.open.graphics_advanced"),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
@@ -1527,6 +1677,7 @@ impl OptionsUi {
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows.push(Row::Big(Opt::SoundDevice));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
@@ -1562,18 +1713,15 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Settings..."),
|
||||
tr("horizon.pages.open.controller"),
|
||||
Act::Push(Page::Controller),
|
||||
),
|
||||
None,
|
||||
@@ -1586,7 +1734,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Binds..."),
|
||||
tr("horizon.pages.open.controller_binds"),
|
||||
Act::Push(Page::PadBinds),
|
||||
),
|
||||
None,
|
||||
@@ -1644,9 +1792,7 @@ impl OptionsUi {
|
||||
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
|
||||
Page::Mods => {
|
||||
if s.mods.installed.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No mods: put .hmod files in the horizon-mods folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.mods.empty")];
|
||||
}
|
||||
s.mods
|
||||
.installed
|
||||
@@ -1656,9 +1802,9 @@ impl OptionsUi {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
label = label.append(" (Stopped)");
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
@@ -1669,7 +1815,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
@@ -1686,17 +1832,17 @@ impl OptionsUi {
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
crate::shader_pack_list::Kind::Horizon => {
|
||||
lang::get("horizon.shader_packs.kind_horizon")
|
||||
}
|
||||
crate::shader_pack_list::Kind::Classic => String::new(),
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
@@ -1756,8 +1902,8 @@ impl OptionsUi {
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
@@ -2190,18 +2336,12 @@ impl OptionsUi {
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
out.push(done(h - 27.0));
|
||||
@@ -2273,6 +2413,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -2313,7 +2454,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
|
||||
tooltip = Some(atlas.font.split(&tr(text), 170.0));
|
||||
}
|
||||
let key = match &wd.kind {
|
||||
WKind::Button {
|
||||
@@ -2340,10 +2481,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& !opt.available()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ToggleMod(i),
|
||||
@@ -2484,9 +2622,7 @@ impl OptionsUi {
|
||||
if search.value.is_empty() && !search.focused {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal("Search settings...")
|
||||
.with_color(text::GRAY)
|
||||
.italic(),
|
||||
&tr("horizon.options.search").with_color(text::GRAY).italic(),
|
||||
search.text_x(),
|
||||
search.text_y(),
|
||||
u32::MAX,
|
||||
@@ -2533,6 +2669,7 @@ impl OptionsUi {
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2568,7 +2705,10 @@ impl OptionsUi {
|
||||
self.pack_focus = Instant::now();
|
||||
}
|
||||
match action {
|
||||
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
|
||||
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
|
||||
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
|
||||
None => Event::OpenPackFolder,
|
||||
}),
|
||||
crate::pack_screen::Action::Done => events.extend(self.back(s)),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
@@ -2820,8 +2960,14 @@ impl OptionsUi {
|
||||
}
|
||||
None => {
|
||||
self.pack_focus = Instant::now();
|
||||
let title = if self.data_pack_dir.is_some() {
|
||||
"dataPack.title"
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
h,
|
||||
self.in_world,
|
||||
@@ -2853,13 +2999,18 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
let data = self.data_pack_dir.take();
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
if data.is_some() {
|
||||
events.push(Event::DataPacks(ids));
|
||||
} else {
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3155,10 +3306,48 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
|
||||
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
|
||||
set_slider(Opt::RenderDistance, &mut s, position);
|
||||
assert_eq!(s.render_distance, distance);
|
||||
let reloaded = Settings::parse(&s.serialize());
|
||||
@@ -3169,10 +3358,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let mut file = crate::options_file::OptionsFile::default();
|
||||
file.set_text("renderDistance", "64").unwrap();
|
||||
assert!(file.set_text("renderDistance", "65").is_err());
|
||||
file.set_text("renderDistance", "128").unwrap();
|
||||
assert!(file.set_text("renderDistance", "129").is_err());
|
||||
assert!(file.set_text("renderDistance", "1").is_err());
|
||||
assert_eq!(file.int("renderDistance"), 64);
|
||||
assert_eq!(file.int("renderDistance"), 128);
|
||||
}
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
@@ -3246,8 +3435,15 @@ mod tests {
|
||||
ui.show(Page::Mods);
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
assert!(empty.iter().any(|w| {
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
|
||||
}));
|
||||
// The empty page points at the bundled key; the English line behind it
|
||||
// is what a player reads, so both halves are checked here.
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
assert!(
|
||||
en.get("horizon.mods.empty")
|
||||
.is_some_and(|line| line.contains(".hmod"))
|
||||
);
|
||||
let open_folder = empty
|
||||
.iter()
|
||||
.find(|w| {
|
||||
@@ -4422,4 +4618,29 @@ mod tests {
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
assert!(pushes(&mut ui).contains(&Page::Controller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_device_button_in_sound_page_and_cycles() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let rows = ui.list_rows(&s);
|
||||
assert!(
|
||||
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
|
||||
"Sound page should contain Row::Big(Opt::SoundDevice)"
|
||||
);
|
||||
assert_eq!(s.sound_device, "");
|
||||
let devices = crate::audio::output_devices();
|
||||
let count = 1 + devices.len();
|
||||
// Cycling forward 'count' times wraps around back to the initial selection ("")
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
// Cycling backward 'count' times also wraps around back to ""
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
@@ -451,6 +466,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -502,6 +533,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -538,6 +570,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -568,6 +603,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -591,6 +627,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -781,7 +818,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use super::{Pack, Source, build_overlay_uncached};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(test, not(unix)))]
|
||||
use std::io::Read;
|
||||
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
|
||||
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
#[cfg(any(test, not(unix)))]
|
||||
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash.update(file.metadata()?.len().to_le_bytes());
|
||||
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
|
||||
stamp(&mut hash, path)?;
|
||||
// By its bytes, not its stamp: a server pack found in the download cache is
|
||||
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
|
||||
// time rebuilt the whole overlay each time. One file reads in a moment.
|
||||
content(&mut hash, path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +350,27 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
/// Waits for the filesystem's clock to move on, as it does between a build and a
|
||||
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
|
||||
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
|
||||
/// tick of the build before kept them all, and the tests failed at random.
|
||||
fn next_tick(&self) {
|
||||
let probe = self.0.with_extension("tick");
|
||||
// New bytes each time: NTFS keeps the time of a write that changes nothing.
|
||||
let stamp = |n: u32| {
|
||||
fs::write(&probe, n.to_le_bytes()).unwrap();
|
||||
fs::metadata(&probe).unwrap().modified().unwrap()
|
||||
};
|
||||
let start = stamp(0);
|
||||
// A second at most: no clock is that coarse, and a test never hangs on one.
|
||||
for n in 1..1000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if stamp(n) != start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
}
|
||||
fn dir_pack(&self, name: &str) -> Pack {
|
||||
self.write(
|
||||
&format!("{name}/pack.mcmeta"),
|
||||
@@ -371,6 +393,7 @@ mod tests {
|
||||
output_signature(&cached).unwrap(),
|
||||
output_signature(&self.0.join("reference")).unwrap()
|
||||
);
|
||||
self.next_tick();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
@@ -647,6 +670,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
|
||||
/// pack found in the download cache is marked used on every join
|
||||
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
|
||||
#[test]
|
||||
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
|
||||
let f = Fixture::new();
|
||||
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
|
||||
let files = vec![
|
||||
(
|
||||
"pack.mcmeta",
|
||||
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
|
||||
),
|
||||
("assets/minecraft/textures/a", b"aaa".to_vec()),
|
||||
];
|
||||
f.write("pack.zip", &archive(&files, false));
|
||||
let zip = f.0.join("pack.zip");
|
||||
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
|
||||
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
// A rebuild empties the output first: this file survives a cache hit only.
|
||||
fs::write(out.join("kept"), b"").unwrap();
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&zip)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
assert!(out.join("kept").exists(), "the overlay was rebuilt");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct Settings {
|
||||
pub master_volume: f32,
|
||||
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
|
||||
pub volumes: [f32; 11],
|
||||
/// Empty for system default, or a specific audio device name.
|
||||
pub sound_device: String,
|
||||
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
|
||||
pub gui_scale: u32,
|
||||
pub view_bobbing: bool,
|
||||
@@ -174,6 +176,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -195,6 +203,7 @@ impl Default for Settings {
|
||||
sensitivity: 0.5,
|
||||
master_volume: 1.0,
|
||||
volumes: [1.0; 11],
|
||||
sound_device: String::new(),
|
||||
gui_scale: 0,
|
||||
view_bobbing: true,
|
||||
gamma: 0.5,
|
||||
@@ -256,6 +265,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -383,6 +395,7 @@ impl Settings {
|
||||
sensitivity: f.double("mouseSensitivity") as f32,
|
||||
master_volume: volumes[0],
|
||||
volumes,
|
||||
sound_device: f.string("soundDevice"),
|
||||
gui_scale: f.int("guiScale").max(0) as u32,
|
||||
view_bobbing: f.bool("bobView"),
|
||||
gamma: f.double("gamma") as f32,
|
||||
@@ -418,6 +431,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -475,6 +491,7 @@ impl Settings {
|
||||
};
|
||||
put_f(&mut f, &format!("soundCategory_{source}"), v);
|
||||
}
|
||||
put_s(&mut f, "soundDevice", &self.sound_device);
|
||||
put_i(&mut f, "guiScale", self.gui_scale as i32);
|
||||
put_b(&mut f, "bobView", self.view_bobbing);
|
||||
put_f(&mut f, "gamma", self.gamma);
|
||||
@@ -518,6 +535,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -637,6 +661,7 @@ pub enum Opt {
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
SoundDevice,
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
Gamma,
|
||||
@@ -664,7 +689,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
@@ -820,4 +851,15 @@ mod tests {
|
||||
1.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sound_device_option() {
|
||||
let s = Settings::parse("soundDevice:\"Headphones\"\n");
|
||||
assert_eq!(s.sound_device, "Headphones");
|
||||
let serialized = s.serialize();
|
||||
assert!(serialized.contains("soundDevice:\"Headphones\""));
|
||||
let again = Settings::parse(&serialized);
|
||||
assert_eq!(again.sound_device, "Headphones");
|
||||
let empty = Settings::parse("");
|
||||
assert_eq!(empty.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ pub enum Ticking {
|
||||
Bee { bee: u32, aggressive: bool },
|
||||
/// `GuardianAttackSoundInstance`.
|
||||
GuardianAttack { guardian: u32 },
|
||||
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
|
||||
Elytra { time: i32 },
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -95,6 +97,12 @@ pub struct TickContext {
|
||||
pub mobs: Vec<MobSound>,
|
||||
/// `!tickRateManager().runsNormally()`.
|
||||
pub frozen: bool,
|
||||
/// The player's position.
|
||||
pub player_position: [f64; 3],
|
||||
/// `getDeltaMovement().lengthSqr()` of the player.
|
||||
pub player_speed_sqr: f64,
|
||||
/// `isFallFlying()` of the player.
|
||||
pub player_fall_flying: bool,
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -390,6 +398,24 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
|
||||
/// its first tick.
|
||||
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
"minecraft:item.elytra.flying",
|
||||
PLAYERS,
|
||||
0.1,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
position,
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Elytra { time: 0 };
|
||||
s
|
||||
}
|
||||
pub fn tickable(&self) -> bool {
|
||||
self.ticking != Ticking::No
|
||||
}
|
||||
@@ -494,6 +520,37 @@ impl SoundInstance {
|
||||
self.volume = 0.0;
|
||||
}
|
||||
}
|
||||
Ticking::Elytra { time } => {
|
||||
*time += 1;
|
||||
let time = *time;
|
||||
if ctx.player_removed || (time > 20 && !ctx.player_fall_flying) {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = ctx.player_position;
|
||||
self.position = [
|
||||
f64::from(x as f32),
|
||||
f64::from(y as f32),
|
||||
f64::from(z as f32),
|
||||
];
|
||||
let speed = ctx.player_speed_sqr as f32;
|
||||
self.volume = if speed >= 1.0e-7 {
|
||||
(speed / 4.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Silent for a second, then a second's fade in.
|
||||
if time < 20 {
|
||||
self.volume = 0.0;
|
||||
} else if time < 40 {
|
||||
self.volume *= (time - 20) as f32 / 20.0;
|
||||
}
|
||||
self.pitch = if self.volume > 0.8 {
|
||||
1.0 + (self.volume - 0.8)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
pub const MAX_PLAYERS: usize = 16;
|
||||
|
||||
/// A player's view: its window (an index into the windows), its corner there and its size.
|
||||
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
|
||||
|
||||
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
|
||||
/// view stays near a screen's shape, where a full grid would leave a long thin one.
|
||||
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[3, 2],
|
||||
&[3, 3],
|
||||
&[3, 2, 2],
|
||||
&[3, 3, 2],
|
||||
&[3, 3, 3],
|
||||
&[4, 3, 3],
|
||||
&[4, 4, 3],
|
||||
&[4, 4, 4],
|
||||
&[4, 3, 3, 3],
|
||||
&[4, 4, 3, 3],
|
||||
&[4, 4, 4, 3],
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(n);
|
||||
for &i in order {
|
||||
if i < n && !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.extend((0..n).filter(|i| !order.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
|
||||
/// The views are still by player.
|
||||
pub fn place_ordered(
|
||||
windows: &[usize],
|
||||
order: &[usize],
|
||||
sizes: &[PhysicalSize<u32>],
|
||||
two: u8,
|
||||
three: u8,
|
||||
) -> Vec<View> {
|
||||
let order = ordered(order, windows.len());
|
||||
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
|
||||
let placed = place(&in_order, sizes, two, three);
|
||||
let mut views = placed.clone();
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
views[i] = placed[k];
|
||||
}
|
||||
views
|
||||
}
|
||||
|
||||
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
|
||||
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
|
||||
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
|
||||
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
|
||||
/// to sixteen in balanced rows (`ROWS`).
|
||||
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
|
||||
windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &window)| {
|
||||
let here = windows.iter().filter(|&&w| w == window).count();
|
||||
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
|
||||
let size = sizes
|
||||
.get(window)
|
||||
.copied()
|
||||
.unwrap_or(PhysicalSize::new(1, 1));
|
||||
let (w, h) = (size.width, size.height);
|
||||
let side_by_side = match two {
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => w >= h,
|
||||
};
|
||||
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
|
||||
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
|
||||
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
|
||||
};
|
||||
let [x0, x1, y0, y1] = match (here, slot) {
|
||||
(1, _) => cell(0, 1, 0, 1, 1),
|
||||
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
|
||||
(2, s) => cell(0, 2, s, s + 1, 2),
|
||||
(3, s) => match (three, s) {
|
||||
(2, s) => cell(s, s + 1, 0, 3, 3),
|
||||
(1, 0) => cell(0, 1, 0, 2, 2),
|
||||
(1, s) => cell(1, 2, s - 1, s, 2),
|
||||
(_, 0) => cell(0, 2, 0, 1, 2),
|
||||
(_, s) => cell(s - 1, s, 1, 2, 2),
|
||||
},
|
||||
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
|
||||
(n, s) => rows(n, s, w, h),
|
||||
};
|
||||
(
|
||||
window,
|
||||
[x0, y0],
|
||||
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
|
||||
/// in columns when the window is taller than wide.
|
||||
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
|
||||
let bands = counts.len() as u32;
|
||||
let (mut band, mut k) = (0, slot);
|
||||
while band + 1 < bands && k >= counts[band as usize] {
|
||||
k -= counts[band as usize];
|
||||
band += 1;
|
||||
}
|
||||
let across = counts[band as usize];
|
||||
// Along: the long side, divided between a band's views; down: the bands.
|
||||
let (along, down) = if w >= h { (w, h) } else { (h, w) };
|
||||
let (a0, a1) = (along * k / across, along * (k + 1) / across);
|
||||
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
|
||||
if w >= h {
|
||||
[a0, a1, d0, d1]
|
||||
} else {
|
||||
[d0, d1, a0, a1]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_on_one_screen_share_it() {
|
||||
let wide = [size(1920, 1080)];
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 0, 0)[1],
|
||||
(0, [960, 0], size(960, 1080))
|
||||
);
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 2, 0)[1],
|
||||
(0, [0, 540], size(1920, 540))
|
||||
);
|
||||
// Auto on a tall screen: top and bottom.
|
||||
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
|
||||
assert_eq!(
|
||||
place(&[0; 4], &wide, 0, 0)[3],
|
||||
(0, [960, 540], size(960, 540))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desk_and_couch() {
|
||||
// P1 alone at the desk; P2 and P3 share the TV.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_on_a_screen() {
|
||||
let tv = [size(1920, 1080)];
|
||||
// One wide on top, two below.
|
||||
let top = place(&[0; 3], &tv, 0, 0);
|
||||
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
|
||||
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
|
||||
// One tall on the left, two stacked on the right.
|
||||
let left = place(&[0; 3], &tv, 0, 1);
|
||||
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
|
||||
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
|
||||
// Three columns.
|
||||
assert_eq!(
|
||||
place(&[0; 3], &tv, 0, 2)[2],
|
||||
(0, [1280, 0], size(640, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_two_players_each() {
|
||||
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
|
||||
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_to_eight_in_rows_of_three_and_two() {
|
||||
let tv = [size(1920, 1080)];
|
||||
let eight = place(&[0; 8], &tv, 0, 0);
|
||||
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
|
||||
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
|
||||
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
|
||||
let seven = place(&[0; 7], &tv, 0, 0);
|
||||
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
|
||||
// Every count tiles the window exactly: no gap, no overlap.
|
||||
for n in 5..=MAX_PLAYERS {
|
||||
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
|
||||
let views = place(&vec![0; n], &[screen], 0, 0);
|
||||
for (i, (_, [x, y], size)) in views.iter().enumerate() {
|
||||
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
|
||||
for (_, [ox, oy], other) in &views[..i] {
|
||||
assert!(
|
||||
x + size.width <= *ox
|
||||
|| ox + other.width <= *x
|
||||
|| y + size.height <= *oy
|
||||
|| oy + other.height <= *y,
|
||||
"overlapping views for {n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let area: u64 = views
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(
|
||||
area,
|
||||
u64::from(screen.width) * u64::from(screen.height),
|
||||
"{n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A tall window: columns.
|
||||
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
|
||||
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
|
||||
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_players_form_a_four_by_four_grid() {
|
||||
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*view,
|
||||
(
|
||||
0,
|
||||
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
|
||||
size(960, 540)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_names_each_player_once_and_the_others_come_last() {
|
||||
assert_eq!(ordered(&[], 3), [0, 1, 2]);
|
||||
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
|
||||
// Gone players and repeats are dropped.
|
||||
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_sharing_a_window_fill_it_in_their_order() {
|
||||
let wide = [size(2000, 1000)];
|
||||
// P2 first: P2 on the left, P1 on the right.
|
||||
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
|
||||
assert_eq!(views[1].1, [0, 0]);
|
||||
assert_eq!(views[0].1, [1000, 0]);
|
||||
// P3 first of three: the wide view on top is P3's.
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet},
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, RwLock,
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
@@ -99,6 +98,9 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
||||
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
||||
crate::lod::Column::from_surface(self.sample(x, z))
|
||||
@@ -168,6 +170,9 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
}
|
||||
pub enum Done {
|
||||
Generated(Chunk),
|
||||
@@ -214,11 +219,52 @@ struct Shared {
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
|
||||
/// flight's 1% lows). The frame takes what is there without waiting; while a
|
||||
/// worker holds the lock, it takes it the next frame.
|
||||
#[derive(Default)]
|
||||
struct Mailbox {
|
||||
done: Mutex<VecDeque<Done>>,
|
||||
arrived: Condvar,
|
||||
}
|
||||
impl Mailbox {
|
||||
fn send(&self, done: Done) {
|
||||
self.done.lock().unwrap().push_back(done);
|
||||
self.arrived.notify_one();
|
||||
}
|
||||
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
|
||||
match self.done.try_lock() {
|
||||
Ok(done) => Some(done),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
/// Every finished job, or none while a worker is sending.
|
||||
fn take(&self) -> VecDeque<Done> {
|
||||
self.try_lock()
|
||||
.map(|mut done| std::mem::take(&mut *done))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn try_recv(&self) -> Option<Done> {
|
||||
self.try_lock()?.pop_front()
|
||||
}
|
||||
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
|
||||
let done = self.done.lock().unwrap();
|
||||
let (mut done, _) = self
|
||||
.arrived
|
||||
.wait_timeout_while(done, timeout, |done| done.is_empty())
|
||||
.unwrap();
|
||||
done.pop_front()
|
||||
}
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -398,8 +444,14 @@ impl GenerationScan {
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood.
|
||||
let reach = margin - 1;
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
// is decorated (or finished, without decoration) and the one its
|
||||
// 3x3 needs.
|
||||
let reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
@@ -455,14 +507,13 @@ impl Streaming {
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
});
|
||||
let (send, results) = mpsc::channel();
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let send = send.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
@@ -510,6 +561,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
@@ -541,9 +596,7 @@ impl Streaming {
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
if send.send(done).is_err() {
|
||||
return;
|
||||
}
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
@@ -556,7 +609,6 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -660,6 +712,17 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Give back meshes the renderer has copied: a worker frees them, ahead of
|
||||
/// other jobs, as vanilla's compile task closes its mesh data on its own
|
||||
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
|
||||
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
|
||||
/// on the allocator behind every busy worker: up to 20 ms of a frame each
|
||||
/// time while flying at render distance 32.
|
||||
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
|
||||
if !meshes.is_empty() {
|
||||
self.submit(None, 0, Job::Free(meshes));
|
||||
}
|
||||
}
|
||||
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
|
||||
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
|
||||
self.lit.remove(&(x, z));
|
||||
@@ -676,7 +739,7 @@ impl Streaming {
|
||||
}
|
||||
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
||||
/// times four plus `bias`, which orders a column's jobs. A job without a
|
||||
/// column (a save) goes ahead of every other.
|
||||
/// column (a save, meshes to free) goes ahead of every other.
|
||||
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
|
||||
self.order += 1;
|
||||
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
|
||||
@@ -909,8 +972,10 @@ impl Streaming {
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = std::iter::from_fn(|| self.results.try_recv().ok());
|
||||
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
self.generation_scan.exhausted = false;
|
||||
@@ -966,12 +1031,14 @@ impl Streaming {
|
||||
}
|
||||
Done::Meshed(x, sy, z, mesh, seq) => {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
@@ -980,6 +1047,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
// Unload far columns.
|
||||
@@ -1021,10 +1089,10 @@ impl Streaming {
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
||||
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
||||
})
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
@@ -1418,12 +1486,12 @@ impl Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Ok(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Ok(other) => self.deferred.push(other),
|
||||
Err(_) => {
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
break;
|
||||
}
|
||||
@@ -1445,7 +1513,7 @@ impl Streaming {
|
||||
/// in `save(true)`, so older generation cannot overwrite the final chunks.
|
||||
pub fn poll_save(&mut self, world: &mut World) -> bool {
|
||||
let started = std::time::Instant::now();
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
while let Some(done) = self.shared.done.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
@@ -1530,7 +1598,7 @@ impl Streaming {
|
||||
/// Wait up to `timeout` for a worker to finish a job; the next `update`
|
||||
/// takes it. A server pumps generation with this between its ticks.
|
||||
pub fn wait(&mut self, timeout: std::time::Duration) {
|
||||
if let Ok(done) = self.results.recv_timeout(timeout) {
|
||||
if let Some(done) = self.shared.done.recv_timeout(timeout) {
|
||||
self.deferred.push(done);
|
||||
}
|
||||
}
|
||||
@@ -1544,6 +1612,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
|
||||
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
|
||||
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
|
||||
}
|
||||
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
|
||||
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
|
||||
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
|
||||
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
|
||||
/// out, features (34) at three, terrain (35) at four.
|
||||
const FORCED_FULL_RING: i32 = 2;
|
||||
const FORCED_FEATURES_RING: i32 = 3;
|
||||
const FORCED_TERRAIN_RING: i32 = 4;
|
||||
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
|
||||
struct Ranges<'a> {
|
||||
center: (i32, i32),
|
||||
@@ -1799,7 +1875,16 @@ impl Ranges<'_> {
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
@@ -1811,10 +1896,10 @@ impl Drop for Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Saved(_)) => self.saving -= 1,
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Saved(_)) => self.saving -= 1,
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
@@ -1852,6 +1937,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frame never waits for a worker: while one holds the mailbox, the
|
||||
/// frame takes nothing and finds the results, in order, the next time.
|
||||
#[test]
|
||||
fn the_frame_takes_finished_jobs_without_waiting() {
|
||||
let mailbox = Mailbox::default();
|
||||
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
|
||||
mailbox.send(lit(1));
|
||||
mailbox.send(lit(2));
|
||||
let sending = mailbox.done.lock().unwrap();
|
||||
assert!(mailbox.take().is_empty());
|
||||
assert!(mailbox.try_recv().is_none());
|
||||
drop(sending);
|
||||
let seqs: Vec<u64> = mailbox
|
||||
.take()
|
||||
.into_iter()
|
||||
.map(|done| match done {
|
||||
Done::Lit(_, _, _, seq) => seq,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seqs, [1, 2]);
|
||||
assert!(mailbox.take().is_empty());
|
||||
let waiting = std::time::Instant::now();
|
||||
assert!(
|
||||
mailbox
|
||||
.recv_timeout(std::time::Duration::from_millis(10))
|
||||
.is_none()
|
||||
);
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
let forced = HashSet::new();
|
||||
@@ -2037,9 +2153,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let forced_reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -(margin - 1)..=(margin - 1) {
|
||||
for dx in -(margin - 1)..=(margin - 1) {
|
||||
for dz in -forced_reach..=forced_reach {
|
||||
for dx in -forced_reach..=forced_reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
if missing(x, z) {
|
||||
wanted.push((input.ranges.distance(x, z), x, z));
|
||||
@@ -2441,6 +2562,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
|
||||
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
|
||||
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
|
||||
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
|
||||
/// rings around it (33), gives the third its features (34) and the
|
||||
/// fourth its terrain (35), and the rings past that are left alone.
|
||||
#[test]
|
||||
fn forced_chunks_reach_vanillas_rings() {
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
let r = Ranges {
|
||||
// The player's own area, far away.
|
||||
center: (-400, 300),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
for x in 40 - 6..=40 + 6 {
|
||||
for z in -40 - 6..=-40 + 6 {
|
||||
// The full ring: the column becomes a level chunk.
|
||||
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
|
||||
// The features ring, and the terrain ring.
|
||||
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
|
||||
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
// Generation reaches the terrain ring: the features ring is
|
||||
// decorated from it.
|
||||
let mut fixture = GenerationFixture::new(2, true);
|
||||
fixture.center = (-400, 300);
|
||||
fixture.forced = forced;
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(fixture.input(), usize::MAX)
|
||||
.expect("a scan");
|
||||
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
|
||||
"the terrain ring at ({}, {}) is not generated",
|
||||
40 + dx,
|
||||
-40 + dz
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
@@ -65,14 +63,14 @@ pub struct World {
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: HashSet<(i32, i32)>,
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
/// interaction range.
|
||||
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
|
||||
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
|
||||
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
|
||||
pub block_names: HashMap<(i32, i32, i32), String>,
|
||||
pub block_names: FxHashMap<(i32, i32, i32), String>,
|
||||
/// States (by id) whose writes are recorded in `watched_writes` (sculk
|
||||
/// blocks, which need a block entity).
|
||||
pub watched: Vec<bool>,
|
||||
@@ -80,7 +78,7 @@ pub struct World {
|
||||
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
|
||||
/// within spawning range (local difficulty).
|
||||
pub inhabited: HashMap<(i32, i32), i64>,
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
@@ -177,13 +175,13 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
modified: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: HashMap::new(),
|
||||
block_names: HashMap::new(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
watched: Vec::new(),
|
||||
watched_writes: Vec::new(),
|
||||
inhabited: HashMap::new(),
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
@@ -274,20 +272,40 @@ impl World {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
let (bx, bz) = (x & 15, z & 15);
|
||||
mark(&mut self.dirty_sections, x, z);
|
||||
if bx == 0 {
|
||||
mark(&mut self.dirty_sections, x - 1, z);
|
||||
} else if bx == 15 {
|
||||
mark(&mut self.dirty_sections, x + 1, z);
|
||||
}
|
||||
if bz == 0 {
|
||||
mark(&mut self.dirty_sections, x, z - 1);
|
||||
} else if bz == 15 {
|
||||
mark(&mut self.dirty_sections, x, z + 1);
|
||||
}
|
||||
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
|
||||
mark(
|
||||
&mut self.dirty_sections,
|
||||
if bx == 0 { x - 1 } else { x + 1 },
|
||||
if bz == 0 { z - 1 } else { z + 1 },
|
||||
);
|
||||
}
|
||||
let _ = sy;
|
||||
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
||||
self.light_edits.push(((x, y, z), previous, state.0));
|
||||
} else {
|
||||
@@ -407,11 +425,59 @@ impl Default for HealthRules {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::World;
|
||||
use crate::chunk::Chunk;
|
||||
use super::{DirtySections, World};
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every block write marks the same section set the 27-cell loop did, but
|
||||
/// only the distinct keys: one in the interior, a neighbour section on a
|
||||
/// section face, a neighbour column on a chunk border and the diagonal at
|
||||
/// a corner (eight keys at a corner on a section face).
|
||||
#[test]
|
||||
fn writes_mark_exactly_the_reachable_sections() {
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
for (x, y, z) in [
|
||||
(3, 70, 3),
|
||||
(0, 70, 3),
|
||||
(15, 70, 3),
|
||||
(3, 70, 0),
|
||||
(0, 70, 0),
|
||||
(15, 70, 15),
|
||||
(0, MIN_Y, 0),
|
||||
(0, 80, 0),
|
||||
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
|
||||
] {
|
||||
world.dirty_sections.clear();
|
||||
world.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
let mut expected = DirtySections::default();
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
expected.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
}
|
||||
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
|
||||
// Four columns, each with the section below and this one.
|
||||
assert_eq!(world.dirty_sections.len(), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A box misses a state only where no section it reaches lists it, and
|
||||
/// unloaded columns or heights past the world count as their air.
|
||||
#[test]
|
||||
|
||||
@@ -14,11 +14,13 @@ use zip::{CompressionMethod, ZipArchive};
|
||||
/// bomb stops here.
|
||||
const MAX_ENTRY: usize = 1 << 30;
|
||||
|
||||
/// One archive with its entries indexed by name (`\` read as `/`, folders
|
||||
/// left out).
|
||||
/// One archive with its entries indexed by name (`\` read as `/`), files
|
||||
/// and folders apart.
|
||||
pub struct Zip {
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
/// Folder entries (`name/`) by their name without the slash.
|
||||
folders: BTreeMap<String, usize>,
|
||||
/// The archive's length: no size an entry claims can be bigger.
|
||||
len: usize,
|
||||
}
|
||||
@@ -33,26 +35,44 @@ impl Zip {
|
||||
normalize_end(&mut data);
|
||||
let len = data.len();
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
.filter(|(name, _)| !name.ends_with('/'))
|
||||
.collect();
|
||||
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
|
||||
for i in 0..archive.len() {
|
||||
let Some(name) = archive.name_for_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.replace('\\', "/");
|
||||
match name.strip_suffix('/') {
|
||||
Some(folder) => folders.insert(folder.to_owned(), i),
|
||||
None => entries.insert(name, i),
|
||||
};
|
||||
}
|
||||
Ok(Self {
|
||||
archive,
|
||||
entries,
|
||||
folders,
|
||||
len,
|
||||
})
|
||||
}
|
||||
/// The files, as `FilePackResources.listResources` lists them (folder
|
||||
/// entries skipped).
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
/// `FilePackResources.getResource` through `ZipFile.getEntry`: the file
|
||||
/// `name`, else the folder entry `name/`, whose bytes Java reads like a
|
||||
/// file's (Loka's pack stores its `pack.mcmeta` and `pack.png` that way).
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.entries
|
||||
.get(name)
|
||||
.or_else(|| self.folders.get(name))
|
||||
.copied()
|
||||
}
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entries.contains_key(name)
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let &index = self
|
||||
.entries
|
||||
.get(name)
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
@@ -133,13 +153,16 @@ mod tests {
|
||||
use super::*;
|
||||
/// A hand-built archive with one stored and one deflated entry.
|
||||
fn archive() -> Vec<u8> {
|
||||
let files: [(&str, &[u8], bool); 2] = [
|
||||
archive_of(&[
|
||||
("pack.mcmeta", b"{\"pack\":{}}", false),
|
||||
("a/b.txt", b"hello hello hello hello", true),
|
||||
];
|
||||
])
|
||||
}
|
||||
/// A hand-built archive of `(name, body, deflated)` entries.
|
||||
fn archive_of(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, body, deflate) in files {
|
||||
for &(name, body, deflate) in files {
|
||||
let data = if deflate {
|
||||
miniz_oxide::deflate::compress_to_vec(body, 6)
|
||||
} else {
|
||||
@@ -174,8 +197,8 @@ mod tests {
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0; 4]);
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0; 2]);
|
||||
@@ -191,6 +214,29 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
#[test]
|
||||
fn a_name_falls_back_to_its_folder_entry() {
|
||||
let zip = Zip::from_bytes(archive_of(&[
|
||||
("pack.mcmeta/", b"{\"pack\":{\"pack_format\":75}}", true),
|
||||
("pack.png/", b"png", false),
|
||||
("a/", b"folder", false),
|
||||
("a", b"file", false),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a"]);
|
||||
assert!(zip.contains("pack.mcmeta"));
|
||||
assert_eq!(
|
||||
zip.read("pack.mcmeta").unwrap(),
|
||||
b"{\"pack\":{\"pack_format\":75}}"
|
||||
);
|
||||
assert_eq!(zip.read("pack.png").unwrap(), b"png");
|
||||
assert_eq!(zip.read("a").unwrap(), b"file");
|
||||
assert!(!zip.contains("pack"));
|
||||
}
|
||||
|
||||
/// A pack as zip tools write it: folder entries, a nested pack root,
|
||||
/// stored and deflated files, and a Windows-style name.
|
||||
fn written() -> Vec<u8> {
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вібрація",
|
||||
"horizon.options.adaptive_triggers": "Адаптивні тригери",
|
||||
"horizon.options.lightbar": "Світлова панель",
|
||||
"horizon.options.look_sensitivity": "Чутливість огляду",
|
||||
"horizon.options.invert_x": "Інвертувати огляд по X",
|
||||
"horizon.options.invert_y": "Інвертувати огляд по Y",
|
||||
"horizon.options.sneak_button": "Кнопка присідання",
|
||||
"horizon.options.move_deadzone": "Мертва зона руху",
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
mob.extra.crouching = flag("isCrouching");
|
||||
mob.extra.passenger = flag("isPassenger");
|
||||
if flag("isHoldingBow") {
|
||||
mob.extra.main_hand = Some("minecraft:bow".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
}
|
||||
}
|
||||
Species::IronGolem => {
|
||||
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
}
|
||||
}
|
||||
Species::Witch if flag("isHoldingItem") => {
|
||||
mob.extra.main_hand = Some("minecraft:potion".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:potion".into()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| kind.model.clone());
|
||||
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
mob.extra.main_hand = None;
|
||||
mob.extra.set_main_hand(None);
|
||||
let case = &v["case"];
|
||||
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
if baby {
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
m.extra.set_main_hand(item);
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
|
||||
@@ -88,6 +88,7 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
@@ -179,6 +180,7 @@ probes! {
|
||||
sound_event_fixture,
|
||||
spawn_rules_fixture,
|
||||
sprint_probe,
|
||||
storage_menu_fixture,
|
||||
subtitle_fixture,
|
||||
tab_list_fixture,
|
||||
team_rules_fixture,
|
||||
|
||||
@@ -733,6 +733,15 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
|
||||
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
|
||||
}
|
||||
if m.species() == Species::Wolf {
|
||||
// NeutralMob anger left (the angry texture), -1 when calm.
|
||||
let anger = if m.extra.anger > 0 {
|
||||
i64::from(m.extra.anger)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let _ = write!(out, "\twolf {anger}");
|
||||
}
|
||||
if let Some(b) = horizon_client::mobs::bee::get(m) {
|
||||
let anger = if b.anger_end > 0 {
|
||||
b.anger_end - now
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.collect();
|
||||
let mut screen = PackScreen::new(
|
||||
&m,
|
||||
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
|
||||
int(&c["width"]),
|
||||
int(&c["height"]),
|
||||
c["inWorld"] == true,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
|
||||
//! block id per line and prints the menu type the server opens it with
|
||||
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
|
||||
//! sends it) and whether that menu's slots take a shulker box: the server
|
||||
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
|
||||
//! `lan::containers`).
|
||||
//!
|
||||
//! usage: storage_menu_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::block_menu_type;
|
||||
use std::fs;
|
||||
|
||||
fn case(block: &str) -> Option<String> {
|
||||
let menu = block_menu_type(block.trim())?;
|
||||
let accepts = if menu == "minecraft:shulker_box" {
|
||||
"refuses"
|
||||
} else {
|
||||
"accepts"
|
||||
};
|
||||
Some(format!("{menu} {accepts} shulker_box"))
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -877,6 +877,22 @@ pub fn item_entity(
|
||||
&& item(&v["item"], stack, w)
|
||||
}
|
||||
|
||||
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
|
||||
/// takes a different one of the stacks used, in order.
|
||||
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
|
||||
let Some(preds) = v.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let mut remaining: Vec<&ItemView> = used.iter().collect();
|
||||
preds.iter().all(|p| {
|
||||
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
|
||||
return false;
|
||||
};
|
||||
remaining.remove(i);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
|
||||
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
match v {
|
||||
|
||||
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"graphics-hdr",
|
||||
"extras",
|
||||
"horizon",
|
||||
"agent",
|
||||
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
|
||||
@@ -27,7 +27,7 @@ impl Ingredient {
|
||||
pub fn of(items: Vec<String>) -> Self {
|
||||
Self(items.iter().cloned().collect(), items)
|
||||
}
|
||||
fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
pub(crate) fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
let more: Vec<String> = match s.strip_prefix('#') {
|
||||
@@ -43,14 +43,6 @@ impl Ingredient {
|
||||
match v {
|
||||
Value::String(s) => add(s),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
|
||||
Value::Object(o) => {
|
||||
if let Some(s) = o.get("item").and_then(Value::as_str) {
|
||||
add(s);
|
||||
}
|
||||
if let Some(t) = o.get("tag").and_then(Value::as_str) {
|
||||
add(&format!("#{t}"));
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
(!list.is_empty()).then(|| Self::of(list))
|
||||
|
||||
@@ -584,6 +584,14 @@ impl Creative {
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
(x, y)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
|
||||
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
|
||||
/// the screen origin, is strictly inside its 26x32 button.
|
||||
pub fn tab_wants_hand(tab: &Tab, selected: bool, mouse: (f32, f32)) -> bool {
|
||||
let (x, y) = Self::tab_position(tab);
|
||||
let (mx, my) = (mouse.0.floor(), mouse.1.floor());
|
||||
!selected && mx > x && my > y && mx < x + 26.0 && my < y + 32.0
|
||||
}
|
||||
}
|
||||
/// Whether a suffix array of `strings` has a suffix starting with `text`:
|
||||
/// some string contains it (none for no strings, even when `text` is empty).
|
||||
@@ -1171,4 +1179,38 @@ mod tests {
|
||||
c.search.key(KeyCode::Backspace, false, false);
|
||||
assert_eq!(c.search.value, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unselected_tabs_want_the_hand_strictly_inside() {
|
||||
let tab = |top, column| Tab {
|
||||
id: String::new(),
|
||||
title: String::new(),
|
||||
icon: String::new(),
|
||||
items: Vec::new(),
|
||||
top,
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, true, (40.0, -10.0)));
|
||||
// Its edges ask for nothing; the mouse counts in whole pixels.
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (27.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (27.9, -10.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (28.0, -10.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (52.9, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (53.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (40.0, -28.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, -27.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, 3.9)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (40.0, 4.0)));
|
||||
// Bottom row, right-aligned inventory column: x 169..195, y 132..164.
|
||||
let b = tab(false, 6);
|
||||
assert!(Creative::tab_wants_hand(&b, false, (180.0, 140.0)));
|
||||
assert!(!Creative::tab_wants_hand(&b, false, (169.0, 140.0)));
|
||||
assert!(Creative::tab_wants_hand(&b, false, (170.0, 140.0)));
|
||||
assert!(!Creative::tab_wants_hand(&b, false, (180.0, 132.0)));
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,11 @@ pub enum Kind {
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
|
||||
/// and one button per dialog they resolve to (`set_list`).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
entries: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
@@ -325,29 +327,36 @@ impl Control {
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"text" => {
|
||||
let initial = string(t.get("initial")).unwrap_or_default();
|
||||
let max_length = int(t, "max_length", 32);
|
||||
// TextInput: the default text may not be longer than the length.
|
||||
if initial.encode_utf16().count() > max_length.max(0) as usize {
|
||||
return None;
|
||||
}
|
||||
Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial,
|
||||
max_length,
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
"single_option" => {
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
let options: Vec<_> = list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
.map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
@@ -355,10 +364,18 @@ impl Control {
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
.collect::<Option<_>>()?;
|
||||
// A non-empty list with at most one initial entry.
|
||||
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
|
||||
return None;
|
||||
}
|
||||
Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
options,
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
}
|
||||
}
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
@@ -395,6 +412,44 @@ impl Control {
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// The text on a cycle button or slider holding `value`
|
||||
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
|
||||
pub fn message(&self, value: &InputValue) -> Option<Text> {
|
||||
match (self, value) {
|
||||
(
|
||||
Self::SingleOption {
|
||||
options,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
},
|
||||
InputValue::Option(i),
|
||||
) => {
|
||||
let (id, display, _) = options.get(*i)?;
|
||||
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
|
||||
Some(if *label_visible {
|
||||
Text::translate("options.generic_value", &[label.clone(), shown])
|
||||
} else {
|
||||
shown
|
||||
})
|
||||
}
|
||||
(
|
||||
Self::NumberRange {
|
||||
label,
|
||||
label_format,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(_),
|
||||
) => {
|
||||
let shown = number_string(self.scaled(value)?);
|
||||
Some(Text::translate(
|
||||
label_format,
|
||||
&[label.clone(), Text::literal(shown)],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
@@ -536,6 +591,7 @@ impl Dialog {
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
entries: Vec::new(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
@@ -543,20 +599,21 @@ impl Dialog {
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
// A body or input it cannot read fails the whole dialog.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
|
||||
Some(one) => vec![Body::parse(one)?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
.map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Option<_>>()?;
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
@@ -588,7 +645,51 @@ impl Dialog {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
|
||||
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
|
||||
}
|
||||
}
|
||||
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
|
||||
/// (what the list names), labelled by its external title, opening it.
|
||||
pub fn set_list(&mut self, dialogs: &[Tag]) {
|
||||
let Kind::DialogList {
|
||||
entries,
|
||||
button_width,
|
||||
..
|
||||
} = &mut self.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*entries = dialogs
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let sub = Dialog::parse(tag)?;
|
||||
Some(Button {
|
||||
label: sub.external_title.unwrap_or(sub.title),
|
||||
tooltip: None,
|
||||
width: *button_width,
|
||||
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
|
||||
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
|
||||
pub fn list_entries(
|
||||
dialogs: &Tag,
|
||||
tag: &dyn Fn(&str) -> Vec<Tag>,
|
||||
registry: &dyn Fn(&str) -> Option<Tag>,
|
||||
) -> Vec<Tag> {
|
||||
let one = |t: &Tag| match t {
|
||||
Tag::String(id) => registry(&id.to_string_lossy()),
|
||||
inline => Some(inline.clone()),
|
||||
};
|
||||
match dialogs {
|
||||
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
|
||||
tag(&id.to_string_lossy()[1..])
|
||||
}
|
||||
Tag::List(items) => items.iter().filter_map(one).collect(),
|
||||
single => one(single).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
|
||||
@@ -31,6 +31,44 @@ pub enum Part {
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
/// A single option input's cycle button.
|
||||
Cycle { input: usize, rect: [f32; 4] },
|
||||
/// A number range input's slider.
|
||||
Slider { input: usize, rect: [f32; 4] },
|
||||
/// An item body's widget; `body` indexes `Dialog::body`.
|
||||
Item { body: usize, rect: [f32; 4] },
|
||||
}
|
||||
impl Part {
|
||||
/// The part moved by `(dx, dy)`.
|
||||
fn moved(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
|
||||
Self::Field { input, rect } => Self::Field {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Check { input, rect } => Self::Check {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Button { index, rect } => Self::Button {
|
||||
index,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Cycle { input, rect } => Self::Cycle {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Slider { input, rect } => Self::Slider {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Item { body, rect } => Self::Item {
|
||||
body,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -55,21 +93,7 @@ struct Element {
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
self.parts.into_iter().map(move |p| p.moved(x, y))
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
|
||||
/// to the right of it, both centred on each other vertically.
|
||||
fn item(
|
||||
font: &Font,
|
||||
body: usize,
|
||||
description: Option<&(Text, i32)>,
|
||||
(w, h): (f32, f32),
|
||||
) -> Element {
|
||||
let widget = |rect_h: f32, y: f32| Part::Item {
|
||||
body,
|
||||
rect: [0.0, y, w, rect_h],
|
||||
};
|
||||
let Some((contents, width)) = description else {
|
||||
return Element {
|
||||
w,
|
||||
h,
|
||||
parts: vec![widget(h, 0.0)],
|
||||
};
|
||||
};
|
||||
let text = message(font, contents, *width as f32);
|
||||
let height = h.max(text.h);
|
||||
let mut parts = vec![widget(h, centre(height, h))];
|
||||
parts.extend(
|
||||
text.parts
|
||||
.into_iter()
|
||||
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
|
||||
);
|
||||
Element {
|
||||
w: w + 2.0 + text.w,
|
||||
h: height,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
// CycleButton and AbstractSliderButton: one 20 high button whose
|
||||
// message holds the label.
|
||||
Control::SingleOption { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Cycle {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
Control::NumberRange { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Slider {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
@@ -210,9 +285,20 @@ impl Layout {
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
.enumerate()
|
||||
.map(|(i, b)| match b {
|
||||
Body::Message { contents, width } => message(font, contents, *width as f32),
|
||||
Body::Item {
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} => item(
|
||||
font,
|
||||
i,
|
||||
description.as_ref(),
|
||||
(*width as f32, *height as f32),
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
@@ -220,26 +306,24 @@ impl Layout {
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
.map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
exit, columns: n, ..
|
||||
}
|
||||
| Kind::DialogList {
|
||||
exit, columns: n, ..
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
let listed = buttons.len() - usize::from(exit.is_some());
|
||||
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
|
||||
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
Kind::ServerLinks { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
@@ -303,24 +387,10 @@ impl Layout {
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
let body = self
|
||||
.body
|
||||
.iter()
|
||||
.map(move |p| (p.clone().moved(0.0, dy), true));
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! End gateway travel for the entities that step into one:
|
||||
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
|
||||
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Traveller {
|
||||
/// The entity's portal process and cooldown (`Entity.portalProcess`).
|
||||
state: PortalState,
|
||||
}
|
||||
|
||||
impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
/// its portal cooldown has run out.
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.state.cooldown == 0 && self.state.process.is_none()
|
||||
}
|
||||
|
||||
/// The entity's portal cooldown, the ticks it stays out of a portal.
|
||||
pub fn cooldown(&self) -> i32 {
|
||||
self.state.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
/// The End gateway block an entity's box swept through
|
||||
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
|
||||
pub fn swept_gateway(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
mut is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
let mut found = None;
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
|
||||
if is_gateway(p) {
|
||||
found = Some(p);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gateway of the issue: a block at [702, 100, 0] in the End.
|
||||
const GATEWAY: [i32; 3] = [702, 100, 0];
|
||||
|
||||
fn at(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
fn is_gateway(p: BlockPos) -> bool {
|
||||
[p.x, p.y, p.z] == GATEWAY
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
|
||||
// A dropped item, 0.25 wide, standing in the gateway block.
|
||||
let item = at(702.5, 100.5, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(item, item, 0.25, 0.25, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
// A cow, 0.9 wide and 1.4 tall, in the same block.
|
||||
let cow = at(702.5, 100.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_out_of_the_gateway_is_not_in_it() {
|
||||
// Two blocks above the gateway: nothing to travel through.
|
||||
let above = at(702.5, 102.5, 0.5);
|
||||
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
|
||||
// Beside the gateway, outside its block.
|
||||
let beside = at(704.5, 100.5, 0.5);
|
||||
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_falling_through_the_gateway_goes_through_it() {
|
||||
// A cow falling past the gateway block passes through it
|
||||
// (Entity.checkInsideBlocks visits the blocks along the way).
|
||||
let from = at(702.5, 103.0, 0.5);
|
||||
let to = at(702.5, 99.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(from, to, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
// Nothing happens while the entity is outside the gateway.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
// It enters the gateway, and goes through on the next tick.
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// Its portal cooldown keeps it from going back at once.
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
// A mob that cannot use a portal does not go through.
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
// centre, the same spot the player arrives at.
|
||||
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if slot == "mainhand"
|
||||
&& let Some(stack) = m.extra.main_hand_stack()
|
||||
{
|
||||
let mut stack = stack.clone();
|
||||
if let Some(g) = crate::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[index].clone();
|
||||
stack.data_mut().trim = g.trims[index].clone();
|
||||
}
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
let keep = slot != "mainhand"
|
||||
&& matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
if slot == "head"
|
||||
&& &**id == crate::mobs::raider::BANNER
|
||||
&& crate::mobs::raider::is_raider(m.species())
|
||||
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
m.extra.main_hand = eq
|
||||
.get("mainhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
.map(Into::into);
|
||||
m.extra
|
||||
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
|
||||
m.extra.off_hand = eq
|
||||
.get("offhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
} else {
|
||||
// Registration hands out default weapons (finalizeSpawn does in
|
||||
// vanilla); a loaded mob holds only what its file lists.
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
}
|
||||
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
@@ -3133,3 +3143,51 @@ mod neutral_anger_tests {
|
||||
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some("Old Sword".into());
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
|
||||
assert_eq!(
|
||||
loaded.extra.main_hand_stack().unwrap().custom_name(),
|
||||
Some("Old Sword")
|
||||
);
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
|
||||
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
Some("minecraft:diamond_sword")
|
||||
);
|
||||
assert!(
|
||||
restored
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1373,6 +1373,55 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
|
||||
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
|
||||
pub(super) fn trigger_recipe_crafted(
|
||||
&mut self,
|
||||
recipe: &str,
|
||||
ingredients: &[Option<ItemStack>],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let used: Vec<ItemView> = ingredients
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref().map_or_else(
|
||||
|| ItemView {
|
||||
id: "minecraft:air".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
item_view,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let recipe = recipe.to_owned();
|
||||
self.trigger("minecraft:recipe_crafted", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
|
||||
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
|
||||
});
|
||||
}
|
||||
/// `LootTableTrigger`: a container's loot table unpacked for this player
|
||||
/// (`RandomizableContainer.unpackLootTable`).
|
||||
pub(super) fn trigger_loot_table(&mut self, table: &str) {
|
||||
let me = self.player_entity();
|
||||
let table = table.to_owned();
|
||||
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
|
||||
});
|
||||
}
|
||||
/// `BredAnimalsTrigger`.
|
||||
pub(super) fn trigger_bred(
|
||||
&mut self,
|
||||
@@ -1500,7 +1549,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
|
||||
@@ -566,7 +566,7 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
@@ -579,7 +579,7 @@ impl Game<'_> {
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
@@ -611,6 +611,9 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer", "disconnected": reason })
|
||||
}
|
||||
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,7 +1457,15 @@ impl Game<'_> {
|
||||
self.playing()?;
|
||||
let all = a.pos.first() == Some(&"all");
|
||||
let before = self.held().map(|s| self.stack_value(s));
|
||||
self.drop_held(all);
|
||||
if self.remote.is_some() {
|
||||
// As Q does on a server (and on the single-player server
|
||||
// thread): the server throws the stack, the client predicts it.
|
||||
if !self.spectator() {
|
||||
self.remote_drop(all);
|
||||
}
|
||||
} else {
|
||||
self.drop_held(all);
|
||||
}
|
||||
ok(
|
||||
json!({ "ok": true, "dropped_from": before, "held": self.held().map(|s| self.stack_value(s)) }),
|
||||
)
|
||||
|
||||
@@ -219,7 +219,7 @@ impl Game<'_> {
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.main_hand = Some(item.into());
|
||||
m.extra.set_main_hand(Some(item.into()));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
|
||||
@@ -153,5 +153,6 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
@@ -142,9 +142,11 @@ impl Game<'_> {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let table = table.to_string_lossy();
|
||||
self.trigger_loot_table(&table);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
|
||||
@@ -16,6 +16,15 @@ pub(super) struct BundleMouse {
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
/// Whether `screen` is an `AbstractContainerScreen`, whose `mouseScrolled` first offers
|
||||
/// the wheel to the hovered slot's `ItemSlotMouseAction`s (the bundle's), and whose
|
||||
/// `onStopHovering` clears the selection: the inventory and container menus, and
|
||||
/// `CreativeModeInventoryScreen`, which scrolls its items only when no bundle took
|
||||
/// the wheel.
|
||||
pub(super) fn takes_bundle_wheel(screen: Screen) -> bool {
|
||||
matches!(screen, Screen::Inventory | Screen::Creative)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
@@ -89,7 +98,7 @@ impl Game<'_> {
|
||||
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
|
||||
/// screen, clears the bundle's selection.
|
||||
pub(super) fn tick_bundle_hover(&mut self) {
|
||||
let now = if self.screen == Screen::Inventory {
|
||||
let now = if takes_bundle_wheel(self.screen) {
|
||||
self.inventory_target().0
|
||||
} else {
|
||||
None
|
||||
@@ -101,3 +110,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `CreativeModeInventoryScreen.mouseScrolled` calls `AbstractContainerScreen`'s
|
||||
/// first: a bundle under the cursor takes the wheel there too (#3211).
|
||||
#[test]
|
||||
fn container_screens_offer_the_wheel_to_bundles() {
|
||||
assert!(takes_bundle_wheel(Screen::Inventory));
|
||||
assert!(takes_bundle_wheel(Screen::Creative));
|
||||
for screen in [
|
||||
Screen::Playing,
|
||||
Screen::Chat,
|
||||
Screen::Paused,
|
||||
Screen::Options,
|
||||
] {
|
||||
assert!(!takes_bundle_wheel(screen));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use std::{
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
fn chat_tree<'a>(
|
||||
pub(super) fn chat_tree<'a>(
|
||||
remote: &'a Option<super::remote::Remote>,
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
@@ -148,7 +148,7 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
loot_tables: ids_under(&data.join("loot_table"), "json"),
|
||||
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
|
||||
templates: templates_under(&root.join("worldgen/structure")),
|
||||
});
|
||||
cache.push((root.to_owned(), ids.clone()));
|
||||
ids
|
||||
@@ -156,6 +156,26 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
ids_in(root, extension, "minecraft")
|
||||
}
|
||||
|
||||
/// The templates of every namespace folder under `root` (vanilla's and the
|
||||
/// content mods'), as `namespace:path`, sorted.
|
||||
fn templates_under(root: &Path) -> Vec<String> {
|
||||
let Ok(namespaces) = std::fs::read_dir(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<String> = namespaces
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.flat_map(|e| ids_in(&e.path(), "nbt", &e.file_name().to_string_lossy()))
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
@@ -175,7 +195,7 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
"{namespace}:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
@@ -427,7 +447,8 @@ impl Game<'_> {
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
}
|
||||
/// The block the player is looking at, for coordinate suggestions.
|
||||
/// The block the player is looking at, for coordinate suggestions, and
|
||||
/// the entity, for entity arguments (ClientSuggestionProvider).
|
||||
/// `ClientSuggestionProvider.getRelevantCoordinates` and
|
||||
/// `getAbsoluteCoordinates` read `Minecraft.hitResult` only when it is a
|
||||
/// block: an entity in front of the block leaves the `~` defaults.
|
||||
@@ -440,6 +461,7 @@ impl Game<'_> {
|
||||
self.ui.registries.target_block = hit.as_ref().map(|h| h.pos);
|
||||
self.ui.registries.target_point =
|
||||
hit.map(|h| h.point.map(horizon_client::java_text::format_fixed2));
|
||||
self.ui.registries.selected_entities = self.crosshair_entity_uuid().into_iter().collect();
|
||||
// ClientPacketListener.getOnlinePlayerNames on a server.
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
@@ -649,7 +671,7 @@ impl Game<'_> {
|
||||
ChatAction::None => {
|
||||
// charTyped: the text the key typed.
|
||||
if let Some(text) = typed_text
|
||||
&& !ctrl
|
||||
&& self.types_text()
|
||||
{
|
||||
screen.type_text(&m, &c, text);
|
||||
}
|
||||
@@ -1181,7 +1203,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(path.clone());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
@@ -1191,6 +1216,7 @@ impl Game<'_> {
|
||||
};
|
||||
let message = match result {
|
||||
Ok(_) => {
|
||||
log::info!("Saved screenshot to {}", path.display());
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let link = Text::styled(
|
||||
name,
|
||||
@@ -1215,24 +1241,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
/// approximated as UTC).
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let days = (secs / 86400) as i64;
|
||||
let (h, m, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
let base = format!("{y}-{mo:02}-{d:02}_{h:02}.{m:02}.{s:02}");
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = if n == 1 {
|
||||
|
||||
@@ -27,6 +27,22 @@ impl Game<'_> {
|
||||
m.alt_key() || held([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
)
|
||||
}
|
||||
/// Whether the held keys let a key's text through to the focused field
|
||||
/// (`clipboard::types_text`): not with the edit modifier, unless it is
|
||||
/// part of AltGr.
|
||||
pub(super) fn types_text(&self) -> bool {
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.physical_keys.contains(k));
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
};
|
||||
clipboard::types_text(
|
||||
edit,
|
||||
held([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
clipboard::ALTGR_IS_CTRL_ALT,
|
||||
)
|
||||
}
|
||||
/// A shortcut in an append-only field limited to `max_chars` characters
|
||||
/// (an `EditBox` with `setMaxLength`). True when the value changed.
|
||||
pub(super) fn append_shortcut(
|
||||
|
||||
@@ -304,6 +304,16 @@ impl Game<'_> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The display component of a custom-named held stack.
|
||||
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mut stack = m.extra.main_hand_stack()?.clone();
|
||||
stack.custom_name()?;
|
||||
if let Some(g) = horizon_client::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
|
||||
}
|
||||
Some(self.stack_display_name(&stack))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
@@ -357,8 +367,16 @@ impl Game<'_> {
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
if let Some((id, name)) = killer {
|
||||
// DamageSource.getLocalizedDeathMessage: the cause's item with a
|
||||
// custom name gets the `<id>.item` message.
|
||||
let (key, args) = horizon_client::scoreboard::death_message_args(
|
||||
&key,
|
||||
&me,
|
||||
&name,
|
||||
self.mob_main_hand_name(id).as_ref(),
|
||||
);
|
||||
return Text::translate(&key, &args);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
|
||||
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
|
||||
use horizon_client::{
|
||||
chat::{ChatScreen, Commands},
|
||||
command_block::{self, CommandBlock, Mode},
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
screen_draw::Measure,
|
||||
text::{self, Text},
|
||||
ui::{self, EditBox},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
level::flags,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use winit::{
|
||||
@@ -28,7 +31,10 @@ type Pos = (i32, i32, i32);
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
command: EditBox,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
opened: std::time::Instant,
|
||||
previous: EditBox,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
@@ -93,6 +99,9 @@ impl CommandBlockScreen {
|
||||
Button::Cancel => tr("gui.cancel"),
|
||||
}
|
||||
}
|
||||
pub(super) fn pos(&self) -> Pos {
|
||||
self.pos
|
||||
}
|
||||
/// `updatePreviousOutput`.
|
||||
fn show_previous(&mut self, block: &CommandBlock) {
|
||||
let shown = if self.track_output {
|
||||
@@ -446,10 +455,14 @@ impl Game<'_> {
|
||||
let Some(state) = self.block_state_at(pos) else {
|
||||
return;
|
||||
};
|
||||
let mut command = EditBox::new(0.0, 50.0, 300.0, 20.0);
|
||||
command.max_length = 32500;
|
||||
command.set_value(&block.command);
|
||||
command.focused = true;
|
||||
let (w, h) = self.gui_size();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
|
||||
command.auto_suggestions = self.settings.auto_suggestions;
|
||||
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
|
||||
previous.max_length = 32500;
|
||||
previous.text_color = UNEDITABLE;
|
||||
@@ -457,6 +470,7 @@ impl Game<'_> {
|
||||
let mut screen = CommandBlockScreen {
|
||||
pos,
|
||||
command,
|
||||
opened: std::time::Instant::now(),
|
||||
previous,
|
||||
track_output: block.track_output,
|
||||
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
|
||||
@@ -485,7 +499,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let mid = (w / 2.0).floor();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
// Screen.extractBackground of an isInGameUi screen: the dimmed world,
|
||||
// with no blur and no menu background.
|
||||
batch.fill_gradient(
|
||||
atlas,
|
||||
(0.0, 0.0, w, h),
|
||||
rgba(0x10, 0x10, 0x10, 0xC0),
|
||||
rgba(0x10, 0x10, 0x10, 0xD0),
|
||||
);
|
||||
let title = Text::translate("advMode.setCommand", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
@@ -499,8 +520,6 @@ impl Game<'_> {
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
let label = Text::translate("advMode.command", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
|
||||
s.command.x = mid - 150.0;
|
||||
s.command.draw(batch, atlas, None);
|
||||
if !s.previous.value.is_empty() {
|
||||
let label = Text::translate("advMode.previousOutput", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
|
||||
@@ -511,12 +530,65 @@ impl Game<'_> {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
|
||||
}
|
||||
// The command box, then its suggestions over the widgets.
|
||||
let m = Measure(&atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.resize(&m, &c, w as i32, h as i32);
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
let blink = s.opened.elapsed().as_millis() as u64;
|
||||
let draws = s.command.draw(&m, mouse, blink, 0);
|
||||
horizon_client::screen_draw::paint(batch, atlas, &draws);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The wheel over the suggestion list (`CommandSuggestions.mouseScrolled`).
|
||||
pub(super) fn command_block_scroll(&mut self, amount: f32) {
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
if let Some(s) = self.command_block_screen.as_mut() {
|
||||
s.command.scroll(mouse, f64::from(amount));
|
||||
}
|
||||
}
|
||||
/// The command box's completion request for an `ask_server` node.
|
||||
pub(super) fn command_block_suggestion_request(&mut self) -> Option<(i32, String)> {
|
||||
self.command_block_screen.as_mut()?.command.take_request()
|
||||
}
|
||||
/// The server's completions for the command box's `ask_server` request.
|
||||
pub(super) fn command_block_server_suggestions(
|
||||
&mut self,
|
||||
id: i32,
|
||||
range: (usize, usize),
|
||||
list: Vec<(String, Option<String>)>,
|
||||
) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.server_suggestions(&m, &c, id, range, list);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
pub(super) fn command_block_click(&mut self) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
// CommandSuggestions.mouseClicked first, then the command box.
|
||||
{
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
if s.command.click(&m, &c, x, y) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -552,8 +624,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: Enter is Done, Escape
|
||||
/// cancels, the rest edits the command.
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: the suggestions first,
|
||||
/// then Enter is Done, Escape cancels, the rest edits the command.
|
||||
pub(super) fn command_block_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -561,27 +633,58 @@ impl Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
self.command_block_key_code(key, shortcut, event.text.as_deref());
|
||||
}
|
||||
/// A key pressed on the command block screen, with the edit shortcut it
|
||||
/// makes and the text it types.
|
||||
pub(super) fn command_block_key_code(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// Windows' AltGr is Ctrl+Alt, and its characters still type.
|
||||
let types_text = self.types_text();
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if s.command.suggestions_key(&m, &c, key, shift) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
let typed = match key {
|
||||
KeyCode::Escape | KeyCode::Enter | KeyCode::NumpadEnter => false,
|
||||
_ => {
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(&m, &c, shortcut, &mut self.clipboard);
|
||||
false
|
||||
} else {
|
||||
!s.command.field_key(&m, &c, key, shift, ctrl) && types_text
|
||||
}
|
||||
}
|
||||
};
|
||||
self.command_block_screen = Some(s);
|
||||
if typed {
|
||||
self.command_block_type(text.unwrap_or(""));
|
||||
}
|
||||
match key {
|
||||
KeyCode::Escape => return self.close_command_block(),
|
||||
KeyCode::Escape => self.close_command_block(),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => {
|
||||
if let Some(s) = self.command_block_screen.take() {
|
||||
self.set_command_block(&s);
|
||||
}
|
||||
return self.close_command_block();
|
||||
self.close_command_block();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let Some(s) = self.command_block_screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !s.command.key(key, ctrl, shift) && !ctrl {
|
||||
self.command_block_type(event.text.as_deref().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
/// Characters typed into the command box (`StringUtil.isAllowedChatCharacter`).
|
||||
pub(super) fn command_block_type(&mut self, typed: &str) {
|
||||
@@ -589,11 +692,19 @@ impl Game<'_> {
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
|
||||
.collect();
|
||||
if let Some(s) = self.command_block_screen.as_mut()
|
||||
&& !allowed.is_empty()
|
||||
{
|
||||
s.command.insert_text(&allowed);
|
||||
if allowed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.type_text(&m, &c, &allowed);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The edit screen's Done: a server's client sends the packet, the
|
||||
/// window's own world applies it (`handleSetCommandBlock`).
|
||||
@@ -604,7 +715,7 @@ impl Game<'_> {
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value.clone(),
|
||||
command: s.command.value().to_owned(),
|
||||
track_output: s.track_output,
|
||||
mode: s.mode,
|
||||
conditional: s.conditional,
|
||||
@@ -653,6 +764,46 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.openCommandBlock`: the command block's data
|
||||
/// (`saveCustomOnly`) goes to the guest whose use opened it, as the
|
||||
/// chunk's update tag leaves it out and the edit screen shows it.
|
||||
pub(super) fn lan_open_command_block(&mut self, host: &mut LanHost, conn: u32, pos: Pos) {
|
||||
if !host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.is_some_and(|g| g.creative() && g.permission >= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(block) = self.command_block(pos) else {
|
||||
return;
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
let state = self.world.block(pos.0, pos.1, pos.2).0;
|
||||
let Some(kind) = host.hub.encoder.entity_type(state) else {
|
||||
return;
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::BlockEntityData(play::BlockEntityData {
|
||||
pos: [pos.0, pos.1, pos.2],
|
||||
kind,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
|
||||
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
let open = self.screen == Screen::CommandBlock
|
||||
&& self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == pos);
|
||||
if open {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
|
||||
@@ -2069,9 +2069,53 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
self.update_spawner_display(key);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// `RandomizableContainer.unpackLootTable` for a player who opens a
|
||||
/// container: a roll the container still holds fires
|
||||
/// `player_generates_container_loot` for its table, and from then on the
|
||||
/// container saves its items, not the table.
|
||||
pub(super) fn unpack_opened_loot(&mut self, key: (i32, i32, i32), name: &str) {
|
||||
let Some(rolled) = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(key.0 >> 4, key.2 >> 4))
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The roll is still there only while the container holds exactly its items.
|
||||
let still_rolled = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&key)
|
||||
.is_some_and(|entity| {
|
||||
let mut tag = horizon_client::save::encode_block_entity(key, entity, name);
|
||||
match &mut tag {
|
||||
horizon_world::nbt::Tag::Compound(map) => rolled.restore(map),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
if still_rolled {
|
||||
self.trigger_loot_table(&rolled.table);
|
||||
}
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
|
||||
return;
|
||||
};
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
let mut extras = chunk
|
||||
.saved
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
|
||||
|
||||
@@ -317,6 +317,7 @@ impl Game<'_> {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
self.update_spawner_display(key);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
let _ = self.world.set(p[0], p[1], p[2], state);
|
||||
|
||||
@@ -235,9 +235,9 @@ impl Game<'_> {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let item = stack.map(|s| s.item);
|
||||
let item = stack.as_ref().map(|s| s.item.clone());
|
||||
match *key {
|
||||
"mainhand" => m.extra.main_hand = item,
|
||||
"mainhand" => m.extra.set_main_hand_stack(stack),
|
||||
"offhand" => m.extra.off_hand = item,
|
||||
"head" | "body" => m.extra.head = item,
|
||||
"chest" => m.extra.chest = item,
|
||||
|
||||
@@ -246,9 +246,9 @@ impl Game<'_> {
|
||||
c.insert("z".into(), Tag::Int(at[2]));
|
||||
}
|
||||
if let Some((_, entity)) = save::decode_block_entity(&tag) {
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((at[0], at[1], at[2]), entity);
|
||||
let key = (at[0], at[1], at[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Game<'_> {
|
||||
|
||||
/// `EntitySelector.findEntities` with a UUID: the player with it, else
|
||||
/// the entity.
|
||||
pub(super) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
|
||||
pub(in crate::game) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
|
||||
let own = self.lan_acting_uuid.or_else(|| {
|
||||
self.level.player_uuid.map(|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
|
||||
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
|
||||
"tp X Y Z teleport the player",
|
||||
"hotbar 1-9 select a hotbar slot",
|
||||
"give ITEM [COUNT] add items to the inventory",
|
||||
"render-distance N chunks, 2..64",
|
||||
"render-distance N chunks, 2..128",
|
||||
"lod-distance N far surface chunks, 0 disables, up to1024",
|
||||
"time TICKS set the time of day",
|
||||
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
|
||||
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
|
||||
"chat TEXT send a chat line or /command as if typed",
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -349,7 +349,10 @@ impl Game<'_> {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock
|
||||
);
|
||||
let key = if typing && arg(2)? != "up" {
|
||||
key
|
||||
} else {
|
||||
@@ -361,7 +364,8 @@ impl Game<'_> {
|
||||
if action != "up" {
|
||||
self.play_mouse(button, true)?;
|
||||
}
|
||||
if action != "down" {
|
||||
// A repeat keeps the key held, as the window's.
|
||||
if action == "up" || action == "tap" {
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
}
|
||||
@@ -516,8 +520,9 @@ impl Game<'_> {
|
||||
}
|
||||
"render-distance" => {
|
||||
let n: i32 = arg(1)?.parse()?;
|
||||
if !(2..=64).contains(&n) {
|
||||
return Err("render distance must be 2..64".into());
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&n) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
}
|
||||
self.settings.render_distance = n;
|
||||
self.apply_settings();
|
||||
@@ -587,7 +592,10 @@ impl Game<'_> {
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
@@ -677,6 +685,7 @@ impl Game<'_> {
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"graphics-hdr" => Page::GraphicsHdr,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
@@ -854,6 +863,10 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Options {
|
||||
return self.options_key(key, None);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock {
|
||||
self.command_block_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.insert(key) {
|
||||
self.key_pressed(key)?;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ const FIREWORK_ROCKET: &str = "minecraft:firework_rocket";
|
||||
|
||||
/// `ProjectileWeaponItem.ARROW_ONLY`, for the arrows Horizon shoots.
|
||||
fn is_arrow(stack: &ItemStack) -> bool {
|
||||
&*stack.item == "minecraft:arrow"
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:arrow" | "minecraft:spectral_arrow" | "minecraft:tipped_arrow"
|
||||
)
|
||||
}
|
||||
|
||||
/// `Player.getProjectile`: what `weapon` supports in the off hand, then in
|
||||
@@ -206,10 +209,14 @@ impl Game<'_> {
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
}
|
||||
if !rocket {
|
||||
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
|
||||
(&*ammo.item == "minecraft:spectral_arrow")
|
||||
.then_some(("minecraft:glowing", 200))
|
||||
});
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: true,
|
||||
effect: None,
|
||||
effect,
|
||||
};
|
||||
let mut arrow = Projectile::new(
|
||||
kind,
|
||||
@@ -277,5 +284,17 @@ mod tests {
|
||||
// The main hand's rocket, with the crossbow in the off hand.
|
||||
let hands = [(OFFHAND_SLOT, crossbow.as_ref()), (3, rocket.as_ref())];
|
||||
assert_eq!(projectile_slot(CROSSBOW, hands, &inventory), Some(3));
|
||||
|
||||
// Spectral and tipped arrows in off hand or inventory are recognized.
|
||||
let spectral = stack("minecraft:spectral_arrow");
|
||||
let tipped = stack("minecraft:tipped_arrow");
|
||||
inventory[7] = spectral.clone();
|
||||
assert_eq!(slot("minecraft:bow", &None, &inventory), Some(7));
|
||||
assert_eq!(
|
||||
slot("minecraft:bow", &tipped, &inventory),
|
||||
Some(OFFHAND_SLOT)
|
||||
);
|
||||
inventory[7] = None;
|
||||
assert_eq!(slot("minecraft:bow", &None, &inventory), None);
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,105 @@ use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
mod status;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
struct Watchdog {
|
||||
base: Instant,
|
||||
next_nanos: AtomicU64,
|
||||
tick_nanos: AtomicU64,
|
||||
running: AtomicBool,
|
||||
}
|
||||
|
||||
impl Watchdog {
|
||||
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
|
||||
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
|
||||
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
|
||||
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
|
||||
let watchdog = Arc::new(Self {
|
||||
base: Instant::now(),
|
||||
next_nanos: AtomicU64::new(0),
|
||||
tick_nanos: AtomicU64::new(50_000_000),
|
||||
running: AtomicBool::new(true),
|
||||
});
|
||||
let shared = watchdog.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Server Watchdog".into())
|
||||
.spawn(move || shared.run(max))
|
||||
.ok()?;
|
||||
Some(watchdog)
|
||||
}
|
||||
/// The tick loop is about to run the tick due at `next`.
|
||||
fn tick_due(&self, next: Instant, tick: Duration) {
|
||||
let nanos = next.saturating_duration_since(self.base).as_nanos();
|
||||
self.next_nanos
|
||||
.store(u64::try_from(nanos).unwrap_or(u64::MAX), Ordering::Relaxed);
|
||||
let tick = u64::try_from(tick.as_nanos()).unwrap_or(u64::MAX);
|
||||
self.tick_nanos.store(tick, Ordering::Relaxed);
|
||||
}
|
||||
/// `ServerWatchdog.run`.
|
||||
fn run(&self, max: Duration) {
|
||||
let max = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
|
||||
while self.running.load(Ordering::Relaxed) {
|
||||
let next = self.next_nanos.load(Ordering::Relaxed);
|
||||
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
let behind = now.saturating_sub(next);
|
||||
if behind > max {
|
||||
let took = format!("{:.2}", behind as f32 / 1e9);
|
||||
let should = format!(
|
||||
"{:.2}",
|
||||
self.tick_nanos.load(Ordering::Relaxed) as f32 / 1e9
|
||||
);
|
||||
log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
&format!("A single server tick took {took} seconds (should be max {should})"),
|
||||
);
|
||||
log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
"Considering it to be crashed, server will forcibly shutdown.",
|
||||
);
|
||||
let message = format!(
|
||||
"ServerWatchdog detected that a single server tick took {took} seconds (should be max {should})"
|
||||
);
|
||||
match horizon_client::crash::report_watchdog(
|
||||
std::path::Path::new("."),
|
||||
"server",
|
||||
message,
|
||||
) {
|
||||
Some(path) => log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
&format!(
|
||||
"This crash report has been saved to: {}",
|
||||
path.canonicalize().unwrap_or(path).display()
|
||||
),
|
||||
),
|
||||
None => log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
"We were unable to save this crash report to disk.",
|
||||
),
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Thread.sleep(nextTickTime + maxTickTime - now).
|
||||
let wait = (next + max).saturating_sub(now).max(1_000_000);
|
||||
std::thread::sleep(Duration::from_nanos(wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double.toString for the magnitudes positions have (no exponent form
|
||||
/// between 1e-3 and 1e7).
|
||||
pub(super) fn java_double(v: f64) -> String {
|
||||
@@ -298,15 +391,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -323,11 +415,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -336,18 +435,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -363,7 +453,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
@@ -608,6 +698,7 @@ impl Game<'_> {
|
||||
motd: props.motd.clone(),
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
compression_threshold: props.network_compression_threshold,
|
||||
rate_limit: props.rate_limit,
|
||||
enable_status: props.enable_status,
|
||||
hide_online_players: props.hide_online_players,
|
||||
favicon,
|
||||
@@ -704,8 +795,13 @@ impl Game<'_> {
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
let mut next = Instant::now();
|
||||
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
while !state.stop {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
}
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
}
|
||||
@@ -746,6 +842,9 @@ impl Game<'_> {
|
||||
state.ticked(Instant::now());
|
||||
self.wait_for_next_server_tick(&mut next, paused);
|
||||
}
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
self.stop_dedicated()
|
||||
}
|
||||
|
||||
@@ -1355,10 +1454,13 @@ impl Game<'_> {
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
for g in &h.hub.guests {
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
if let Some(host) = self.lan.take() {
|
||||
for g in &host.hub.guests {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(g.vehicle);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
if let Some(h) = self.lan.as_ref() {
|
||||
for g in &h.hub.guests {
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
@@ -26,6 +26,8 @@ struct Open {
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
/// The number range input whose slider is held.
|
||||
dragging: Option<usize>,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
|
||||
/// it, wrapping.
|
||||
fn cycle(open: &mut Open, input: usize, delta: i32) {
|
||||
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
|
||||
return;
|
||||
};
|
||||
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
|
||||
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
@@ -57,10 +70,35 @@ impl Game<'_> {
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
let Some(mut dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
|
||||
let entries = self.remote.as_ref().map(|r| {
|
||||
let list = r.registries.registries.get("minecraft:dialog");
|
||||
let by_id = |id: &str| {
|
||||
let name = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
list?.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
};
|
||||
let by_tag = |name: &str| {
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
|
||||
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
|
||||
.collect()
|
||||
};
|
||||
Dialog::list_entries(dialogs, &by_tag, &by_id)
|
||||
});
|
||||
dialog.set_list(&entries.unwrap_or_default());
|
||||
}
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
@@ -87,6 +125,7 @@ impl Game<'_> {
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
dragging: None,
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
@@ -123,6 +162,17 @@ impl Game<'_> {
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
// defaultHandleClickEvent: an open_url never sets the screen itself, so
|
||||
// the dialog stays unless the confirmation takes over (Web Links off,
|
||||
// or Prompt on Links off, opens nothing or the browser behind it).
|
||||
if let Some(Click::OpenUrl(url)) = &click
|
||||
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
|
||||
{
|
||||
if self.settings.chat_links {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
@@ -153,7 +203,11 @@ impl Game<'_> {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
// ConfirmLinkScreen over the screen the dialog leaves.
|
||||
Some(Click::OpenUrl(url)) => self.click_url(url),
|
||||
// Screen.insertText: the screen after the dialog takes no text.
|
||||
Some(Click::SuggestCommand(_)) => {}
|
||||
Some(other) => log::error!("Don't know how to handle {other:?}"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
@@ -262,7 +316,8 @@ impl Game<'_> {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
// Out of `self` while it draws: an item body draws through `self`.
|
||||
let Some(mut open) = self.dialogs.open.take() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -291,6 +346,7 @@ impl Game<'_> {
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut item_tip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
@@ -333,6 +389,64 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Cycle { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let Some(label) = open
|
||||
.values
|
||||
.get(input)
|
||||
.and_then(|v| open.dialog.inputs[input].control.message(v))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
|
||||
}
|
||||
Part::Slider { input, rect } => {
|
||||
let [x, y, sw, sh] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
|
||||
let Some((InputValue::Slider(position), Some(label))) = open
|
||||
.values
|
||||
.get(input)
|
||||
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let held = open.dragging == Some(input);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&label,
|
||||
position,
|
||||
hover || held,
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Item { body, rect } => {
|
||||
let [x, y, iw, ih] = rect;
|
||||
let Some(Body::Item {
|
||||
item,
|
||||
show_decorations,
|
||||
show_tooltip,
|
||||
..
|
||||
}) = open.dialog.body.get(body)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
|
||||
continue;
|
||||
};
|
||||
// ItemDisplayWidget: the item at its corner, then its decorations.
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
if *show_decorations {
|
||||
self.draw_item_decorations(batch, &stack, x, y, None);
|
||||
}
|
||||
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
|
||||
item_tip = Some(stack);
|
||||
}
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
@@ -371,6 +485,10 @@ impl Game<'_> {
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
self.dialogs.open = Some(open);
|
||||
if let Some(stack) = item_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
@@ -420,6 +538,7 @@ impl Game<'_> {
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -450,6 +569,23 @@ impl Game<'_> {
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
// CycleButton.onPress: Shift goes back.
|
||||
cycle(open, input, if shift { -1 } else { 1 });
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Slider {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.dragging = Some(input);
|
||||
if let Some(v) = open.values.get_mut(input) {
|
||||
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
|
||||
}
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
@@ -479,17 +615,65 @@ impl Game<'_> {
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
/// The mouse wheel over a dialog: a cycle button under it takes the
|
||||
/// step, else the body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
|
||||
let hovered = layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => Some(input),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(input) = hovered {
|
||||
// CycleButton.mouseScrolled: up goes back.
|
||||
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
|
||||
pub(super) fn dialog_drag(&mut self) {
|
||||
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let x = self.cursor.0;
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let slider = layout.body.iter().find_map(|p| match p {
|
||||
Part::Slider { input: i, rect } if *i == input => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
|
||||
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
|
||||
}
|
||||
}
|
||||
|
||||
/// The left button released: a held slider lets go and clicks.
|
||||
pub(super) fn dialog_release(&mut self) {
|
||||
if let Some(open) = self.dialogs.open.as_mut()
|
||||
&& open.dragging.take().is_some()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
|
||||
@@ -296,7 +296,7 @@ pub(super) fn mob_set_slot(m: &mut Mob, slot: Slot, stack: ItemStack) {
|
||||
let item: Arc<str> = stack.item.clone();
|
||||
let x = &mut m.extra;
|
||||
match slot {
|
||||
Slot::MainHand => x.main_hand = Some(item),
|
||||
Slot::MainHand => x.set_main_hand(Some(item)),
|
||||
Slot::OffHand => x.off_hand = Some(item),
|
||||
Slot::Feet => x.feet = Some(item),
|
||||
Slot::Legs => x.legs = Some(item),
|
||||
@@ -2140,6 +2140,11 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
|
||||
def.effects
|
||||
.iter()
|
||||
.find(|(id, _, _)| !horizon_client::brewing::effect(id).is_some_and(|e| e.instant))
|
||||
.map(|(id, duration, _)| (*id, (duration / 8).max(1)))
|
||||
.map(|(id, duration, _)| {
|
||||
(
|
||||
horizon_client::brewing::namespaced(id),
|
||||
(duration / 8).max(1),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -801,10 +801,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !self.fight.killed {
|
||||
if self.dragon.is_none() {
|
||||
// createNewDragon: over the podium, holding pattern, a random yaw.
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
let seed = self.world.random.next_long();
|
||||
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
|
||||
self.create_new_dragon();
|
||||
}
|
||||
self.fight.crystal_scan += 1;
|
||||
if self.fight.crystal_scan >= 100 {
|
||||
@@ -2593,54 +2590,9 @@ impl Game<'_> {
|
||||
self.crystal_beam(pos, to - pos, self.ticks as f32 + a, camera, out);
|
||||
}
|
||||
}
|
||||
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
|
||||
/// `EnderDragonRenderer.submitRays`: untextured, additive death rays.
|
||||
fn dragon_rays(&self, base: glam::Mat4, t: f32, out: &mut Vec<Vertex>) {
|
||||
let white = self.gui.atlas.font.white();
|
||||
let uv = [white.u0, white.v0];
|
||||
let over = if t > 0.8 {
|
||||
((t - 0.8) / 0.2).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let outer = 0x00FF_00FF;
|
||||
let mut random = LegacyRandom::new(432);
|
||||
let mut m = base;
|
||||
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
|
||||
let half_sqrt3 = 3f32.sqrt() / 2.0;
|
||||
let tau = std::f32::consts::TAU;
|
||||
for _ in 0..count {
|
||||
let mut f = || random.next_float() * tau;
|
||||
let q1 = glam::Quat::from_rotation_x(f())
|
||||
* glam::Quat::from_rotation_y(f())
|
||||
* glam::Quat::from_rotation_z(f());
|
||||
let (a, b, c) = (f(), f(), f());
|
||||
let q2 = glam::Quat::from_rotation_x(a)
|
||||
* glam::Quat::from_rotation_y(b)
|
||||
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
|
||||
m *= glam::Mat4::from_quat(q1 * q2);
|
||||
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
|
||||
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
|
||||
let o = glam::Vec3::ZERO;
|
||||
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
|
||||
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
|
||||
let bottom = glam::Vec3::new(0.0, length, width);
|
||||
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
|
||||
// A triangle as a quad with its last corner repeated.
|
||||
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
dragon_rays(base, t, self.gui.atlas.font.white(), out);
|
||||
}
|
||||
/// `submitCrystalBeams`: an eight-sided tube from `from` (+2 up) along
|
||||
/// `delta`, the beam texture scrolling along it.
|
||||
@@ -3022,10 +2974,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
|
||||
/// does not hurt it (the dragon flies its death flight when its health
|
||||
/// runs out).
|
||||
/// runs out). The fight's dragon ends the fight there and then
|
||||
/// (`dragonFight.setDragonKilled`): the exit portal, a gateway and the
|
||||
/// first dragon's egg, and no new dragon. No experience: `tickDeath`
|
||||
/// drops it, and a killed dragon never gets there.
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
self.dragon = None;
|
||||
// ServerLevel.getDragonFight: only the End has a fight.
|
||||
if self.dim == Dim::End {
|
||||
self.dragon_killed();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Any entity touching `room` (`Level.getEntities(null, box)`).
|
||||
@@ -3317,6 +3276,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
|
||||
fn create_new_dragon(&mut self) {
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
let seed = self.world.random.next_long();
|
||||
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
|
||||
}
|
||||
/// The END stage: the portal crystals blow up (no block damage) and a
|
||||
/// new dragon takes over.
|
||||
fn finish_respawn(&mut self) {
|
||||
@@ -3343,6 +3308,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.fight.killed = false;
|
||||
// setRespawnStage(END): the new dragon is summoned for every player
|
||||
// of the fight (the boss bar's players: alive within 192 blocks).
|
||||
self.create_new_dragon();
|
||||
let view = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.map(|d| horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:ender_dragon".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(view) = view {
|
||||
self.for_each_player(|g| {
|
||||
let near = g.player.as_ref().is_some_and(|p| {
|
||||
!p.health.dead_or_dying()
|
||||
&& p.position.x.powi(2)
|
||||
+ (p.position.y - 128.0).powi(2)
|
||||
+ p.position.z.powi(2)
|
||||
<= 192.0 * 192.0
|
||||
});
|
||||
if near {
|
||||
g.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.fight.dirty = true;
|
||||
self.count_crystals();
|
||||
}
|
||||
@@ -3562,10 +3552,93 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
|
||||
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
|
||||
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
|
||||
// its border can round into transparent neighbours during interpolation.
|
||||
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
|
||||
let over = if t > 0.8 {
|
||||
((t - 0.8) / 0.2).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let outer = 0x00FF_00FF;
|
||||
let mut random = LegacyRandom::new(432);
|
||||
let mut m = base;
|
||||
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
|
||||
let half_sqrt3 = 3f32.sqrt() / 2.0;
|
||||
let tau = std::f32::consts::TAU;
|
||||
for _ in 0..count {
|
||||
let mut f = || random.next_float() * tau;
|
||||
let q1 = glam::Quat::from_rotation_x(f())
|
||||
* glam::Quat::from_rotation_y(f())
|
||||
* glam::Quat::from_rotation_z(f());
|
||||
let (a, b, c) = (f(), f(), f());
|
||||
let q2 = glam::Quat::from_rotation_x(a)
|
||||
* glam::Quat::from_rotation_y(b)
|
||||
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
|
||||
m *= glam::Mat4::from_quat(q1 * q2);
|
||||
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
|
||||
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
|
||||
let o = glam::Vec3::ZERO;
|
||||
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
|
||||
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
|
||||
let bottom = glam::Vec3::new(0.0, length, width);
|
||||
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
|
||||
// A triangle as a quad with its last corner repeated.
|
||||
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn death_rays_sample_inside_the_white_sprite() {
|
||||
// A four-texel white sprite surrounded by transparent atlas pixels.
|
||||
// A border UV can round outside it during perspective interpolation.
|
||||
let size = 1024.0;
|
||||
let white = horizon_client::hud::Rect {
|
||||
u0: 20.0 / size,
|
||||
v0: 60.0 / size,
|
||||
u1: 24.0 / size,
|
||||
v1: 64.0 / size,
|
||||
w: 4,
|
||||
h: 4,
|
||||
};
|
||||
for t in [0.25, 0.8, 0.95, 1.0] {
|
||||
let mut vertices = Vec::new();
|
||||
dragon_rays(glam::Mat4::IDENTITY, t, white, &mut vertices);
|
||||
assert!(!vertices.is_empty());
|
||||
for v in &vertices {
|
||||
assert!(
|
||||
v.uv[0] > white.u0 + 0.5 / size && v.uv[0] < white.u1 - 0.5 / size,
|
||||
"death-ray U touches the atlas border: {}",
|
||||
v.uv[0]
|
||||
);
|
||||
assert!(
|
||||
v.uv[1] > white.v0 + 0.5 / size && v.uv[1] < white.v1 - 0.5 / size,
|
||||
"death-ray V touches the atlas border: {}",
|
||||
v.uv[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dragon(phase: Phase) -> Dragon {
|
||||
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
|
||||
d.phase = phase;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
@@ -16,10 +17,18 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// A list of doubles, as an NBT list of them.
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
@@ -94,6 +115,11 @@ pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
/// The entities that went through a gateway, waiting for its exit.
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -947,6 +973,17 @@ impl Game<'_> {
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -1043,13 +1080,7 @@ impl Game<'_> {
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
let bottom_center = horizon_client::end_gateway::bottom_center;
|
||||
let find_exit = |game: &Self, exit: [i32; 3]| {
|
||||
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
@@ -1059,6 +1090,14 @@ impl Game<'_> {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// The player and the entities may resolve the same gateway: the
|
||||
// first one to arrive finds the exit, the others use it.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
if let Some((exit, exact)) = known {
|
||||
return Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})));
|
||||
}
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -1420,6 +1459,194 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
continue;
|
||||
}
|
||||
// An entity that just came through any portal stays out of this one.
|
||||
if self.restart_portal_cooldown(id) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
self.end.gateways.remove(&id);
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_gateway(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.dropped.remove(i);
|
||||
self.forget_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
/// gateway leads, once its chunks have loaded.
|
||||
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
let areas = &self.end.areas;
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
}
|
||||
self.end.areas = areas;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -132,6 +132,12 @@ impl Game<'_> {
|
||||
"minecraft:glow_item_frame" => true,
|
||||
_ => return false,
|
||||
};
|
||||
let invisible = stack.extra.as_ref().is_some_and(|extra| {
|
||||
extra.other.iter().any(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data")
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
})
|
||||
});
|
||||
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
let frame = ItemFrame {
|
||||
id: self.next_entity + 1,
|
||||
@@ -142,7 +148,7 @@ impl Game<'_> {
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
invisible,
|
||||
check_interval: 0,
|
||||
};
|
||||
if !self.frame_survives(&frame) {
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(super) struct FnState {
|
||||
load_pending: bool,
|
||||
pub schedule: Vec<Scheduled>,
|
||||
next_seq: u64,
|
||||
/// A dedicated server's `function-permission-level`.
|
||||
pub permission: Option<u8>,
|
||||
}
|
||||
/// What a function reported through `return`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -45,6 +47,16 @@ pub(super) enum Outcome {
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl FnState {
|
||||
/// The level function files compile at (`ServerFunctionLibrary`'s
|
||||
/// `functionCompilationPermissions`): `function-permission-level` on a
|
||||
/// dedicated server, game master (2) otherwise. Whoever runs a function,
|
||||
/// its commands need no more than this.
|
||||
fn command_level(&self) -> u8 {
|
||||
self.permission.unwrap_or(2)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
// ---- loading -----------------------------------------------------------
|
||||
|
||||
@@ -278,6 +290,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.exec.function_depth += 1;
|
||||
let outer = self.exec.returned.take();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, self.fns.command_level());
|
||||
let inner = Source {
|
||||
silent: true,
|
||||
store: None,
|
||||
@@ -292,6 +305,7 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.ui.registries.permission = level;
|
||||
let returned = std::mem::replace(&mut self.exec.returned, outer);
|
||||
self.exec.function_depth -= 1;
|
||||
Ok(match returned {
|
||||
|
||||
@@ -85,8 +85,36 @@ impl Game<'_> {
|
||||
{
|
||||
m.extra.player_created = true;
|
||||
}
|
||||
self.trigger_golem_summoned(id);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
|
||||
/// whose box is within 5 blocks of the golem's box (the local player here).
|
||||
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
|
||||
let Some(golem) = id
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let half = f64::from(golem.width()) / 2.0;
|
||||
let height = f64::from(golem.height());
|
||||
let reach = 5.0;
|
||||
let [x, y, z] = me.position;
|
||||
let p = golem.position;
|
||||
// The player's box is 0.6 wide and 1.8 tall.
|
||||
let near = x + 0.3 >= p.x - half - reach
|
||||
&& x - 0.3 <= p.x + half + reach
|
||||
&& y + 1.8 >= p.y - reach
|
||||
&& y <= p.y + height + reach
|
||||
&& z + 0.3 >= p.z - half - reach
|
||||
&& z - 0.3 <= p.z + half + reach;
|
||||
if near {
|
||||
let view = self.mob_entity(&golem);
|
||||
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a golem.
|
||||
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some() || self.copper_golem_base(head)
|
||||
@@ -192,6 +220,7 @@ impl Game<'_> {
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
|
||||
self.trigger_golem_summoned(id);
|
||||
self.update_pattern_blocks(&cells)?;
|
||||
let direction = match facing.as_str() {
|
||||
"south" => horizon_core::geometry::Direction::South,
|
||||
|
||||
@@ -61,6 +61,11 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
|
||||
impl Game<'_> {
|
||||
/// Opening a screen stops breaking before the next game tick (which may pause).
|
||||
pub(super) fn stop_mining(&mut self) {
|
||||
if self.clicks.destroying.active {
|
||||
let pos = self.clicks.destroying.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, -1.0);
|
||||
}
|
||||
let mut destroying = std::mem::take(&mut self.clicks.destroying);
|
||||
destroying.stop(&mut GameHands {
|
||||
game: self,
|
||||
@@ -141,6 +146,12 @@ impl Game<'_> {
|
||||
progress: d.progress,
|
||||
ticks: d.ticks as u32,
|
||||
});
|
||||
if let Some(mining) = &self.mining {
|
||||
let pos = mining.pos;
|
||||
let progress = mining.progress;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, progress);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
@@ -578,6 +589,8 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let state = self.game.world.block(pos[0], pos[1], pos[2]);
|
||||
self.game.tutorial_destroy(state, 1.0);
|
||||
let r = self.game.break_block(pos);
|
||||
self.keep(r);
|
||||
true
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
|
||||
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
|
||||
//! LAN guests and dedicated servers use the same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
@@ -177,6 +177,8 @@ pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
/// The save's folder in `saves/`, for the world icon once joined.
|
||||
folder: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
@@ -287,6 +289,7 @@ impl Game<'_> {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
folder: folder.to_owned(),
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
@@ -318,13 +321,25 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
mut handle,
|
||||
player,
|
||||
folder,
|
||||
..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
|
||||
// the Select World list shows, and needs the save folder for it.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(&folder)
|
||||
.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,11 +344,24 @@ impl Game<'_> {
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
self.submit_item_lit(state, pose, light, lights, out);
|
||||
}
|
||||
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
|
||||
/// `Lighting.Entry` other than the level's), in the pose's output space.
|
||||
pub(super) fn submit_item_lit(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
lights: (glam::Vec3, glam::Vec3),
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut sink = WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
lights: Some(lights),
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
|
||||
@@ -148,6 +148,20 @@ impl Game<'_> {
|
||||
self.lan_songs.push((pos, Some(song.to_owned())));
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1010 on a client of a server: `playJukeboxSong`
|
||||
/// for the song the event's data names in the server's `jukebox_song`
|
||||
/// registry (`Registry.get(int).ifPresent`: an unknown id does nothing).
|
||||
/// The server's jukebox plays it; this client only hears it.
|
||||
pub(super) fn jukebox_song_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
if let Some(song) = jukebox_song_name(self.remote.as_ref().map(|r| &*r.registries), data) {
|
||||
self.start_song(pos, &song);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1011: `stopJukeboxSongAndNotifyNearby`.
|
||||
pub(super) fn jukebox_stop_event(&mut self, pos: [i32; 3]) {
|
||||
self.stop_sound(pos);
|
||||
self.notify_record_playing(pos, false);
|
||||
}
|
||||
fn stop_sound(&mut self, pos: [i32; 3]) {
|
||||
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
|
||||
self.audio.stop_and_release(id);
|
||||
@@ -256,15 +270,37 @@ impl Game<'_> {
|
||||
.map(|(k, id)| (*k, *id))
|
||||
.collect();
|
||||
for (k, id) in sounds {
|
||||
if !self.audio.engine.is_active(id)
|
||||
|| !matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing)
|
||||
{
|
||||
// A server's client has no playing jukebox of its own: its songs
|
||||
// stop with level event 1011, or when they end.
|
||||
let playing = matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing);
|
||||
if !keeps_song(
|
||||
self.audio.engine.is_active(id),
|
||||
self.remote.is_some(),
|
||||
playing,
|
||||
) {
|
||||
self.audio.stop_and_release(id);
|
||||
self.jukebox_sounds.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The song a level event 1010's data names: its id in the `jukebox_song`
|
||||
/// registry the server sent (`Registry.get(int)`). Only a server's client
|
||||
/// has one; a level that plays its own jukeboxes starts their songs itself.
|
||||
fn jukebox_song_name(
|
||||
registries: Option<&horizon_protocol::registry::Dynamic>,
|
||||
id: i32,
|
||||
) -> Option<String> {
|
||||
registries?
|
||||
.name("minecraft:jukebox_song", id)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// Whether a jukebox song keeps playing: until it ends, and, in a level that
|
||||
/// plays its own jukeboxes, while that jukebox plays. A server's client
|
||||
/// stops it at level event 1011 (`LevelRenderer.playingJukeboxSongs`).
|
||||
fn keeps_song(active: bool, server_client: bool, jukebox_playing: bool) -> bool {
|
||||
active && (server_client || jukebox_playing)
|
||||
}
|
||||
/// The jukebox's redstone signal (15 while a song plays) and comparator output.
|
||||
pub(super) fn jukebox_signals(
|
||||
world: &horizon_client::world::World,
|
||||
@@ -284,3 +320,36 @@ pub(super) fn jukebox_signals(
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{jukebox_song_name, keeps_song};
|
||||
use horizon_protocol::registry::Dynamic;
|
||||
|
||||
/// On a server's client the song comes from level event 1010, whose data
|
||||
/// is the song's id in the server's `jukebox_song` registry (#3173), and
|
||||
/// it plays on although this client's jukebox isn't playing.
|
||||
#[test]
|
||||
fn a_server_clients_jukebox_song_comes_from_level_event_1010() {
|
||||
let mut registries = Dynamic::default();
|
||||
registries.registries.insert(
|
||||
"minecraft:jukebox_song".into(),
|
||||
["minecraft:11", "minecraft:13", "minecraft:cat"]
|
||||
.map(|n| (n.to_owned(), None))
|
||||
.to_vec(),
|
||||
);
|
||||
assert_eq!(
|
||||
jukebox_song_name(Some(®istries), 2).as_deref(),
|
||||
Some("minecraft:cat")
|
||||
);
|
||||
assert_eq!(jukebox_song_name(Some(®istries), 3), None);
|
||||
assert_eq!(jukebox_song_name(Some(®istries), -1), None);
|
||||
assert_eq!(jukebox_song_name(None, 2), None);
|
||||
// A server's client: on until the song ends (or level event 1011).
|
||||
assert!(keeps_song(true, true, false));
|
||||
assert!(!keeps_song(false, true, false));
|
||||
// A level that plays its own jukebox: also until the jukebox stops.
|
||||
assert!(keeps_song(true, false, true));
|
||||
assert!(!keeps_song(true, false, false));
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ pub(super) struct LanHost {
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests
|
||||
/// see; the players' hurt time by entity id.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
@@ -177,7 +178,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:axolotl"
|
||||
| "minecraft:dolphin"
|
||||
| "minecraft:squid"
|
||||
| "minecraft:glow_squid" => Some(16),
|
||||
| "minecraft:glow_squid"
|
||||
| "minecraft:sulfur_cube" => Some(16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -325,10 +327,12 @@ fn mob_meta(
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
|
||||
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
|
||||
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, sprinting 0x08 (a dashing
|
||||
// cat or ocelot), swimming 0x10 (a drowned under water,
|
||||
// `Drowned.setSwimming`), invisible 0x20 (the effect's flag) and glowing
|
||||
// 0x40 (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.is_on_fire())
|
||||
| i8::from(m.extra.sprinting) << 3
|
||||
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
|
||||
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
|
||||
| i8::from(m.is_currently_glowing()) << 6;
|
||||
@@ -600,10 +604,11 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
|
||||
// guest swells the creeper from them. The direction always goes in,
|
||||
@@ -1242,15 +1247,53 @@ impl Game<'_> {
|
||||
/// Save a guest when they leave or the world closes: an integrated
|
||||
/// server's owner with the world (`save_game`), anyone else in their file.
|
||||
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(guest.vehicle);
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
if guest.placed {
|
||||
playerdata::store(guest, &mut self.level);
|
||||
playerdata::store(guest, &mut self.level, vehicle);
|
||||
}
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
playerdata::save(guest, &self.world_root, vehicle);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
|
||||
/// vehicle it rode rides it again, once the vehicle is in the world.
|
||||
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
|
||||
let waiting: Vec<(usize, [i32; 4])> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
|
||||
.filter(|(i, _)| {
|
||||
let g = &host.hub.guests[*i];
|
||||
let chunk = (
|
||||
(g.position[0].floor() as i32).div_euclid(16),
|
||||
(g.position[2].floor() as i32).div_euclid(16),
|
||||
);
|
||||
g.placed
|
||||
&& g.vehicle.is_none()
|
||||
&& g.dimension == self.dim.id()
|
||||
&& g.sent_chunks.contains(&chunk)
|
||||
})
|
||||
.collect();
|
||||
for (i, uuid) in waiting {
|
||||
let raw = host.hub.guests[i].saved.clone();
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
g.vehicle = Some(id);
|
||||
g.saved_vehicle = None;
|
||||
// Pair the stationary restored entities before their passenger packet.
|
||||
g.moved = true;
|
||||
}
|
||||
}
|
||||
/// The file name of a player's advancements and statistics: an
|
||||
/// integrated server's owner keeps single player's.
|
||||
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
|
||||
@@ -1321,14 +1364,28 @@ impl Game<'_> {
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
let Some(uuid) = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.owner.as_ref())
|
||||
.map(|o| o.uuid)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let riding = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == uuid && g.placed)
|
||||
.and_then(|g| g.vehicle)
|
||||
});
|
||||
// The vehicle the owner rides, by the UUID it is saved with.
|
||||
let vehicle = self.root_vehicle_uuid(riding);
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
|
||||
return;
|
||||
};
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level);
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1568,8 @@ impl Game<'_> {
|
||||
m.rider = None;
|
||||
}
|
||||
}
|
||||
// A guest whose file names the vehicle it rode rides it again.
|
||||
self.restore_guest_vehicles(&mut host);
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
@@ -1527,6 +1586,7 @@ impl Game<'_> {
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
@@ -1793,6 +1853,7 @@ impl Game<'_> {
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
@@ -2016,6 +2077,34 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
|
||||
// time and was not blocked by a shield broadcasts the damage event,
|
||||
// so the clients around (and the player's own) flash it red.
|
||||
let host_player = self.player.as_ref().map(|p| {
|
||||
(
|
||||
host.host_entity,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
p,
|
||||
)
|
||||
});
|
||||
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
|
||||
let players: Vec<_> = host_player
|
||||
.into_iter()
|
||||
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
|
||||
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
|
||||
.collect();
|
||||
for (id, at, hurt, record) in players {
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) {
|
||||
// Starving keeps no record: any type flashes the same.
|
||||
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, false, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
@@ -2124,11 +2213,13 @@ impl Game<'_> {
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
m.extra.main_hand = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.map(|s| s.item.clone());
|
||||
// The hurt red flash (LivingEntity.hurtTime).
|
||||
m.hurt_time = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra
|
||||
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
|
||||
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
|
||||
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
|
||||
.armor
|
||||
|
||||
@@ -626,7 +626,9 @@ impl Game<'_> {
|
||||
}) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
self.command_block_screen = host_command_block;
|
||||
// A command block the guest's use opened (Player.openCommandBlock).
|
||||
let guest_command_block =
|
||||
std::mem::replace(&mut self.command_block_screen, host_command_block).map(|s| s.pos());
|
||||
// A mount inventory the guest's action opened (shift + use, or the
|
||||
// inventory key while riding).
|
||||
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
|
||||
@@ -675,9 +677,7 @@ impl Game<'_> {
|
||||
let new_mode = self.mode_id();
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.selected = std::mem::replace(&mut g.selected, selected);
|
||||
// `g.selected` now holds the host's value: swap the inventories back.
|
||||
std::mem::swap(&mut self.selected, &mut g.selected);
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
@@ -779,6 +779,9 @@ impl Game<'_> {
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if let Some(pos) = guest_command_block {
|
||||
self.lan_open_command_block(host, conn, pos);
|
||||
}
|
||||
if let Some(pos) = opened_table {
|
||||
self.lan_open_crafting(host, conn, pos);
|
||||
}
|
||||
@@ -892,6 +895,12 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// The block entity's createMenu: a shulker box opens a ShulkerBoxMenu
|
||||
// (not a 9x3 ChestMenu), whose slots refuse shulker boxes.
|
||||
let menu = match slots {
|
||||
Slots::Block(_) => horizon_client::containers::block_menu_type(name).unwrap_or(menu),
|
||||
_ => menu,
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
@@ -2035,8 +2044,11 @@ impl Game<'_> {
|
||||
let d = vanilla::game_dispatcher();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
let ((start, end), mut suggestions) =
|
||||
d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
// ServerCommandSuggestionsProvider.limitSuggestionCount.
|
||||
suggestions.truncate(1000);
|
||||
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
w.varint(id);
|
||||
|
||||
@@ -162,6 +162,22 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
}
|
||||
// LivingEntity.setSprinting: SPEED_MODIFIER_SPRINTING (a dashing cat or ocelot).
|
||||
if let (true, Some(a)) = (
|
||||
m.extra.sprinting,
|
||||
self.synced_attribute(kind, "minecraft:movement_speed"),
|
||||
) {
|
||||
add_modifier(
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, Attr::MovementSpeed),
|
||||
AttributeModifier {
|
||||
id: "minecraft:sprinting".into(),
|
||||
amount: f64::from(0.3f32),
|
||||
operation: 2,
|
||||
},
|
||||
);
|
||||
}
|
||||
let base = |name: &str| {
|
||||
Attr::ALL
|
||||
.into_iter()
|
||||
|
||||
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user