mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
Compare commits
250
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221d303a90 | ||
|
|
83cb8979be | ||
|
|
1f10f8bbb0 | ||
|
|
67de3115e2 | ||
|
|
53aab90fb5 | ||
|
|
0e120f96d5 | ||
|
|
1f1fa1fc4d | ||
|
|
8ffe3913ea | ||
|
|
87fe5531ed | ||
|
|
255baf27e3 | ||
|
|
a713ed14e5 | ||
|
|
4973e64a25 | ||
|
|
0a42c8f111 | ||
|
|
8c1118a3cf | ||
|
|
6a88a1335d | ||
|
|
e02af2c3bf | ||
|
|
a31f9f4763 | ||
|
|
50486a59b0 | ||
|
|
e6ab43107a | ||
|
|
912e18ed0a | ||
|
|
eef3c52455 | ||
|
|
0e4511ae40 | ||
|
|
dfa0603931 | ||
|
|
cf3d748474 | ||
|
|
865f209838 | ||
|
|
aefc8c5254 | ||
|
|
b1f72f3449 | ||
|
|
450a11d633 | ||
|
|
cb0513691e | ||
|
|
b36353a08a | ||
|
|
aeca67e1e7 | ||
|
|
95cfe9ab5c | ||
|
|
7b3a3d06fa | ||
|
|
d5f9b60203 | ||
|
|
fbd69c11f1 | ||
|
|
6ed7be61d9 | ||
|
|
aff92ca2df | ||
|
|
54ee56ea1c | ||
|
|
9221eaf7b5 | ||
|
|
7b42678bcd | ||
|
|
e7af6c5683 | ||
|
|
26f0f7b710 | ||
|
|
37518b7953 | ||
|
|
c5ebfaad17 | ||
|
|
1ce50ca842 | ||
|
|
03fe63b323 | ||
|
|
0e9d4928a1 | ||
|
|
10dd71a575 | ||
|
|
f722c4455a | ||
|
|
ff4dd57b32 | ||
|
|
6e7600425c | ||
|
|
9ba4c83c35 | ||
|
|
98d56df183 | ||
|
|
ec5b969a9c | ||
|
|
fddebc2c59 | ||
|
|
d4f99b0b3b | ||
|
|
cc3b9703a4 | ||
|
|
85e2eddf52 | ||
|
|
d907493401 | ||
|
|
09ab1bf799 | ||
|
|
c822ad5a5b | ||
|
|
550bfa1815 | ||
|
|
9b6036466e | ||
|
|
9c1241b581 | ||
|
|
9fb79e9ae5 | ||
|
|
ee286fe931 | ||
|
|
926790a953 | ||
|
|
5991716f9b | ||
|
|
41bf552edc | ||
|
|
705826487f | ||
|
|
87327e8228 | ||
|
|
743c4af6a8 | ||
|
|
8f761ea16e | ||
|
|
50be3bbb80 | ||
|
|
d4e561413b | ||
|
|
ce94025311 | ||
|
|
885a98f2df | ||
|
|
a72bf4935b | ||
|
|
4af062b305 | ||
|
|
a4b68b5d40 | ||
|
|
14c1840967 | ||
|
|
7866f95d80 | ||
|
|
ab98b12b18 | ||
|
|
d7b00ddb4b | ||
|
|
9fcedb7da9 | ||
|
|
2a17e04399 | ||
|
|
bbe2b1be3c | ||
|
|
1b30bc56af | ||
|
|
ee2e56ced0 | ||
|
|
32a7bbc853 | ||
|
|
a02e0034cf | ||
|
|
4a93a8edcc | ||
|
|
f09665e100 | ||
|
|
41036d79a3 | ||
|
|
dba73d15d4 | ||
|
|
ea6b0327df | ||
|
|
4c3c5d6dd1 | ||
|
|
53d72939bc | ||
|
|
e2f5019d91 | ||
|
|
dc468324cf | ||
|
|
bffad5a2aa | ||
|
|
db8460de3a | ||
|
|
a1b69de138 | ||
|
|
857436a4f7 | ||
|
|
dd415f3cf3 | ||
|
|
2f0107b159 | ||
|
|
93831bb101 | ||
|
|
404c0d8314 | ||
|
|
159615221a | ||
|
|
4f5fa9c7ed | ||
|
|
50f9032a65 | ||
|
|
511921359c | ||
|
|
1158ed5a3a | ||
|
|
3eaaf3070c | ||
|
|
74abfb6fa7 | ||
|
|
0990beeb2b | ||
|
|
4a73bc0b34 | ||
|
|
e631c28418 | ||
|
|
4871665711 | ||
|
|
594e8622a7 | ||
|
|
ea212b9152 | ||
|
|
a0677225dd | ||
|
|
7950bfbc6f | ||
|
|
c9cd62bd5b | ||
|
|
1aec392a34 | ||
|
|
dd81778073 | ||
|
|
1c93067327 | ||
|
|
b47d5104da | ||
|
|
6395c90680 | ||
|
|
43f69a7018 | ||
|
|
197aeeeea6 | ||
|
|
6b7e237730 | ||
|
|
b6fc1844be | ||
|
|
9fcdc6481d | ||
|
|
5df2749ffe | ||
|
|
8e942033a2 | ||
|
|
da83875ae3 | ||
|
|
1405044d09 | ||
|
|
be247b49e0 | ||
|
|
55509cffaf | ||
|
|
58d23e2654 | ||
|
|
fc51d09b64 | ||
|
|
b782c0d9b5 | ||
|
|
4264ba0c4e | ||
|
|
36fb430fbc | ||
|
|
0ea7d1707c | ||
|
|
8933e8c2d8 | ||
|
|
226a707d0d | ||
|
|
acd16c9ae8 | ||
|
|
5f0ea2347a | ||
|
|
5c6f115aa6 | ||
|
|
9e7fc5f18c | ||
|
|
5499eea24e | ||
|
|
6a4ea1890f | ||
|
|
9dc8b78a8f | ||
|
|
20d37726a2 | ||
|
|
a95c4653f4 | ||
|
|
2d79ae6ace | ||
|
|
dc2b1acd07 | ||
|
|
0f5b71e021 | ||
|
|
2a4d90b9cf | ||
|
|
22bfb7d055 | ||
|
|
80687d32d1 | ||
|
|
82d1afa019 | ||
|
|
c7c8dd8f82 | ||
|
|
74a38d29aa | ||
|
|
e10e2144bd | ||
|
|
b13153310c | ||
|
|
3a642c76c0 | ||
|
|
79551ef835 | ||
|
|
d4a019ad08 | ||
|
|
a794f852d7 | ||
|
|
ff65ebc7fc | ||
|
|
6767556a09 | ||
|
|
0a079cdd75 | ||
|
|
fd70c6aa11 | ||
|
|
c451a0eca5 | ||
|
|
dc2a951126 | ||
|
|
722f39f691 | ||
|
|
218dc467c4 | ||
|
|
a79f9c81ba | ||
|
|
222d449920 | ||
|
|
bd35a34d53 | ||
|
|
d6020c9fcc | ||
|
|
bc3893a448 | ||
|
|
79f8b1c258 | ||
|
|
46b6725e3e | ||
|
|
b529106e69 | ||
|
|
78bfaf571a | ||
|
|
30e60a02ec | ||
|
|
7ae38e4441 | ||
|
|
469ca1d618 | ||
|
|
d18ae680a9 | ||
|
|
51e490f6f9 | ||
|
|
cc900e6c6e | ||
|
|
6b6d099127 | ||
|
|
a05066c560 | ||
|
|
59b4f00696 | ||
|
|
8e8f6ff6fd | ||
|
|
e916d5a66c | ||
|
|
1d7c9f150e | ||
|
|
fdd5bac5a8 | ||
|
|
1d60510ba1 | ||
|
|
3e9ac65dba | ||
|
|
820af79e89 | ||
|
|
c1cf271fbb | ||
|
|
96665e3a8e | ||
|
|
98ff540d65 | ||
|
|
9d5f89a756 | ||
|
|
f881b09c67 | ||
|
|
624b34b3f9 | ||
|
|
352a141050 | ||
|
|
f6fbdbeebc | ||
|
|
f52894e0dc | ||
|
|
0fb13fa3c8 | ||
|
|
e5332ab63e | ||
|
|
909d83c284 | ||
|
|
e104190b38 | ||
|
|
f67f4687a1 | ||
|
|
3e7b3fe7a1 | ||
|
|
2caff14d10 | ||
|
|
dc59cbc710 | ||
|
|
da831bab1d | ||
|
|
f25a5afc00 | ||
|
|
cc03d693a2 | ||
|
|
d9ef984808 | ||
|
|
d3408a7e1e | ||
|
|
0f23e5e538 | ||
|
|
45a2f32631 | ||
|
|
ef85f6dd7b | ||
|
|
a3c2a271ef | ||
|
|
8fe0b417be | ||
|
|
13a8b0c3fe | ||
|
|
7fd4a41cec | ||
|
|
6ddde6fcde | ||
|
|
770f30306a | ||
|
|
323705ef9b | ||
|
|
94a2f9a782 | ||
|
|
fc4a51753d | ||
|
|
af8fa33bf0 | ||
|
|
12d3b1f189 | ||
|
|
d0f851fca9 | ||
|
|
7a08fd9041 | ||
|
|
32e184c196 | ||
|
|
957ecaff5f | ||
|
|
3cd63747b9 | ||
|
|
99f76baf29 | ||
|
|
52939c99ab | ||
|
|
8590b6e650 | ||
|
|
70f07d8757 |
No files matched your search
@@ -0,0 +1,34 @@
|
||||
name: runner watch
|
||||
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
|
||||
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
|
||||
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
|
||||
# gh workflow run "runner watch" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '11,41 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with the issue
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: runner-watch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
watch:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Jobs waiting for a self-hosted runner
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
+145
-45
@@ -2,6 +2,9 @@
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
>
|
||||
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
|
||||
> the mods decide moderation matters). The council can overturn this amendment by poll.
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
@@ -50,13 +53,13 @@ the Horizon Team, for example.
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
@@ -64,6 +67,55 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
### 2.1 Three kinds of action
|
||||
|
||||
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
|
||||
|
||||
| Kind | What | How |
|
||||
| --- | --- | --- |
|
||||
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
|
||||
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
|
||||
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
|
||||
|
||||
Free actions, for example:
|
||||
|
||||
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
|
||||
- a moderator gives or removes 🤝 Support, through Kira;
|
||||
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
|
||||
unbans;
|
||||
- the Team merges and labels;
|
||||
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
|
||||
- a lead pins, renames or creates threads in their group's channels;
|
||||
- Kira's automatic actions.
|
||||
|
||||
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
|
||||
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
|
||||
within 24 hours of a result.
|
||||
|
||||
### 2.2 Who may do what
|
||||
|
||||
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
|
||||
council matter.
|
||||
|
||||
| Post | Decides | Discord | Official server | GitHub |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
|
||||
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
|
||||
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
|
||||
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
|
||||
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
|
||||
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
|
||||
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
|
||||
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
|
||||
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
|
||||
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
|
||||
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
|
||||
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
|
||||
|
||||
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
|
||||
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
|
||||
posts as a list of clicks for a named person.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
@@ -84,13 +136,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
|
||||
group's elections and decisions (section 5.3).
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
@@ -151,7 +203,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
@@ -165,20 +217,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
|
||||
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
|
||||
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
|
||||
discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
@@ -220,7 +272,7 @@ false age declaration (section 9).
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
@@ -254,25 +306,63 @@ A nomination is refused below these floors.
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
### 5.3 Elections
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
|
||||
for a recall.
|
||||
|
||||
| Post | Who votes | Passes with | Who can start it |
|
||||
| --- | --- | --- | --- |
|
||||
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
|
||||
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
|
||||
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
|
||||
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
|
||||
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
|
||||
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
|
||||
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
|
||||
|
||||
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
|
||||
and has at least one merged pull request or review with the group's label in the last 60 days.
|
||||
|
||||
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
|
||||
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
|
||||
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
|
||||
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
|
||||
the voters judge the rest.
|
||||
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
|
||||
runs. Kira shows how many voted, never who. It closes early once the result can't change.
|
||||
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
|
||||
rest as clicks for a named person. Only the result is published, never the ballots.
|
||||
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
|
||||
majority, without the people involved) decides; if it passes, Kira undoes the result.
|
||||
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
|
||||
|
||||
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
|
||||
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
|
||||
|
||||
#### Hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Fewer than 3 can vote | The council decides by poll |
|
||||
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
|
||||
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
|
||||
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
|
||||
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
|
||||
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
|
||||
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
|
||||
| The council is already voting on it | No election starts for the same person and post |
|
||||
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
|
||||
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
|
||||
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
|
||||
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
|
||||
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
|
||||
|
||||
#### Reminders, not nominations
|
||||
|
||||
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
|
||||
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
|
||||
lists privately the people who meet a floor. These are suggestions: only people start elections.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
@@ -299,8 +389,8 @@ holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
|
||||
floor and is nominated within its first 12 hours, both go on the same ballot.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
@@ -339,8 +429,9 @@ the design system:
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- giving and removing the group's helper role, through Kira (a free action);
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
@@ -356,21 +447,28 @@ the design system:
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
|
||||
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
|
||||
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
|
||||
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
|
||||
decision, any council member can call a council poll, and a simple majority overturns it.
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
|
||||
involved don't vote, and the council's veto applies. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
|
||||
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
|
||||
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
|
||||
any other, and the mods can reverse it by vote.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
@@ -458,7 +556,9 @@ and the founder carries it out.
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
|
||||
and body votes are posted there too; for elections, only the result.
|
||||
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
|
||||
@@ -70,6 +70,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
|
||||
one ACK per completed batch; use explore for a controlled pacing test
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
@@ -171,6 +173,8 @@ struct Options {
|
||||
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
|
||||
crits: bool,
|
||||
flood: usize,
|
||||
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
|
||||
ack_rate: Option<f32>,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
@@ -209,6 +213,7 @@ fn options() -> Result<Options, String> {
|
||||
no_swing: false,
|
||||
crits: false,
|
||||
flood: 100,
|
||||
ack_rate: None,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
@@ -266,6 +271,13 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
|
||||
"--ack-mode" => {
|
||||
o.ack_rate = match value.as_str() {
|
||||
"vanilla" => None,
|
||||
"64" => Some(64.0),
|
||||
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
|
||||
};
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
@@ -950,6 +962,7 @@ fn read(
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
ack_rate: Option<f32>,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
@@ -1035,7 +1048,8 @@ fn read(
|
||||
batch.finished(r.varint().unwrap_or(0));
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: batch.desired_chunks_per_tick(),
|
||||
chunks_per_tick: ack_rate
|
||||
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
@@ -1139,7 +1153,8 @@ fn run_bot(
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
let ack_rate = o.ack_rate;
|
||||
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1519,6 +1534,7 @@ fn main() {
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
|
||||
"pvp": o.pvp,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
@@ -1584,6 +1600,83 @@ mod tests {
|
||||
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
|
||||
};
|
||||
|
||||
/// Exercise the production reader over framed connections. Pings are barriers
|
||||
/// so extra ACKs are observable without sleeps or timing guesses.
|
||||
fn check_chunk_acks(ack_rate: Option<f32>) {
|
||||
use horizon_protocol::connection::Pipe;
|
||||
let (client, server) = Pipe::pair();
|
||||
let (reader, writer) = Connection::local(client).split();
|
||||
let mut server = Connection::local(server);
|
||||
server
|
||||
.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let bot = Arc::new(Bot::default());
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
let handle = thread::spawn(move || {
|
||||
read(
|
||||
reader,
|
||||
Arc::new(Mutex::new(writer)),
|
||||
bot,
|
||||
false,
|
||||
ack_rate,
|
||||
None,
|
||||
)
|
||||
});
|
||||
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"batch start must not send an ACK"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
|
||||
w.varint(size)
|
||||
}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
|
||||
let rate = Reader::new(&body).f32().unwrap();
|
||||
if let Some(fixed) = ack_rate {
|
||||
assert_eq!(rate, fixed);
|
||||
} else if size == 0 {
|
||||
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
|
||||
} else {
|
||||
assert!(rate.is_finite() && rate > 0.0);
|
||||
}
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"exactly one ACK per finished batch"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
}
|
||||
server.writer.shutdown();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_64_ack_is_sent_once_per_finished_batch() {
|
||||
check_chunk_acks(Some(64.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_acks_remain_once_per_finished_batch() {
|
||||
check_chunk_acks(None);
|
||||
}
|
||||
|
||||
fn feed(entities: &mut Entities, packet: &Clientbound) {
|
||||
let bytes = packet.encode();
|
||||
let mut r = Reader::new(&bytes);
|
||||
|
||||
@@ -514,6 +514,34 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_fallback_references_stay_bounded() {
|
||||
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
|
||||
// 4 KB component, which ran a client out of memory.
|
||||
let bomb = |depth: usize, text: &str| {
|
||||
let pattern = "%1$s".repeat(300);
|
||||
let mut v = serde_json::json!({ "text": text });
|
||||
for _ in 0..depth {
|
||||
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
|
||||
}
|
||||
v
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
|
||||
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
|
||||
// One long argument referenced many times multiplies bytes, not spans.
|
||||
let long = "B".repeat(64 << 10);
|
||||
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
|
||||
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
|
||||
assert!(bytes <= 4 << 20, "{bytes} bytes");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A real fallback still formats.
|
||||
let ok = serde_json::json!({
|
||||
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
|
||||
});
|
||||
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
|
||||
}
|
||||
use crate::lang::{self, Language, ResourceSource};
|
||||
|
||||
struct Mem(std::collections::HashMap<String, Vec<u8>>);
|
||||
|
||||
@@ -529,10 +529,13 @@ impl Team {
|
||||
None => t,
|
||||
}
|
||||
}
|
||||
/// `PlayerTeam.getFormattedDisplayName`.
|
||||
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
|
||||
/// `displayNameStyle`, which its constructor makes the team's name on hover
|
||||
/// (`ShowText`) and as insertion.
|
||||
pub fn formatted_display_name(&self) -> Text {
|
||||
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
|
||||
click: None,
|
||||
insertion: Some(self.name.clone()),
|
||||
..Style::default()
|
||||
});
|
||||
self.apply_color(bracketed(inner))
|
||||
@@ -1411,6 +1414,21 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
|
||||
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
|
||||
#[test]
|
||||
fn a_teams_formatted_name_inserts_the_team_name() {
|
||||
let team = Team::new("reds");
|
||||
let c = team.formatted_display_name().component();
|
||||
assert_eq!(c["translate"], "chat.square_brackets");
|
||||
let inner = &c["with"][0];
|
||||
assert_eq!(inner["text"], "reds");
|
||||
assert_eq!(inner["insertion"], "reds");
|
||||
assert_eq!(inner["hover_event"]["action"], "show_text");
|
||||
// `{"text":"reds"}` or its short form `"reds"`: the same component.
|
||||
let value = &inner["hover_event"]["value"];
|
||||
assert!(value == "reds" || value["text"] == "reds", "{value}");
|
||||
}
|
||||
|
||||
fn board() -> Scoreboard {
|
||||
let mut sb = Scoreboard::default();
|
||||
|
||||
@@ -282,17 +282,20 @@ impl Text {
|
||||
pub fn push(&mut self, other: impl Into<Text>) {
|
||||
let other = other.into();
|
||||
let sibling = other.component();
|
||||
let mut root = if self.spans.is_empty() && self.source.is_none() {
|
||||
json!({ "text": "" })
|
||||
} else {
|
||||
let c = self.component();
|
||||
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
|
||||
json!({ "text": "", "extra": [c] })
|
||||
} else {
|
||||
c
|
||||
}
|
||||
// The tree is appended to in place: cloning all of it on every append made a component
|
||||
// with N siblings (a server's chat or MOTD "extra") cost N^2.
|
||||
let root = match self.source.take() {
|
||||
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
|
||||
None => std::sync::Arc::new(self.component()),
|
||||
Some(c) => c,
|
||||
};
|
||||
if let Value::Object(o) = &mut root {
|
||||
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
|
||||
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
|
||||
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
|
||||
} else {
|
||||
root
|
||||
};
|
||||
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
|
||||
match o.get_mut("extra") {
|
||||
Some(Value::Array(a)) => a.push(sibling),
|
||||
_ => {
|
||||
@@ -300,7 +303,7 @@ impl Text {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.source = Some(root.into());
|
||||
self.source = Some(root);
|
||||
self.spans.extend(other.spans);
|
||||
}
|
||||
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
|
||||
@@ -671,11 +674,22 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
|
||||
/// references to its arguments, so its cost stays linear in the input; copying them here makes
|
||||
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
|
||||
/// `fallback` template is any text it likes.
|
||||
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
|
||||
/// The most text one translation expands to: one long argument referenced many times multiplies
|
||||
/// bytes, not spans.
|
||||
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim.
|
||||
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
|
||||
/// [`MAX_TRANSLATION_BYTES`].
|
||||
pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let mut out = Text::default();
|
||||
let mut bytes = 0usize;
|
||||
let mut literal = String::new();
|
||||
let mut next = 0usize;
|
||||
let chars: Vec<char> = template.chars().collect();
|
||||
@@ -725,6 +739,14 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let Some(arg) = args.get(index) else {
|
||||
return Text::literal(template);
|
||||
};
|
||||
bytes = bytes
|
||||
.saturating_add(literal.len())
|
||||
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
|
||||
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|
||||
|| bytes > MAX_TRANSLATION_BYTES
|
||||
{
|
||||
return Text::literal(template);
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
out.spans.push(Span {
|
||||
text: std::mem::take(&mut literal),
|
||||
@@ -750,6 +772,42 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn appending_builds_the_same_tree_in_linear_time() {
|
||||
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
|
||||
// takes the siblings itself; a copy taken before an append keeps its own tree.
|
||||
let mut styled = Text::literal("a").with_style(&Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
styled.push(Text::literal("b"));
|
||||
let before = styled.clone();
|
||||
styled.push(Text::literal("c"));
|
||||
assert_eq!(
|
||||
styled.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
|
||||
);
|
||||
assert_eq!(
|
||||
before.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
|
||||
);
|
||||
let mut plain = Text::default();
|
||||
plain.push(Text::literal("x"));
|
||||
plain.push(Text::literal("y"));
|
||||
assert_eq!(
|
||||
plain.component(),
|
||||
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
|
||||
);
|
||||
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
|
||||
// whole tree.
|
||||
let started = std::time::Instant::now();
|
||||
let mut long = Text::default();
|
||||
for _ in 0..20_000 {
|
||||
long.push(Text::literal("a"));
|
||||
}
|
||||
assert_eq!(long.spans.len(), 20_000);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
}
|
||||
#[test]
|
||||
fn click_events_serialize_and_inherit_as_one_style_field() {
|
||||
for (event, json) in [
|
||||
(
|
||||
|
||||
@@ -6,7 +6,12 @@ use horizon_world::{
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -182,6 +187,23 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
/// Every block registry [`Assets::load`] has handed out, one per distinct
|
||||
/// content. Callers keep `&'static` references, so a registry is never freed;
|
||||
/// loading equal data again (tests load the same root once per fixture)
|
||||
/// returns the one already leaked instead of leaking another (about 11 MB for
|
||||
/// vanilla).
|
||||
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
|
||||
|
||||
/// `registry` leaked, or the equal one leaked before it.
|
||||
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
|
||||
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
|
||||
return same;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
leaked.push(registry);
|
||||
registry
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
|
||||
@@ -256,7 +278,7 @@ impl Assets {
|
||||
// Chunks hold every state of this registry: content mods can take it
|
||||
// past vanilla's 16-bit global palette.
|
||||
crate::chunk::set_block_states(registry.len());
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
let registry = intern_registry(registry);
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
@@ -320,6 +342,16 @@ impl RenderAssets {
|
||||
}
|
||||
None => crate::model_library::ParsedModels::default(),
|
||||
};
|
||||
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
|
||||
// layer of its own, decided before the models insert their textures.
|
||||
let ctm_rules = if draws {
|
||||
horizon_optifine::ctm::load(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
|
||||
&ctm_rules,
|
||||
));
|
||||
let mut models = if draws {
|
||||
let _step = crate::spans::startup::step("core.models_bake");
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
@@ -359,6 +391,7 @@ impl RenderAssets {
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
ctm_rules,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
@@ -496,6 +529,29 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_registries_are_leaked_once() {
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
let registry = |extra: &str| {
|
||||
let facts = format!(
|
||||
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["{extra}",false,false,"","fixture:{extra}"]
|
||||
]}}"#
|
||||
);
|
||||
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
|
||||
};
|
||||
let first = super::intern_registry(registry("interned_once"));
|
||||
let again = super::intern_registry(registry("interned_once"));
|
||||
let other = super::intern_registry(registry("interned_other"));
|
||||
assert!(std::ptr::eq(first, again), "equal content leaked twice");
|
||||
assert!(!std::ptr::eq(first, other), "different content shared");
|
||||
assert_eq!(
|
||||
other.default_state("fixture:interned_other"),
|
||||
Some(horizon_world::registry::BlockStateId(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
|
||||
@@ -1974,7 +1974,11 @@ impl ChatScreen {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
// Round instead of truncating: touchpad/PixelDelta produces fractional
|
||||
// amounts and truncation drops movement in one direction (#14338).
|
||||
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
|
||||
.round()
|
||||
.clamp(0.0, max as f64) as usize;
|
||||
true
|
||||
}
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input. The
|
||||
@@ -2086,6 +2090,11 @@ impl ChatScreen {
|
||||
) {
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
// SuggestionsList.extractRenderState: the whole list asks for the
|
||||
// pointing hand while the pointer is inside its rectangle.
|
||||
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
|
||||
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
|
||||
}
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
let moved = l.last_mouse != mouse;
|
||||
|
||||
@@ -1066,3 +1066,32 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
fn the_suggestion_list_asks_for_the_pointing_hand() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/");
|
||||
let rect = screen
|
||||
.suggestions
|
||||
.as_ref()
|
||||
.expect("the root commands are a suggestion list")
|
||||
.rect;
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
|
||||
assert_eq!(
|
||||
crate::widgets::take_cursor(),
|
||||
crate::widgets::Cursor::PointingHand
|
||||
);
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
|
||||
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
|
||||
}
|
||||
@@ -124,15 +124,25 @@ fn sprite_name(resource: &str) -> String {
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
|
||||
/// [`Sprites::keep_apart`]: before the models insert theirs.
|
||||
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
|
||||
(rules.iter())
|
||||
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
|
||||
.map(|t| sprite_name(t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
|
||||
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
|
||||
/// by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
mut rules: Vec<Rule>,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules with a custom name load no tiles: section meshes see no block entity.
|
||||
rules.retain(|r| r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
@@ -1270,6 +1280,41 @@ mod tests {
|
||||
assert!(!ctm.stacked_pane_hides(3, 3, post));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_name_the_sprites_they_match() {
|
||||
// `matchTiles` among the textures and beside the file (a chain onto another rule's
|
||||
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
|
||||
// under.
|
||||
let dir = "assets/minecraft/optifine/ctm/pack/crops";
|
||||
let rule = |file: &str, text: &str| {
|
||||
Rule::parse(
|
||||
&format!("{dir}/{file}"),
|
||||
&horizon_optifine::properties::Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let rules = [
|
||||
rule(
|
||||
"1.properties",
|
||||
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
|
||||
),
|
||||
rule(
|
||||
"2.properties",
|
||||
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
|
||||
connectTiles=textures/block/stone.png\ntiles=0-16",
|
||||
),
|
||||
];
|
||||
assert_eq!(
|
||||
ConnectedTextures::matched_sprites(&rules),
|
||||
[
|
||||
format!("{dir}/19.png"),
|
||||
"minecraft:block/extra/placeholder".to_owned(),
|
||||
"minecraft:block/stone".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -12,6 +12,8 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
mod noise;
|
||||
|
||||
pub struct Environment {
|
||||
system: System,
|
||||
/// What the packs' expressions read; the frame sets it before sampling.
|
||||
@@ -559,13 +561,9 @@ impl<'a> BlockHost<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
|
||||
std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| {
|
||||
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
|
||||
})
|
||||
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
|
||||
noise::builtin()
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
@@ -615,7 +613,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
(V::Object(2), "noise") => {
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(f64::from(polytone_noise().get2(x, y)))
|
||||
num(polytone_noise().get(x, y))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
|
||||
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
|
||||
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
|
||||
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
|
||||
//! `{1}` or `{0, 1}` matches this construction too.
|
||||
|
||||
use horizon_core::noise::SimplexNoise;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
|
||||
pub(crate) struct PerlinSimplex {
|
||||
levels: Vec<Option<SimplexNoise>>,
|
||||
input_factor: f64,
|
||||
value_factor: f64,
|
||||
}
|
||||
|
||||
impl PerlinSimplex {
|
||||
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
|
||||
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
|
||||
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
|
||||
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let lowest = octaves.iter().copied().min().unwrap_or(0);
|
||||
let highest = octaves.iter().copied().max().unwrap_or(0);
|
||||
let count = (highest - lowest + 1) as usize;
|
||||
let first = SimplexNoise::new(&mut random);
|
||||
let top = highest;
|
||||
let wanted = |index: i32| octaves.contains(&(top - index));
|
||||
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
|
||||
for index in top + 1..count as i32 {
|
||||
if index >= 0 && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut random));
|
||||
} else {
|
||||
random.consume_count(262);
|
||||
}
|
||||
}
|
||||
if highest > 0 {
|
||||
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
|
||||
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
|
||||
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
|
||||
let mut upper = LegacyRandom::new(value as i64);
|
||||
for index in (0..top).rev() {
|
||||
if (index as usize) < count && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
|
||||
} else {
|
||||
upper.consume_count(262);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (0..count as i32).contains(&top) && octaves.contains(&0) {
|
||||
levels[top as usize] = Some(first);
|
||||
}
|
||||
Self {
|
||||
levels,
|
||||
input_factor: 2f64.powi(highest),
|
||||
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
|
||||
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
|
||||
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
|
||||
for level in &self.levels {
|
||||
if let Some(noise) = level {
|
||||
sum += noise.get2_f64(x * input, y * input) * value;
|
||||
}
|
||||
input /= 2.0;
|
||||
value *= 2.0;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
|
||||
pub(crate) fn builtin() -> &'static PerlinSimplex {
|
||||
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
|
||||
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
|
||||
#[test]
|
||||
fn the_builtin_noise_reads_what_the_mod_draws() {
|
||||
for (x, z, mod_value) in [
|
||||
(0, 10, -0.28373),
|
||||
(3, 10, -0.14683),
|
||||
(10, 0, 0.31833),
|
||||
(24, 14, -0.55941),
|
||||
(40, 25, -0.48358),
|
||||
(7, 3, -0.68202),
|
||||
] {
|
||||
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
|
||||
assert!(
|
||||
(value - mod_value).abs() < 0.02,
|
||||
"({x}, {z}): {value} against {mod_value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
|
||||
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
|
||||
#[test]
|
||||
fn two_octaves_weigh_a_third_and_two_thirds() {
|
||||
let both = PerlinSimplex::new(0, &[0, 1]);
|
||||
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
|
||||
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
|
||||
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
|
||||
assert!((both.get(x, y) - mixed).abs() < 1e-12);
|
||||
}
|
||||
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ mod entity_outline;
|
||||
mod fluid;
|
||||
#[cfg(test)]
|
||||
mod inter_stage_tests;
|
||||
mod section_fade;
|
||||
mod terrain_model;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
@@ -74,7 +75,10 @@ pub use entity_batch::{
|
||||
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
|
||||
mark_oit,
|
||||
};
|
||||
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
|
||||
use section_fade::{
|
||||
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
|
||||
section_visibility,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -190,7 +194,6 @@ mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
pub mod external;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_culling;
|
||||
@@ -367,62 +370,6 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
|
||||
let whole = camera.floor();
|
||||
whole + ((camera - whole) * 65536.0).round() / 65536.0
|
||||
}
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
struct FadeInputs<'a> {
|
||||
now: Instant,
|
||||
fade_ms: u64,
|
||||
uploaded: &'a FxHashMap<SectionKey, Instant>,
|
||||
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
/// A new value for `Renderer::block_entity_revision`.
|
||||
fn next_block_entity_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -1178,6 +1125,8 @@ pub struct Renderer {
|
||||
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
|
||||
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
|
||||
packed_quads: bool,
|
||||
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
|
||||
packed_on: bool,
|
||||
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
|
||||
/// `HORIZON_VISBUFFER=1`).
|
||||
visbuf: Option<visbuffer::Visbuffer>,
|
||||
@@ -1420,10 +1369,8 @@ pub struct Renderer {
|
||||
/// Per-frame scratch, kept to avoid allocating every frame.
|
||||
visible: Vec<resident::Visible>,
|
||||
visible_lods: Vec<lod::Visible>,
|
||||
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
|
||||
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
|
||||
shadow_visible: Vec<resident::Visible>,
|
||||
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
|
||||
external: Option<Box<dyn external::ExternalPipeline>>,
|
||||
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
|
||||
origins: Vec<[f32; 4]>,
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
@@ -4957,8 +4904,8 @@ impl Renderer {
|
||||
}
|
||||
None => (solid, solid_opaque, solid_cutout, solid_mirror),
|
||||
};
|
||||
// The solid quads in the compact layout, while no shader pack reads the arena
|
||||
// (`follow_packed_layout`).
|
||||
// The solid quads in the compact layout where every pass reads it; the first world
|
||||
// frames settle it for the picked pack (`follow_packed_layout`).
|
||||
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
|
||||
packed::pack_quads(packed_quads);
|
||||
// A native mod's world warp builds its pipelines with these layouts once it is given one.
|
||||
@@ -5126,6 +5073,7 @@ impl Renderer {
|
||||
world_warps,
|
||||
flat_on: flat.is_some(),
|
||||
packed_quads,
|
||||
packed_on: packed_wanted(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
gpu_culling: None,
|
||||
@@ -5294,7 +5242,6 @@ impl Renderer {
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
external: None,
|
||||
block_entities: Default::default(),
|
||||
block_entity_section_revisions: Default::default(),
|
||||
block_entity_membership_revisions: Default::default(),
|
||||
@@ -5678,9 +5625,6 @@ impl Renderer {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.resize([width, height]);
|
||||
}
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -6766,13 +6710,17 @@ impl Renderer {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
}
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
|
||||
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
|
||||
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
|
||||
/// for the game to mesh again, as `follow_translucent_layout` does.
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
|
||||
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
|
||||
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
|
||||
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
|
||||
/// mesh again, as `follow_translucent_layout` does.
|
||||
fn follow_packed_layout(&mut self) {
|
||||
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
|
||||
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
|
||||
let want = self.flat.is_some()
|
||||
&& (!pack || self.pack_terrain_flat)
|
||||
&& self.native.is_none()
|
||||
&& self.packed_on;
|
||||
if want == self.packed_quads {
|
||||
return;
|
||||
}
|
||||
@@ -6783,6 +6731,10 @@ impl Renderer {
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: compact solid terrain {}",
|
||||
if want { "on" } else { "off" }
|
||||
);
|
||||
self.packed_quads = want;
|
||||
packed::pack_quads(want);
|
||||
let laid_out = self.sections.values().any(|s| s.packed)
|
||||
@@ -6792,6 +6744,17 @@ impl Renderer {
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
}
|
||||
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
|
||||
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
|
||||
pub fn set_packed_layout(&mut self, on: bool) -> bool {
|
||||
if on != self.packed_on {
|
||||
self.packed_on = on;
|
||||
self.follow_packed_layout();
|
||||
self.sections_dropped = true;
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
self.packed_quads
|
||||
}
|
||||
/// Whether every section and far tile was dropped since the last call, to be
|
||||
/// laid out again (`follow_translucent_layout`): the game meshes them again,
|
||||
/// as for a new renderer.
|
||||
@@ -7234,7 +7197,8 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |t| [t.width(), t.height()]),
|
||||
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7297,19 +7261,12 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Sets the external shader pipeline world frames call (`external`), or none.
|
||||
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
|
||||
self.external = pipeline;
|
||||
}
|
||||
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
|
||||
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
|
||||
pub fn reset_shader_pack_history(&mut self) {
|
||||
// A frame still on the submit thread uses the targets and readbacks replaced
|
||||
// here.
|
||||
self.land_frame();
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.reset_history();
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
@@ -7411,7 +7368,8 @@ impl Renderer {
|
||||
lightmap,
|
||||
gui_size,
|
||||
terrain: terrain.then(|| pack_post::Geometry {
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
table: table.as_ref(),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7576,7 +7534,8 @@ impl Renderer {
|
||||
);
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs,
|
||||
pull: pack_atlas::PULL.to_owned(),
|
||||
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
|
||||
pull: String::new(),
|
||||
index: "hz_indices[@]".to_owned(),
|
||||
}
|
||||
});
|
||||
@@ -8644,13 +8603,6 @@ impl Renderer {
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
|
||||
// an Iris-format pack, only those whose shadow can fall into the camera's view.
|
||||
let state = external::FrameState {
|
||||
frame,
|
||||
output: &output,
|
||||
};
|
||||
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
|
||||
pipeline.begin_frame(&state);
|
||||
}
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
@@ -8662,12 +8614,6 @@ impl Renderer {
|
||||
let native = self.native.as_ref().filter(|_| !frame.title)?;
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.external
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)?
|
||||
.shadow_request(&state)
|
||||
});
|
||||
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -12799,6 +12745,61 @@ impl Cull for Frusta {
|
||||
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
|
||||
}
|
||||
}
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
@@ -12821,6 +12822,17 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -12944,6 +12956,21 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
@@ -12970,6 +12997,68 @@ fn facing_runs_from(
|
||||
None => sides.every(part, draw),
|
||||
}
|
||||
}
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
|
||||
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
|
||||
// Widen x/y before extracting the planes: this includes every such shifted
|
||||
@@ -15191,8 +15280,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
|
||||
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15268,7 +15357,7 @@ mod shadow_face_tests {
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
@@ -15281,8 +15370,8 @@ mod shadow_face_tests {
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> external::ShadowView {
|
||||
external::ShadowView {
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
@@ -15496,7 +15585,7 @@ mod shadow_caster_tests {
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = external::ShadowView {
|
||||
let view = pack_post::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
|
||||
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
|
||||
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
|
||||
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
|
||||
//!
|
||||
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
|
||||
//! has one implementer in this workspace, carries no stability promise and is never exposed to
|
||||
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
|
||||
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
|
||||
//! frame, and a pipeline draws into the frame it gives.
|
||||
//!
|
||||
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
|
||||
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
|
||||
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
|
||||
//! visibility.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
|
||||
|
||||
/// What a world frame gives an external pipeline at its points: the frame's own values, and
|
||||
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
|
||||
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
|
||||
pub struct FrameState<'a> {
|
||||
pub frame: &'a Frame<'a>,
|
||||
pub output: &'a EffectiveOutput,
|
||||
}
|
||||
|
||||
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
|
||||
/// title frames). Every method has a default that leaves vanilla's way in place.
|
||||
pub trait ExternalPipeline: Send {
|
||||
/// A world frame starts, before culling.
|
||||
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
|
||||
/// The shadow pass this frame wants; `None` draws none.
|
||||
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
|
||||
None
|
||||
}
|
||||
/// The window's drawable size changed.
|
||||
fn resize(&mut self, _size: [u32; 2]) {}
|
||||
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
|
||||
fn reset_history(&mut self) {}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
pub(super) fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
@@ -548,27 +548,107 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
/// inputs, for vertex `hz_vertex_index`.
|
||||
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
|
||||
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
|
||||
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
|
||||
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
|
||||
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
|
||||
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
|
||||
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
|
||||
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
|
||||
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
|
||||
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
|
||||
/// give back their quad's vertices bit for bit, so packs read either layout alike.
|
||||
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
|
||||
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
|
||||
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_w = hz_av * 6u;\n \
|
||||
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
|
||||
hz_uv = hz_vertices[hz_w + 3u];\n\
|
||||
}\n\
|
||||
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
|
||||
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
|
||||
}\n\
|
||||
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
|
||||
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
|
||||
uint hz_axis = (hz_o >> 12u) & 3u;\n \
|
||||
uint hz_side = (hz_o >> 14u) & 1u;\n \
|
||||
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
|
||||
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
|
||||
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
|
||||
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
|
||||
uint hz_sel = hz_lo.z >> 24u;\n \
|
||||
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
|
||||
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
|
||||
hz_p = floatBitsToUint(vec3(hz_pos));\n \
|
||||
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
|
||||
}\n\
|
||||
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
|
||||
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
|
||||
hz_l &= 0xfffu;\n \
|
||||
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
|
||||
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
|
||||
uint hz_t = hz_lo.x;\n \
|
||||
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
|
||||
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
|
||||
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
|
||||
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
|
||||
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
|
||||
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
|
||||
}\n";
|
||||
|
||||
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
|
||||
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
|
||||
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
|
||||
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
|
||||
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
|
||||
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
|
||||
uint hz_own = hz_qv & 3u;\n \
|
||||
uint hz_av = hz_qv & ~3u;\n \
|
||||
uint hz_slot = hz_qv;\n \
|
||||
bool hz_rec = false;\n \
|
||||
uvec3 hz_lo = uvec3(0u);\n \
|
||||
uvec3 hz_hi = uvec3(0u);\n \
|
||||
if (hz_av >= 0x10000000u) {\n \
|
||||
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
|
||||
uint hz_rw = hz_slot * 6u;\n \
|
||||
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
|
||||
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
|
||||
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
|
||||
hz_av = hz_slot + hz_lo.x;\n \
|
||||
}\n \
|
||||
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
|
||||
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
|
||||
uvec2 hz_rest;\n \
|
||||
if (hz_rec) {\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
|
||||
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
|
||||
} else {\n \
|
||||
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
|
||||
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
|
||||
uint hz_ow = (hz_av + hz_own) * 6u;\n \
|
||||
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
|
||||
}\n \
|
||||
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
|
||||
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
|
||||
hz_v_pos = uintBitsToFloat(hz_vp);\n \
|
||||
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
|
||||
hz_v_layer = hz_vr.y & 65535u;\n \
|
||||
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
|
||||
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
|
||||
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
|
||||
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
|
||||
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
|
||||
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
|
||||
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
|
||||
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
|
||||
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
|
||||
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
|
||||
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
|
||||
uint hz_w3 = hz_uv3;\n \
|
||||
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
|
||||
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
|
||||
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
|
||||
@@ -620,11 +700,19 @@ impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
|
||||
let mut declarations = String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
);
|
||||
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
|
||||
// solid quads in the compact layout; others read them as vertex attributes.
|
||||
let mut declarations = if flat {
|
||||
String::from(
|
||||
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
)
|
||||
};
|
||||
let origin = if flat {
|
||||
declarations.push_str(
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
@@ -637,7 +725,8 @@ impl PackAtlas {
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
declarations.push_str(CORNER_FN);
|
||||
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
|
||||
} else {
|
||||
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
|
||||
"hz_v_origin"
|
||||
@@ -700,6 +789,7 @@ impl PackAtlas {
|
||||
);
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.insert_str(0, PULL_FLAT);
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
|
||||
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
|
||||
inputs: atlas.terrain_inputs(true),
|
||||
pull: super::PULL.to_owned(),
|
||||
pull: String::new(),
|
||||
index: "@".to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -245,6 +245,135 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
|
||||
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
|
||||
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
|
||||
#[test]
|
||||
fn compact_records_feed_a_pack_the_vertices_they_hold() {
|
||||
use crate::mesher::packed;
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
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 | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
atlas.separate_ao = true;
|
||||
let tint = 0x7F_B238u32;
|
||||
let ao = [255u32, 153, 102, 204];
|
||||
let face = |x: f32, y: f32| {
|
||||
let pos = [
|
||||
[x, y, 5.0],
|
||||
[x, y, 6.0],
|
||||
[x + 1.0, y, 6.0],
|
||||
[x + 1.0, y, 5.0],
|
||||
];
|
||||
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
|
||||
(0..4).map(move |i| {
|
||||
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
|
||||
TerrainVertex {
|
||||
pos: pos[i],
|
||||
uv: uv[i],
|
||||
layer: 0x8000,
|
||||
light: 0xF0 | 0x40 << 8,
|
||||
color: r | g << 8 | b << 16 | ao[i] << 24,
|
||||
}
|
||||
})
|
||||
};
|
||||
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, y)| face(x, y))
|
||||
.collect();
|
||||
let records = packed::layout(&solid, &[tint; 4]);
|
||||
assert_eq!(
|
||||
packed::packed_quads(&records, 4),
|
||||
3,
|
||||
"whole faces pack, the slab doesn't"
|
||||
);
|
||||
let page = bytemuck::cast_slice(&records);
|
||||
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let mut failures = Vec::new();
|
||||
for expression in [
|
||||
"gl_Vertex",
|
||||
"gl_Color",
|
||||
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
|
||||
"vec4(gl_Normal, 0.0)",
|
||||
"at_tangent",
|
||||
"at_midBlock",
|
||||
"vec4(mc_midTexCoord, mc_Entity.xy)",
|
||||
] {
|
||||
for shadow in [false, true] {
|
||||
let flags = [true, shadow];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
|
||||
let compact = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
packed_range.clone(),
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
if bits(&wide) != bits(&compact) {
|
||||
failures.push(format!(
|
||||
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
|
||||
));
|
||||
}
|
||||
if expression == "gl_Color" {
|
||||
for (corner, want) in ao.iter().enumerate() {
|
||||
let got = compact[corner][3];
|
||||
if (got - *want as f32 / 255.0).abs() > 1e-6 {
|
||||
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's frames keep the translucent quads in the section arena after the solid range: three
|
||||
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
|
||||
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
|
||||
let water: Vec<_> = face(9.0, 15.0).collect();
|
||||
let at = page.len() as u32;
|
||||
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
|
||||
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
|
||||
let flags = [true, false];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
|
||||
let page = bytemuck::cast_slice(&page);
|
||||
let after = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
at..at + 4,
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
if wide != after {
|
||||
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -271,18 +400,38 @@ fn sample_probe(
|
||||
atlas: &PackAtlas,
|
||||
vertices: &[TerrainVertex],
|
||||
scale: f32,
|
||||
flags: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let page = bytemuck::cast_slice(vertices);
|
||||
let drawn = 0..vertices.len() as u32;
|
||||
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
|
||||
}
|
||||
|
||||
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sample_page(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
atlas: &PackAtlas,
|
||||
page: &[u8],
|
||||
drawn: std::ops::Range<u32>,
|
||||
scale: f32,
|
||||
[flat, shadow]: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let count = drawn.len() as u32;
|
||||
let pp = |code| horizon_shaders::Preprocessed {
|
||||
code,
|
||||
..Default::default()
|
||||
};
|
||||
let vertex = pp(format!(
|
||||
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
|
||||
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
probe = {expression};\n\
|
||||
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
2. / vertices.len() as f32,
|
||||
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
drawn.start,
|
||||
2. / count as f32,
|
||||
));
|
||||
// Integer attachments retain the float bits without requiring float-render-target
|
||||
// capabilities on the adapter that the test runner picks.
|
||||
@@ -369,7 +518,8 @@ fn sample_probe(
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
|
||||
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
|
||||
buffers: if flat { &[] } else { &vertex_layouts[..] },
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::PointList,
|
||||
@@ -408,7 +558,7 @@ fn sample_probe(
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(vertices),
|
||||
page,
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
@@ -444,7 +594,7 @@ fn sample_probe(
|
||||
let output = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: vertices.len() as u32,
|
||||
width: count,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
@@ -457,7 +607,7 @@ fn sample_probe(
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: vertices.len() as u64 * 16,
|
||||
size: u64::from(count) * 16,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -481,11 +631,11 @@ fn sample_probe(
|
||||
for (k, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(k as u32, group, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
if !flat {
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
pass.set_vertex_buffer(1, origin.slice(..));
|
||||
}
|
||||
pass.draw(0..vertices.len() as u32, 0..1);
|
||||
pass.draw(drawn, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
|
||||
@@ -1201,7 +1201,31 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
pub(super) use super::external::ShadowView;
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub(super) struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
@@ -1137,6 +1137,10 @@ fn components(name: &str) -> usize {
|
||||
| "endFlashPosition" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
"entityColor" => 4,
|
||||
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
|
||||
// are ints.
|
||||
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
|
||||
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
}
|
||||
@@ -1208,6 +1212,28 @@ mod tests {
|
||||
(&matrix, "gbufferModelView", "mat4", true),
|
||||
(&matrix, "gbufferModelView", "mat3", false),
|
||||
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
|
||||
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
|
||||
// ints.
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"float",
|
||||
false,
|
||||
),
|
||||
(
|
||||
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
|
||||
"horizonWaterPatch0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
|
||||
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
|
||||
] {
|
||||
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
pub(super) fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FadeInputs<'a> {
|
||||
pub(super) now: Instant,
|
||||
pub(super) fade_ms: u64,
|
||||
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
|
||||
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
@@ -30,8 +30,9 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
|
||||
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
|
||||
/// uses it, and the HDR page offers calibration.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ pub struct Reported {
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
/// The system says, but not where its peak comes from: the page offers calibration.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
@@ -1355,7 +1356,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
@@ -1370,7 +1371,7 @@ mod tests {
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
// Auto still takes the reported peak, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
|
||||
@@ -697,7 +697,7 @@ fn description(
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
@@ -705,7 +705,7 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
// A peak of unknown source isn't said to be set up; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
@@ -1219,10 +1219,13 @@ impl Item {
|
||||
}
|
||||
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
match self {
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
|
||||
Text::option_name_value(
|
||||
Text::literal(lang::get("Full Detail Distance")),
|
||||
Text::tr("options.chunks", &[&s.render_distance]),
|
||||
)
|
||||
}
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s, out),
|
||||
Item::Extra(e) => Text::option_name_value(
|
||||
@@ -1318,7 +1321,11 @@ impl Item {
|
||||
|
||||
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
|
||||
fn shown(item: Item, s: &Settings) -> bool {
|
||||
item != Item::Horizon(H::Upscaling) || s.horizon.taa
|
||||
match item {
|
||||
Item::Horizon(H::Upscaling) => s.horizon.taa,
|
||||
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
|
||||
@@ -1430,7 +1437,7 @@ impl Screen for GraphicsScreen {
|
||||
g::gpu_culling_offer(),
|
||||
g::reduced_far_detail_suggested(),
|
||||
);
|
||||
shell::stamp_of(&(read(s), offers))
|
||||
shell::stamp_of(&(read(s), s.fullbright_held, offers))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
|
||||
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
|
||||
@@ -1990,6 +1997,31 @@ mod tests {
|
||||
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
|
||||
}
|
||||
|
||||
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
|
||||
/// kept for the next world that allows it (#3823).
|
||||
#[test]
|
||||
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
|
||||
let mut s = Settings::default();
|
||||
s.extras.fullbright = true;
|
||||
let screen = GraphicsScreen::default();
|
||||
let listed = |s: &Settings, item| {
|
||||
let sheets = screen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).any(|r| r.id == item)
|
||||
};
|
||||
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
|
||||
let lights = Item::Extra(crate::extras::Item::DynamicLights);
|
||||
assert!(listed(&s, fullbright));
|
||||
let free = screen.stamp(&s);
|
||||
s.fullbright_held = true;
|
||||
assert!(!listed(&s, fullbright));
|
||||
assert!(listed(&s, lights));
|
||||
assert_ne!(screen.stamp(&s), free);
|
||||
assert!(s.extras.fullbright);
|
||||
s.fullbright_held = false;
|
||||
assert!(listed(&s, fullbright));
|
||||
}
|
||||
|
||||
/// The strip says what a row does, what its chosen value does where that differs, and why a
|
||||
/// greyed row can't be used; the search finds a row by what it does.
|
||||
#[test]
|
||||
@@ -2110,10 +2142,36 @@ mod tests {
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
);
|
||||
}
|
||||
|
||||
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
|
||||
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
|
||||
/// (#14880).
|
||||
#[test]
|
||||
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"options.renderDistance": "Render Distance",
|
||||
"options.chunks": "%s Chunks",
|
||||
"options.generic_value": "%s: %s"
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
let near = s.render_distance;
|
||||
let render = Item::Vanilla(Opt::RenderDistance);
|
||||
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
let off = render.label(&s, &NONE).plain();
|
||||
s.horizon.lod_distance = 256;
|
||||
(off, render.label(&s, &NONE).plain())
|
||||
});
|
||||
assert_eq!(off, format!("Render Distance: {near} Chunks"));
|
||||
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -164,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
|
||||
#[test]
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
@@ -200,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
@@ -219,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
@@ -348,3 +348,30 @@ fn undo_never_restores_a_held_hdr_start() {
|
||||
screen.undo(&mut s, &opened);
|
||||
assert_eq!(s.horizon, opened.horizon);
|
||||
}
|
||||
|
||||
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
|
||||
/// 1080p window at scale 4 is wider): a longer one would end in "…".
|
||||
#[test]
|
||||
fn the_calibrate_descriptions_fit_the_strip() {
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut guessed = offering(true, Some(true));
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let mut changed = Settings::default();
|
||||
changed.horizon.hdr_peak = 800;
|
||||
let item = Item::Horizon(H::HdrCalibrate);
|
||||
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
|
||||
for (s, out) in [
|
||||
(Settings::default(), NONE),
|
||||
(Settings::default(), guessed),
|
||||
(Settings::default(), offering(true, Some(true))),
|
||||
(changed, guessed),
|
||||
] {
|
||||
let text = description(item, &s, &out, "-");
|
||||
assert_ne!(text, "-");
|
||||
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
|
||||
assert!(lines.len() <= 2, "{text}");
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@
|
||||
//! - word 1: each corner's grey level before the tint;
|
||||
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
|
||||
//! corner, which edge of it its u and v take;
|
||||
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
|
||||
//! - word 4: corners 1 and 2's light;
|
||||
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
|
||||
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
|
||||
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
|
||||
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
|
||||
//! of the block, and where and which way its corners go round), and `FULL` clear.
|
||||
use super::TerrainVertex;
|
||||
@@ -27,6 +28,8 @@ pub const WHITE: u32 = 0xFF_FFFF;
|
||||
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
|
||||
/// to its first vertex.
|
||||
pub const FULL: u32 = 1 << 31;
|
||||
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
|
||||
pub const ALPHA_STEP: u32 = 51;
|
||||
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
|
||||
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
|
||||
pub const PACKED_BASE: u32 = 1 << 28;
|
||||
@@ -133,12 +136,24 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
|
||||
for (i, v) in quad.iter().enumerate() {
|
||||
grays |= gray_of(v.color, tint)? << (8 * i);
|
||||
}
|
||||
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
|
||||
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
|
||||
let mut codes = [0u32; 3];
|
||||
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
|
||||
let a = v.color >> 24;
|
||||
if v.color & 0xFF00_0000 != alpha {
|
||||
if a == 0 || a % ALPHA_STEP != 0 {
|
||||
return None;
|
||||
}
|
||||
*code = a / ALPHA_STEP;
|
||||
}
|
||||
}
|
||||
let words = [
|
||||
tint | alpha,
|
||||
grays,
|
||||
rect | select << 24,
|
||||
u32::from(quad[0].layer) | light[0] << 16,
|
||||
light[1] | light[2] << 12,
|
||||
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
|
||||
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
|
||||
light[3] | orient << 12,
|
||||
];
|
||||
let exact = (0..4).all(|c| {
|
||||
@@ -190,12 +205,18 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
|
||||
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
|
||||
let g = w1 >> (8 * i) & 255;
|
||||
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
|
||||
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
|
||||
let alpha = if code == 0 {
|
||||
w0 & 0xFF00_0000
|
||||
} else {
|
||||
(code * ALPHA_STEP) << 24
|
||||
};
|
||||
TerrainVertex {
|
||||
pos: pos.map(|c| c as f32),
|
||||
uv,
|
||||
layer: (w3 & 0xFFFF) as u16,
|
||||
light,
|
||||
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
|
||||
color: r | gr << 8 | b << 16 | alpha,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +268,10 @@ pub fn place_into(
|
||||
let n = quads.len();
|
||||
let packed = records.iter().filter(|r| r.is_some()).count();
|
||||
let full = n - packed;
|
||||
let pad = if full > 0 {
|
||||
n.next_multiple_of(4) - n
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
|
||||
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
|
||||
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
|
||||
let pad = n.next_multiple_of(4) - n;
|
||||
if n + pad + 4 * full >= 4 * n {
|
||||
return;
|
||||
}
|
||||
@@ -334,6 +354,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
|
||||
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
|
||||
let mut quad = top([0, 1, 2, 3]);
|
||||
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
|
||||
v.color = v.color & 0x00FF_FFFF | a << 24;
|
||||
}
|
||||
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
|
||||
for (corner, v) in quad.iter().enumerate() {
|
||||
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
|
||||
}
|
||||
// A partial face's blended occlusion falls between the steps.
|
||||
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
|
||||
assert!(pack_quad(&quad, TINT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
|
||||
// Half a block up: a slab's top.
|
||||
@@ -375,4 +411,13 @@ mod tests {
|
||||
// Nothing smaller to gain: no layout at all.
|
||||
assert!(layout(&odd, &[TINT]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_records_end_on_a_whole_quad_of_slots() {
|
||||
// Five whole faces, every one a record: the translucent vertices after them start a quad.
|
||||
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
|
||||
let out = layout(&solid, &[TINT; 5]);
|
||||
assert_eq!(packed_quads(&out, 5), 5);
|
||||
assert_eq!(out.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -368,6 +368,57 @@ pub struct PackScreen {
|
||||
browser_search: TextField,
|
||||
/// The browser's search box has the keyboard.
|
||||
browser_focus: bool,
|
||||
/// Which atlas slot holds which icon, kept from one frame to the next
|
||||
/// ([`PackScreen::bind_icons`]).
|
||||
icon_slots: std::cell::RefCell<IconSlots>,
|
||||
}
|
||||
|
||||
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
|
||||
/// written into a slot, so an icon is sent once, when it takes a slot, and not
|
||||
/// again every frame it is drawn.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct IconSlots {
|
||||
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
|
||||
revision: u64,
|
||||
/// Frames bound so far: an icon drawn in this one keeps its slot.
|
||||
frame: u64,
|
||||
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
|
||||
/// frame it was last drawn in.
|
||||
held: Vec<(String, usize, Arc<[u8]>, u64)>,
|
||||
}
|
||||
|
||||
impl IconSlots {
|
||||
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
|
||||
fn begin(&mut self, atlas: &HudAtlas) {
|
||||
if self.revision != atlas.revision {
|
||||
self.held.clear();
|
||||
self.revision = atlas.revision;
|
||||
}
|
||||
self.frame += 1;
|
||||
}
|
||||
|
||||
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
|
||||
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
|
||||
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
|
||||
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
|
||||
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
|
||||
let mut kept = self.held.remove(at);
|
||||
let same = Arc::ptr_eq(&kept.2, icon);
|
||||
kept.2 = icon.clone();
|
||||
kept.3 = self.frame;
|
||||
let slot = kept.1;
|
||||
self.held.push(kept);
|
||||
return Some((slot, !same));
|
||||
}
|
||||
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
|
||||
Some(free) => free,
|
||||
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
|
||||
None => return None,
|
||||
};
|
||||
self.held
|
||||
.push((texture.to_owned(), slot, icon.clone(), self.frame));
|
||||
Some((slot, true))
|
||||
}
|
||||
}
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
@@ -459,6 +510,7 @@ impl PackScreen {
|
||||
field
|
||||
},
|
||||
browser_focus: false,
|
||||
icon_slots: Default::default(),
|
||||
changed: false,
|
||||
};
|
||||
s.reposition(m);
|
||||
@@ -931,10 +983,13 @@ impl PackScreen {
|
||||
self.model.commit()
|
||||
}
|
||||
/// Points the icon blits of `draws` at the atlas's pack icon slots and
|
||||
/// writes the icons into them, one slot per icon drawn (vanilla registers a
|
||||
/// texture per pack). Icons past the last slot keep `unknown_pack`.
|
||||
/// writes the icons into them (vanilla registers a texture per pack). An
|
||||
/// icon keeps its slot from one frame to the next and is sent when it takes
|
||||
/// the slot, not every frame; the slot of the icon drawn longest ago is
|
||||
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
|
||||
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
|
||||
let mut slots = 0..PACK_ICON_SLOTS;
|
||||
let mut slots = self.icon_slots.borrow_mut();
|
||||
slots.begin(atlas);
|
||||
for draw in draws {
|
||||
let Draw::Blit { texture, .. } = draw else {
|
||||
continue;
|
||||
@@ -942,20 +997,23 @@ impl PackScreen {
|
||||
let Some(icon) = self.icons.get(texture.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(slot) = slots.next() else {
|
||||
return;
|
||||
let Some((slot, send)) = slots.slot_for(texture, icon) else {
|
||||
continue;
|
||||
};
|
||||
let sprite = format!("horizon/pack-icon{slot}");
|
||||
let Some(rect) = atlas.sprites.get(&sprite) else {
|
||||
slots.held.pop();
|
||||
return;
|
||||
};
|
||||
patches.push(Patch {
|
||||
x: (rect.u0 * atlas.width as f32).round() as u32,
|
||||
y: (rect.v0 * atlas.height as f32).round() as u32,
|
||||
width: PACK_ICON_SIZE,
|
||||
height: PACK_ICON_SIZE,
|
||||
rgba: icon.to_vec(),
|
||||
});
|
||||
if send {
|
||||
patches.push(Patch {
|
||||
x: (rect.u0 * atlas.width as f32).round() as u32,
|
||||
y: (rect.v0 * atlas.height as f32).round() as u32,
|
||||
width: PACK_ICON_SIZE,
|
||||
height: PACK_ICON_SIZE,
|
||||
rgba: icon.to_vec(),
|
||||
});
|
||||
}
|
||||
*texture = sprite;
|
||||
}
|
||||
}
|
||||
@@ -1618,6 +1676,12 @@ impl PackScreen {
|
||||
let hover = hovered == Some(i)
|
||||
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
Self::draw_options_button(out, bx, by, hover);
|
||||
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
|
||||
// while the list shows it whole.
|
||||
let mark = crate::widgets::targets_mark();
|
||||
let size = OPTIONS_SIZE as f32;
|
||||
crate::widgets::target([bx as f32, by as f32, size, size]);
|
||||
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
|
||||
if hover {
|
||||
tooltip = Some(tr("horizon.pack.optionsInfo"));
|
||||
}
|
||||
@@ -1745,9 +1809,14 @@ mod icon_tests {
|
||||
.unwrap();
|
||||
assert_eq!(id, "file/Configurable");
|
||||
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
|
||||
// Hovered, it explains itself.
|
||||
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
|
||||
let draws = screen.draw(&m, (cx, cy), 0, 0);
|
||||
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
let size = OPTIONS_SIZE as f32;
|
||||
let button = [bx as f32, by as f32, size, size];
|
||||
let targets = crate::widgets::last_targets();
|
||||
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
|
||||
assert_eq!(
|
||||
screen.click(&m, cx as f64, cy as f64),
|
||||
Action::Options("file/Configurable".into())
|
||||
@@ -2070,3 +2139,177 @@ mod footer_label_tests {
|
||||
assert_eq!(lang::code(), before, "the global language was left alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_slot_tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
|
||||
fn packs(count: usize) -> Vec<PackInfo> {
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
(0..count)
|
||||
.map(|i| PackInfo {
|
||||
id: format!("file/pack{i:03}"),
|
||||
title: Text::literal(format!("pack{i:03}")),
|
||||
description: Text::literal(""),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: Some(vec![i as u8; side * side * 4].into()),
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
|
||||
/// (their texture keys before binding), the textures after, and the patches sent.
|
||||
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
|
||||
let m = Measure(&atlas.font);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0, 0);
|
||||
let drawn: Vec<String> = draws
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
|
||||
Some(texture.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, atlas, &mut patches);
|
||||
let bound = draws
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
|
||||
Some(texture.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(drawn, bound, patches)
|
||||
}
|
||||
|
||||
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
|
||||
/// window `height` high.
|
||||
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
|
||||
PackScreen::new(
|
||||
m,
|
||||
tr("resourcePack.title"),
|
||||
480,
|
||||
height,
|
||||
false,
|
||||
packs(count),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
|
||||
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
|
||||
#[test]
|
||||
fn an_icon_is_sent_once_and_not_every_frame() {
|
||||
let atlas = atlas();
|
||||
let screen = screen(&Measure(&atlas.font), 5, 400);
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
|
||||
for again in 0..3 {
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!(rebound, bound, "frame {again}");
|
||||
assert_eq!(resent.len(), 0, "frame {again}");
|
||||
}
|
||||
}
|
||||
|
||||
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
|
||||
/// `unknown_pack`, and the next frame does not move them or send anything.
|
||||
#[test]
|
||||
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
|
||||
let atlas = atlas();
|
||||
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(
|
||||
drawn.len(),
|
||||
PACK_ICON_SLOTS + 6,
|
||||
"the window shows every row"
|
||||
);
|
||||
assert_eq!(
|
||||
(bound.len(), patches.len()),
|
||||
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
|
||||
);
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!((rebound, resent.len()), (bound, 0));
|
||||
}
|
||||
|
||||
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
|
||||
/// once on the way down, and a frame that draws what the last one drew sends nothing.
|
||||
#[test]
|
||||
fn scrolling_sends_only_the_icons_that_come_into_view() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = screen(&m, 70, 330);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut sent = 0;
|
||||
// 150 wheel steps of 18 are more than the 2520 high list.
|
||||
for step in 0..150 {
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
|
||||
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
|
||||
assert_eq!(patches.len(), new, "step {step}");
|
||||
sent += patches.len();
|
||||
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
|
||||
screen.scroll(100.0, 200.0, -1.0);
|
||||
}
|
||||
assert_eq!((seen.len(), sent), (70, 70));
|
||||
}
|
||||
|
||||
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
|
||||
/// sent again when it comes back.
|
||||
#[test]
|
||||
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
|
||||
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
|
||||
.into_iter()
|
||||
.map(|p| p.icon.unwrap())
|
||||
.collect();
|
||||
let mut slots = IconSlots::default();
|
||||
let key = |i: usize| format!("k{i}");
|
||||
slots.begin(&atlas());
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
|
||||
}
|
||||
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
|
||||
slots.frame += 1;
|
||||
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
|
||||
assert_eq!(
|
||||
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
|
||||
Some((1, true))
|
||||
);
|
||||
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
|
||||
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
|
||||
}
|
||||
|
||||
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
|
||||
/// of the icons, so all are sent again.
|
||||
#[test]
|
||||
fn changed_pixels_and_a_new_atlas_send_again() {
|
||||
let atlas = atlas();
|
||||
let mut screen = screen(&Measure(&atlas.font), 3, 400);
|
||||
let (_, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(patches.len(), 3);
|
||||
let key = icon_texture("file/pack001");
|
||||
screen
|
||||
.icons
|
||||
.insert(key, vec![200; patches[1].rgba.len()].into());
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!(rebound, bound);
|
||||
assert_eq!(resent.len(), 1);
|
||||
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
|
||||
assert_eq!(resent[0].rgba[0], 200);
|
||||
let other = self::atlas();
|
||||
assert_ne!(other.revision, atlas.revision);
|
||||
assert_eq!(frame(&screen, &other).2.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,10 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod simple;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1325,6 +1327,9 @@ pub struct ParticleEngine {
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
|
||||
parallel_min: usize,
|
||||
parallel_threads: usize,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// Vanilla particle types a pack replaces with a custom one of the same id
|
||||
@@ -1527,6 +1532,8 @@ impl ParticleEngine {
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
parallel_min: PARALLEL_MIN,
|
||||
parallel_threads: parallel_threads(),
|
||||
polytone: None,
|
||||
polytone_overrides: HashMap::new(),
|
||||
polytone_gpu: Vec::new(),
|
||||
@@ -2121,23 +2128,7 @@ impl ParticleEngine {
|
||||
}
|
||||
/// `SingleQuadParticle.setSpriteFromAge`.
|
||||
fn sprite_from_age(&self, p: &mut Particle) {
|
||||
if p.removed {
|
||||
return;
|
||||
}
|
||||
if let Some(set) = p.set {
|
||||
// A sprite set's index can outlive the reload that rebuilt them.
|
||||
let Some(sprites) = self.set_sprites.get(set as usize) else {
|
||||
return;
|
||||
};
|
||||
let lifetime = p.lifetime.max(1);
|
||||
let index = if p.frame_ticks > 0 {
|
||||
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
|
||||
} else {
|
||||
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
|
||||
.clamp(0, sprites.len() as i64 - 1)
|
||||
};
|
||||
p.sprite = Sprite::Index(sprites[index as usize]);
|
||||
}
|
||||
sprite_from_age_in(&self.set_sprites, p);
|
||||
}
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// `ParticleProvider.createParticle`: provider draws use the engine's
|
||||
@@ -3847,9 +3838,7 @@ impl ParticleEngine {
|
||||
self.emitters = emitters;
|
||||
let mut particles = std::mem::take(&mut self.particles);
|
||||
self.spawned_in_tick = Some(Vec::new());
|
||||
for p in &mut particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
self.tick_all(&mut particles, level);
|
||||
particles.retain(|p| !p.removed);
|
||||
self.particles = particles;
|
||||
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
|
||||
@@ -3954,7 +3943,15 @@ impl ParticleEngine {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Base
|
||||
| Behaviour::Mod { .. }
|
||||
| Behaviour::Animated
|
||||
| Behaviour::Snowflake
|
||||
| Behaviour::Crit
|
||||
| Behaviour::Spark { .. } => {
|
||||
let simple = tick_simple(&self.set_sprites, p, level);
|
||||
self.finish_simple(p, simple);
|
||||
}
|
||||
Behaviour::Controlled { .. } => {
|
||||
if let Some(mut controller) = p.controller.take() {
|
||||
let update = controller.tick(p, level);
|
||||
@@ -3973,23 +3970,6 @@ impl ParticleEngine {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
|
||||
self.sprite_from_age(p);
|
||||
}
|
||||
Behaviour::Animated => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
}
|
||||
Behaviour::Snowflake => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
p.velocity[0] *= f64::from(0.95f32);
|
||||
p.velocity[1] *= f64::from(0.9f32);
|
||||
p.velocity[2] *= f64::from(0.95f32);
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
@@ -4000,11 +3980,6 @@ impl ParticleEngine {
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
p.base_tick(level);
|
||||
p.rgba[1] *= 0.96;
|
||||
p.rgba[2] *= 0.9;
|
||||
}
|
||||
Behaviour::Drip {
|
||||
fluid,
|
||||
stage,
|
||||
@@ -4475,27 +4450,6 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::Spark {
|
||||
fade,
|
||||
trail,
|
||||
twinkle,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age > p.lifetime / 2 {
|
||||
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
|
||||
if let Some(f) = fade {
|
||||
for i in 0..3 {
|
||||
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
|
||||
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
|
||||
child.age = child.lifetime / 2;
|
||||
self.pending.push(child);
|
||||
}
|
||||
}
|
||||
Behaviour::Spell { original_alpha } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
|
||||
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
|
||||
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
|
||||
num(crate::environment::polytone_noise().get(x, y))
|
||||
}
|
||||
(id, _) if id >= BLOCKS => {
|
||||
let at = *self
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
|
||||
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
|
||||
//! threads in a busy scene.
|
||||
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
|
||||
|
||||
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
|
||||
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
|
||||
pub(super) const PARALLEL_MIN: usize = 2048;
|
||||
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
|
||||
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
|
||||
pub(super) fn parallel_threads() -> usize {
|
||||
(horizon_threads::worker_count() / 2).clamp(1, 4)
|
||||
}
|
||||
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
|
||||
if p.removed {
|
||||
return;
|
||||
}
|
||||
if let Some(set) = p.set {
|
||||
// A sprite set's index can outlive the reload that rebuilt them.
|
||||
let Some(sprites) = set_sprites.get(set as usize) else {
|
||||
return;
|
||||
};
|
||||
let lifetime = p.lifetime.max(1);
|
||||
let index = if p.frame_ticks > 0 {
|
||||
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
|
||||
} else {
|
||||
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
|
||||
.clamp(0, sprites.len() as i64 - 1)
|
||||
};
|
||||
p.sprite = Sprite::Index(sprites[index as usize]);
|
||||
}
|
||||
}
|
||||
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
|
||||
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
|
||||
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
|
||||
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
|
||||
pub(super) fn tick_simple(
|
||||
set_sprites: &[Vec<u32>],
|
||||
p: &mut Particle,
|
||||
level: &ParticleLevel,
|
||||
) -> Simple {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
}
|
||||
Behaviour::Animated => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
}
|
||||
Behaviour::Snowflake => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
p.velocity[0] *= f64::from(0.95f32);
|
||||
p.velocity[1] *= f64::from(0.9f32);
|
||||
p.velocity[2] *= f64::from(0.95f32);
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
p.base_tick(level);
|
||||
p.rgba[1] *= 0.96;
|
||||
p.rgba[2] *= 0.9;
|
||||
}
|
||||
Behaviour::Spark { fade, trail, .. } => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
if p.age > p.lifetime / 2 {
|
||||
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
|
||||
if let Some(f) = fade {
|
||||
for i in 0..3 {
|
||||
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
|
||||
return Simple::SparkTrail;
|
||||
}
|
||||
}
|
||||
_ => return Simple::No,
|
||||
}
|
||||
Simple::Done
|
||||
}
|
||||
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
|
||||
///
|
||||
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
|
||||
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
|
||||
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
|
||||
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
|
||||
ParticleLevel {
|
||||
world: level.world,
|
||||
facts: level.facts,
|
||||
collision: level.collision,
|
||||
player: level.player,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
}
|
||||
}
|
||||
/// What `tick_simple` did with a particle.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Simple {
|
||||
/// Not one of its behaviours: the engine ticks it.
|
||||
No,
|
||||
/// Ticked completely.
|
||||
Done,
|
||||
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
|
||||
/// it takes the next seed, so the children come in the order the sparks have.
|
||||
SparkTrail,
|
||||
}
|
||||
impl ParticleEngine {
|
||||
/// What `tick_simple` leaves to the engine: a spark's trail child.
|
||||
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
|
||||
if simple != Simple::SparkTrail {
|
||||
return;
|
||||
}
|
||||
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
|
||||
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
|
||||
child.age = child.lifetime / 2;
|
||||
self.pending.push(child);
|
||||
}
|
||||
}
|
||||
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
|
||||
/// other and of the engine, so with enough of them they tick on several threads; every other
|
||||
/// one then ticks here, in the order it had, so the particles, the engine's random and what
|
||||
/// is spawned come out as when all of them ticked one after another.
|
||||
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
|
||||
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
|
||||
{
|
||||
for p in particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sets = &self.set_sprites;
|
||||
let per = particles.len().div_ceil(self.parallel_threads);
|
||||
let mut handled = vec![Simple::No; particles.len()];
|
||||
std::thread::scope(|scope| {
|
||||
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
|
||||
for (p, done) in part.iter_mut().zip(done) {
|
||||
*done = tick_simple(sets, p, level);
|
||||
}
|
||||
};
|
||||
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
|
||||
let own = parts.next();
|
||||
for (part, done) in parts {
|
||||
let view = thread_view(level);
|
||||
scope.spawn(move || run(part, done, &view));
|
||||
}
|
||||
// This thread takes the first share, instead of waiting for the others.
|
||||
if let Some((part, done)) = own {
|
||||
run(part, done, level);
|
||||
}
|
||||
});
|
||||
for (p, done) in particles.iter_mut().zip(handled) {
|
||||
match done {
|
||||
Simple::No => self.tick_particle(p, level),
|
||||
simple => self.finish_simple(p, simple),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
|
||||
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
|
||||
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
|
||||
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
|
||||
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game.
|
||||
//!
|
||||
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
|
||||
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
|
||||
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
|
||||
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
|
||||
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
|
||||
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
|
||||
//! and the engine tick apart, once the engine holds as many particles as it will.
|
||||
use super::*;
|
||||
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
|
||||
use std::sync::Arc;
|
||||
@@ -25,6 +29,7 @@ impl Lcg {
|
||||
}
|
||||
}
|
||||
|
||||
/// What to spawn each tick: a type, how many, around where, how far, how fast.
|
||||
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
|
||||
|
||||
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
|
||||
@@ -58,9 +63,29 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
|
||||
fn emitters() -> [Emitter; 5] {
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
[
|
||||
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
|
||||
(
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
),
|
||||
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
|
||||
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
|
||||
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
|
||||
]
|
||||
}
|
||||
|
||||
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
|
||||
/// without prepared resources.
|
||||
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -88,69 +113,127 @@ fn particle_tick_cost() {
|
||||
world.set(x, FLOOR_Y, z, floor).unwrap();
|
||||
}
|
||||
}
|
||||
let level = ParticleLevel {
|
||||
f(&ParticleLevel {
|
||||
world: &world,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
|
||||
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
|
||||
let cosy: Emitter = (
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
});
|
||||
}
|
||||
|
||||
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
engine.parallel_min = parallel_min;
|
||||
engine.parallel_threads = threads;
|
||||
engine
|
||||
}
|
||||
|
||||
/// Every live particle's fields, as text.
|
||||
fn state(engine: &ParticleEngine) -> Vec<String> {
|
||||
engine
|
||||
.particles
|
||||
.iter()
|
||||
.map(|p| format!("{:?}", engine.snapshot(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tick_matches_serial_tick() {
|
||||
with_scene(|level| {
|
||||
let types = emitters();
|
||||
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
|
||||
let mut serial = engine(usize::MAX, 1);
|
||||
let mut threaded = engine(0, 3);
|
||||
for e in [&mut serial, &mut threaded] {
|
||||
e.seed_randoms(7, 8, 9);
|
||||
}
|
||||
let (mut a, mut b) = (Lcg(99), Lcg(99));
|
||||
for tick in 0..90 {
|
||||
spawn_tick(&mut serial, &mut a, &types, 3);
|
||||
spawn_tick(&mut threaded, &mut b, &types, 3);
|
||||
serial.tick(level);
|
||||
threaded.tick(level);
|
||||
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
|
||||
if tick % 10 == 9 {
|
||||
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
|
||||
}
|
||||
}
|
||||
assert!(serial.len() > 1000, "{} particles", serial.len());
|
||||
assert_eq!(serial.sounds.len(), threaded.sounds.len());
|
||||
assert_eq!(serial.next_randoms(), threaded.next_randoms());
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
|
||||
fn measure(
|
||||
level: &ParticleLevel,
|
||||
name: &str,
|
||||
(types, rockets): (&[Emitter], usize),
|
||||
(parallel_min, threads): (usize, usize),
|
||||
secs: f64,
|
||||
) {
|
||||
let mut engine = engine(parallel_min, threads);
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, types, rockets);
|
||||
engine.tick(level);
|
||||
}
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
}
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
);
|
||||
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
|
||||
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let cases: [(&str, Vec<Emitter>, usize); 7] = [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
|
||||
];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
for (name, types, rockets) in cases {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
engine.tick(&level);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
with_scene(|level| {
|
||||
let [flame, cosy, rod, poof, smoke] = emitters();
|
||||
let all = [flame, cosy, rod, poof, smoke];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
let one = (usize::MAX, 1);
|
||||
eprintln!("default threads: {}", parallel_threads());
|
||||
for (name, types, rockets) in [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
] {
|
||||
measure(level, name, (&types, rockets), one, secs);
|
||||
}
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(&level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
|
||||
for threads in [2, 3, 4, 6] {
|
||||
measure(
|
||||
level,
|
||||
&format!("whole scene, {threads} threads"),
|
||||
(&all, 3),
|
||||
(PARALLEL_MIN, threads),
|
||||
secs,
|
||||
);
|
||||
}
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -203,6 +203,9 @@ pub struct Settings {
|
||||
pub startup_api: crate::graphics::GraphicsApi,
|
||||
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
|
||||
pub video_modes: Vec<crate::window::VideoMode>,
|
||||
/// Fullbright is held off: this window plays on another's server (set by the game, not
|
||||
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
|
||||
pub fullbright_held: bool,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
pub controller: crate::gamepad::PadSettings,
|
||||
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
|
||||
@@ -293,6 +296,7 @@ impl Default for Settings {
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
fullbright_held: false,
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -514,6 +518,7 @@ impl Settings {
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
fullbright_held: false,
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
|
||||
@@ -932,7 +932,56 @@ pub struct Downloader {
|
||||
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
|
||||
/// How long a failed texture download waits before it is tried again.
|
||||
retry_after: std::time::Duration,
|
||||
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
|
||||
state_order: std::collections::VecDeque<(Kind, String)>,
|
||||
/// The `resources` keys, oldest first ([`Bounds::resources`]).
|
||||
resource_order: std::collections::VecDeque<String>,
|
||||
/// Downloads started whose answer `state` has not taken yet: one thread each.
|
||||
in_flight: usize,
|
||||
/// Fetches a texture's body; the HTTP GET, but for a test.
|
||||
fetch: fn(&str) -> Option<Vec<u8>>,
|
||||
bounds: Bounds,
|
||||
}
|
||||
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
|
||||
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
|
||||
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
|
||||
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
|
||||
struct Answer {
|
||||
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
|
||||
key: Option<(Kind, String)>,
|
||||
}
|
||||
impl Answer {
|
||||
fn give(&mut self, image: Option<Vec<u8>>) {
|
||||
if let Some(key) = self.key.take() {
|
||||
let _ = self.send.send((key, image));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Answer {
|
||||
fn drop(&mut self) {
|
||||
self.give(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
|
||||
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
|
||||
/// `texture` id a `resources` entry, none of them ever dropped.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Bounds {
|
||||
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
|
||||
/// dropped texture is loaded again from the disk cache when it is asked for.
|
||||
states: usize,
|
||||
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
|
||||
resources: usize,
|
||||
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
|
||||
/// caller, which asks again every frame, starts it when one has finished.
|
||||
downloads: usize,
|
||||
}
|
||||
const BOUNDS: Bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
const MAX_SKIN_IMAGES: usize = 1024;
|
||||
@@ -964,6 +1013,11 @@ impl Downloader {
|
||||
resources: Default::default(),
|
||||
failed: Default::default(),
|
||||
retry_after: std::time::Duration::from_secs(60),
|
||||
state_order: Default::default(),
|
||||
resource_order: Default::default(),
|
||||
in_flight: 0,
|
||||
fetch: http_get,
|
||||
bounds: BOUNDS,
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
@@ -973,6 +1027,7 @@ impl Downloader {
|
||||
self.images.remove(&location);
|
||||
self.image_order.retain(|l| *l != location);
|
||||
}
|
||||
self.resource_order.clear();
|
||||
self.resource_root = root;
|
||||
}
|
||||
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
|
||||
@@ -1002,12 +1057,47 @@ impl Downloader {
|
||||
}
|
||||
None => Download::Failed,
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
if self.resources.insert(location.to_owned(), state).is_none() {
|
||||
self.resource_order.push_back(location.to_owned());
|
||||
while self.resources.len() > self.bounds.resources
|
||||
&& let Some(old) = self.resource_order.pop_front()
|
||||
{
|
||||
self.resources.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Forgets how `location` loaded, so `resource` reads it again.
|
||||
fn forget_resource(&mut self, location: &str) {
|
||||
if self.resources.remove(location).is_some() {
|
||||
self.resource_order.retain(|l| l != location);
|
||||
}
|
||||
}
|
||||
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
|
||||
/// downloads running are never dropped, so their answer has a key to land on.
|
||||
fn set_state(&mut self, key: (Kind, String), state: Download) {
|
||||
if self.states.insert(key.clone(), state).is_some() {
|
||||
return;
|
||||
}
|
||||
self.state_order.push_back(key);
|
||||
while self.states.len() > self.bounds.states {
|
||||
let Some(at) = self
|
||||
.state_order
|
||||
.iter()
|
||||
.position(|k| self.states.get(k) != Some(&Download::Pending))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some(old) = self.state_order.remove(at) {
|
||||
self.states.remove(&old);
|
||||
self.failed.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time. A
|
||||
/// failure is permanent for `retry_after`, then tried again.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
self.in_flight = self.in_flight.saturating_sub(1);
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
match key.0 {
|
||||
@@ -1023,29 +1113,42 @@ impl Downloader {
|
||||
Download::Failed
|
||||
}
|
||||
};
|
||||
self.states.insert(key, state);
|
||||
self.set_state(key, state);
|
||||
}
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
return Download::Failed;
|
||||
};
|
||||
let key = (kind, hash);
|
||||
let full = self.in_flight >= self.bounds.downloads;
|
||||
if let Some(state) = self.states.get(&key).copied() {
|
||||
let due = self
|
||||
.failed
|
||||
.get(&key)
|
||||
.is_none_or(|at| at.elapsed() >= self.retry_after);
|
||||
if state != Download::Failed || !due {
|
||||
// A failure that is due waits for a free download like a new texture does.
|
||||
if state != Download::Failed || !due || full {
|
||||
return state;
|
||||
}
|
||||
self.states.remove(&key);
|
||||
self.failed.remove(&key);
|
||||
self.state_order.retain(|k| *k != key);
|
||||
}
|
||||
self.states.insert(key.clone(), Download::Pending);
|
||||
if full {
|
||||
// Not recorded: the next call asks again, and starts it once one has finished.
|
||||
return Download::Pending;
|
||||
}
|
||||
self.in_flight += 1;
|
||||
self.set_state(key.clone(), Download::Pending);
|
||||
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
|
||||
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
|
||||
let fetch = self.fetch;
|
||||
std::thread::spawn(move || {
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
|
||||
let _ = send.send((key, image));
|
||||
let mut answer = Answer {
|
||||
send,
|
||||
key: Some(key),
|
||||
};
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
|
||||
answer.give(image);
|
||||
});
|
||||
Download::Pending
|
||||
}
|
||||
@@ -1108,7 +1211,7 @@ impl Downloader {
|
||||
if location.starts_with("patch/") {
|
||||
let Some(image) = self.images.get(location).cloned() else {
|
||||
// Dropped from memory: read from the packs again.
|
||||
self.resources.remove(location);
|
||||
self.forget_resource(location);
|
||||
return false;
|
||||
};
|
||||
self.queue(location.to_owned(), image);
|
||||
@@ -1925,3 +2028,225 @@ mod tests {
|
||||
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_bound_tests {
|
||||
use super::{Bounds, Download, Downloader, Kind};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut d = Downloader::new(root);
|
||||
d.fetch = fetch;
|
||||
d.bounds = bounds;
|
||||
d
|
||||
}
|
||||
|
||||
fn url(i: usize) -> String {
|
||||
format!("https://textures.example/texture/skin{i:05}")
|
||||
}
|
||||
|
||||
/// Asks again until the texture's download has settled.
|
||||
fn wait(d: &mut Downloader, url: &str) -> Download {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let state = d.state(Kind::Skin, url);
|
||||
if state != Download::Pending || std::time::Instant::now() > deadline {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
static RUNNING: AtomicUsize = AtomicUsize::new(0);
|
||||
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
|
||||
fn slow_failure(_: &str) -> Option<Vec<u8>> {
|
||||
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
PEAK.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
RUNNING.fetch_sub(1, Ordering::SeqCst);
|
||||
None
|
||||
}
|
||||
|
||||
/// The same, not counted: tests run side by side, and the count is another test's.
|
||||
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
None
|
||||
}
|
||||
|
||||
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
|
||||
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
|
||||
#[test]
|
||||
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 4,
|
||||
};
|
||||
let mut d = downloader("threads", bounds, slow_failure);
|
||||
let urls: Vec<String> = (0..60).map(url).collect();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
let mut first = true;
|
||||
loop {
|
||||
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
|
||||
if first {
|
||||
// Asked all at once: four started, and the others were not even recorded.
|
||||
assert_eq!(d.in_flight, 4);
|
||||
assert_eq!(d.states.len(), 4);
|
||||
assert!(states.iter().all(|s| *s == Download::Pending));
|
||||
first = false;
|
||||
}
|
||||
if states.iter().all(|s| *s == Download::Failed) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{} still pending",
|
||||
states.len()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(
|
||||
PEAK.load(Ordering::SeqCst) <= 4,
|
||||
"{}",
|
||||
PEAK.load(Ordering::SeqCst)
|
||||
);
|
||||
assert_eq!(d.states.len(), 60);
|
||||
assert_eq!(d.in_flight, 0);
|
||||
}
|
||||
|
||||
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
|
||||
/// (and their failure time with them), and one that went is asked again as a new texture.
|
||||
#[test]
|
||||
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
|
||||
let bounds = Bounds {
|
||||
states: 8,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("states", bounds, |_| None);
|
||||
for i in 0..40 {
|
||||
let u = url(i);
|
||||
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
|
||||
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
|
||||
assert!(d.failed.len() <= 8);
|
||||
}
|
||||
let kept: Vec<usize> = (0..40)
|
||||
.filter(|i| {
|
||||
d.states
|
||||
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kept, (32..40).collect::<Vec<_>>());
|
||||
// The first one went: asked again, it starts again (and fails again).
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
}
|
||||
|
||||
/// A fetch that panics, as a decoder can on a body a server chose.
|
||||
fn panicking(_: &str) -> Option<Vec<u8>> {
|
||||
panic!("a fetch that panics, on purpose");
|
||||
}
|
||||
|
||||
/// A thread that panics before it answers still gives its place back: with two places and six
|
||||
/// textures whose fetch panics, every one settles as failed instead of the places staying
|
||||
/// taken for good.
|
||||
#[test]
|
||||
fn a_download_that_panics_gives_its_place_back() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 2,
|
||||
};
|
||||
let mut d = downloader("panics", bounds, panicking);
|
||||
for i in 0..6 {
|
||||
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
|
||||
}
|
||||
assert_eq!(d.in_flight, 0);
|
||||
assert_eq!(d.states.len(), 6);
|
||||
}
|
||||
|
||||
/// A fetch long enough for a test to ask between its start and its end.
|
||||
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
None
|
||||
}
|
||||
|
||||
/// A failure whose wait is over is retried like a new texture: not while every place is taken
|
||||
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
|
||||
#[test]
|
||||
fn a_failure_that_is_due_waits_for_a_free_place() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 1,
|
||||
};
|
||||
let mut d = downloader("due", bounds, slowish_failure);
|
||||
let wait_time = d.retry_after;
|
||||
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
|
||||
// Another texture takes the only place; the first one's retry becomes due but has to wait.
|
||||
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
d.retry_after = wait_time;
|
||||
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
|
||||
// The place is free: the retry starts.
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
}
|
||||
|
||||
/// A texture that is running is never the one dropped, however many settle behind it.
|
||||
#[test]
|
||||
fn a_running_download_keeps_its_place_in_the_states() {
|
||||
let bounds = Bounds {
|
||||
states: 2,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("running", bounds, sleepy_failure);
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
for i in 1..6 {
|
||||
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
|
||||
}
|
||||
assert_eq!(
|
||||
d.states
|
||||
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
|
||||
Some(&Download::Pending)
|
||||
);
|
||||
assert!(d.states.len() <= 3);
|
||||
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
|
||||
}
|
||||
|
||||
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
|
||||
/// longer pile up, and one that was dropped is read from the packs again.
|
||||
#[test]
|
||||
fn the_resource_textures_keep_to_their_bound() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 8,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("resources", bounds, |_| None);
|
||||
for i in 0..40 {
|
||||
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
|
||||
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
|
||||
}
|
||||
let kept: Vec<usize> = (0..40)
|
||||
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
|
||||
.collect();
|
||||
assert_eq!(kept, (32..40).collect::<Vec<_>>());
|
||||
// Forgetting one by hand (as `again` does) leaves no stale key behind.
|
||||
d.forget_resource("patch/n/35");
|
||||
assert_eq!(d.resource_order.len(), d.resources.len());
|
||||
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
|
||||
// New packs start the count over.
|
||||
d.set_resource_root(std::env::temp_dir());
|
||||
assert!(d.resources.is_empty() && d.resource_order.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ pub struct Sprites {
|
||||
/// textures under their own names, and one layer serves them all (the GPU's array may
|
||||
/// hold as few as 2048).
|
||||
still: std::collections::HashMap<StillKey, u32>,
|
||||
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
|
||||
/// layer of its own ([`Sprites::keep_apart`]).
|
||||
apart: std::collections::HashSet<String>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
@@ -218,6 +221,13 @@ impl Sprites {
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
|
||||
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
|
||||
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
|
||||
/// with the same pixels). Before the textures that bear these names are inserted.
|
||||
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
|
||||
self.apart.extend(names);
|
||||
}
|
||||
/// Write what this run decoded to the cache file.
|
||||
pub fn save_disk_cache(&mut self) {
|
||||
if let Some(disk) = &mut self.disk {
|
||||
@@ -287,8 +297,13 @@ impl Sprites {
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
let frames: Arc<Vec<Vec<u8>>> =
|
||||
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
|
||||
// Native frames already match the atlas size: share the immutable
|
||||
// buffers with animations rather than copying every frame again.
|
||||
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
|
||||
Arc::clone(frames)
|
||||
} else {
|
||||
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
|
||||
};
|
||||
self.pixels.extend_from_slice(&frames[*initial]);
|
||||
resized.push(frames);
|
||||
}
|
||||
@@ -686,23 +701,24 @@ impl Sprites {
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut frame = std::collections::hash_map::DefaultHasher::new();
|
||||
frames[0].hash(&mut frame);
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
StillKey {
|
||||
frame: frame.finish(),
|
||||
pbr: maps.finish(),
|
||||
side,
|
||||
settings: [
|
||||
settings.strategy as u32,
|
||||
settings.alpha_cutoff_bias.to_bits(),
|
||||
],
|
||||
flags: [settings.has_transparent, settings.item, opaque, translucent],
|
||||
}
|
||||
});
|
||||
let still =
|
||||
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut frame = std::collections::hash_map::DefaultHasher::new();
|
||||
frames[0].hash(&mut frame);
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
StillKey {
|
||||
frame: frame.finish(),
|
||||
pbr: maps.finish(),
|
||||
side,
|
||||
settings: [
|
||||
settings.strategy as u32,
|
||||
settings.alpha_cutoff_bias.to_bits(),
|
||||
],
|
||||
flags: [settings.has_transparent, settings.item, opaque, translucent],
|
||||
}
|
||||
});
|
||||
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
|
||||
self.layers.insert(name.to_owned(), same);
|
||||
return Ok(same);
|
||||
@@ -872,6 +888,35 @@ mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
#[test]
|
||||
fn native_frames_at_atlas_size_are_shared_without_resampling() {
|
||||
let pixels = vec![
|
||||
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
|
||||
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
|
||||
];
|
||||
let native = Arc::new(pixels.clone());
|
||||
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
|
||||
let metadata = r#"{"frametime":2}"#;
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
(SPRITE_SIZE, Arc::clone(&native), 1),
|
||||
(8, Arc::clone(&smaller), 0),
|
||||
],
|
||||
pbr: vec![[None, None], [None, None]],
|
||||
animations: vec![
|
||||
test_frames(0, pixels.clone(), metadata),
|
||||
test_frames(1, smaller.as_ref().clone(), metadata),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
|
||||
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
|
||||
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
|
||||
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
|
||||
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
|
||||
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
|
||||
}
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
|
||||
@@ -1282,6 +1327,40 @@ mod tests {
|
||||
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
|
||||
// A resource pack's transparent placeholders: a model's layer and a tile another
|
||||
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
|
||||
// rule finds its faces by layer, so the one it names can't share the others'.
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
|
||||
let directory = root.join("assets/a/textures/block");
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
let write = |name: &str| {
|
||||
let mut encoder =
|
||||
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[0; 16 * 16 * 4])
|
||||
.unwrap();
|
||||
};
|
||||
for name in ["placeholder.png", "named.png", "again.png"] {
|
||||
write(name);
|
||||
}
|
||||
let mut sprites = Sprites::default();
|
||||
sprites.keep_apart(["a:block/named".to_owned()]);
|
||||
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
|
||||
let named = sprites.insert(&root, "a:block/named").unwrap();
|
||||
let again = sprites.insert(&root, "a:block/again").unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The named one alone; the others still share.
|
||||
assert_ne!(named, placeholder);
|
||||
assert_eq!(again, placeholder);
|
||||
assert_eq!(sprites.count(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
|
||||
@@ -605,6 +605,9 @@ pub struct Streaming {
|
||||
/// Set while `update` runs, after it refreshed `cover`: nothing else can
|
||||
/// change `extra` meanwhile, so `cover` answers without comparing them.
|
||||
cover_current: bool,
|
||||
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
|
||||
/// they change; read like `cover`, while `cover_current`.
|
||||
forced_cover: Option<ForcedCover>,
|
||||
/// Mesh the sections for drawing (false on a server thread).
|
||||
pub mesh: bool,
|
||||
/// The pregeneration band (`set_band`).
|
||||
@@ -806,6 +809,7 @@ impl GenerationScan {
|
||||
forced: input.ranges.forced,
|
||||
mod_forced: input.ranges.mod_forced,
|
||||
cover: input.ranges.cover,
|
||||
forced_cover: None,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
@@ -1066,8 +1070,10 @@ pub struct WorkerContext {
|
||||
pub light: Arc<LightTables>,
|
||||
}
|
||||
mod bounded;
|
||||
mod forced_cover;
|
||||
mod relight_now;
|
||||
use bounded::nearest_first;
|
||||
use forced_cover::ForcedCover;
|
||||
impl Streaming {
|
||||
pub fn new<G: ChunkGenerator>(
|
||||
generator: Arc<G>,
|
||||
@@ -1176,6 +1182,7 @@ impl Streaming {
|
||||
extra: Vec::new(),
|
||||
cover: None,
|
||||
cover_current: false,
|
||||
forced_cover: None,
|
||||
mesh: true,
|
||||
band: None,
|
||||
band_ranked: None,
|
||||
@@ -1691,6 +1698,7 @@ impl Streaming {
|
||||
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
|
||||
columns.clear();
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
|
||||
self.cover_current = true;
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
// at once when the view moves and every eighth tick otherwise: a
|
||||
@@ -1870,6 +1878,7 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
let (protos, generating) = (&self.protos, &self.generating);
|
||||
@@ -2040,6 +2049,7 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
self.waiting.retain(|&(x, sy, z)| {
|
||||
@@ -2145,6 +2155,7 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
},
|
||||
world,
|
||||
@@ -2283,6 +2294,7 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
}
|
||||
}
|
||||
@@ -2563,6 +2575,8 @@ struct Ranges<'a> {
|
||||
/// `extra` indexed by a grid or buckets: coverage queries without a
|
||||
/// scan of every area.
|
||||
cover: Option<&'a ExtraCover>,
|
||||
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
|
||||
forced_cover: Option<&'a ForcedCover>,
|
||||
band: Option<&'a Band>,
|
||||
}
|
||||
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
|
||||
@@ -2611,6 +2625,7 @@ impl Band {
|
||||
+ i64::from(x - self.x0 + 8).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// For every column near the extra areas, how far it lies outside the
|
||||
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
|
||||
/// loaded and wanted column whether one reaches it, several times a tick:
|
||||
@@ -2918,16 +2933,6 @@ impl Ranges<'_> {
|
||||
.iter()
|
||||
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
|
||||
}
|
||||
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
||||
fn is_forced(&self, x: i32, z: i32) -> bool {
|
||||
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
||||
}
|
||||
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
||||
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
||||
self.forced
|
||||
.iter()
|
||||
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
||||
}
|
||||
/// The columns meshed for drawing: those vanilla draws around the
|
||||
/// player (`in_view_distance`), and forced ones.
|
||||
fn in_view(&self, x: i32, z: i32) -> bool {
|
||||
@@ -2939,18 +2944,7 @@ impl Ranges<'_> {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
// 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_chunks()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
self.near_forced(x, z, margin)
|
||||
}
|
||||
}
|
||||
impl Drop for Streaming {
|
||||
@@ -3484,6 +3478,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let generate = |x, z, priority, order| Queued {
|
||||
@@ -3724,6 +3719,7 @@ mod tests {
|
||||
forced: &self.forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
},
|
||||
world: &self.world,
|
||||
@@ -4107,6 +4103,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let square = |x: i32, z: i32, n: i32| {
|
||||
@@ -4164,6 +4161,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let span = radius * 3 + 20;
|
||||
@@ -4201,6 +4199,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
@@ -4235,6 +4234,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
|
||||
@@ -4290,6 +4290,7 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
@@ -4430,6 +4431,7 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
|
||||
@@ -4473,6 +4475,7 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
|
||||
@@ -4539,6 +4542,7 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
// The columns streaming asks about: everything kept, and the
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
|
||||
//! distance map.
|
||||
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
|
||||
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
|
||||
/// through every forced chunk (it is asked for every column, several times a tick).
|
||||
pub(super) struct ForcedCover {
|
||||
forced: HashSet<(i32, i32)>,
|
||||
mod_forced: HashSet<(i32, i32)>,
|
||||
near: FxHashMap<(i32, i32), u8>,
|
||||
}
|
||||
impl ForcedCover {
|
||||
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
|
||||
const REACH: i32 = FORCED_TERRAIN_RING + 1;
|
||||
/// Past this many forced chunks the cover is not made: the scan answers.
|
||||
const MOST: usize = 4096;
|
||||
pub(super) fn refresh(
|
||||
cover: &mut Option<Self>,
|
||||
forced: &HashSet<(i32, i32)>,
|
||||
mod_forced: &HashSet<(i32, i32)>,
|
||||
) {
|
||||
if cover
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if forced.len() + mod_forced.len() > Self::MOST {
|
||||
*cover = None;
|
||||
return;
|
||||
}
|
||||
let mut near = FxHashMap::default();
|
||||
for &(fx, fz) in forced.iter().chain(mod_forced) {
|
||||
for dx in -Self::REACH..=Self::REACH {
|
||||
for dz in -Self::REACH..=Self::REACH {
|
||||
let d = dx.abs().max(dz.abs()) as u8;
|
||||
near.entry((fx + dx, fz + dz))
|
||||
.and_modify(|e: &mut u8| *e = (*e).min(d))
|
||||
.or_insert(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
*cover = Some(Self {
|
||||
forced: forced.clone(),
|
||||
mod_forced: mod_forced.clone(),
|
||||
near,
|
||||
});
|
||||
}
|
||||
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
|
||||
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
|
||||
(reach <= Self::REACH).then(|| {
|
||||
self.near
|
||||
.get(&(x, z))
|
||||
.is_some_and(|&d| i32::from(d) <= reach)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranges<'_> {
|
||||
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
||||
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
|
||||
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
||||
}
|
||||
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
||||
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
||||
self.forced
|
||||
.iter()
|
||||
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
||||
}
|
||||
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
|
||||
/// `margin` asks for.
|
||||
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
// 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,
|
||||
};
|
||||
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
|
||||
return hit;
|
||||
}
|
||||
self.forced_chunks()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
|
||||
let mut next = |m: i32| {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
(*seed % (2 * m as u64 + 1)) as i32 - m
|
||||
};
|
||||
(0..n).map(|_| (next(spread), next(spread))).collect()
|
||||
}
|
||||
|
||||
/// With the forced chunks by distance, every column gets the answer the scan of every forced
|
||||
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
|
||||
/// forced chunks change too.
|
||||
#[test]
|
||||
fn the_distances_answer_as_the_scan() {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut cover = None;
|
||||
for case in 0..24 {
|
||||
let spread = [3, 12, 40][case % 3];
|
||||
let forced = chunks(&mut seed, 1 + case % 9, spread);
|
||||
// Some mod tickets on forced chunks, some apart.
|
||||
let mut mod_forced = chunks(&mut seed, case % 4, spread);
|
||||
mod_forced.extend(forced.iter().take(case % 2));
|
||||
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
|
||||
let made = cover.as_ref().expect("a few forced chunks get a map");
|
||||
let ranges = |forced_cover| Ranges {
|
||||
// The player's circle far away: only the forced chunks answer.
|
||||
center: (10_000, 10_000),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
mod_forced: &mod_forced,
|
||||
cover: None,
|
||||
forced_cover,
|
||||
band: None,
|
||||
};
|
||||
let (scan, map) = (ranges(None), ranges(Some(made)));
|
||||
let span = spread + 10;
|
||||
for z in -span..=span {
|
||||
for x in -span..=span {
|
||||
for margin in 0..=7 {
|
||||
assert_eq!(
|
||||
map.in_main_range(x, z, margin),
|
||||
scan.in_main_range(x, z, margin),
|
||||
"case {case} ({x}, {z}) margin {margin}"
|
||||
);
|
||||
}
|
||||
for d in [false, true] {
|
||||
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
|
||||
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
|
||||
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Too many forced chunks for a map: none is made, and the scan answers.
|
||||
#[test]
|
||||
fn past_the_most_no_map_is_made() {
|
||||
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
|
||||
let mut cover = None;
|
||||
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
|
||||
assert!(cover.is_some());
|
||||
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
|
||||
assert!(cover.is_none());
|
||||
}
|
||||
}
|
||||
@@ -524,8 +524,18 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
|
||||
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
|
||||
let g = (lo.y >> (8u * corner)) & 255u;
|
||||
let t = lo.x;
|
||||
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
|
||||
var code = 0u;
|
||||
if corner == 1u {
|
||||
code = (hi.x >> 28u) & 7u;
|
||||
} else if corner == 2u {
|
||||
code = (hi.y >> 24u) & 7u;
|
||||
} else if corner == 3u {
|
||||
code = (hi.y >> 27u) & 7u;
|
||||
}
|
||||
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
|
||||
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
|
||||
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
|
||||
| ((g * (t & 255u) / 255u) << 16u) | alpha;
|
||||
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
|
||||
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped.": "Auto uses the peak Windows reports. Calibrate if highlights look clipped.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
|
||||
@@ -1989,13 +1989,13 @@ mod tests {
|
||||
}
|
||||
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
|
||||
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
|
||||
/// with HORIZON_RESOURCES set to the prepared resources.
|
||||
/// with HORIZON_TEST_RESOURCES set to the prepared resources.
|
||||
#[test]
|
||||
#[ignore = "needs the prepared resources"]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn index_on_vanilla_tree() {
|
||||
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let data = root.join("data");
|
||||
let t = std::time::Instant::now();
|
||||
let tree = Tree::load(&data);
|
||||
|
||||
@@ -26,8 +26,9 @@ pub struct Tab {
|
||||
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
|
||||
/// pages them.
|
||||
pub page: u32,
|
||||
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
|
||||
pub name: Option<(String, Option<u32>)>,
|
||||
/// A Polytone creative tab modifier's `name`: its text (a translation key when
|
||||
/// the flag is set) and colour, over the tab's own.
|
||||
pub name: Option<(String, Option<u32>, bool)>,
|
||||
}
|
||||
impl Tab {
|
||||
/// The tab without its stacks.
|
||||
@@ -103,25 +104,28 @@ pub struct Creative {
|
||||
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
|
||||
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
|
||||
}
|
||||
/// A text component's plain text and its colour (a name, or `#rrggbb`).
|
||||
fn component_text(json: &Value) -> (String, Option<u32>) {
|
||||
/// A text component's text, its colour (a name, or `#rrggbb`) and whether the
|
||||
/// text is a `translate` key, to be resolved when drawn.
|
||||
fn component_text(json: &Value) -> (String, Option<u32>, bool) {
|
||||
match json {
|
||||
Value::String(s) => (s.clone(), None),
|
||||
Value::String(s) => (s.clone(), None, false),
|
||||
Value::Object(o) => {
|
||||
let translate = o.get("translate").and_then(Value::as_str);
|
||||
let text = o
|
||||
.get("text")
|
||||
.or_else(|| o.get("translate"))
|
||||
.and_then(Value::as_str)
|
||||
.or(translate)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let translate = o.get("text").is_none() && translate.is_some();
|
||||
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
|
||||
.or_else(|| crate::text::named_color(c))
|
||||
});
|
||||
(text, color)
|
||||
(text, color, translate)
|
||||
}
|
||||
_ => (String::new(), None),
|
||||
_ => (String::new(), None, false),
|
||||
}
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
@@ -912,6 +916,16 @@ impl Creative {
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
(x, y)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.checkTabHovering`: the tab whose icon
|
||||
/// rectangle, 3 pixels in from its 26x32 slot (`isHovering(x + 3, y + 3,
|
||||
/// 21, 27)`), holds the cursor. The screen draws its display name.
|
||||
pub fn hovered_tab(tabs: &[Tab], origin: (f32, f32), cursor: (f32, f32)) -> Option<usize> {
|
||||
tabs.iter().position(|tab| {
|
||||
let (x, y) = Self::tab_position(tab);
|
||||
let (x, y) = (origin.0 + x + 3.0, origin.1 + y + 3.0);
|
||||
cursor.0 >= x && cursor.0 < x + 21.0 && cursor.1 >= y && cursor.1 < y + 27.0
|
||||
})
|
||||
}
|
||||
/// `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.
|
||||
@@ -1789,4 +1803,21 @@ mod tests {
|
||||
assert!(c.tab_headers().is_empty());
|
||||
assert_eq!(c.search_tab(), None);
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.checkTabHovering`: the 3-pixel inset of a
|
||||
/// tab's 26x32 slot (21x27) is the hover area whose name the screen shows.
|
||||
#[test]
|
||||
fn only_a_tabs_inset_icon_is_hovered() {
|
||||
let screen = tooltip_tabs();
|
||||
let tab = &screen.tabs[0];
|
||||
let (x, y) = Creative::tab_position(tab);
|
||||
let at =
|
||||
|dx: f32, dy: f32| Creative::hovered_tab(&screen.tabs, (0.0, 0.0), (x + dx, y + dy));
|
||||
assert_eq!(at(4.0, 4.0), Some(0));
|
||||
assert_eq!(at(23.5, 4.0), Some(0));
|
||||
assert_eq!(at(2.0, 4.0), None);
|
||||
assert_eq!(at(24.0, 4.0), None);
|
||||
assert_eq!(at(4.0, 2.0), None);
|
||||
assert_eq!(at(4.0, 30.0), None);
|
||||
}
|
||||
}
|
||||
@@ -8,28 +8,51 @@ use crate::{
|
||||
text::{Style, Text},
|
||||
};
|
||||
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
|
||||
/// message glyph, using the same visual order and advances as rendering.
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the last style `wanted`
|
||||
/// under the cursor, in the same visual order and advances as rendering.
|
||||
/// A drawn glyph is hit where it is drawn (`BakedSheetGlyph.GlyphInstance`),
|
||||
/// even far below the line when its font's `ascent` moves it; an empty one
|
||||
/// covers the line's 9 pixels (`EmptyArea`).
|
||||
pub fn line_style_at(
|
||||
font: &Font,
|
||||
text: &Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cursor: (f32, f32),
|
||||
wanted: impl Fn(&Style) -> bool,
|
||||
) -> Option<Style> {
|
||||
let mut left = cursor.0 - x;
|
||||
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
|
||||
return None;
|
||||
}
|
||||
let text = text.resolve_legacy();
|
||||
let visual = crate::bidi::visual(&text);
|
||||
let mut pen = x;
|
||||
let mut found = None;
|
||||
for (c, style) in visual.chars() {
|
||||
left -= font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
return Some(style.clone());
|
||||
let advance = font.advance(c, style);
|
||||
let object = c == '\u{fffc}' && style.inline_object.is_some();
|
||||
let glyph = (!object).then(|| font.glyph_styled(c, style));
|
||||
let (left, top, bottom) = match glyph {
|
||||
Some(g) if g.rect.is_some() => {
|
||||
let thick = if style.is_bold() { 0.1 } else { 0.0 };
|
||||
let shear = if style.is_italic() {
|
||||
1.0 - 0.25 * g.bottom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Dialog text is drawn with a shadow, which extends the bottom.
|
||||
(
|
||||
pen + g.left + shear - thick,
|
||||
y + g.top - thick,
|
||||
y + g.bottom + g.offset + thick,
|
||||
)
|
||||
}
|
||||
_ => (pen, y, y + 9.0),
|
||||
};
|
||||
let inside = (left..pen + advance).contains(&cursor.0) && (top..bottom).contains(&cursor.1);
|
||||
if inside && wanted(style) {
|
||||
found = Some(style.clone());
|
||||
}
|
||||
pen += advance;
|
||||
}
|
||||
None
|
||||
found
|
||||
}
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
@@ -485,7 +508,7 @@ mod tests {
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y), |_| true);
|
||||
assert!(style(9.9, 20.0).is_none());
|
||||
assert_eq!(style(17.9, 20.0).unwrap().click, None);
|
||||
for x in [18.0, 21.0, 25.9] {
|
||||
@@ -503,6 +526,77 @@ mod tests {
|
||||
assert!(style(21.0, 29.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_moved_down_by_its_ascent_is_clicked_where_it_is_drawn() {
|
||||
use crate::{component, hud::Rect, text::ClickEvent};
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-dialog-ascent-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut sheet = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut sheet, 8, 8);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[255; 8 * 8 * 4])
|
||||
.unwrap();
|
||||
}
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"assets/minecraft/font/low.json",
|
||||
br#"{"providers":[{"type":"bitmap","file":"minecraft:font/box.png","height":8,"ascent":-28,"chars":["a"]}]}"#
|
||||
.as_slice(),
|
||||
),
|
||||
("assets/minecraft/textures/font/box.png", sheet.as_slice()),
|
||||
] {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
let font = Font::load(
|
||||
&root,
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(4096, 1024),
|
||||
(512, 512),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0,
|
||||
v1: 0.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let text = component::to_text(
|
||||
&serde_json::json!([
|
||||
{"text": "a", "font": "minecraft:low"},
|
||||
{"text": "a", "font": "minecraft:low", "click_event": {
|
||||
"action": "custom", "id": "test:requests"
|
||||
}},
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let click = |x, y| {
|
||||
line_style_at(&font, &text, 10.0, 20.0, (x, y), |s| s.click.is_some())
|
||||
.and_then(|s| s.click)
|
||||
};
|
||||
let requests = Some(ClickEvent::Custom {
|
||||
id: "test:requests".into(),
|
||||
payload: None,
|
||||
});
|
||||
for (x, y) in [(19.0, 55.0), (22.0, 60.0), (27.9, 63.9)] {
|
||||
assert_eq!(click(x, y), requests, "drawn 35 below the line at {x}, {y}");
|
||||
}
|
||||
assert_eq!(click(22.0, 24.0), None, "the line's own row holds nothing");
|
||||
assert_eq!(click(22.0, 54.9), None, "above the glyph");
|
||||
assert_eq!(click(22.0, 65.0), None, "below the glyph and its shadow");
|
||||
assert_eq!(click(15.0, 60.0), None, "the plain glyph left of it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
|
||||
@@ -166,7 +166,8 @@ impl FurnaceCooking for Furnace {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
// Vanilla Mth.clamp is min(max(v, 0), max): saturates at a negative total instead of panicking.
|
||||
self.cooking_timer = (self.cooking_timer - 2).max(0).min(self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
|
||||
@@ -1524,14 +1524,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
fn block_entity_signal(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
_state: BlockStateId,
|
||||
state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
// TestBlock.ownSignal.
|
||||
// TestBlock.ownSignal: the block state's mode gates the entity.
|
||||
if let Some(BlockEntity::TestBlock(b)) =
|
||||
self.world.block_entities.get(&(pos.x, pos.y, pos.z))
|
||||
{
|
||||
return b.own_signal();
|
||||
return crate::game::test_blocks::own_signal(self.registry, state, b.powered);
|
||||
}
|
||||
// TrappedChestBlock.getSignal: the players with it open, at most 15.
|
||||
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
|
||||
@@ -2873,9 +2873,9 @@ struct Game<'a> {
|
||||
water_plane_memo: std::cell::Cell<Option<f32>>,
|
||||
/// The camera's block and the second `water_plane` last scanned at.
|
||||
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
|
||||
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
|
||||
/// worked out for (`vehicle_mob_reach`).
|
||||
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
|
||||
/// What the boats' and minecarts' ticks know about the mobs: their box reach
|
||||
/// (`vehicle_mob_reach`) and a grid of their boxes (`mobs_meeting`).
|
||||
vehicle_mobs: boat_passengers::VehicleMobs,
|
||||
persist: persist::EntityMemory,
|
||||
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
@@ -11992,7 +11992,7 @@ impl<'a> Game<'a> {
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
vehicle_mob_reach: Default::default(),
|
||||
vehicle_mobs: Default::default(),
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
|
||||
@@ -1255,7 +1255,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.check_inventory_trigger();
|
||||
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
|
||||
let me = self.player_entity_for("minecraft:tick");
|
||||
let me = self.player_entity_if_listened("minecraft:tick");
|
||||
self.trigger("minecraft:tick", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1289,7 +1289,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_marks();
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity_for("minecraft:location");
|
||||
let me = self.player_entity_if_listened("minecraft:location");
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! An allay dances to the jukebox it heard first: `Allay.setJukeboxPlaying`
|
||||
//! keeps that jukebox's position while it dances, and only a stop from there
|
||||
//! ends the dance; another jukebox's `jukebox_play` (every 20 ticks of its
|
||||
//! song) starts it again (tools/test_allay_jukebox_source_parity.py measures
|
||||
//! the same on vanilla 26.3).
|
||||
use crate::game::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
for command in [
|
||||
"tick freeze",
|
||||
"forceload add 0 0",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
"fill 7 99 7 9 102 9 glass",
|
||||
"fill 8 100 8 8 101 8 air",
|
||||
"setblock 5 100 8 jukebox",
|
||||
"setblock 12 100 8 jukebox",
|
||||
"summon allay 8.5 100 8.5 {PersistenceRequired:1b,UUID:[I;2577,0,0,1]}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
step(&mut game, 12);
|
||||
game
|
||||
}
|
||||
|
||||
fn step(game: &mut Game<'_>, ticks: usize) {
|
||||
for _ in 0..ticks {
|
||||
assert!(game.run_command("tick step 1"));
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn dancing(game: &Game<'_>) -> bool {
|
||||
game.mobs
|
||||
.iter()
|
||||
.find(|m| m.species() == horizon_client::mobs::Species::Allay)
|
||||
.expect("the allay")
|
||||
.extra
|
||||
.flag
|
||||
}
|
||||
|
||||
fn play(game: &mut Game<'_>, x: i32, item: &str) {
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace block {x} 100 8 container.0 with {item}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn stopping_the_first_jukebox_stops_the_dance_until_the_other_plays_again() {
|
||||
let mut game = game();
|
||||
assert!(!dancing(&game));
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
assert!(dancing(&game), "jukebox A starts the dance");
|
||||
play(&mut game, 12, "minecraft:music_disc_cat");
|
||||
step(&mut game, 25);
|
||||
assert!(dancing(&game));
|
||||
// B has played for 25 ticks: its next jukebox_play is 15 ticks away.
|
||||
play(&mut game, 5, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(
|
||||
!dancing(&game),
|
||||
"the stop of A, the dance's jukebox, ends it though B still plays"
|
||||
);
|
||||
step(&mut game, 20);
|
||||
assert!(dancing(&game), "B's next jukebox_play starts it again");
|
||||
}
|
||||
|
||||
// Controls added after the baseline: the source through an in-place load, a
|
||||
// NoAI allay, and the out-of-reach check of `aiStep`.
|
||||
|
||||
const ALLAY: &str = "00000a11-0000-0000-0000-000000000001";
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_data_merge_keeps_the_dance_and_its_jukebox() {
|
||||
let mut game = game();
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
play(&mut game, 12, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
assert!(game.run_command(&format!("data merge entity {ALLAY} {{CustomName:\"x\"}}")));
|
||||
step(&mut game, 1);
|
||||
assert!(dancing(&game), "Entity.load keeps DATA_DANCING");
|
||||
// The kept jukebox is still A: B's stop does not end the dance, A's does.
|
||||
play(&mut game, 12, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(dancing(&game));
|
||||
play(&mut game, 5, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_a_no_ai_allay_never_dances() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command(&format!("data merge entity {ALLAY} {{NoAI:1b}}")));
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 25);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_an_allay_out_of_reach_stops_on_a_twentieth_tick() {
|
||||
let mut game = game();
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 2);
|
||||
assert!(dancing(&game));
|
||||
assert!(game.run_command("fill 7 99 7 9 102 9 air"));
|
||||
assert!(game.run_command(&format!("tp {ALLAY} 5.5 112 8.5")));
|
||||
// Farther than 10 blocks from A's centre: stopped by the next tickCount % 20.
|
||||
step(&mut game, 20);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
//! and the hand-over interaction (`Allay.mobInteract`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
@@ -74,9 +73,6 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() != Species::Allay
|
||||
|| !self.mobs[i].alive()
|
||||
@@ -85,26 +81,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let pos = self.mobs[i].position;
|
||||
// Dancing: a playing jukebox within 10 blocks.
|
||||
let jukeboxes = jukeboxes.get_or_insert_with(|| {
|
||||
self.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
|
||||
.map(|(k, _)| *k)
|
||||
.collect()
|
||||
});
|
||||
let nearest = jukeboxes
|
||||
.iter()
|
||||
.map(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
|
||||
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
|
||||
// The dance follows the jukebox events the allay hears
|
||||
// (`mob_listen`), `Allay.tick` and `aiStep`.
|
||||
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
@@ -425,3 +403,7 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allay_jukebox_source_tests.rs"]
|
||||
mod allay_jukebox_source_tests;
|
||||
@@ -7,10 +7,13 @@
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
fast_hash::FxHashMap,
|
||||
join_seq::ListStamp,
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders, seen_grid::SeenGrid},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
@@ -18,6 +21,35 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// How many box queries the walk over every mob answers, once a write to the mob list has
|
||||
/// made the grid stale, before a new grid is built: about what building one costs (with
|
||||
/// 25,000 mobs, six to eight walks). It doubles with each grid of a stretch, so vehicles
|
||||
/// that each write a mob (a boat pushing the pig beside it) never pay more for grids than
|
||||
/// for their walks.
|
||||
const WALKS_BEFORE_GRID: u32 = 8;
|
||||
|
||||
/// What a stretch of vehicle ticks knows about the mobs, so a boat or a minecart reads the
|
||||
/// mobs near it and not every mob of the level ([`Game::mobs_meeting`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct VehicleMobs {
|
||||
/// The mobs' [`BoxReach`] with the mob count it was worked out for; None outside a stretch.
|
||||
reach: Cell<Option<(usize, BoxReach)>>,
|
||||
grid: RefCell<MobGrid>,
|
||||
/// The last query's candidates, kept for their allocation.
|
||||
near: Cell<Vec<u32>>,
|
||||
/// Each ridden vehicle's mobs by slot, for the mob list as it stood ([`Game::vehicle_riders`]).
|
||||
riders: RefCell<Option<(ListStamp, FxHashMap<u32, Vec<usize>>)>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MobGrid {
|
||||
/// The mobs' boxes by column and the list they were read from: they hold until that
|
||||
/// list's next write ([`ListStamp`]), which every push, pick-up, death and join is.
|
||||
built: Option<(ListStamp, SeenGrid)>,
|
||||
/// The queries walked since the grid went stale, and how many are before the next grid.
|
||||
walked: u32,
|
||||
patience: u32,
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
|
||||
/// to return false, so no vehicle ever takes them aboard.
|
||||
pub(super) fn species_can_ride(species: Species) -> bool {
|
||||
@@ -66,32 +98,165 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
|
||||
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
|
||||
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
|
||||
/// a vehicle's box query ([`Self::mobs_meeting`]) reads the mobs near it from a grid of
|
||||
/// their boxes, or tests the mobs' positions against their [`BoxReach`] first. A mob
|
||||
/// joining or leaving works the reach out again, and so does [`Self::vehicle_picked_up`].
|
||||
/// The first query that needs one works it out (the reach's count is one no list has), so
|
||||
/// a level without boats or carts never walks its mobs for them.
|
||||
pub(super) fn vehicles_ticking(&self, on: bool) {
|
||||
self.vehicle_mob_reach
|
||||
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
|
||||
let reach = on.then(|| (usize::MAX, BoxReach::default()));
|
||||
self.vehicle_mobs.reach.set(reach);
|
||||
// The boats' grid serves the carts ticking right after them; an older one goes.
|
||||
let mut grid = self.vehicle_mobs.grid.borrow_mut();
|
||||
let stamp = self.mobs.stamp();
|
||||
let built = grid.built.take().filter(|(s, _)| *s == stamp);
|
||||
*grid = MobGrid {
|
||||
built,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
|
||||
/// changes its box, so the next query works the [`BoxReach`] out again.
|
||||
pub(super) fn vehicle_picked_up(&self) {
|
||||
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
|
||||
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
|
||||
if let Some((_, reach)) = self.vehicle_mobs.reach.get() {
|
||||
self.vehicle_mobs.reach.set(Some((usize::MAX, reach)));
|
||||
}
|
||||
}
|
||||
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
|
||||
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
|
||||
let (count, reach) = self.vehicle_mob_reach.get()?;
|
||||
let (count, reach) = self.vehicle_mobs.reach.get()?;
|
||||
if count == self.mobs.len() {
|
||||
return Some(reach);
|
||||
}
|
||||
let reach = BoxReach::of(&self.mobs);
|
||||
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
|
||||
self.vehicle_mobs.reach.set(Some((self.mobs.len(), reach)));
|
||||
Some(reach)
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose vehicle is `id`, whatever their state. While
|
||||
/// vehicles tick, one walk over the mobs answers for every vehicle until the mob list is
|
||||
/// next written: each boat asks for its passengers and each cart on rails whether it is
|
||||
/// ridden, and each one walked every mob of the level.
|
||||
fn vehicle_riders(&self, id: u32) -> Vec<usize> {
|
||||
let rides = |v: u32| move |&j: &usize| self.mobs[j].extra.mount == Some(v);
|
||||
if !self.vehicle_mobs_indexed() {
|
||||
return (0..self.mobs.len()).filter(rides(id)).collect();
|
||||
}
|
||||
let stamp = self.mobs.stamp();
|
||||
let mut riders = self.vehicle_mobs.riders.borrow_mut();
|
||||
if riders.as_ref().is_none_or(|(s, _)| *s != stamp) {
|
||||
let mut by_vehicle = FxHashMap::<u32, Vec<usize>>::default();
|
||||
for (j, m) in self.mobs.iter().enumerate() {
|
||||
if let Some(v) = m.extra.mount {
|
||||
by_vehicle.entry(v).or_default().push(j);
|
||||
}
|
||||
}
|
||||
*riders = Some((stamp, by_vehicle));
|
||||
}
|
||||
let of = riders
|
||||
.as_ref()
|
||||
.and_then(|(_, by_vehicle)| by_vehicle.get(&id));
|
||||
of.cloned().unwrap_or_default()
|
||||
}
|
||||
/// Whether a mob (one not removed) rides vehicle `id`: `Entity.isVehicle` for a cart.
|
||||
pub(super) fn mob_rides(&self, id: u32) -> bool {
|
||||
let riders = self.vehicle_riders(id);
|
||||
riders.iter().any(|&j| !self.mobs[j].removed)
|
||||
}
|
||||
/// Whether what a stretch of vehicle ticks reads from the mob list may stand in for a walk
|
||||
/// over it: a stretch runs, and no native mod is loaded (a hook can move a mob unseen, as
|
||||
/// for the carts' `CartIndex`).
|
||||
fn vehicle_mobs_indexed(&self) -> bool {
|
||||
self.vehicle_mobs.reach.get().is_some() && horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose box may meet `area` into `out` (a superset: the
|
||||
/// caller still tests each box), from a grid of the mobs' boxes that the first query of a
|
||||
/// stretch of vehicle ticks builds. False: walk them all. That is outside a stretch or with
|
||||
/// native mods loaded ([`Self::vehicle_mobs_indexed`]), when the grid cannot say, and for
|
||||
/// a while after a write to the mob list ([`MobGrid`]).
|
||||
fn vehicle_mobs_near(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !self.vehicle_mobs_indexed() {
|
||||
return false;
|
||||
}
|
||||
let stamp = self.mobs.stamp();
|
||||
let mut grid = self.vehicle_mobs.grid.borrow_mut();
|
||||
if grid.built.as_ref().is_none_or(|(s, _)| *s != stamp) {
|
||||
if grid.walked < grid.patience {
|
||||
grid.walked += 1;
|
||||
return false;
|
||||
}
|
||||
grid.built = Some((stamp, SeenGrid::new(self.mobs.iter().map(Mob::bounds))));
|
||||
grid.walked = 0;
|
||||
grid.patience = (grid.patience * 2).max(WALKS_BEFORE_GRID);
|
||||
}
|
||||
let built = grid.built.as_ref();
|
||||
built.is_some_and(|(_, cells)| cells.candidates(area, out))
|
||||
}
|
||||
/// A vehicle's box query: `each` gets every mob whose box meets `area`, in slot order,
|
||||
/// with its slot and its box. While vehicles tick it reads the mobs near the area
|
||||
/// ([`Self::vehicle_mobs_near`]) or, walking them all, tests each position against the
|
||||
/// [`BoxReach`] before working the box out. The mobs and their order are those of a walk
|
||||
/// that tests every box.
|
||||
pub(super) fn mobs_meeting(&self, area: Aabb, mut each: impl FnMut(usize, &Mob, Aabb)) {
|
||||
let mut near = self.vehicle_mobs.near.take();
|
||||
if self.vehicle_mobs_near(area, &mut near) {
|
||||
for &j in &near {
|
||||
let m = &self.mobs[j as usize];
|
||||
let b = m.bounds();
|
||||
if b.intersects(area) {
|
||||
each(j as usize, m, b);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let reach = self.vehicle_mob_reach();
|
||||
for (j, m) in self.mobs.iter().enumerate() {
|
||||
if reach.is_some_and(|r| !r.may_meet(m, area)) {
|
||||
continue;
|
||||
}
|
||||
let b = m.bounds();
|
||||
if b.intersects(area) {
|
||||
each(j, m, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.vehicle_mobs.near.set(near);
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose box may meet a cart, read from the mobs' grid
|
||||
/// once per cart. None when the mobs are the fewer (asking the carts' grid once per mob
|
||||
/// is then the shorter way) or the grid does not answer.
|
||||
pub(super) fn mobs_near_carts(&self) -> Option<Vec<u32>> {
|
||||
if self.mobs.len() <= self.carts.len() {
|
||||
return None;
|
||||
}
|
||||
let (mut slots, mut near) = (Vec::new(), Vec::new());
|
||||
for c in self.carts.iter() {
|
||||
if !self.vehicle_mobs_near(c.bounds(), &mut near) {
|
||||
return None;
|
||||
}
|
||||
slots.extend_from_slice(&near);
|
||||
}
|
||||
slots.sort_unstable();
|
||||
slots.dedup();
|
||||
Some(slots)
|
||||
}
|
||||
/// Whether mob `m`, whose box `reach` bounds, may touch a cart: the carts' grid (`cells`)
|
||||
/// names the carts near it, or every cart when the grid cannot (a superset either way).
|
||||
pub(super) fn mob_may_meet_cart(
|
||||
&self,
|
||||
reach: BoxReach,
|
||||
m: &Mob,
|
||||
cells: &SeenGrid,
|
||||
near: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !cells.candidates(reach.around(m.position), near) {
|
||||
near.clear();
|
||||
near.extend(0..self.carts.len() as u32);
|
||||
}
|
||||
near.iter()
|
||||
.any(|&k| reach.may_meet(m, self.carts[k as usize].bounds()))
|
||||
}
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
@@ -105,12 +270,9 @@ impl Game<'_> {
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
let mut riders = self.vehicle_riders(id);
|
||||
riders.retain(|&j| !self.mobs[j].removed && self.mobs[j].alive());
|
||||
riders
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
@@ -174,18 +336,12 @@ impl Game<'_> {
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
// The box first: it turns away nearly every mob of the level.
|
||||
reach.is_none_or(|r| r.may_meet(m, area))
|
||||
&& m.bounds().intersects(area)
|
||||
&& pushable(m)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
self.mobs_meeting(area, |j, m, b| {
|
||||
if pushable(m) && (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id)) {
|
||||
out.push(b);
|
||||
}
|
||||
});
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
@@ -230,19 +386,14 @@ impl Game<'_> {
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// The box first: it turns away nearly every mob of the level, and the
|
||||
// teams are only worked out when something touches the boat.
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let sweep = area.union_bounds(bb);
|
||||
let near: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
|
||||
return false;
|
||||
}
|
||||
let mb = self.mobs[j].bounds();
|
||||
mb.intersects(area) || mb.intersects(bb)
|
||||
})
|
||||
.collect();
|
||||
// The mobs near the two boxes: the teams are only worked out when something
|
||||
// touches the boat.
|
||||
let mut near = Vec::new();
|
||||
self.mobs_meeting(area.union_bounds(bb), |j, _, mb| {
|
||||
if mb.intersects(area) || mb.intersects(bb) {
|
||||
near.push(j);
|
||||
}
|
||||
});
|
||||
let outside = self.players_outside(id);
|
||||
let touching = !near.is_empty()
|
||||
|| outside
|
||||
@@ -531,4 +682,124 @@ mod tests {
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
|
||||
/// A mob pushes the cart its box touches while a mob far from every cart pushes none and
|
||||
/// is not pushed, whichever grid finds them: the carts' (four carts in four of its columns,
|
||||
/// asked once per mob while the mobs are the fewer) or, in a crowd, the mobs' (asked once
|
||||
/// per cart).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn only_the_mob_touching_a_cart_pushes_it() {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
let pushed = [0, 12].map(|crowd: u32| {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
for (i, (x, z)) in [(0.5, 0.5), (20.5, 0.5), (0.5, 20.5), (20.5, 20.5)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = Vec3::new(x, 64.0, z);
|
||||
game.carts
|
||||
.push(Minecart::new(100 + i as u32, CartKind::Chest, at));
|
||||
}
|
||||
// 0.5 west of the second cart (boxes 0.9 and 0.98 wide), in the middle of them
|
||||
// all, and the crowd well away.
|
||||
let spots = [(20.0, 0.5), (10.5, 10.5)].into_iter();
|
||||
let crowd = (0..crowd).map(|i| (40.5 + 3.0 * f64::from(i), 40.5));
|
||||
for (i, (x, z)) in spots.chain(crowd).enumerate() {
|
||||
let at = Vec3::new(x, 64.0, z);
|
||||
game.mobs.push(Mob::new(1 + i as u32, pig.clone(), at, 0.0));
|
||||
}
|
||||
game.tick_carts();
|
||||
let velocity = |id| game.mobs.iter().find(|m| m.id == id).unwrap().velocity;
|
||||
assert!(velocity(1).x < 0.0, "pushed back west: {:?}", velocity(1));
|
||||
let idle = game.mobs.iter().filter(|m| m.velocity == Vec3::ZERO);
|
||||
assert_eq!(idle.count(), game.mobs.len() - 1);
|
||||
velocity(1)
|
||||
});
|
||||
assert_eq!(pushed[0], pushed[1]);
|
||||
}
|
||||
|
||||
/// A vehicle's box query during a stretch of vehicle ticks names the mobs a walk over
|
||||
/// every box names, in the same order, also right after a mob moved into the box (a
|
||||
/// push or a pick-up writes the mob list in the middle of a stretch).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_vehicles_box_query_matches_a_walk_over_every_mob() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
// A small LCG keeps the scene the same on every run.
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |range: f64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
|
||||
};
|
||||
for id in 1..=400 {
|
||||
let at = Vec3::new(next(48.0), 64.0 + next(3.0), next(48.0));
|
||||
game.mobs.push(Mob::new(id, pig.clone(), at, 0.0));
|
||||
}
|
||||
let mut met = 0;
|
||||
for round in 0..300 {
|
||||
// Three stretches, each with two writes to the mob list.
|
||||
if round % 100 == 0 {
|
||||
game.vehicles_ticking(true);
|
||||
}
|
||||
let corner = Vec3::new(next(46.0), 63.0 + next(3.0), next(46.0));
|
||||
let size = Vec3::new(0.5 + next(2.5), 0.5 + next(1.5), 0.5 + next(2.5));
|
||||
let area = Aabb::new(corner, corner.add_vector(size));
|
||||
if round % 40 == 20 {
|
||||
let j = round % game.mobs.len();
|
||||
game.mobs[j].position = area.center();
|
||||
}
|
||||
let walked: Vec<Aabb> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.map(Mob::bounds)
|
||||
.filter(|b| b.intersects(area))
|
||||
.collect();
|
||||
met += walked.len();
|
||||
assert_eq!(game.boat_obstacles(u32::MAX, area), walked, "round {round}");
|
||||
if round % 100 == 99 {
|
||||
// The last comparisons ran against the grid: it stands, for the list as it is.
|
||||
let fresh = |(stamp, _): &(ListStamp, SeenGrid)| *stamp == game.mobs.stamp();
|
||||
assert!(
|
||||
game.vehicle_mobs
|
||||
.grid
|
||||
.borrow()
|
||||
.built
|
||||
.as_ref()
|
||||
.is_some_and(fresh)
|
||||
);
|
||||
game.vehicles_ticking(false);
|
||||
}
|
||||
}
|
||||
assert!(met > 100, "the boxes met {met} mobs");
|
||||
}
|
||||
|
||||
/// Who rides a vehicle follows the mob list through a stretch of vehicle ticks: a mob that
|
||||
/// boards counts from then on, one removed no longer does, and a dead one still makes
|
||||
/// its cart a vehicle but is no passenger of a boat.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_vehicle_knows_its_mob_riders_as_soon_as_they_board() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
for id in 1..=4 {
|
||||
game.mobs.push(Mob::new(id, pig.clone(), Vec3::ZERO, 0.0));
|
||||
}
|
||||
let riders =
|
||||
|game: &Game| [100, 101, 102].map(|id| (game.mob_rides(id), game.boat_mobs(id)));
|
||||
game.mobs[0].extra.mount = Some(100);
|
||||
game.vehicles_ticking(true);
|
||||
let none = (false, vec![]);
|
||||
assert_eq!(riders(&game), [(true, vec![0]), none.clone(), none.clone()]);
|
||||
game.mobs[0].removed = true;
|
||||
game.mobs[1].extra.mount = Some(101);
|
||||
game.mobs[3].extra.mount = Some(102);
|
||||
game.mobs[3].health = 0.0;
|
||||
let after = [none, (true, vec![1]), (true, vec![])];
|
||||
assert_eq!(riders(&game), after);
|
||||
game.vehicles_ticking(false);
|
||||
assert_eq!(riders(&game), after);
|
||||
}
|
||||
}
|
||||
@@ -1467,6 +1467,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
|
||||
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.yaw = yaw;
|
||||
b.previous_yaw = yaw;
|
||||
}
|
||||
}
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
@@ -1477,7 +1484,11 @@ impl Game<'_> {
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
|
||||
self.boats.push(boat);
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
|
||||
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
|
||||
self.set_boat_yaw(id, yaw);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_vehicle_create(
|
||||
self.boats.last().unwrap().entity_type().as_str(),
|
||||
|
||||
@@ -1594,15 +1594,17 @@ impl Game<'_> {
|
||||
&tr("gui.done", "Done"),
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
// show_text hover event, as the chat shows it.
|
||||
if let Some(hover) = pages
|
||||
.get(*page)
|
||||
.and_then(|t| self.book_style_at(t, left))
|
||||
.and_then(|s| s.hover)
|
||||
{
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: a clickable word
|
||||
// asks for the hand, and a show_text hover shows its tooltip,
|
||||
// as the chat does.
|
||||
if let Some(style) = pages.get(*page).and_then(|t| self.book_style_at(t, left)) {
|
||||
if let Some(cursor) = link_cursor(&style) {
|
||||
horizon_client::widgets::request_cursor(cursor);
|
||||
}
|
||||
if let Some(hover) = style.hover {
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1647,6 +1649,16 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
|
||||
}
|
||||
}
|
||||
|
||||
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: only a word with a click event asks
|
||||
/// for the pointing hand over a book's page; a hover event alone shows its
|
||||
/// tooltip.
|
||||
fn link_cursor(style: &horizon_client::text::Style) -> Option<horizon_client::widgets::Cursor> {
|
||||
style
|
||||
.click
|
||||
.is_some()
|
||||
.then_some(horizon_client::widgets::Cursor::PointingHand)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_box_tests {
|
||||
use super::book_title_box;
|
||||
@@ -1836,6 +1848,60 @@ mod lectern_signal_tests {
|
||||
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
|
||||
}
|
||||
}
|
||||
impl BookScreen {
|
||||
/// `BookViewScreen.isPauseScreen`: the class inherits `Screen`'s true, so
|
||||
/// a book read in hand stops a single-player world. `LecternScreen`
|
||||
/// overrides it false, so the same pages read on a lectern leave the
|
||||
/// world ticking (its menu is an `AbstractContainerScreen`, which does
|
||||
/// not pause either).
|
||||
pub(super) fn pauses_world(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pause_tests {
|
||||
use super::BookScreen;
|
||||
use horizon_client::{text::Text, ui};
|
||||
|
||||
fn view(lectern: Option<(i32, i32, i32)>) -> BookScreen {
|
||||
BookScreen::View {
|
||||
pages: Vec::<Text>::new(),
|
||||
page: 0,
|
||||
lectern,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lecterns_book_does_not_pause_the_world() {
|
||||
// BookViewScreen keeps Screen.isPauseScreen's true; LecternScreen
|
||||
// overrides it false although both read the same pages.
|
||||
assert!(view(None).pauses_world());
|
||||
assert!(!view(Some((10, 64, -3))).pauses_world());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_being_edited_or_signed_pauses() {
|
||||
// BookEditScreen and BookSignScreen do not override isPauseScreen.
|
||||
let edit = |signing| BookScreen::Edit {
|
||||
slot: 0,
|
||||
pages: Vec::new(),
|
||||
page: 0,
|
||||
signing,
|
||||
title: String::new(),
|
||||
title_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
|
||||
page_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
|
||||
};
|
||||
assert!(edit(false).pauses_world());
|
||||
assert!(edit(true).pauses_world());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lectern_reach_tests {
|
||||
@@ -1929,3 +1995,26 @@ mod sign_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod link_cursor_tests {
|
||||
use super::link_cursor;
|
||||
use horizon_client::text::{ClickEvent, Style, Text};
|
||||
use horizon_client::widgets::Cursor;
|
||||
|
||||
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: a clickable word asks for the
|
||||
/// pointing hand; a hover event alone only shows its tooltip.
|
||||
#[test]
|
||||
fn only_a_clickable_book_word_asks_for_the_hand() {
|
||||
let link = Style {
|
||||
click: Some(ClickEvent::OpenUrl("https://example.com".into())),
|
||||
..Style::default()
|
||||
};
|
||||
let hover_only = Style {
|
||||
hover: Some(Box::new(Text::literal("tip"))),
|
||||
..Style::default()
|
||||
};
|
||||
assert_eq!(link_cursor(&link), Some(Cursor::PointingHand));
|
||||
assert_eq!(link_cursor(&hover_only), None);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Breeze particles (`Breeze.tick`): block dust at its feet while it stands,
|
||||
//! a burst of twenty while it slides and a short trail along a long jump.
|
||||
use super::Game;
|
||||
use super::{BlockStateId, Game};
|
||||
use horizon_client::{
|
||||
mobs::{Species, breeze},
|
||||
particles::Particle,
|
||||
blocks::RenderShape,
|
||||
mobs::{Mob, Species, breeze},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -33,21 +34,16 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.mobs[i].extra.scratch[1] = 0;
|
||||
}
|
||||
let m = &self.mobs[i];
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let mut at = [x, y, z];
|
||||
let mut state = self.world.block(x, y, z);
|
||||
if self.res.facts().is_air(state) {
|
||||
at[1] = (m.position.y - 0.2).floor() as i32;
|
||||
state = self.world.block(x, at[1], z);
|
||||
let (block, state) = self.breeze_ground(&self.mobs[i]);
|
||||
// `emitGroundParticles` skips an invisible block; the jump trail
|
||||
// (`emitJumpTrailParticles`) does not ask.
|
||||
if !jump_trail && self.res.facts().get(state).render_shape == RenderShape::Invisible {
|
||||
continue;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
|
||||
continue;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let at = if jump_trail {
|
||||
[
|
||||
m.position.x + m.velocity.x,
|
||||
@@ -57,11 +53,198 @@ impl Game<'_> {
|
||||
} else {
|
||||
[m.position.x, m.position.y, m.position.z]
|
||||
};
|
||||
// `ClientLevel.addParticle` with no speed: each crumb's own
|
||||
// generator draws its lifetime, its little push and the quarter of
|
||||
// the block's sprite it shows, never the level's random.
|
||||
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
|
||||
for _ in 0..amount {
|
||||
let particle =
|
||||
Particle::terrain(&mut self.world.random, at, [0.0; 3], sprite.clone(), tint);
|
||||
self.particles.push(particle);
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Breeze.emitGroundParticles`'s block: `getInBlockState`, else
|
||||
/// `getBlockStateOn`, which is `Entity.getOnPos(1.0E-5F)` — the supporting
|
||||
/// block the feet rest on, or the cell just below them. A fence, a wall and
|
||||
/// a fence gate have a 1.5-block collision, so a breeze on one has its feet
|
||||
/// half a block above that block's own cell, where the cell below them is
|
||||
/// air and only the supporting block has the material.
|
||||
pub(super) fn breeze_ground(&self, m: &Mob) -> ([i32; 3], BlockStateId) {
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
let in_block = self.world.block(feet[0], feet[1], feet[2]);
|
||||
if !self.res.facts().is_air(in_block) {
|
||||
return (feet, in_block);
|
||||
}
|
||||
let on_ground = m
|
||||
.on_ground
|
||||
.then(|| self.supporting_block(m.position, m.bounds()));
|
||||
let at = on_ground.flatten().map_or(
|
||||
[
|
||||
feet[0],
|
||||
(m.position.y - f64::from(1.0e-5_f32)).floor() as i32,
|
||||
feet[2],
|
||||
],
|
||||
|p| [p.x, p.y, p.z],
|
||||
);
|
||||
(at, self.world.block(at[0], at[1], at[2]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A game on the prepared resources with an empty chunk at (0, 0), the
|
||||
/// window's player (the camera the dust needs) and one breeze standing
|
||||
/// with its feet at `feet`.
|
||||
fn game_with_breeze(feet: Vec3) -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
false,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
// Eight blocks south of the breeze: inside the 32 blocks the dust needs.
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 16.5))
|
||||
.unwrap(),
|
||||
);
|
||||
game.particles.camera = [8.5, 66.0, 16.5];
|
||||
let kind = game.res.mob_types()["minecraft:breeze"].clone();
|
||||
game.mobs.push(Mob::new(13001, kind, feet, 0.0));
|
||||
game
|
||||
}
|
||||
|
||||
/// A breeze standing on a fence rests its feet at 64 + 1.5, where its own
|
||||
/// cell is air: `Breeze.emitGroundParticles` asks `getBlockStateOn` for the
|
||||
/// supporting block, so the crumbs are the fence's. Every one of them is
|
||||
/// made by the particle's own generator (`Particle.random`), so the
|
||||
/// client level's random is where it was before the tick.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_dust_of_a_breeze_on_a_fence_keeps_the_client_level_random() {
|
||||
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
|
||||
let registry = &game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let fence = registry.default_state("minecraft:oak_fence").unwrap();
|
||||
game.world.set(8, 63, 8, stone).unwrap();
|
||||
game.world.set(8, 64, 8, fence).unwrap();
|
||||
game.mobs[0].on_ground = true;
|
||||
assert_eq!(
|
||||
game.breeze_ground(&game.mobs[0]),
|
||||
([8, 64, 8], fence),
|
||||
"the feet's own cell is air; the fence is what holds them up"
|
||||
);
|
||||
game.world.random = LegacyRandom::new(1234);
|
||||
let level = game.world.random.state();
|
||||
|
||||
game.tick_breeze_effects();
|
||||
|
||||
assert_eq!(
|
||||
game.particles.made().get("block").copied().unwrap_or(0),
|
||||
1,
|
||||
"vanilla emits one crumb of the fence the breeze stands on"
|
||||
);
|
||||
assert_eq!(
|
||||
game.world.random.state(),
|
||||
level,
|
||||
"the crumb's constructor draws belong to the particle, not the level"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control: a breeze standing on a full block's top also crumbles it,
|
||||
/// and its constructor's draws still leave the client level's random alone.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_dust_of_a_breeze_on_a_full_block_keeps_the_client_level_random() {
|
||||
let mut game = game_with_breeze(Vec3::new(8.5, 64.0, 8.5));
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
game.world.set(8, 63, 8, stone).unwrap();
|
||||
game.mobs[0].on_ground = true;
|
||||
game.world.random = LegacyRandom::new(1234);
|
||||
let level = game.world.random.state();
|
||||
|
||||
game.tick_breeze_effects();
|
||||
|
||||
assert_eq!(
|
||||
game.world.random.state(),
|
||||
level,
|
||||
"the crumb's constructor draws belong to the particle, not the level"
|
||||
);
|
||||
assert_eq!(game.particles.made().get("block").copied().unwrap_or(0), 1);
|
||||
}
|
||||
|
||||
/// `Entity.getOnPos(1.0E-5F)` keeps a fence's, a wall's or a fence gate's
|
||||
/// own cell while the feet rest on its 1.5-block collision; on a full block
|
||||
/// it is the block below the feet. Reading 0.2 below the feet, as the dust
|
||||
/// used to, lands in air on all three.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_ground_of_a_breeze_is_the_fence_wall_or_gate_it_stands_on() {
|
||||
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
|
||||
let registry = &game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
game.world.set(8, 63, 8, stone).unwrap();
|
||||
game.mobs[0].on_ground = true;
|
||||
for name in [
|
||||
"minecraft:oak_fence",
|
||||
"minecraft:cobblestone_wall",
|
||||
"minecraft:oak_fence_gate",
|
||||
] {
|
||||
let state = registry.default_state(name).unwrap();
|
||||
game.world.set(8, 64, 8, state).unwrap();
|
||||
assert_eq!(
|
||||
game.breeze_ground(&game.mobs[0]),
|
||||
([8, 64, 8], state),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
// The same breeze on the full block's top reads that block.
|
||||
game.world.set(8, 64, 8, stone).unwrap();
|
||||
game.mobs[0].position = Vec3::new(8.5, 65.0, 8.5);
|
||||
assert_eq!(game.breeze_ground(&game.mobs[0]), ([8, 64, 8], stone));
|
||||
}
|
||||
}
|
||||
@@ -2694,7 +2694,7 @@ mod damage_type_registry_tests {
|
||||
let pack = dir.join("datapacks/model");
|
||||
let types = pack.join("data/example/damage_type");
|
||||
std::fs::create_dir_all(&types).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
|
||||
std::fs::write(
|
||||
types.join("test.json"),
|
||||
r#"{"message_id":"example.test","scaling":"never","exhaustion":0.0}"#,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod admin;
|
||||
pub(super) mod compute;
|
||||
mod data;
|
||||
mod display_names;
|
||||
pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
@@ -782,13 +783,7 @@ impl Game<'_> {
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|
||||
|| Text::translate("entity.", &[]),
|
||||
|m| match self.mob_team_of(m) {
|
||||
Some(team) => team.formatted_name(m.command_name()),
|
||||
None => m.command_name(),
|
||||
},
|
||||
),
|
||||
Target::Mob(id) => self.mob_command_name(id),
|
||||
other => {
|
||||
// Entity.getDisplayName: the CustomName component, else the type.
|
||||
let custom = super::native_entities::target_id(other)
|
||||
@@ -2618,9 +2613,10 @@ impl Game<'_> {
|
||||
if self.res.mob_types().contains_key(&kind) {
|
||||
// SummonCommand.createEntity loads the mob from `{id:..}`: its yaw
|
||||
// is the compound's (none, so 0), with no level-random draw.
|
||||
self.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
|
||||
let id = self
|
||||
.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
|
||||
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
name = self.target_name(Target::Mob(id));
|
||||
} else {
|
||||
match short.as_str() {
|
||||
"item" => {
|
||||
|
||||
@@ -421,6 +421,16 @@ impl Game<'_> {
|
||||
// random (Entity.random) goes on, as a spawn egg's entity
|
||||
// data finds it.
|
||||
slot.random = old.random.clone();
|
||||
// Entity.load keeps the unsaved Allay.jukeboxPos and
|
||||
// DATA_DANCING of the entity it loads into.
|
||||
if slot.species() == horizon_client::mobs::Species::Allay {
|
||||
slot.extra.flag = old.extra.flag;
|
||||
if let (Some(st), Some(was)) =
|
||||
(slot.extra.allay.as_mut(), old.extra.allay.as_ref())
|
||||
{
|
||||
st.jukebox = was.jukebox;
|
||||
}
|
||||
}
|
||||
if keep_liked_player {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `Entity.getDisplayName` for the mobs command replies name: the name as its
|
||||
//! team shows it, with the `show_entity` hover and the UUID insertion.
|
||||
use super::super::Game;
|
||||
use horizon_client::text::Text;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.getDisplayName` for the mob `id`: `PlayerTeam.formatNameForTeam`
|
||||
/// of `getName()`, then `withHoverEvent(createHoverEvent())` (a `ShowEntity`
|
||||
/// of the type, UUID and name) and `withInsertion(getStringUUID())`. A mob
|
||||
/// gets its UUID when it joins the level; one without keeps the bare name.
|
||||
pub(in crate::game) fn mob_command_name(&self, id: u32) -> Text {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return Text::translate("entity.", &[]);
|
||||
};
|
||||
let name = m.command_name();
|
||||
let shown = match self.mob_team_of(m) {
|
||||
Some(team) => team.formatted_name(name.clone()),
|
||||
None => name.clone(),
|
||||
};
|
||||
let Some(uuid) = self.persist.uuid_of(id) else {
|
||||
return shown;
|
||||
};
|
||||
let uuid = horizon_client::save::uuid_text(uuid);
|
||||
let kind = horizon_client::command::args::normalize(&m.kind.id);
|
||||
shown.with_component_fields(vec![
|
||||
(
|
||||
"hover_event".into(),
|
||||
serde_json::json!({"action": "show_entity", "id": kind, "uuid": uuid,
|
||||
"name": name.component()}),
|
||||
),
|
||||
("insertion".into(), uuid.into()),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -520,11 +520,12 @@ impl Game<'_> {
|
||||
let Some(pos) = self.target_position(t) else {
|
||||
return Ok(0);
|
||||
};
|
||||
let current = self.target_rotation(t);
|
||||
let mut look_at_target = None;
|
||||
let rot = if let Some(Arg::Coords(c)) = ctx.get("rotation") {
|
||||
// Relative parts are offsets from the entity's own rotation.
|
||||
c.rotation(current)
|
||||
// RotateCommand.rotate: relative parts are offsets from the command
|
||||
// source's rotation (WorldCoordinates.getRotation); the entity's own
|
||||
// rotation cancels out in Entity.forceSetRotation.
|
||||
c.rotation(self.source_rotation())
|
||||
} else {
|
||||
let mut faced_entity = None;
|
||||
let to = if ctx.has("entity") {
|
||||
|
||||
@@ -441,6 +441,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "flat": r.flat_draws() })))
|
||||
}
|
||||
"section-layout" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
let compact = match words.get(1).copied() {
|
||||
Some("compact") => r.set_packed_layout(true),
|
||||
Some("wide") => r.set_packed_layout(false),
|
||||
_ => return Err("section-layout compact|wide".into()),
|
||||
};
|
||||
Ok(Some(json!({ "ok": true, "compact": compact })))
|
||||
}
|
||||
"section-cull" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
|
||||
@@ -325,6 +325,8 @@ fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
|
||||
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
|
||||
}
|
||||
|
||||
#[path = "mob_display_name_tests.rs"]
|
||||
mod mob_display_name_tests;
|
||||
#[path = "pet_order_tests.rs"]
|
||||
mod pet_order_tests;
|
||||
/// The packet's location is `EntityHitResult.getLocation() -
|
||||
|
||||
@@ -261,9 +261,11 @@ fn worn_of(
|
||||
}
|
||||
|
||||
/// The UUID the service knows the player's own model by. On a server, the profile it gave
|
||||
/// (`profile`). In single player the window's own server (`solo`) names the player by their
|
||||
/// offline profile, which the service doesn't know: the signed-in account's (`account`) then, only
|
||||
/// with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
|
||||
/// (`profile`), but an offline-mode server's is `nameUUIDFromBytes` of the name (version 3), which
|
||||
/// the service doesn't know: the signed-in account's (`account`) then, as an online server's would
|
||||
/// be (#12820). Only the own model: another player's name can be anyone's there. In single player
|
||||
/// the window's own server (`solo`) names the player by their offline profile too: the account's
|
||||
/// then, only with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
|
||||
fn own_uuid(
|
||||
profile: Option<u128>,
|
||||
solo: bool,
|
||||
@@ -271,6 +273,7 @@ fn own_uuid(
|
||||
account: impl FnOnce() -> Option<u128>,
|
||||
) -> Option<u128> {
|
||||
match solo {
|
||||
false if profile.is_some_and(|p| (p >> 76) & 0xf == 3) => account().or(profile),
|
||||
false => profile,
|
||||
true if shown && profile.is_some() => account(),
|
||||
true => None,
|
||||
@@ -1060,6 +1063,37 @@ mod tests {
|
||||
assert!(worn.is_none() && asked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_servers_profile_asks_by_my_account_and_an_online_one_by_its_own() {
|
||||
const ACCOUNT: u128 = 0x1f2e_3d4c_5b6a_4978_8695_a4b3_c2d1_e0ff;
|
||||
let offline = horizon_protocol::crypto::offline_uuid("Steve");
|
||||
let online = 0x069a_79f4_44e9_4726_a5be_fca9_0e38_aaf5;
|
||||
let signed_in = || Some(ACCOUNT);
|
||||
// An offline-mode server's profile: the service knows the account, so the account's.
|
||||
assert_eq!(
|
||||
own_uuid(Some(offline), false, false, signed_in),
|
||||
Some(ACCOUNT)
|
||||
);
|
||||
// An online server's profile is the account's own already: kept as it is.
|
||||
assert_eq!(
|
||||
own_uuid(Some(online), false, false, signed_in),
|
||||
Some(online)
|
||||
);
|
||||
// Signed out: the profile, as before.
|
||||
assert_eq!(
|
||||
own_uuid(Some(offline), false, false, || None),
|
||||
Some(offline)
|
||||
);
|
||||
// No profile yet: nothing to ask by.
|
||||
assert_eq!(own_uuid(None, false, false, signed_in), None);
|
||||
// Single player still follows its own setting.
|
||||
assert_eq!(own_uuid(Some(offline), true, false, signed_in), None);
|
||||
assert_eq!(
|
||||
own_uuid(Some(offline), true, true, signed_in),
|
||||
Some(ACCOUNT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_player_shows_my_cosmetics_only_with_its_setting_and_asks_by_my_account() {
|
||||
// The window's own server names the player by their offline profile.
|
||||
|
||||
@@ -28,8 +28,12 @@ impl Game<'_> {
|
||||
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
|
||||
/// `itemGroup.<mod>`), else `title` as English text.
|
||||
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
if let Some((name, _)) = &tab.name {
|
||||
return name.clone();
|
||||
if let Some((name, _, translate)) = &tab.name {
|
||||
return if *translate {
|
||||
self.translate(name, name)
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
}
|
||||
match horizon_client::creative::item_group_key(&tab.id) {
|
||||
Some(key) => self.translate(key, &tab.title),
|
||||
@@ -291,6 +295,13 @@ impl Game<'_> {
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
// CreativeModeInventoryScreen.checkTabHovering: a tab says its name.
|
||||
if let Some(i) = Creative::hovered_tab(&tabs, (ox, oy), self.cursor)
|
||||
&& let Some(t) = tabs.get(i)
|
||||
{
|
||||
let title = self.creative_tab_title(t);
|
||||
self.tooltip(batch, &title, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
|
||||
@@ -3059,7 +3059,7 @@ impl Game<'_> {
|
||||
// `CrossbowItem.createProjectile`: a rocket, from 0.15 under the eyes.
|
||||
let mut at = arrow.position;
|
||||
at.y -= f64::from(0.15f32) - 0.1;
|
||||
self.launch_angled_rocket(at, arrow.velocity, &ammo, arrow.owner);
|
||||
self.launch_angled_rocket(at, &ammo, arrow.owner, |_| arrow.velocity);
|
||||
} else {
|
||||
Self::arrow_from_ammo(&ammo, &mut arrow);
|
||||
self.launch_mob_projectile(arrow);
|
||||
|
||||
@@ -10,7 +10,6 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
random_source,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
@@ -342,10 +341,12 @@ impl Game<'_> {
|
||||
let rocket = &*ammo.item == FIREWORK_ROCKET;
|
||||
if rocket {
|
||||
let at = Vec3::new(eye[0], eye[1] - f64::from(0.15f32), eye[2]);
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut random_source::entity(), dir.to_array(), power, 1.0);
|
||||
// Its first tick posts the shot (Projectile.tick).
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
self.launch_angled_rocket(at, ammo, Some(Who::Player), |own| {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(own, dir.to_array(), power, 1.0);
|
||||
shot.velocity
|
||||
});
|
||||
}
|
||||
if !rocket {
|
||||
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
|
||||
|
||||
@@ -90,6 +90,22 @@ pub(super) struct DialogState {
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
impl DialogState {
|
||||
/// `Minecraft.pause` over these screens: `DialogScreen.isPauseScreen` is
|
||||
/// the dialog's own `pause` (`CommonDialogData.pause`, true by default),
|
||||
/// `WaitingForResponseScreen` overrides it false, and the warning above
|
||||
/// the dialog is a `ConfirmScreen`, which inherits `Screen`'s true.
|
||||
pub(super) fn pauses_world(&self) -> bool {
|
||||
if self.warning {
|
||||
return true;
|
||||
}
|
||||
if self.waiting.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.open.as_ref().is_some_and(|open| open.dialog.pause)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
|
||||
/// it, wrapping.
|
||||
fn cycle(open: &mut Open, input: usize, delta: i32) {
|
||||
@@ -557,17 +573,19 @@ impl Game<'_> {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
// AbstractStringWidget: only the styled glyph under the
|
||||
// pointer requests a hand, not the whole message widget.
|
||||
if (!clipped || in_area(cursor.1))
|
||||
&& let Some(style) = horizon_client::dialog_screen::line_style_at(
|
||||
let under = |wanted: fn(&horizon_client::text::Style) -> bool| {
|
||||
horizon_client::dialog_screen::line_style_at(
|
||||
&atlas.font,
|
||||
&text,
|
||||
x,
|
||||
y,
|
||||
cursor,
|
||||
wanted,
|
||||
)
|
||||
{
|
||||
widgets::widget_cursor(style.click.is_some(), true);
|
||||
if let Some(hover) = style.hover {
|
||||
};
|
||||
if !clipped || in_area(cursor.1) {
|
||||
widgets::widget_cursor(under(|s| s.click.is_some()).is_some(), true);
|
||||
if let Some(hover) = under(|s| s.hover.is_some()).and_then(|s| s.hover) {
|
||||
tooltip = Some(*hover);
|
||||
}
|
||||
}
|
||||
@@ -898,6 +916,7 @@ impl Game<'_> {
|
||||
x,
|
||||
y,
|
||||
cursor,
|
||||
|style| style.click.is_some(),
|
||||
)
|
||||
.and_then(|style| style.click);
|
||||
}
|
||||
@@ -1259,3 +1278,52 @@ mod background_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod pause_tests {
|
||||
use super::*;
|
||||
|
||||
fn dialog(pause: bool) -> Dialog {
|
||||
let s = |value: &str| Tag::String(value.into());
|
||||
Dialog::parse(&Tag::compound([
|
||||
("type", s("notice")),
|
||||
("title", s("Hi")),
|
||||
("pause", Tag::Byte(i8::from(pause))),
|
||||
]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn state(pause: bool) -> DialogState {
|
||||
DialogState {
|
||||
open: Some(Open {
|
||||
dialog: dialog(pause),
|
||||
values: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
dragging: None,
|
||||
selecting: None,
|
||||
}),
|
||||
waiting: None,
|
||||
warning: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.isPauseScreen` is `dialog.common().pause()`, true by
|
||||
/// default, so a server's dialog stops a single-player world unless it
|
||||
/// asks otherwise. The waiting screen and the warning override it.
|
||||
#[test]
|
||||
fn a_dialog_pauses_the_world_only_while_its_pause_says_so() {
|
||||
assert!(state(true).pauses_world());
|
||||
assert!(!state(false).pauses_world());
|
||||
// No dialog screen at all.
|
||||
assert!(!DialogState::default().pauses_world());
|
||||
// DialogScreen.WarningScreen extends ConfirmScreen: true.
|
||||
let mut warning = state(false);
|
||||
warning.warning = true;
|
||||
assert!(warning.pauses_world());
|
||||
// WaitingForResponseScreen.isPauseScreen is false.
|
||||
let mut waiting = state(true);
|
||||
waiting.waiting = Some(Instant::now());
|
||||
assert!(!waiting.pauses_world());
|
||||
}
|
||||
}
|
||||
@@ -1097,6 +1097,16 @@ impl Game<'_> {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
};
|
||||
// A rocket makes its own random in `launch_angled_rocket`: the constructor's draws
|
||||
// and then the shot's spread come from that one.
|
||||
if item == "minecraft:firework_rocket" {
|
||||
self.launch_angled_rocket(at, &one, None, |own| {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(own, dir, power, uncertainty);
|
||||
shot.velocity
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Projectile.shoot spreads the shot with the projectile's own random.
|
||||
let mut own = random_source::entity();
|
||||
match item.as_str() {
|
||||
@@ -1107,11 +1117,6 @@ impl Game<'_> {
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one, None);
|
||||
}
|
||||
_ => {
|
||||
let kind = match item.as_str() {
|
||||
"minecraft:arrow" => ProjectileKind::Arrow {
|
||||
@@ -1248,7 +1253,12 @@ impl Game<'_> {
|
||||
boat = boat.with_chest();
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
|
||||
self.boats.push(boat);
|
||||
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
|
||||
// then setYRot(direction.toYRot()).
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
|
||||
self.set_boat_yaw(id, yaw);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let boat = self.boats.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
@@ -1295,6 +1305,14 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.carts.push(cart);
|
||||
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
|
||||
let id = self.next_entity;
|
||||
self.apply_vehicle_stack_config(
|
||||
super::commands::Target::Cart(id),
|
||||
kind.id(),
|
||||
&stack,
|
||||
false,
|
||||
);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
|
||||
@@ -3108,18 +3108,8 @@ impl Game<'_> {
|
||||
if hurts {
|
||||
self.note_dragon_attacker("minecraft:mob_attack");
|
||||
}
|
||||
let (health, rules) = (&self.health_resources, self.world.health_rules);
|
||||
let id = health.damage.id("minecraft:mob_attack");
|
||||
let mut canceled = false;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
push_seen_with_hurt(p, push, |p| {
|
||||
if let Some(id) = id.filter(|_| hurts)
|
||||
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
|
||||
{
|
||||
canceled = o.canceled;
|
||||
}
|
||||
});
|
||||
}
|
||||
let id = self.health_resources.damage.id("minecraft:mob_attack");
|
||||
let canceled = self.push_seen_with_equipment_hurt(push, id.filter(|_| hurts), 5.0);
|
||||
self.combat.forget_noted(canceled);
|
||||
if !sitting
|
||||
&& !hurt_lately
|
||||
@@ -4815,6 +4805,7 @@ use death_render::{dissolve_color, dissolve_mask, dragon_rays};
|
||||
/// reaches the players tracking them, not them. So the push moves the player
|
||||
/// only together with a hurt that lands outside the hurt cooldown, and is
|
||||
/// dropped otherwise.
|
||||
#[cfg(test)]
|
||||
fn push_seen_with_hurt(
|
||||
p: &mut horizon_client::motion::PlayerMotion,
|
||||
push: Vec3,
|
||||
|
||||
@@ -108,7 +108,12 @@ fn a_ticking_gateway_counts_its_age_in_its_block_entity() {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((2, 0), Arc::new(Chunk::empty(2, 0)));
|
||||
assert!(game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"));
|
||||
// A dedicated server with nobody on it: the console runs the command, on
|
||||
// the command registries (as block_entity_anims' gateway test does).
|
||||
game.build_registries();
|
||||
assert!(
|
||||
game.as_console(|game| game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"))
|
||||
);
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.block_ticking_area()(40, 8));
|
||||
// What `/data get block` and the chunk save encode (`TheEndGatewayBlockEntity.saveAdditional`).
|
||||
|
||||
@@ -247,6 +247,11 @@ pub(super) trait Entity: EntityRules {
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
None
|
||||
}
|
||||
/// Whether [`Self::portal_size`] is Some, without working the size out (a
|
||||
/// mob's depends on its age, scale and pose).
|
||||
fn sweeps_portals(&self) -> bool {
|
||||
self.portal_size().is_some()
|
||||
}
|
||||
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
|
||||
/// cannot stays tracked, so its cooldown and process run out.
|
||||
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
|
||||
@@ -1105,6 +1110,9 @@ impl Entity for Mob {
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((self.width(), self.height()))
|
||||
}
|
||||
fn sweeps_portals(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.mob_can_use_portal(self)
|
||||
}
|
||||
|
||||
@@ -272,18 +272,35 @@ pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
.and_then(|d| d.fireworks.clone())
|
||||
.unwrap_or((1, Vec::new()))
|
||||
}
|
||||
/// `FireworkRocketEntity(Level, double, double, double, ItemStack)`, after the UUID:
|
||||
/// the motion's two triangles, then the lifetime's two `nextInt`s, all from `random`.
|
||||
fn rocket_draws(random: &mut horizon_core::random::LegacyRandom, flight: u8) -> (Vec3, i32) {
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
|
||||
let velocity = Vec3::new(tri(random), 0.05, tri(random));
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ random.next_int_bound(6).unwrap_or(0)
|
||||
+ random.next_int_bound(7).unwrap_or(0);
|
||||
(velocity, lifetime)
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
|
||||
/// A rocket built from `own`, the rocket's own random (`Entity.random`): the
|
||||
/// UUID, the motion and the lifetime draw from it in that order, and a rocket
|
||||
/// that is then shot (`Projectile.shoot`) goes on drawing from the same one.
|
||||
pub(super) fn new_rocket(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
stack: &ItemStack,
|
||||
attached: bool,
|
||||
own: &mut horizon_core::random::LegacyRandom,
|
||||
) -> Rocket {
|
||||
let (flight, explosions) = fireworks_of(stack);
|
||||
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
|
||||
let r = &mut random_source::entity();
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
|
||||
let velocity = Vec3::new(tri(r), 0.05, tri(r));
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
+ r.next_int_bound(7).unwrap_or(0);
|
||||
// Entity.<init>: the id comes from the one counter at construction.
|
||||
// Entity.<init>: the id comes from the one counter at construction, the UUID
|
||||
// from the random's first two longs.
|
||||
let id = self.take_entity_id();
|
||||
self.persist
|
||||
.uuids
|
||||
.insert(id, horizon_client::save::insecure_uuid(own));
|
||||
let (velocity, lifetime) = rocket_draws(own, flight);
|
||||
Rocket {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
@@ -318,7 +335,7 @@ impl Game<'_> {
|
||||
hit.point[1] + f64::from(hit.face[1]) * 0.15,
|
||||
hit.point[2] + f64::from(hit.face[2]) * 0.15,
|
||||
);
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
let mut rocket = self.new_rocket(at, stack, false, &mut random_source::entity());
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
@@ -330,15 +347,19 @@ impl Game<'_> {
|
||||
}
|
||||
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
|
||||
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
|
||||
///
|
||||
/// `shoot` gives the shot's velocity from the rocket's own random, after the
|
||||
/// constructor's draws (`asProjectile`, then `spawnProjectileUsingShoot`).
|
||||
pub(super) fn launch_angled_rocket(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
velocity: Vec3,
|
||||
stack: &ItemStack,
|
||||
owner: Option<Who>,
|
||||
shoot: impl FnOnce(&mut horizon_core::random::LegacyRandom) -> Vec3,
|
||||
) {
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.velocity = velocity;
|
||||
let mut own = random_source::entity();
|
||||
let mut rocket = self.new_rocket(at, stack, false, &mut own);
|
||||
rocket.velocity = shoot(&mut own);
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
@@ -413,7 +434,7 @@ impl Game<'_> {
|
||||
self.play_player_bound_sound("minecraft:item.lead.break", 6, 1.0, 1.0, false);
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
let mut rocket = self.new_rocket(at, stack, true, &mut random_source::entity());
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
@@ -842,6 +863,30 @@ fn use_on_passes(fall_flying: bool) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A rocket draws its UUID's two longs first, then the motion's two triangles
|
||||
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
|
||||
/// the one random. The expected values come from `java.util.Random` replayed
|
||||
/// outside the game: a rocket whose UUID draw were skipped would read 30, 22
|
||||
/// and 26 here.
|
||||
#[test]
|
||||
fn a_rockets_lifetime_follows_its_uuid_and_motion_draws() {
|
||||
use horizon_core::random::LegacyRandom;
|
||||
for (seed, flight, lifetime) in [
|
||||
(1, 1, 26),
|
||||
(12_345, 1, 26),
|
||||
(987_654_321, 1, 25),
|
||||
(1, 3, 46),
|
||||
] {
|
||||
let mut own = LegacyRandom::new(seed);
|
||||
horizon_client::save::insecure_uuid(&mut own);
|
||||
assert_eq!(
|
||||
super::rocket_draws(&mut own, flight).1,
|
||||
lifetime,
|
||||
"seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A flying rocket's vertices carry its own id for frame generation's and a temporal
|
||||
/// upscaler's motion vectors (`Frame::entity_motion`), so the motion matched between
|
||||
/// two frames is its flight, not only the camera's.
|
||||
|
||||
@@ -54,6 +54,33 @@ impl Game<'_> {
|
||||
.finish_equipment_hurt(&self.health_resources, hit)?;
|
||||
Ok((outcome, actual))
|
||||
}
|
||||
/// `push_seen_with_hurt` for the host: the push is kept only when the hurt
|
||||
/// marks the velocity as seen, and the hurt goes through
|
||||
/// `hurt_player_equipment` so the worn armor loses durability. Answers
|
||||
/// whether a native hook canceled the hurt.
|
||||
pub(super) fn push_seen_with_equipment_hurt(
|
||||
&mut self,
|
||||
push: Vec3,
|
||||
source: Option<horizon_world::damage_source::DamageTypeId>,
|
||||
amount: f32,
|
||||
) -> bool {
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let before = p.velocity;
|
||||
let marked_before = std::mem::take(&mut p.health.sync_velocity);
|
||||
p.velocity = before.add_vector(push);
|
||||
let canceled = source
|
||||
.and_then(|id| self.hurt_player_equipment(id, amount, None, false, 0).ok())
|
||||
.is_some_and(|(o, _)| o.canceled);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if !p.health.sync_velocity {
|
||||
p.velocity = before;
|
||||
}
|
||||
p.health.sync_velocity |= marked_before;
|
||||
}
|
||||
canceled
|
||||
}
|
||||
/// The armor hits among a tick's motion events (`Player.hurtArmor` from
|
||||
/// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion,
|
||||
/// a cactus, lava, a campfire.
|
||||
|
||||
@@ -708,6 +708,20 @@ impl Game<'_> {
|
||||
}
|
||||
for &(pos, _) in &view.parts {
|
||||
self.container_changed(pos);
|
||||
// `ShelfBlockEntity.setChanged()` (the no-argument override posts
|
||||
// `BLOCK_ACTIVATE`): a container writing a shelf's slot is heard.
|
||||
if matches!(
|
||||
self.world.block_entities.get(&pos),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_activate",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BlockEntity.setChanged` at `pos`: comparators re-read the container.
|
||||
@@ -899,6 +913,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// Remove one item from a slot (`removeItem(slot, 1)`).
|
||||
fn take_one(&mut self, view: &View, slot: usize) -> Option<ItemStack> {
|
||||
if view.kind == Kind::Pot {
|
||||
return self.pot_take_one(view.parts[0].0);
|
||||
}
|
||||
let target = self.slot_mut_at(view, slot)?;
|
||||
let current = target.as_mut().filter(|s| s.count > 0)?;
|
||||
let one = ItemStack {
|
||||
@@ -911,8 +928,56 @@ impl Game<'_> {
|
||||
}
|
||||
Some(one)
|
||||
}
|
||||
/// `ContainerSingleItem.removeItem(0, 1)` on a decorated pot's saved slot.
|
||||
fn pot_take_one(&mut self, pos: Pos) -> Option<ItemStack> {
|
||||
let mut tag = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2])?;
|
||||
let mut item = tag.get("item").and_then(save::item_from_nbt)?;
|
||||
if item.count == 0 {
|
||||
return None;
|
||||
}
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..item.clone()
|
||||
};
|
||||
item.count -= 1;
|
||||
self.pot_store(pos, &mut tag, (item.count > 0).then_some(item));
|
||||
Some(one)
|
||||
}
|
||||
/// Undo `pot_take_one` after a failed move.
|
||||
fn pot_put_back(&mut self, pos: Pos, one: ItemStack) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2]) else {
|
||||
return;
|
||||
};
|
||||
let item = match tag.get("item").and_then(save::item_from_nbt) {
|
||||
Some(mut current) => {
|
||||
current.count += one.count;
|
||||
current
|
||||
}
|
||||
None => one,
|
||||
};
|
||||
self.pot_store(pos, &mut tag, Some(item));
|
||||
}
|
||||
/// Write a pot's saved item slot, keeping the tag's other metadata.
|
||||
fn pot_store(&mut self, pos: Pos, tag: &mut Tag, item: Option<ItemStack>) {
|
||||
if let Tag::Compound(map) = tag {
|
||||
match item {
|
||||
Some(item) => {
|
||||
map.insert("item".into(), save::item_to_nbt(&item));
|
||||
}
|
||||
None => {
|
||||
map.remove(&"item".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
spill::put_saved_entity(&mut self.world, [pos.0, pos.1, pos.2], tag.clone());
|
||||
self.mark_block_entity_updated(pos);
|
||||
}
|
||||
/// Undo `take_one` after a failed move.
|
||||
fn put_back(&mut self, view: &View, slot: usize, one: ItemStack) {
|
||||
if view.kind == Kind::Pot {
|
||||
self.pot_put_back(view.parts[0].0, one);
|
||||
return;
|
||||
}
|
||||
if let Some(target) = self.slot_mut_at(view, slot) {
|
||||
match target {
|
||||
Some(current) => current.count += one.count,
|
||||
@@ -1003,7 +1068,11 @@ impl Game<'_> {
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(above.0, above.1, above.2))
|
||||
.is_none_or(|s| s.name != "minecraft:composter")
|
||||
.is_none_or(|s| {
|
||||
// Composters and decorated pots are containers without a
|
||||
// modeled block entity.
|
||||
s.name != "minecraft:composter" && s.name != "minecraft:decorated_pot"
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1274,7 +1343,7 @@ impl Game<'_> {
|
||||
/// (`getSourceContainerOrHandler`). `None` when there is no container.
|
||||
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
|
||||
let c = self.containers();
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let Some(source) = c.hopper_insertion_view(above) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
@@ -1339,10 +1408,10 @@ impl Game<'_> {
|
||||
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
|
||||
{
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
if !c.can_take(source, slot, &stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2295,4 +2364,81 @@ mod pot_hopper_insertion_tests {
|
||||
"actual hopper/pot transfer missing: {failures:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn actual_hopper_pulls_from_the_saved_pot_slot() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(
|
||||
game.run_command(
|
||||
"setblock 5 201 4 decorated_pot{item:{id:\"minecraft:wheat\",count:2}}"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
game.run_command(
|
||||
"setblock 5 200 4 hopper[facing=down,enabled=true]{TransferCooldown:0}"
|
||||
)
|
||||
);
|
||||
let start = game.world.game_time;
|
||||
for elapsed in 1..=9 {
|
||||
game.world.game_time = start + elapsed;
|
||||
game.tick_hopper((5, 200, 4));
|
||||
}
|
||||
let Some(BlockEntity::Chest(slots)) = game.world.block_entities.get(&(5, 200, 4)) else {
|
||||
panic!("hopper inventory missing");
|
||||
};
|
||||
let held: u32 = slots.iter().flatten().map(|s| s.count).sum();
|
||||
assert_eq!(held, 2, "the hopper took both items");
|
||||
let tag = spill::saved_entity(&game.world, [5, 201, 4]).expect("saved pot");
|
||||
assert!(tag.get("item").is_none(), "the pot is empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
|
||||
/// `ShelfBlockEntity.setChanged` (no arguments) posts `BLOCK_ACTIVATE`:
|
||||
/// a container writing a shelf's slot is a block activation.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_container_writing_a_shelf_posts_block_activate() {
|
||||
let mut game = server_game();
|
||||
let key = (2, 4, 3);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_shelf")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Shelf(Box::default()));
|
||||
let view = game
|
||||
.containers()
|
||||
.view(key, false)
|
||||
.expect("the shelf's view");
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
|
||||
game.view_changed(&view);
|
||||
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
|
||||
assert!(
|
||||
e.borrow().as_ref().unwrap().contains(&(
|
||||
"minecraft:block_activate",
|
||||
[2.5, 4.5, 3.5],
|
||||
None
|
||||
)),
|
||||
"{:?}",
|
||||
e.borrow()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -469,11 +469,12 @@ impl Game<'_> {
|
||||
[x, y, z]
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.isRedstoneConductor`: the block state's own flag
|
||||
/// (soul sand conducts without a full collision shape).
|
||||
fn conductor(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.res
|
||||
.facts()
|
||||
.get(self.world.block(x, y, z))
|
||||
.full_collision
|
||||
self.res.behaviours().facts(self.world.block(x, y, z)).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
}
|
||||
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
|
||||
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
|
||||
@@ -529,11 +530,9 @@ impl Game<'_> {
|
||||
/// collision, which sets a TNT minecart off.
|
||||
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
|
||||
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
// Every cart asks about every entity of the level each tick, and the box
|
||||
// turns nearly all of them away: it is tested first, before a mob's
|
||||
// pushable state, and only the boxes kept are collected.
|
||||
// Every moving cart asks each tick, and the box turns nearly every entity of
|
||||
// the level away: it is tested first, and only the mobs near it are read.
|
||||
let close = |b: &Aabb| b.intersects(near);
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
@@ -546,22 +545,13 @@ impl Game<'_> {
|
||||
.map(super::boats::Boat::bounds)
|
||||
.filter(close),
|
||||
)
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
|
||||
.map(|m| (m, m.bounds()))
|
||||
.filter(|(m, b)| {
|
||||
close(b)
|
||||
&& !m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
})
|
||||
.map(|(_, b)| b),
|
||||
)
|
||||
.collect();
|
||||
self.mobs_meeting(near, |_, m, b| {
|
||||
let counts = m.pushable() || m.species() == Species::Shulker && m.alive();
|
||||
if !m.removed && m.extra.mount != Some(c.id) && counts {
|
||||
boxes.push(b);
|
||||
}
|
||||
});
|
||||
if self.riding != Some(c.id)
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
@@ -700,12 +690,7 @@ impl Game<'_> {
|
||||
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
|
||||
/// this, not on who rides.
|
||||
fn cart_is_vehicle(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| !m.removed && m.extra.mount == Some(id))
|
||||
self.riding == Some(id) || self.ridden_by_guest(id) || self.mob_rides(id)
|
||||
}
|
||||
/// `OldMinecartBehavior.moveAlongTrack`.
|
||||
fn move_along_track(
|
||||
@@ -714,7 +699,8 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
rail: &Rail,
|
||||
intent: [f64; 2],
|
||||
) -> (Vec3, Vec3) {
|
||||
slot: usize,
|
||||
) {
|
||||
c.fall_distance = 0.0;
|
||||
let ridden = self.cart_is_vehicle(c.id);
|
||||
let (x, z) = (c.position.x, c.position.z);
|
||||
@@ -785,11 +771,11 @@ impl Game<'_> {
|
||||
0.0,
|
||||
(scale * c.velocity.z).clamp(-max, max),
|
||||
);
|
||||
// Entity.move records this step at the rail's top height; the tick's
|
||||
// applyEffectsFromBlocks sweeps it, whatever the cart does after.
|
||||
let step_from = c.position;
|
||||
self.move_cart(c, delta);
|
||||
let step = (step_from, c.position);
|
||||
// 🤖 Agent: `AbstractMinecart.move` runs applyEffectsFromBlocks at
|
||||
// once, the cart still at the rail's top height (below it is lowered).
|
||||
let here = c.position;
|
||||
self.cart_inside_blocks(c, slot, here, here);
|
||||
let at = |e: [i32; 3], c: &Minecart| {
|
||||
c.position.x.floor() as i32 - pos[0] == e[0]
|
||||
&& c.position.z.floor() as i32 - pos[2] == e[2]
|
||||
@@ -839,7 +825,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
step
|
||||
}
|
||||
/// `Entity.checkInsideBlocks` for a recorded move of the cart from `from`
|
||||
/// to `to`: the entity-sensing blocks (plates, buttons, tripwires, detector
|
||||
@@ -1367,11 +1352,10 @@ impl Game<'_> {
|
||||
// applyGravity
|
||||
c.velocity.y -= c.get_gravity();
|
||||
let pos = self.rail_block(c.position);
|
||||
let mut step = None;
|
||||
if self.minecart_improvements() {
|
||||
self.move_along_track_new(c, intent);
|
||||
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
step = Some(self.move_along_track(c, pos, &rail, intent));
|
||||
self.move_along_track(c, pos, &rail, intent, slot);
|
||||
if &*rail.name == "minecraft:activator_rail" {
|
||||
self.activate_cart(c, rail.powered);
|
||||
}
|
||||
@@ -1382,6 +1366,8 @@ impl Game<'_> {
|
||||
// The old behavior derives yaw once per tick; the experiment has
|
||||
// already recorded each step's yaw and pitch.
|
||||
if !self.minecart_improvements() {
|
||||
// OldMinecartBehavior.tick: setXRot(0.0F) before the yaw update.
|
||||
c.pitch = 0.0;
|
||||
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
|
||||
if xd * xd + zd * zd > 0.001 {
|
||||
c.yaw = horizon_core::geometry::trig::atan2_degrees_double(zd, xd);
|
||||
@@ -1395,13 +1381,13 @@ impl Game<'_> {
|
||||
c.flipped = !c.flipped;
|
||||
}
|
||||
c.yaw %= 360.0;
|
||||
c.pitch %= 360.0;
|
||||
}
|
||||
if !self.minecart_improvements() {
|
||||
self.cart_block_effects(c);
|
||||
// applyEffectsFromBlocks over the step moveAlongTrack recorded.
|
||||
if let Some((from, to)) = step {
|
||||
self.cart_inside_blocks(c, slot, from, to);
|
||||
}
|
||||
// OldMinecartBehavior.tick: applyEffectsFromBlocks() once settled.
|
||||
let here = c.position;
|
||||
self.cart_inside_blocks(c, slot, here, here);
|
||||
}
|
||||
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
|
||||
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
|
||||
@@ -1631,27 +1617,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.push_carts_from_mobs(&cells);
|
||||
}
|
||||
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
|
||||
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
|
||||
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
|
||||
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
|
||||
fn push_carts_from_mobs(&mut self, cells: &horizon_client::mobs::seen_grid::SeenGrid) {
|
||||
let teams = self.mob_teams();
|
||||
/// `AbstractMinecart.push(mob)` from each mob walking into a cart (`LivingEntity.pushEntities`
|
||||
/// -> `doPush`): the cart gets the push and the mob a quarter of it back (`Entity.push`, which
|
||||
/// needs the mob to be pushable and to carry no one). Bats push nothing (`Bat.pushEntities`),
|
||||
/// nor does a mob whose box reaches no cart: its box is worked out only if it may.
|
||||
fn push_carts_from_mobs(&mut self, cells: &SeenGrid) {
|
||||
let (teams, mut near) = (self.mob_teams(), Vec::new());
|
||||
// The mobs a cart is near, or every mob, each then asked which carts it is near.
|
||||
let slots = self.mobs_near_carts();
|
||||
let reach = slots.is_none().then(|| self.vehicle_mob_reach()).flatten();
|
||||
let slots = slots.unwrap_or_else(|| (0..self.mobs.len() as u32).collect());
|
||||
// (mob index, feet, box)
|
||||
let pushers: Vec<(usize, Vec3, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
let pushers: Vec<(usize, Vec3, Aabb)> = slots
|
||||
.into_iter()
|
||||
.map(|j| (j as usize, &self.mobs[j as usize]))
|
||||
.filter(|(_, m)| {
|
||||
!m.removed
|
||||
&& m.alive()
|
||||
&& m.species() != Species::Bat
|
||||
&& reach.is_none_or(|r| self.mob_may_meet_cart(r, m, cells, &mut near))
|
||||
&& pushable_by(teams.of(Who::Mob(m.id)), None, true)
|
||||
})
|
||||
.map(|(j, m)| (j, m.position, m.bounds()))
|
||||
.collect();
|
||||
drop(teams);
|
||||
let mut near = Vec::new();
|
||||
for (j, feet, bb) in pushers {
|
||||
if !cells.candidates(bb, &mut near) {
|
||||
near.extend(0..self.carts.len() as u32);
|
||||
@@ -2131,6 +2120,51 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
|
||||
/// item's custom name and custom data (`applyComponentsFromItemStack`:
|
||||
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
|
||||
/// `data`), then its
|
||||
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
|
||||
/// when it names this type. An op-only type (command block and spawner
|
||||
/// minecarts) takes it from an op player only, never from a dispenser
|
||||
/// (`by_player` false).
|
||||
pub(super) fn apply_vehicle_stack_config(
|
||||
&mut self,
|
||||
target: super::commands::Target,
|
||||
kind: &str,
|
||||
stack: &ItemStack,
|
||||
by_player: bool,
|
||||
) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let name = stack.data().and_then(|d| d.custom_name.clone());
|
||||
let custom = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:custom_data"))
|
||||
.cloned();
|
||||
let op = by_player && self.acting_player_is_op();
|
||||
let data =
|
||||
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
|
||||
if name.is_none() && custom.is_none() && data.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(mut own) = self.entity_nbt(target) else {
|
||||
return;
|
||||
};
|
||||
if let Tag::Compound(map) = &mut own {
|
||||
if let Some(name) = name {
|
||||
map.insert("CustomName".into(), name);
|
||||
}
|
||||
if let Some(custom) = custom {
|
||||
map.insert("data".into(), custom);
|
||||
}
|
||||
}
|
||||
let tag = match &data {
|
||||
Some(data) => horizon_client::data_cmd::merged(&own, data),
|
||||
None => own,
|
||||
};
|
||||
// The vehicle's own compound, its name and data merged in: it loads back.
|
||||
let _ = self.apply_entity_nbt(target, tag);
|
||||
}
|
||||
/// `MinecartItem.useOn`: a cart on the clicked rail.
|
||||
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(kind) = CartKind::from_id(&stack.item) else {
|
||||
@@ -2151,6 +2185,8 @@ impl Game<'_> {
|
||||
.modified
|
||||
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
|
||||
self.carts.push(cart);
|
||||
let id = self.next_entity;
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
@@ -3500,6 +3536,29 @@ mod tnt_impact_tests {
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_powered_rail_pushes_a_resting_cart_away_from_soul_sand() {
|
||||
let mut game = server_game();
|
||||
let registry = game.pack.assets.registry;
|
||||
let rail = registry.default_state("minecraft:powered_rail").unwrap();
|
||||
let mut state = registry.state(rail).unwrap().clone();
|
||||
state.properties.insert("shape".into(), "east_west".into());
|
||||
state.properties.insert("powered".into(), "true".into());
|
||||
let rail = registry.id(&state).unwrap();
|
||||
game.world.set(5, 64, 8, rail).unwrap();
|
||||
for (block, pushed) in [
|
||||
("minecraft:soul_sand", true),
|
||||
("minecraft:stone", true),
|
||||
("minecraft:oak_slab", false),
|
||||
] {
|
||||
let id = registry.default_state(block).unwrap();
|
||||
game.world.set(4, 64, 8, id).unwrap();
|
||||
let push = game.redstone_direction([5, 64, 8], super::Shape::EastWest);
|
||||
assert_eq!(push.x == 1.0, pushed, "{block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_tnt_cart_stopped_head_on_by_a_wall_does_not_blow_up() {
|
||||
@@ -3568,6 +3627,89 @@ mod tnt_impact_tests {
|
||||
registry.state(game.world.block(6, 65, 8))
|
||||
);
|
||||
}
|
||||
|
||||
/// A cart blocked by a fence on a slope sits below the plate on the fence,
|
||||
/// but `AbstractMinecart.move` sweeps its blocks while it is still at the
|
||||
/// rail's top height, so the plate clocks on and off.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_cart_resting_against_a_fence_keeps_toggling_the_plate_above_it() {
|
||||
let mut game = server_game();
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let fence = registry.default_state("minecraft:oak_fence").unwrap();
|
||||
let plate = registry
|
||||
.default_state("minecraft:oak_pressure_plate")
|
||||
.unwrap();
|
||||
let rail = registry.default_state("minecraft:rail").unwrap();
|
||||
let mut slope = registry.state(rail).unwrap().clone();
|
||||
slope
|
||||
.properties
|
||||
.insert("shape".into(), "ascending_east".into());
|
||||
let slope = registry.id(&slope).unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
game.world.set(x, 62, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
game.world.set(0, 64, 8, fence).unwrap();
|
||||
game.world.set(0, 65, 8, plate).unwrap();
|
||||
game.world.set(1, 63, 8, stone).unwrap();
|
||||
game.world.set(1, 64, 8, slope).unwrap();
|
||||
game.world.set(2, 64, 8, stone).unwrap();
|
||||
game.world.set(2, 65, 8, rail).unwrap();
|
||||
game.carts.push(Minecart::new(
|
||||
1,
|
||||
CartKind::Rideable,
|
||||
Vec3::new(1.9, 64.9, 8.5),
|
||||
));
|
||||
// Scheduled ticks and carts only run in lit, surrounded chunks near a
|
||||
// player.
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 12.5))
|
||||
.unwrap(),
|
||||
);
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
if (cx, cz) != (0, 0) {
|
||||
let mut chunk = horizon_client::chunk::Chunk::empty(cx, cz);
|
||||
chunk.lit = true;
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, cz), std::sync::Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(cx, cz))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut center = (**game.world.chunks.get(&(0, 0)).unwrap()).clone();
|
||||
center.lit = true;
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), std::sync::Arc::new(center));
|
||||
game.update_chunk_tickets();
|
||||
let mut edges = 0;
|
||||
let mut last = false;
|
||||
for _ in 0..200 {
|
||||
game.world.game_time += 1;
|
||||
game.tick_carts_now();
|
||||
game.tick_blocks().unwrap();
|
||||
let on = registry
|
||||
.state(game.world.block(0, 65, 8))
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("powered")
|
||||
.is_some_and(|v| v == "true");
|
||||
if on != last {
|
||||
edges += 1;
|
||||
last = on;
|
||||
}
|
||||
}
|
||||
assert!(edges >= 4, "the plate changed {edges} times in 200 ticks");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! A mob named in a command reply carries `Entity.getDisplayName`'s hover and
|
||||
//! insertion (#14220): `HoverEvent.ShowEntity` of its type, UUID and name, and
|
||||
//! its UUID string to insert.
|
||||
use super::*;
|
||||
use crate::game::commands::Target;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
for command in ["forceload add 0 0", "fill 0 63 0 15 63 15 stone"] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_summoned_mobs_name_shows_the_entity_on_hover() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command("summon minecraft:pig 4.5 64 4.5 {NoAI:1b}"));
|
||||
let id = game.mobs.last().unwrap().id;
|
||||
let uuid = horizon_client::save::uuid_text(game.persist.uuid_of(id).expect("a UUID"));
|
||||
let name = game.target_name(Target::Mob(id)).component();
|
||||
assert_eq!(name["translate"], "entity.minecraft.pig");
|
||||
assert_eq!(name["insertion"], uuid.as_str());
|
||||
let hover = &name["hover_event"];
|
||||
assert_eq!(hover["action"], "show_entity");
|
||||
assert_eq!(hover["id"], "minecraft:pig");
|
||||
assert_eq!(hover["uuid"], uuid.as_str());
|
||||
assert_eq!(hover["name"]["translate"], "entity.minecraft.pig");
|
||||
// On a team the name is the team's formatting of it, the hover on the whole.
|
||||
assert!(game.run_command("team add reds"));
|
||||
assert!(game.run_command("team modify reds color red"));
|
||||
assert!(game.run_command("team join reds @e[type=minecraft:pig]"));
|
||||
let name = game.target_name(Target::Mob(id)).component();
|
||||
assert_eq!(name["color"], "red");
|
||||
assert_eq!(name["insertion"], uuid.as_str());
|
||||
assert_eq!(name["hover_event"]["uuid"], uuid.as_str());
|
||||
}
|
||||
@@ -450,6 +450,24 @@ impl Game<'_> {
|
||||
used = true;
|
||||
}
|
||||
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
|
||||
// AbstractHorse.handleEating (Camel's too): the HAPPY_VILLAGER's
|
||||
// `getRandomX(1.0)`, `getRandomY() + 0.5` and `getRandomZ(1.0)`
|
||||
// each draw a double from the mount's own random before it ages
|
||||
// up. A server's `Level.addParticle` shows nothing: the client
|
||||
// draws its own sparkle (`remote_feed_particles`).
|
||||
let (feet, width, height) = (
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
f64::from(m.width()),
|
||||
f64::from(m.height()),
|
||||
);
|
||||
horizon_client::particles::around_self(
|
||||
&mut m.random,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
|_, _| {},
|
||||
);
|
||||
m.extra.age = (m.extra.age + age_up * 20).min(0);
|
||||
used = true;
|
||||
}
|
||||
@@ -460,22 +478,29 @@ impl Game<'_> {
|
||||
if !used {
|
||||
return false;
|
||||
}
|
||||
// eating(): the mouth opens and the horse eats.
|
||||
// eating(): the mouth opens and, unless the mount is silent, it eats at
|
||||
// its feet, its own random drawing `1.0F + (nextFloat() - nextFloat())
|
||||
// * 0.2F` for the pitch.
|
||||
m.anim.v[2] = 1.0;
|
||||
let at = m.center();
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let id = m.id;
|
||||
let sound = match m.species() {
|
||||
Species::Camel => "minecraft:entity.camel.eat",
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
Species::Donkey => "minecraft:entity.donkey.eat",
|
||||
Species::Mule => "minecraft:entity.mule.eat",
|
||||
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
|
||||
// ZombieHorse.getEatingSound.
|
||||
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
|
||||
_ => "minecraft:entity.horse.eat",
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
if !m.silent {
|
||||
let sound = match m.species() {
|
||||
Species::Camel => "minecraft:entity.camel.eat",
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
Species::Donkey => "minecraft:entity.donkey.eat",
|
||||
Species::Mule => "minecraft:entity.mule.eat",
|
||||
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
|
||||
// ZombieHorse.getEatingSound.
|
||||
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
|
||||
_ => "minecraft:entity.horse.eat",
|
||||
};
|
||||
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
}
|
||||
// gameEvent(GameEvent.EAT): at the mount, from the mount.
|
||||
let ctx = self.who_context(Some(Who::Mob(id)), None);
|
||||
self.post_game_event("minecraft:eat", at, ctx);
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.love_cause.insert(id, self.lan_acting);
|
||||
@@ -1578,6 +1603,203 @@ mod tests {
|
||||
assert_eq!(pose, Some(Pose::Swimming));
|
||||
}
|
||||
|
||||
/// A server-side game with nothing in it but an empty chunk, or None when
|
||||
/// the prepared resources are missing (CI has no vanilla data).
|
||||
fn server_game() -> Option<Game<'static>> {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return None;
|
||||
}
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
let mut options = Options::parse(std::iter::empty()).ok()??;
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui?,
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
game.creative = false;
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.streaming.forced.insert((0, 0));
|
||||
Some(game)
|
||||
}
|
||||
|
||||
/// What one feeding made the level show.
|
||||
#[derive(Debug)]
|
||||
struct Shown {
|
||||
sounds: Vec<(String, [f64; 3], f32, f32)>,
|
||||
events: Vec<(&'static str, [f64; 3], Option<Who>)>,
|
||||
}
|
||||
|
||||
/// Feeds `item` to mob 0 and collects the sounds and the `minecraft:eat`
|
||||
/// game events it made.
|
||||
fn feed(game: &mut Game<'_>, item: &str) -> (bool, Shown) {
|
||||
game.inventory[game.selected] = Some(ItemStack::new(item, 2));
|
||||
game.audio.capture = Some(Vec::new());
|
||||
crate::game::sculk::TEST_POSTED_EVENTS
|
||||
.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
let used = game.interact_mount(0, item);
|
||||
let sounds = game
|
||||
.audio
|
||||
.capture
|
||||
.take()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| (s.event, s.position, s.volume, s.pitch))
|
||||
.collect();
|
||||
let events = crate::game::sculk::TEST_POSTED_EVENTS.with(|events| {
|
||||
events
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|(event, _, _)| *event == "minecraft:eat")
|
||||
.collect()
|
||||
});
|
||||
(used, Shown { sounds, events })
|
||||
}
|
||||
|
||||
/// A whole, tamed mount of `kind` standing at `at` with `seed` for its own
|
||||
/// random, alone in `game`.
|
||||
fn mount(game: &mut Game<'_>, kind: &str, id: u32, at: [f64; 3], seed: i64) {
|
||||
let kind = game.res.mob_types().get(kind).unwrap().clone();
|
||||
let mut m = horizon_client::mobs::Mob::new(id, kind, Vec3::new(at[0], at[1], at[2]), 0.0);
|
||||
m.extra.tame = true;
|
||||
m.health = m.attrs.max_health;
|
||||
m.random = horizon_core::random::LegacyRandom::new(seed);
|
||||
game.mobs.clear();
|
||||
game.mobs.push(m);
|
||||
}
|
||||
|
||||
/// `AbstractHorse.handleEating`'s growth particle position draws:
|
||||
/// `getRandomX(1.0)`, `getRandomY()` and `getRandomZ(1.0)` take a double each.
|
||||
fn sparkle_draws(r: &mut horizon_core::random::LegacyRandom) {
|
||||
for _ in 0..3 {
|
||||
r.next_double();
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractHorse.handleEating`: a mouthful that ages a baby up draws the
|
||||
/// happy villager's position from the mount's own random (`getRandomX`,
|
||||
/// `getRandomY` and `getRandomZ` take a double each) before it grows; a
|
||||
/// mount that is not silent then eats with `1.0F + (nextFloat() -
|
||||
/// nextFloat()) * 0.2F` of its own random at its feet, and every used
|
||||
/// mouthful is a `minecraft:eat` game event. A silent mount eats
|
||||
/// noiselessly, drawing no pitch, and a locked baby grows not at all.
|
||||
#[test]
|
||||
fn feeding_a_mount_draws_from_its_own_random_and_posts_eat() {
|
||||
let Some(mut game) = server_game() else {
|
||||
return;
|
||||
};
|
||||
// A silent baby horse fed wheat: the particle's draws, the growth, the
|
||||
// event, and no eating sound (so no pitch draws).
|
||||
mount(&mut game, "minecraft:horse", 1, [1.5, 64.0, -2.5], 7);
|
||||
game.mobs[0].extra.age = -24000;
|
||||
game.mobs[0].silent = true;
|
||||
let level_random = game.world.random.clone().next_long();
|
||||
let (used, shown) = feed(&mut game, "minecraft:wheat");
|
||||
assert!(used, "wheat is horse food");
|
||||
let mut r = horizon_core::random::LegacyRandom::new(7);
|
||||
sparkle_draws(&mut r);
|
||||
let at = [1.5, 64.0, -2.5];
|
||||
assert!(shown.sounds.is_empty(), "Silent: {shown:?}");
|
||||
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(1)))]);
|
||||
assert_eq!(game.mobs[0].extra.age, -23600, "wheat ages a horse by 20 s");
|
||||
assert_eq!(
|
||||
game.mobs[0].random.clone().next_double(),
|
||||
r.next_double(),
|
||||
"three doubles for the particle, none for a silent pitch"
|
||||
);
|
||||
assert_eq!(
|
||||
game.world.random.clone().next_long(),
|
||||
level_random,
|
||||
"the level's own random drew nothing"
|
||||
);
|
||||
|
||||
// A hurt adult draws no particle and eats at its feet, its pitch from
|
||||
// its own random, after healing.
|
||||
mount(&mut game, "minecraft:horse", 2, [0.5, 64.0, 0.5], 11);
|
||||
game.mobs[0].health = game.mobs[0].attrs.max_health - 5.0;
|
||||
let max = game.mobs[0].attrs.max_health;
|
||||
let (used, shown) = feed(&mut game, "minecraft:wheat");
|
||||
assert!(used);
|
||||
let mut r = horizon_core::random::LegacyRandom::new(11);
|
||||
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
|
||||
let at = [0.5, 64.0, 0.5];
|
||||
assert_eq!(
|
||||
shown.sounds,
|
||||
vec![("minecraft:entity.horse.eat".to_string(), at, 1.0, pitch)],
|
||||
"{shown:?}"
|
||||
);
|
||||
assert_eq!(game.mobs[0].health, max - 3.0, "wheat heals 2");
|
||||
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(2)))]);
|
||||
|
||||
// An age-locked baby that is tamed and whole eats nothing at all.
|
||||
mount(&mut game, "minecraft:horse", 3, [0.5, 64.0, 2.5], 13);
|
||||
game.mobs[0].extra.age = -24000;
|
||||
game.mobs[0].extra.age_locked = true;
|
||||
let (used, shown) = feed(&mut game, "minecraft:wheat");
|
||||
assert!(!used, "a locked baby leaves the wheat alone: {shown:?}");
|
||||
assert!(
|
||||
shown.sounds.is_empty() && shown.events.is_empty(),
|
||||
"{shown:?}"
|
||||
);
|
||||
assert_eq!(game.mobs[0].extra.age, -24000);
|
||||
assert_eq!(
|
||||
game.mobs[0].random.clone().next_double(),
|
||||
horizon_core::random::LegacyRandom::new(13).next_double(),
|
||||
"nothing drawn"
|
||||
);
|
||||
|
||||
// A baby camel fed cactus: `Camel.handleEating` draws the same particle,
|
||||
// then eats and posts EAT, the three doubles before the two floats.
|
||||
mount(&mut game, "minecraft:camel", 4, [0.5, 64.0, 4.5], 21);
|
||||
game.mobs[0].extra.age = -24000;
|
||||
let (used, shown) = feed(&mut game, "minecraft:cactus");
|
||||
assert!(used, "cactus is camel food");
|
||||
let mut r = horizon_core::random::LegacyRandom::new(21);
|
||||
sparkle_draws(&mut r);
|
||||
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
|
||||
let at = [0.5, 64.0, 4.5];
|
||||
assert_eq!(
|
||||
shown.sounds,
|
||||
vec![("minecraft:entity.camel.eat".to_string(), at, 1.0, pitch)],
|
||||
"{shown:?}"
|
||||
);
|
||||
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(4)))]);
|
||||
assert_eq!(game.mobs[0].extra.age, -23800, "camel cactus ages 10 s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_escape_vector_reaches_the_edge_of_the_square() {
|
||||
let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
|
||||
use super::{Game, advancements::item_view, commands::Target};
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView},
|
||||
advancements::{self as adv, EntityView, Tree},
|
||||
items::ItemStack,
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -129,6 +129,25 @@ fn passes(
|
||||
&& adv::conditions(&conditions["player"], &player, world)
|
||||
}
|
||||
|
||||
/// Whether a per-tick trigger needs the player's view. Until the advancements
|
||||
/// are loaded the tree is not read yet, so the view is built as before. After
|
||||
/// that, only the lookup `trigger` makes (`by_trigger`) can use it.
|
||||
fn view_needed(loaded: bool, tree: &Tree, trigger: &str) -> bool {
|
||||
!loaded || tree.by_trigger.contains_key(trigger)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// [`Self::player_entity_for`] when [`view_needed`] says so, and an empty
|
||||
/// view otherwise: `trigger` then finds no listener and never reads it.
|
||||
pub(super) fn player_entity_if_listened(&mut self, trigger: &str) -> EntityView {
|
||||
if view_needed(self.adv.loaded, &self.adv.tree, trigger) {
|
||||
self.player_entity_for(trigger)
|
||||
} else {
|
||||
EntityView::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -225,4 +244,16 @@ mod tests {
|
||||
assert!(!mod_trigger("inventory_changed"));
|
||||
assert!(!mod_trigger(":pipe_connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_is_needed_only_for_a_listened_trigger_once_loaded() {
|
||||
let mut tree = Tree::default();
|
||||
// Before loading the tree is empty but not read yet: built as before.
|
||||
assert!(view_needed(false, &tree, "minecraft:tick"));
|
||||
assert!(!view_needed(true, &tree, "minecraft:tick"));
|
||||
tree.by_trigger
|
||||
.insert("minecraft:tick".into(), vec![(0, 0)]);
|
||||
assert!(view_needed(true, &tree, "minecraft:tick"));
|
||||
assert!(!view_needed(true, &tree, "minecraft:location"));
|
||||
}
|
||||
}
|
||||
@@ -661,7 +661,7 @@ impl Game<'_> {
|
||||
let mut waiting: Vec<u32> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let can_sweep = entity.portal_size().is_some();
|
||||
let can_sweep = entity.sweeps_portals();
|
||||
let has_portal_state = tracked.contains_key(&entity.id());
|
||||
// Entity.processPortalCooldown still runs for entities this
|
||||
// portal pass cannot send, such as an ender pearl.
|
||||
@@ -880,15 +880,21 @@ impl Game<'_> {
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// No loaded section has a portal state in its palette (nearly always): no
|
||||
// sweep below can meet a portal block, so the walk would find nothing.
|
||||
if !self.level_may_hold_portal(&mut portal_sections) {
|
||||
self.portals.sections = portal_sections;
|
||||
return;
|
||||
}
|
||||
// Only the entities that tick (in entity-ticking chunks) move, so only
|
||||
// they can have swept into a portal.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
// The first portal block each entity's box swept through since the
|
||||
// start of its tick (a stuck arrow that did not move enters none).
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let Some((width, height)) = entity.portal_size() else {
|
||||
if !entity.sweeps_portals() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let (id, to) = (entity.id(), entity.position());
|
||||
if !entity_ticking(to.x.floor() as i32, to.z.floor() as i32) {
|
||||
continue;
|
||||
@@ -906,6 +912,9 @@ impl Game<'_> {
|
||||
let from = entity
|
||||
.portal_from()
|
||||
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
|
||||
let Some((width, height)) = entity.portal_size() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
@@ -994,20 +1003,33 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
|
||||
let behaviours = self.res.behaviours();
|
||||
let chunks = &self.world.chunks;
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
let portals = sections.get((cx, cz), chunk, |v| {
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
});
|
||||
let portals = sections.get((cx, cz), chunk, |v| self.is_portal_state(v));
|
||||
!(portals & y_mask).is_empty()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// [`Self::may_hold_portal`] for the whole level: whether any loaded section has
|
||||
/// a nether or End portal state in its palette.
|
||||
fn level_may_hold_portal(
|
||||
&self,
|
||||
sections: &mut horizon_client::portal_sections::PortalSections,
|
||||
) -> bool {
|
||||
self.world.chunks.iter().any(|(&key, chunk)| {
|
||||
!sections
|
||||
.get(key, chunk, |v| self.is_portal_state(v))
|
||||
.is_empty()
|
||||
})
|
||||
}
|
||||
/// A nether or End portal state, by its id in a palette.
|
||||
fn is_portal_state(&self, v: u32) -> bool {
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
self.res.behaviours().portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
}
|
||||
/// `Entity.teleportCrossDimension` away from the loaded dimension: the
|
||||
/// entity is saved and removed, and waits for its dimension to load.
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
|
||||
@@ -126,6 +126,8 @@ impl Game<'_> {
|
||||
self.options_from_title = from_title;
|
||||
let minecart_improvements = self.minecart_improvements();
|
||||
let mods_live = self.mods_live_now();
|
||||
// From the title after leaving a server, the row is back before the next world ticks.
|
||||
self.hold_fullbright();
|
||||
let ui = &mut self.ui.options;
|
||||
ui.open_at(!from_title && self.title.is_none(), page);
|
||||
ui.mods_live = mods_live;
|
||||
@@ -560,10 +562,10 @@ impl Game<'_> {
|
||||
{
|
||||
self.settings.horizon.save(dir);
|
||||
}
|
||||
self.hold_fullbright();
|
||||
let s = &self.settings;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.gamma = s.gamma;
|
||||
r.fullbright = s.extras.fullbright;
|
||||
r.improved_transparency = s.improved_transparency;
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
@@ -740,6 +742,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Fullbright as this window may draw it (`fullbright_held`): the renderer's flag and the
|
||||
/// graphics screen's row. Each tick (`tick_frame_visuals`) and when settings apply, so
|
||||
/// joining or leaving a server needs no call of its own.
|
||||
pub(super) fn hold_fullbright(&mut self) {
|
||||
let held = fullbright_held(
|
||||
self.remote.as_ref().map(|r| r.save_folder.is_some()),
|
||||
self.studio_replay.is_some(),
|
||||
);
|
||||
self.settings.fullbright_held = held;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.fullbright = self.settings.extras.fullbright && !held;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fullbright is held off on another's server, where it would light what the server shows
|
||||
/// dark (#3823): a connection with no save of this window's own (`Remote::save_folder`), as a
|
||||
/// multiplayer server or a LAN guest has. Single player, a LAN host, a splitscreen seat in
|
||||
/// P1's world and Replay Studio keep it. `connection`: none, or whether it plays a save.
|
||||
fn fullbright_held(connection: Option<bool>, studio: bool) -> bool {
|
||||
connection == Some(false) && !studio
|
||||
}
|
||||
|
||||
/// The named device can be a connected pad that has not driven the game yet.
|
||||
fn detected_controller_style(
|
||||
device: Option<&horizon_client::controller_ui::Device>,
|
||||
@@ -775,6 +801,23 @@ fn windowed_again(w: &winit::window::Window) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fullbright_tests {
|
||||
use super::fullbright_held;
|
||||
|
||||
#[test]
|
||||
fn fullbright_is_held_only_on_another_servers_world() {
|
||||
// The title screen, with no connection.
|
||||
assert!(!fullbright_held(None, false));
|
||||
// Single player and a LAN host play their own save; so does a splitscreen seat in P1's.
|
||||
assert!(!fullbright_held(Some(true), false));
|
||||
// A multiplayer server, or another player's world opened to LAN.
|
||||
assert!(fullbright_held(Some(false), false));
|
||||
// Replay Studio plays a recording, not a server.
|
||||
assert!(!fullbright_held(Some(false), true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod controller_context_tests {
|
||||
use super::detected_controller_style;
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::{
|
||||
type ChunkKey = (i32, i32);
|
||||
type SectionKey = (i32, i32, i32);
|
||||
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
|
||||
const MAX_PARTICLE_ANSWERS: usize = 4_096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CullKey {
|
||||
@@ -175,25 +176,48 @@ impl ParticleAsyncCuller {
|
||||
}
|
||||
|
||||
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
|
||||
while let Ok(mut results) = self.results_rx.try_recv() {
|
||||
self.pending = false;
|
||||
let scratch = std::mem::take(&mut results.scratch_queries);
|
||||
if scratch.capacity() > self.queries.get_mut().capacity() {
|
||||
*self.queries.get_mut() = scratch;
|
||||
}
|
||||
self.results = Some(results);
|
||||
}
|
||||
self.camera = camera.to_array();
|
||||
self.stamp = SnapshotKey {
|
||||
world_revision: world.revision,
|
||||
chunks_revision: world.chunks.membership_revision(),
|
||||
bottom_y: world.bottom_y(),
|
||||
};
|
||||
if self
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.stamp != self.stamp)
|
||||
{
|
||||
let camera_bits = self.camera.map(f64::to_bits);
|
||||
while let Ok(mut results) = self.results_rx.try_recv() {
|
||||
self.pending = false;
|
||||
let scratch = std::mem::take(&mut results.scratch_queries);
|
||||
if scratch.capacity() > self.queries.get_mut().capacity() {
|
||||
*self.queries.get_mut() = scratch;
|
||||
}
|
||||
// A job only answers the clusters that missed, so merge instead of replacing.
|
||||
if results.stamp != self.stamp {
|
||||
continue;
|
||||
}
|
||||
results.answers.retain(|(key, _)| key.camera == camera_bits);
|
||||
if let Some(old) = self
|
||||
.results
|
||||
.as_mut()
|
||||
.filter(|old| old.stamp == results.stamp)
|
||||
{
|
||||
old.answers.retain(|(key, _)| key.camera == camera_bits);
|
||||
// Newest first, so dedup keeps the newest answer of a key.
|
||||
let mut merged = std::mem::take(&mut results.answers);
|
||||
merged.append(&mut old.answers);
|
||||
merged.sort_by_key(|(key, _)| *key);
|
||||
merged.dedup_by_key(|(key, _)| *key);
|
||||
merged.truncate(MAX_PARTICLE_ANSWERS);
|
||||
old.answers = merged;
|
||||
} else {
|
||||
self.results = Some(results);
|
||||
}
|
||||
}
|
||||
if self.results.as_ref().is_some_and(|results| {
|
||||
results.stamp != self.stamp
|
||||
|| results
|
||||
.answers
|
||||
.iter()
|
||||
.any(|(key, _)| key.camera != camera_bits)
|
||||
}) {
|
||||
self.results = None;
|
||||
}
|
||||
self.queries.get_mut().clear();
|
||||
@@ -781,4 +805,43 @@ mod tests {
|
||||
assert!(results.answers[0].1.is_none());
|
||||
drop(live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_of_an_earlier_job_survive_a_later_job_for_other_clusters() {
|
||||
use std::time::{Duration, Instant};
|
||||
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let mut culler = ParticleAsyncCuller::new().unwrap();
|
||||
culler.begin_frame(glam::DVec3::ZERO, &world);
|
||||
let (snapshot, _live) = wall(None);
|
||||
let key = |x: f64| CullKey {
|
||||
camera: [0.0; 3].map(f64::to_bits),
|
||||
min: [x, -0.55, -0.55].map(f64::to_bits),
|
||||
max: [x + 2.1, 0.55, 0.55].map(f64::to_bits),
|
||||
};
|
||||
let (a, b) = (key(7.95), key(11.95));
|
||||
for k in [a, b] {
|
||||
culler
|
||||
.jobs
|
||||
.send(CullJob {
|
||||
stamp: culler.stamp,
|
||||
snapshot: Arc::clone(&snapshot),
|
||||
queries: vec![k],
|
||||
})
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
culler.begin_frame(glam::DVec3::ZERO, &world);
|
||||
if culler.cached(&k).is_some() {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "worker did not answer");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
culler.cached(&a).is_some(),
|
||||
"answer of the first job was dropped"
|
||||
);
|
||||
assert!(culler.cached(&b).is_some());
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
Screen::Paused | Screen::Inventory => {
|
||||
// RecipeBookComponent.keyPressed: a narrow window's open
|
||||
// book takes Escape to close itself, not the screen.
|
||||
if self.screen == Screen::Inventory && self.recipe_book_escape() {
|
||||
return Ok(());
|
||||
}
|
||||
self.close_container();
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Playing;
|
||||
|
||||
@@ -49,7 +49,14 @@ impl Game<'_> {
|
||||
&& !self.is_published()
|
||||
&& !self.has_split_players()
|
||||
&& self.title.is_none()
|
||||
&& (self.screen.is_pause_screen() || self.book.is_some() || self.win_screen_pauses())
|
||||
&& top_screen_pauses(
|
||||
// A dialog (and its warning or waiting screen) is the
|
||||
// screen Minecraft set, over whatever was open.
|
||||
self.dialog_open().then(|| self.dialogs.pauses_world()),
|
||||
self.book.as_ref().map(|book| book.pauses_world()),
|
||||
self.screen.is_pause_screen(),
|
||||
self.win_screen_pauses(),
|
||||
)
|
||||
}
|
||||
/// A screen open over gameplay while `screen` stays `Playing`: a book or
|
||||
/// lectern, a sign, a server dialog, the code of conduct, a link or
|
||||
@@ -362,9 +369,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the things over gameplay decides `Minecraft.pause`: a dialog is
|
||||
/// its own `DialogScreen` (Minecraft set it over whatever was open) and
|
||||
/// comes first, then a book, else `Gui.screen`; the win screen's poem rides
|
||||
/// with its screen. `None` means that kind of screen is not open.
|
||||
fn top_screen_pauses(dialog: Option<bool>, book: Option<bool>, screen: bool, win: bool) -> bool {
|
||||
dialog.or(book).unwrap_or(screen || win)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::studio_suppresses_focus_pause;
|
||||
use super::{studio_suppresses_focus_pause, top_screen_pauses};
|
||||
|
||||
#[test]
|
||||
fn studio_replay_and_freecam_never_pause_on_focus_loss() {
|
||||
@@ -372,4 +387,27 @@ mod tests {
|
||||
assert!(studio_suppresses_focus_pause(false, true));
|
||||
assert!(!studio_suppresses_focus_pause(false, false));
|
||||
}
|
||||
|
||||
/// `Minecraft.pause` asks the screen Minecraft set, and each of a dialog,
|
||||
/// a book and the win screen is a screen of its own that replaces what
|
||||
/// was open: the dialog's `pause`, the book's `BookViewScreen`/
|
||||
/// `LecternScreen`, and `WinScreen`'s `!poem` decide, not the screen
|
||||
/// underneath.
|
||||
#[test]
|
||||
fn the_screen_on_top_decides_whether_the_world_pauses() {
|
||||
// Nothing replaces the screen: its own isPauseScreen answer, with
|
||||
// the win screen's poem rule when it is the win screen.
|
||||
assert!(top_screen_pauses(None, None, true, false));
|
||||
assert!(top_screen_pauses(None, None, false, true));
|
||||
assert!(!top_screen_pauses(None, None, false, false));
|
||||
// A dialog over a pause screen: its own pause answers.
|
||||
assert!(!top_screen_pauses(Some(false), None, true, false));
|
||||
assert!(top_screen_pauses(Some(true), None, false, false));
|
||||
// A lectern's book over a pause screen does not pause; a book in
|
||||
// hand does.
|
||||
assert!(!top_screen_pauses(None, Some(false), true, false));
|
||||
assert!(top_screen_pauses(None, Some(true), false, false));
|
||||
// A dialog opens over the book.
|
||||
assert!(!top_screen_pauses(Some(false), Some(true), false, false));
|
||||
}
|
||||
}
|
||||
@@ -1211,7 +1211,12 @@ impl Game<'_> {
|
||||
twinkle: false,
|
||||
}],
|
||||
));
|
||||
let rocket = self.new_rocket(Vec3::new(at[0], at[1], at[2]), &stack, false);
|
||||
let rocket = self.new_rocket(
|
||||
Vec3::new(at[0], at[1], at[2]),
|
||||
&stack,
|
||||
false,
|
||||
&mut random_source::entity(),
|
||||
);
|
||||
self.rockets.push(rocket);
|
||||
}
|
||||
/// `BehaviorUtils.throwItem`: a hero's gift tossed toward the player.
|
||||
|
||||
@@ -31,6 +31,13 @@ const SWAP_TICKS: f32 = 30.0;
|
||||
/// Widest GUI at which the book sits next to the menu (`widthTooNarrow`).
|
||||
const NARROW: f32 = 379.0;
|
||||
|
||||
/// `RecipeBookComponent.isOffsetNextToMainGUI`: false in a window narrower than
|
||||
/// `NARROW`, where the open book covers the menu. Escape closes only the book
|
||||
/// there (`keyPressed`), not the container.
|
||||
pub(super) fn escape_closes_book(width: f32, open: bool) -> bool {
|
||||
open && width < NARROW
|
||||
}
|
||||
|
||||
/// A ghost recipe slot: the items it cycles through.
|
||||
struct Ghost {
|
||||
slot: usize,
|
||||
@@ -1115,6 +1122,22 @@ impl Game<'_> {
|
||||
self.send_book_settings(book_type);
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
/// `RecipeBookComponent.keyPressed`: Escape closes the book, not the
|
||||
/// screen, while it covers a narrow menu; a wide window's Escape reaches
|
||||
/// the container.
|
||||
pub(super) fn recipe_book_escape(&mut self) -> bool {
|
||||
let Some(book_type) = self.active_book() else {
|
||||
return false;
|
||||
};
|
||||
let Some(batch) = self.batch_for_size() else {
|
||||
return false;
|
||||
};
|
||||
if !escape_closes_book(batch.width, self.book_open(book_type)) {
|
||||
return false;
|
||||
}
|
||||
self.close_narrow_book(book_type, &batch);
|
||||
true
|
||||
}
|
||||
/// `RecipeBookComponent.tryPlaceRecipe`: false when the click is refused.
|
||||
fn try_place(&mut self, entry_index: usize, use_max: bool) -> bool {
|
||||
let Some(book_type) = self.active_book() else {
|
||||
@@ -1899,3 +1922,16 @@ fn sub_rect(r: Rect, [x, y, w, h]: [f32; 4]) -> Rect {
|
||||
h: h as u32,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `RecipeBookComponent.keyPressed`: Escape closes a narrow window's open
|
||||
/// book (`!isOffsetNextToMainGUI`), not the container; a wide window's
|
||||
/// book leaves Escape to the container.
|
||||
#[test]
|
||||
fn escape_closes_only_a_narrow_books_menu() {
|
||||
assert!(super::escape_closes_book(super::NARROW - 1.0, true));
|
||||
assert!(!super::escape_closes_book(super::NARROW, true));
|
||||
assert!(!super::escape_closes_book(super::NARROW - 1.0, false));
|
||||
}
|
||||
}
|
||||
@@ -11199,7 +11199,7 @@ pub(super) fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let id = info.id.trim_start_matches("minecraft:");
|
||||
id.ends_with("_spear")
|
||||
|| info.consumable.is_some()
|
||||
|| info.equip_slot.is_some()
|
||||
|| (info.equip_slot.is_some() && info.swappable)
|
||||
|| info.blocks_attacks.is_some()
|
||||
|| horizon_client::items::bucket_contents(&info.id).is_some()
|
||||
|| id.ends_with("_boat")
|
||||
@@ -13479,6 +13479,9 @@ mod tests {
|
||||
let rod = items.get(rod).unwrap();
|
||||
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
|
||||
}
|
||||
// A carved pumpkin has an equip_slot (head) but is not swappable, so it has no Item.use.
|
||||
let pumpkin = items.get("minecraft:carved_pumpkin").unwrap();
|
||||
assert!(!super::use_ends_click(pumpkin, gliding));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const HELP: &[&str] = &[
|
||||
"particle NAME X Y Z [DX DY DZ] [COUNT] spawn particles (vanilla /particle spread)",
|
||||
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
|
||||
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
|
||||
"section-layout compact|wide solid quads' layout in the arena, all meshed again (A/B checks); replies compact",
|
||||
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
|
||||
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
|
||||
"gpu-culling [on|off|auto] GPU Culling as its setting sets it, not saved (A/B checks); replies the mode",
|
||||
|
||||
@@ -426,6 +426,7 @@ impl Game<'_> {
|
||||
/// flicker whenever the game is not paused, frozen or not, while `TextureManager.tick`
|
||||
/// needs the level running normally. `HORIZON_STILL` keeps both with the world.
|
||||
pub(super) fn tick_frame_visuals(&mut self, running: bool) {
|
||||
self.hold_fullbright();
|
||||
if running || !self.clock.still() {
|
||||
self.fog.tick_flicker();
|
||||
}
|
||||
|
||||
@@ -583,20 +583,25 @@ impl Game<'_> {
|
||||
if include_world {
|
||||
let root = world_root.as_ref().expect("a local world's save folder");
|
||||
if let Some(server) = self.integrated.as_ref() {
|
||||
server.set_paused(true);
|
||||
let saved = server.call(|game| game.save_game().map_err(|e| e.to_string()));
|
||||
let copied = copy_tree(root, &dir.join("world"));
|
||||
server.set_paused(self.pause);
|
||||
match saved {
|
||||
Some(Ok(())) => {}
|
||||
Some(Err(e)) => {
|
||||
// The server thread saves, then waits there for the copy: pausing it instead
|
||||
// runs its pause save (which renames files into place) during the copy.
|
||||
let (saved_tx, saved) = mpsc::channel();
|
||||
let (copied, copied_rx) = mpsc::channel::<()>();
|
||||
// A server that has stopped drops the task, and `saved_tx` with it.
|
||||
server.post(move |game| {
|
||||
let _ = saved_tx.send(game.save_game().map_err(|e| e.to_string()));
|
||||
let _ = copied_rx.recv();
|
||||
});
|
||||
match saved.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
return Err(format!("saving the world for the recording: {e}").into());
|
||||
}
|
||||
None => {
|
||||
log::warn!("Horizon: the integrated server did not save for the recording")
|
||||
}
|
||||
Err(_) => return Err("the integrated server has stopped".into()),
|
||||
}
|
||||
copied.map_err(|e| format!("copying the world: {e}"))?;
|
||||
let result = copy_tree(root, &dir.join("world"));
|
||||
drop(copied);
|
||||
result.map_err(|e| format!("copying the world: {e}"))?;
|
||||
} else {
|
||||
self.save_game_with(true)?;
|
||||
copy_tree(root, &dir.join("world"))
|
||||
|
||||
@@ -241,7 +241,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn holder_for(&mut self, t: Target) -> Option<Holder> {
|
||||
let name = self.holder_name(t)?;
|
||||
let shown = self.target_name(t);
|
||||
let shown = feedback_name(self.target_name(t), &name);
|
||||
Some(Holder {
|
||||
display: Some(literal(&shown.plain())),
|
||||
text: shown,
|
||||
@@ -957,6 +957,20 @@ pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlaye
|
||||
players
|
||||
}
|
||||
|
||||
/// `ScoreHolder.getFeedbackDisplayName`: the holder's display name with its
|
||||
/// hover replaced by `HoverEvent.ShowText` of its scoreboard name (a player's
|
||||
/// name, an entity's UUID string); the insertion stays.
|
||||
fn feedback_name(display: Text, scoreboard_name: &str) -> Text {
|
||||
let mut component = display.component();
|
||||
if let serde_json::Value::Object(o) = &mut component {
|
||||
o.insert(
|
||||
"hover_event".into(),
|
||||
serde_json::json!({"action": "show_text", "value": scoreboard_name}),
|
||||
);
|
||||
}
|
||||
display.with_component(component)
|
||||
}
|
||||
|
||||
/// The commands' view of the game.
|
||||
struct GameEnv<'a, 'b> {
|
||||
g: &'a mut Game<'b>,
|
||||
@@ -997,7 +1011,7 @@ impl Env for GameEnv<'_, '_> {
|
||||
Target::Mob(id) | Target::Item(id) => uuid_text(self.g.uuid_for(id)),
|
||||
_ => return None,
|
||||
};
|
||||
let text = self.g.target_name(entity);
|
||||
let text = feedback_name(self.g.target_name(entity), &name);
|
||||
Some((
|
||||
Holder {
|
||||
display: Some(literal(&text.plain())),
|
||||
@@ -1107,6 +1121,26 @@ mod tab_list_tests {
|
||||
#[cfg(test)]
|
||||
mod team_tests {
|
||||
use super::*;
|
||||
/// #14279: `ScoreHolder.getFeedbackDisplayName` keeps the display name and
|
||||
/// its insertion, and shows the scoreboard name (an entity's UUID) on hover.
|
||||
#[test]
|
||||
fn a_feedback_name_shows_the_scoreboard_name_on_hover() {
|
||||
let uuid = "00000001-0000-0002-0000-000300000004";
|
||||
let display = Text::translate("entity.minecraft.pig", &[]).with_component_fields(vec![
|
||||
(
|
||||
"hover_event".into(),
|
||||
serde_json::json!({"action": "show_entity", "id": "minecraft:pig", "uuid": uuid}),
|
||||
),
|
||||
("insertion".into(), uuid.into()),
|
||||
]);
|
||||
let c = feedback_name(display, uuid).component();
|
||||
assert_eq!(c["translate"], "entity.minecraft.pig");
|
||||
assert_eq!(c["insertion"], uuid);
|
||||
assert_eq!(
|
||||
c["hover_event"],
|
||||
serde_json::json!({"action": "show_text", "value": uuid})
|
||||
);
|
||||
}
|
||||
|
||||
const WOLF: [i32; 4] = [1, 2, 3, 4];
|
||||
const CAT: [i32; 4] = [5, 6, 7, 8];
|
||||
|
||||
@@ -786,6 +786,19 @@ impl Game<'_> {
|
||||
}
|
||||
if self.mobs[index].species() == Species::Allay && self.mobs[index].alive() {
|
||||
self.allay_listen(index, event, pos, ctx);
|
||||
// Allay.JukeboxListener, its own listener beside the vibrations'.
|
||||
let playing = match event {
|
||||
"minecraft:jukebox_play" => Some(true),
|
||||
"minecraft:jukebox_stop_play" => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
if let Some(playing) = playing
|
||||
&& allay::hears_jukebox(m, pos)
|
||||
{
|
||||
let at = pos.map(|v| v.floor() as i32);
|
||||
allay::set_jukebox_playing(m, at, playing);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A game event at a block (`Level.gameEvent(source, event, pos)`).
|
||||
@@ -2230,8 +2243,9 @@ impl Game<'_> {
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
}
|
||||
/// `Entity.checkSupportingBlock`: the block under this box closest to the
|
||||
/// entity. Everything but the player works it out when it asks.
|
||||
fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
|
||||
/// entity. Everything but the player works it out when it asks (`Breeze`'s
|
||||
/// ground dust too).
|
||||
pub(super) fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
let foot = Aabb::new(
|
||||
Vec3::new(min.x, min.y - 1.0e-6, min.z),
|
||||
|
||||
@@ -109,3 +109,19 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
|
||||
"minecraft:redstone_wire[east=side,north=none,power=15,south=none,west=side]"
|
||||
);
|
||||
}
|
||||
|
||||
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
|
||||
/// entity's saved copy: a `start` state with a powered block entity answers
|
||||
/// 15 even when `/data merge` left a different mode in the entity.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_test_block_reads_the_state_mode_for_its_own_signal() {
|
||||
let mut game = game();
|
||||
run(&mut game, &["setblock 5 100 2 test_block[mode=start]"]);
|
||||
assert!(game.run_command("data merge block 5 100 2 {mode:\"fail\",powered:1b}"));
|
||||
let pos = BlockPos::new(5, 100, 2);
|
||||
let state = game.world.block(5, 100, 2);
|
||||
let signal =
|
||||
game.with_level(|_, level, _| level.block_entity_signal(pos, state, Direction::North));
|
||||
assert_eq!(signal, 15, "the state says start and the entity is powered");
|
||||
}
|
||||
@@ -7,9 +7,20 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
|
||||
/// `TestBlock.ownSignal`: the block state's mode is the gate, and only then
|
||||
/// does the block entity's powered flag count.
|
||||
pub(super) fn own_signal(registry: &BlockRegistry, state: BlockStateId, powered: bool) -> i32 {
|
||||
let start = registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("mode"))
|
||||
.is_some_and(|mode| mode == "start");
|
||||
if start && powered { 15 } else { 0 }
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn test_block_mut(&mut self, pos: Pos) -> Option<&mut TestBlock> {
|
||||
match self.world.block_entities.get_mut(&pos)? {
|
||||
|
||||
@@ -213,7 +213,11 @@ fn dragon_takes_player_thorns_through_its_body() {
|
||||
Some(("minecraft:thorns", Some(77), Some(77)))
|
||||
);
|
||||
assert_eq!(d.hurt_by_player, Some((FightWho::Host, 0)));
|
||||
assert_eq!(g.armor[1].as_ref().unwrap().damage, 2);
|
||||
// Thorns' own 2 plus the hit's `max(1, damage / 4)`: 2 for the head's 10, 1 for the wing's 5.
|
||||
assert_eq!(
|
||||
g.armor[1].as_ref().unwrap().damage,
|
||||
if wing { 3 } else { 4 }
|
||||
);
|
||||
assert_eq!(
|
||||
g.audio
|
||||
.capture
|
||||
@@ -231,10 +235,10 @@ fn dragon_takes_player_thorns_through_its_body() {
|
||||
let health = g.player.as_ref().unwrap().health.health();
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 1.0);
|
||||
assert_eq!(g.player.as_ref().unwrap().health.health(), health);
|
||||
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
|
||||
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
|
||||
let before = g.dragon.as_ref().unwrap().health;
|
||||
g.dragon_hurts_player("minecraft:indirect_magic", 1.0);
|
||||
assert_eq!(g.dragon.as_ref().unwrap().health, before);
|
||||
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
|
||||
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,8 @@ pub(super) const HELP: &[&str] = &[
|
||||
];
|
||||
|
||||
/// After a click, `ui`, `click` and `set` wait for a frame drawn since, so
|
||||
/// they never read the screen the click left.
|
||||
/// they never read the screen the click left; before the first frame, for
|
||||
/// that one, as a game answers its socket before it has drawn anything.
|
||||
#[derive(Default)]
|
||||
pub(super) struct UiWait {
|
||||
/// The widget generation at the last click, and when it happened.
|
||||
@@ -33,9 +34,10 @@ pub(super) struct UiWait {
|
||||
|
||||
impl UiWait {
|
||||
fn stale(&self) -> bool {
|
||||
self.after.is_some_and(|(generation, at)| {
|
||||
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
|
||||
})
|
||||
widgets::generation() == 0
|
||||
|| self.after.is_some_and(|(generation, at)| {
|
||||
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
|
||||
})
|
||||
}
|
||||
/// The commands that can run now.
|
||||
pub(super) fn ready(&mut self) -> Vec<Request> {
|
||||
@@ -121,7 +123,8 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `ui`, `click` and `set`; `Handled::No` for other commands.
|
||||
/// `ui`, `click` and `set`, and `screen` and `click-button` held until the first frame;
|
||||
/// `Handled::No` for other commands.
|
||||
pub(super) fn ui_command(
|
||||
&mut self,
|
||||
words: &[&str],
|
||||
@@ -129,7 +132,10 @@ impl Game<'_> {
|
||||
reply: &Sender<String>,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
) -> Result<Handled> {
|
||||
if !matches!(words[0], "ui" | "click" | "set") {
|
||||
// `screen` and `click-button` read the last drawn frame too: they wait for the first.
|
||||
let first_frame =
|
||||
matches!(words[0], "screen" | "click-button") && widgets::generation() == 0;
|
||||
if !matches!(words[0], "ui" | "click" | "set") && !first_frame {
|
||||
return Ok(Handled::No);
|
||||
}
|
||||
if self.ui_wait.stale() {
|
||||
@@ -218,3 +224,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A game answers its socket before it draws: `ui` would read no widgets yet.
|
||||
#[test]
|
||||
fn widget_commands_wait_for_the_first_frame() {
|
||||
let wait = UiWait::default();
|
||||
assert!(wait.stale());
|
||||
widgets::end_frame(1);
|
||||
assert!(!wait.stale());
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn datapack_damage_initial_snapshot_uses_the_prepared_worlds_features() {
|
||||
fs::create_dir_all(pack.join("data/demo/damage_type")).unwrap();
|
||||
fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
|
||||
r#"{"pack":{"description":""},"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
|
||||
@@ -21,10 +21,16 @@ use horizon_core::{
|
||||
#[cfg(test)]
|
||||
#[path = "fall_flying_tests.rs"]
|
||||
mod fall_flying_tests;
|
||||
#[path = "ai_glide.rs"]
|
||||
mod glide;
|
||||
use glide::update_fall_flying;
|
||||
#[cfg(test)]
|
||||
#[path = "wet_fire_tests.rs"]
|
||||
mod wet_fire_tests;
|
||||
|
||||
mod step;
|
||||
pub(crate) use step::step_sound;
|
||||
|
||||
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
|
||||
|
||||
/// The inside-block effects of fire and lava (`InsideBlockEffectType`
|
||||
@@ -707,9 +713,7 @@ pub fn tick(
|
||||
if m.no_ai {
|
||||
update_fall_flying(m, w, events);
|
||||
reset_effect_fall_distance(m);
|
||||
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
|
||||
// so undead still burn in daylight and the endermite's life keeps
|
||||
// counting.
|
||||
// Mob.aiStep still runs without AI: undead burn, endermites age.
|
||||
burn_undead(m, w, events);
|
||||
if m.species() == Species::Endermite {
|
||||
endermite_lifetime(m);
|
||||
@@ -758,6 +762,7 @@ pub fn tick(
|
||||
if m.species() == Species::GlowSquid {
|
||||
aquatic::glow_squid_dark_tick(m);
|
||||
}
|
||||
super::cube::no_ai_tick(m, w, events); // AbstractCubeMob.tick
|
||||
// Shulker.tick after its living tick: a NoAI shell still keeps a
|
||||
// face and eases its lid, pushing what the lid opens into.
|
||||
if m.species() == Species::Shulker {
|
||||
@@ -1677,61 +1682,6 @@ fn ageable_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
|
||||
/// the glide flag is set, with or without AI: a mob that cannot glide
|
||||
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
|
||||
/// glider whose next damage will not break it, `canGlideUsing`) stops
|
||||
/// gliding; else on every 20th glide tick one of its gliders, picked with
|
||||
/// `Util.getRandom` from the mob's own random, takes a point of wear.
|
||||
/// `checkFallDistanceAccumulation` and the `ELYTRA_GLIDE` game event of
|
||||
/// every 10th tick are not ported here.
|
||||
fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
if !m.extra.fall_flying {
|
||||
return;
|
||||
}
|
||||
let gliders: Vec<EquipmentSlot> = match w.items {
|
||||
Some(items) => EquipmentSlot::ALL
|
||||
.into_iter()
|
||||
.filter(|&slot| {
|
||||
super::finalize::equipment_stack(m, slot).is_some_and(|stack| {
|
||||
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if m.on_ground
|
||||
|| m.extra.mount.is_some()
|
||||
|| m.has_effect("minecraft:levitation")
|
||||
|| gliders.is_empty()
|
||||
{
|
||||
m.extra.fall_flying = false;
|
||||
return;
|
||||
}
|
||||
let ticks = m.extra.fall_fly_ticks + 1;
|
||||
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
|
||||
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
|
||||
events.push(MobEvent::GliderWear {
|
||||
mob: m.id,
|
||||
slot: gliders[pick],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
|
||||
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
|
||||
use horizon_world::player_inventory::EquipmentSlot as S;
|
||||
match slot {
|
||||
S::MainHand => "mainhand",
|
||||
S::OffHand => "offhand",
|
||||
S::Feet => "feet",
|
||||
S::Legs => "legs",
|
||||
S::Chest => "chest",
|
||||
S::Head => "head",
|
||||
S::Body => "body",
|
||||
S::Saddle => "saddle",
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tick` after `aiStep`: the glide's tick count.
|
||||
fn count_fall_fly_ticks(m: &mut Mob) {
|
||||
m.extra.fall_fly_ticks = if m.extra.fall_flying {
|
||||
@@ -2225,7 +2175,7 @@ fn species_ai_step(
|
||||
Species::Vex => super::vex::ai_step(m, w),
|
||||
Species::Ravager => super::ravager::ai_step(m, w, events),
|
||||
Species::Wither => super::wither::ai_step(m, w, level_random, events),
|
||||
Species::Allay => super::allay::ai_step(m),
|
||||
Species::Allay => super::allay::ai_step(m, w),
|
||||
Species::Bee => super::bee::ai_step(m, w),
|
||||
Species::PolarBear => super::polar_bear::ai_step(m, w),
|
||||
Species::Breeze => super::breeze::ai_step(m),
|
||||
@@ -2308,10 +2258,10 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
let persistent = m.persistent || requires_custom_persistence(m);
|
||||
// removeWhenFarAway: animals, villagers, golems, patrols and raiders,
|
||||
// zombie villagers being cured and ridden mobs stay, bar the two felines.
|
||||
let creature = m.kind.category == "creature";
|
||||
let feline_goes = feline_removed_when_far(m.species(), m.extra.tame || m.extra.trusting, m.age);
|
||||
let removable = !(never_removed_when_far(m.species())
|
||||
|| creature && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|
||||
|| super::aquatic::named_keeps_it(m)
|
||||
|| m.kind.category == "creature" && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|
||||
|| super::raider::keeps_alive(m, d)
|
||||
|| super::zombie_villager::keeps_alive(m)
|
||||
|| super::riders::keeps_alive(m));
|
||||
@@ -3260,109 +3210,6 @@ pub fn movement_sounds(m: &Mob) -> bool {
|
||||
| Species::Breeze
|
||||
)
|
||||
}
|
||||
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
|
||||
pub fn step_sound(
|
||||
m: &Mob,
|
||||
w: &MobWorld,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> Option<(&'static str, f32, f32)> {
|
||||
use Species as S;
|
||||
let baby = m.is_baby();
|
||||
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
|
||||
match m.species() {
|
||||
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
|
||||
// a happy ghast and a nautilus have no footfall of their own, so the
|
||||
// block's step sound is not taken either.
|
||||
S::Allay
|
||||
| S::Bee
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
| S::TropicalFish
|
||||
| S::Pufferfish
|
||||
| S::Tadpole
|
||||
| S::HappyGhast
|
||||
| S::Nautilus
|
||||
| S::ZombieNautilus => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken | S::Cow | S::Pig | S::Wolf => {
|
||||
super::finalize::variant_sound(m, "step").and_then(quiet)
|
||||
}
|
||||
S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
|
||||
S::Frog => quiet("entity.frog.step"),
|
||||
S::Goat => quiet("entity.goat.step"),
|
||||
S::Panda => quiet("entity.panda.step"),
|
||||
S::Parrot => quiet("entity.parrot.step"),
|
||||
S::PolarBear => quiet("entity.polar_bear.step"),
|
||||
S::Sheep => quiet("entity.sheep.step"),
|
||||
S::Sniffer => quiet("entity.sniffer.step"),
|
||||
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
|
||||
S::Turtle => quiet("entity.turtle.shamble"),
|
||||
S::Creaking => quiet("entity.creaking.step"),
|
||||
S::Hoglin => quiet("entity.hoglin.step"),
|
||||
S::Zoglin => quiet("entity.zoglin.step"),
|
||||
S::Piglin => quiet("entity.piglin.step"),
|
||||
S::PiglinBrute => quiet("entity.piglin_brute.step"),
|
||||
S::Ravager => quiet("entity.ravager.step"),
|
||||
S::Skeleton => quiet("entity.skeleton.step"),
|
||||
S::Stray => quiet("entity.stray.step"),
|
||||
S::Bogged => quiet("entity.bogged.step"),
|
||||
S::Parched => quiet("entity.parched.step"),
|
||||
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
|
||||
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
|
||||
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
|
||||
// their own step at 15%, though neither type's movement emission plays
|
||||
// it (`movement_sounds`).
|
||||
S::Silverfish => quiet("entity.silverfish.step"),
|
||||
S::Endermite => quiet("entity.endermite.step"),
|
||||
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
|
||||
S::Husk => quiet("entity.husk.step"),
|
||||
S::Drowned => quiet("entity.drowned.step"),
|
||||
S::ZombieVillager => quiet("entity.zombie_villager.step"),
|
||||
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
|
||||
// powder have their own step.
|
||||
S::CamelHusk | S::Camel => {
|
||||
let sand = w.has_tag(
|
||||
"minecraft:camel_sand_step_sound_blocks",
|
||||
&w.facts.get(state).name,
|
||||
);
|
||||
Some(match (m.species(), sand) {
|
||||
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
|
||||
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
|
||||
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
|
||||
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
|
||||
})
|
||||
}
|
||||
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
|
||||
// CopperGolem.playStepSound: its oxidation level's step.
|
||||
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
|
||||
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
|
||||
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
|
||||
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
|
||||
_ => {
|
||||
// Entity.playStepSound: the block's sound type.
|
||||
let _ = pos;
|
||||
let sounds = &w.facts.get(state).sounds;
|
||||
let step: &'static str = super::ai::intern_sound(&sounds.step);
|
||||
Some((step, sounds.volume * 0.15, sounds.pitch))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A block sound id as a static string (the block facts intern them).
|
||||
pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
|
||||
use std::{collections::HashSet, sync::Mutex};
|
||||
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
|
||||
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let set = names.get_or_insert_with(HashSet::new);
|
||||
let key = s.strip_prefix("minecraft:").unwrap_or(s);
|
||||
if let Some(found) = set.get(key) {
|
||||
return found;
|
||||
}
|
||||
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
|
||||
set.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
/// `getSwimSound`. A skeleton horse's own override counts the steps it takes
|
||||
/// through the water, so it needs the mob, not just its species.
|
||||
pub fn swim_sound(m: &mut Mob) -> &'static str {
|
||||
@@ -3434,6 +3281,31 @@ mod despawn_tests {
|
||||
assert!(!feline_removed_when_far(s, false, 12000), "{s:?}");
|
||||
}
|
||||
}
|
||||
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
|
||||
/// an axolotl with a custom name is kept however far it swims, while the name
|
||||
/// saves no other mob — a monster still goes.
|
||||
#[test]
|
||||
fn a_named_fish_or_axolotl_never_despawns() {
|
||||
crate::mobs::world::with_test_world(&[], |w, types| {
|
||||
let far = MobWorld {
|
||||
// 200 blocks from the player: past the 128 a monster loses.
|
||||
nearest_player: Some(Vec3::new(200.0, 64.0, 0.0)),
|
||||
..w.clone()
|
||||
};
|
||||
let checked = |id: &str, name: Option<&str>| {
|
||||
let kind = std::sync::Arc::new(types.types[id].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
m.custom_name = name.map(str::to_owned);
|
||||
despawn_check(&mut m, &far)
|
||||
};
|
||||
for fish in ["minecraft:cod", "minecraft:salmon", "minecraft:axolotl"] {
|
||||
assert!(checked(fish, Some("Bubbles")), "{fish} keeps its name");
|
||||
assert!(!checked(fish, None), "{fish} without one goes");
|
||||
}
|
||||
// A name is not persistence for anything else.
|
||||
assert!(!checked("minecraft:zombie", Some("Bubbles")));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! `Mob.playStepSound` and its overrides: the species' own footstep, or the block
|
||||
//! sound at 15%, and the block sound ids the facts carry.
|
||||
use super::{Mob, MobWorld, Species};
|
||||
|
||||
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
|
||||
pub fn step_sound(
|
||||
m: &Mob,
|
||||
w: &MobWorld,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> Option<(&'static str, f32, f32)> {
|
||||
use Species as S;
|
||||
let baby = m.is_baby();
|
||||
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
|
||||
match m.species() {
|
||||
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
|
||||
// a happy ghast and a nautilus have no footfall of their own, so the
|
||||
// block's step sound is not taken either.
|
||||
S::Allay
|
||||
| S::Bee
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
| S::TropicalFish
|
||||
| S::Pufferfish
|
||||
| S::Tadpole
|
||||
| S::HappyGhast
|
||||
| S::Nautilus
|
||||
| S::ZombieNautilus => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken | S::Cow | S::Pig | S::Wolf => {
|
||||
crate::mobs::finalize::variant_sound(m, "step").and_then(quiet)
|
||||
}
|
||||
S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
|
||||
S::Frog => quiet("entity.frog.step"),
|
||||
S::Goat => quiet("entity.goat.step"),
|
||||
S::Panda => quiet("entity.panda.step"),
|
||||
S::Parrot => quiet("entity.parrot.step"),
|
||||
S::PolarBear => quiet("entity.polar_bear.step"),
|
||||
S::Sheep => quiet("entity.sheep.step"),
|
||||
S::Sniffer => quiet("entity.sniffer.step"),
|
||||
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
|
||||
S::Turtle => quiet("entity.turtle.shamble"),
|
||||
S::Creaking => quiet("entity.creaking.step"),
|
||||
S::Hoglin => quiet("entity.hoglin.step"),
|
||||
S::Zoglin => quiet("entity.zoglin.step"),
|
||||
S::Piglin => quiet("entity.piglin.step"),
|
||||
S::PiglinBrute => quiet("entity.piglin_brute.step"),
|
||||
S::Ravager => quiet("entity.ravager.step"),
|
||||
S::Skeleton => quiet("entity.skeleton.step"),
|
||||
S::Stray => quiet("entity.stray.step"),
|
||||
S::Bogged => quiet("entity.bogged.step"),
|
||||
S::Parched => quiet("entity.parched.step"),
|
||||
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
|
||||
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
|
||||
// SulfurCube.playStepSound: a cube carrying a block in its body makes no
|
||||
// footstep at all; an empty one takes the block's sound at the bottom.
|
||||
S::SulfurCube if crate::mobs::sulfur_cube::has_body(m) => None,
|
||||
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
|
||||
// their own step at 15%, though neither type's movement emission plays
|
||||
// it (`movement_sounds`).
|
||||
S::Silverfish => quiet("entity.silverfish.step"),
|
||||
S::Endermite => quiet("entity.endermite.step"),
|
||||
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
|
||||
S::Husk => quiet("entity.husk.step"),
|
||||
S::Drowned => quiet("entity.drowned.step"),
|
||||
S::ZombieVillager => quiet("entity.zombie_villager.step"),
|
||||
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
|
||||
// powder have their own step.
|
||||
S::CamelHusk | S::Camel => {
|
||||
let sand = w.has_tag(
|
||||
"minecraft:camel_sand_step_sound_blocks",
|
||||
&w.facts.get(state).name,
|
||||
);
|
||||
Some(match (m.species(), sand) {
|
||||
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
|
||||
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
|
||||
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
|
||||
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
|
||||
})
|
||||
}
|
||||
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
|
||||
// CopperGolem.playStepSound: its oxidation level's step.
|
||||
S::CopperGolem => Some((crate::mobs::copper_golem::sound(m, "step"), 1.0, 1.0)),
|
||||
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
|
||||
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
|
||||
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
|
||||
_ => {
|
||||
// Entity.playStepSound: the block's sound type.
|
||||
let _ = pos;
|
||||
let sounds = &w.facts.get(state).sounds;
|
||||
let step: &'static str = intern_sound(&sounds.step);
|
||||
Some((step, sounds.volume * 0.15, sounds.pitch))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A block sound id as a static string (the block facts intern them).
|
||||
pub(crate) fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
|
||||
use std::{collections::HashSet, sync::Mutex};
|
||||
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
|
||||
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let set = names.get_or_insert_with(HashSet::new);
|
||||
let key = s.strip_prefix("minecraft:").unwrap_or(s);
|
||||
if let Some(found) = set.get(key) {
|
||||
return found;
|
||||
}
|
||||
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
|
||||
set.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `SulfurCube.playStepSound`: a cube carrying a block in its body returns
|
||||
/// before the block's sound, so it walks silently, while an empty cube takes
|
||||
/// the block's step at 15% like any other mob.
|
||||
#[test]
|
||||
fn a_loaded_sulfur_cube_walks_silently() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
for x in 0..=4 {
|
||||
for z in 0..=4 {
|
||||
world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
},
|
||||
|w, types| {
|
||||
let stepped = |body: Option<&str>| {
|
||||
let kind = Arc::new(types.types["minecraft:sulfur_cube"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.body = body.map(Into::into);
|
||||
let state = w.world.block(0, 63, 0);
|
||||
step_sound(&m, w, [0, 63, 0], state)
|
||||
};
|
||||
assert_eq!(stepped(Some("minecraft:stone")), None, "loaded");
|
||||
let empty = stepped(None);
|
||||
assert!(empty.is_some(), "an empty cube takes the block's step");
|
||||
assert_eq!(empty.unwrap().1, 0.15, "the block's 15%");
|
||||
},
|
||||
);
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `LivingEntity.updateFallFlying` for a mob: the glide's checks and its gliders' wear.
|
||||
use super::*;
|
||||
|
||||
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
|
||||
/// the glide flag is set, with or without AI: a mob that cannot glide
|
||||
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
|
||||
/// glider whose next damage will not break it, `canGlideUsing`) stops
|
||||
/// gliding; else on every 20th glide tick one of its gliders, picked with
|
||||
/// `Util.getRandom` from the mob's own random, takes a point of wear.
|
||||
/// It starts with `checkFallDistanceAccumulation`: a glide that is not
|
||||
/// diving keeps at most 1 of its fall. The `ELYTRA_GLIDE` game event of
|
||||
/// every 10th tick is not ported here.
|
||||
pub(super) fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
if !m.extra.fall_flying {
|
||||
return;
|
||||
}
|
||||
crate::mobs::leash::check_fall_distance(m);
|
||||
let gliders: Vec<EquipmentSlot> = match w.items {
|
||||
Some(items) => EquipmentSlot::ALL
|
||||
.into_iter()
|
||||
.filter(|&slot| {
|
||||
crate::mobs::finalize::equipment_stack(m, slot).is_some_and(|stack| {
|
||||
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if m.on_ground
|
||||
|| m.extra.mount.is_some()
|
||||
|| m.has_effect("minecraft:levitation")
|
||||
|| gliders.is_empty()
|
||||
{
|
||||
m.extra.fall_flying = false;
|
||||
return;
|
||||
}
|
||||
let ticks = m.extra.fall_fly_ticks + 1;
|
||||
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
|
||||
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
|
||||
events.push(MobEvent::GliderWear {
|
||||
mob: m.id,
|
||||
slot: gliders[pick],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
|
||||
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
|
||||
use horizon_world::player_inventory::EquipmentSlot as S;
|
||||
match slot {
|
||||
S::MainHand => "mainhand",
|
||||
S::OffHand => "offhand",
|
||||
S::Feet => "feet",
|
||||
S::Legs => "legs",
|
||||
S::Chest => "chest",
|
||||
S::Head => "head",
|
||||
S::Body => "body",
|
||||
S::Saddle => "saddle",
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,71 @@ pub fn considers_item_equal(held: &ItemStack, candidate: &ItemStack) -> bool {
|
||||
held.item == candidate.item && held.potion() == candidate.potion()
|
||||
}
|
||||
|
||||
/// `Allay.setDancing` marks an allay dancing only under `isEffectiveAi()`, so
|
||||
/// `NoAI` never dances; panicking stops it, and 10 blocks is the jukebox reach
|
||||
/// (`distance_sq` to the nearest playing jukebox's centre, infinite for none).
|
||||
pub fn dances(no_ai: bool, panicking: bool, distance_sq: f64) -> bool {
|
||||
!no_ai && !panicking && distance_sq <= 100.0
|
||||
/// `GameEvent.JUKEBOX_PLAY.notificationRadius()`: the `JukeboxListener`'s radius.
|
||||
const JUKEBOX_LISTENER_RADIUS: i64 = 10;
|
||||
|
||||
/// `EuclideanGameEventListenerRegistry.getPostableListenerPosition` for the
|
||||
/// `JukeboxListener`: its source is the allay's eye (`EntityPositionSource`
|
||||
/// with `getEyeHeight`), and the event is heard when the blocks of the eye and
|
||||
/// of the event are at most 10 apart (`BlockPos.distSqr`).
|
||||
pub fn hears_jukebox(m: &Mob, pos: [f64; 3]) -> bool {
|
||||
let block = |p: [f64; 3]| p.map(|v| i64::from(v.floor() as i32));
|
||||
let (source, listener) = (block(pos), block([m.position.x, m.eye_y(), m.position.z]));
|
||||
let d = [0, 1, 2].map(|i| source[i] - listener[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= JUKEBOX_LISTENER_RADIUS * JUKEBOX_LISTENER_RADIUS
|
||||
}
|
||||
|
||||
/// `Allay.setDancing`: only under `isEffectiveAi()` (so `NoAI` never changes
|
||||
/// it), and a panicking allay does not start.
|
||||
fn set_dancing(m: &mut Mob, dancing: bool) {
|
||||
if m.no_ai || dancing && super::goals::should_panic(m) {
|
||||
return;
|
||||
}
|
||||
m.extra.flag = dancing;
|
||||
}
|
||||
|
||||
/// `Allay.setJukeboxPlaying`, from the `JukeboxListener` (`jukebox_play` and
|
||||
/// `jukebox_stop_play` at `jukebox`): a play starts a dance and keeps that
|
||||
/// jukebox while the allay is not dancing yet; a stop ends it only from the
|
||||
/// kept jukebox, or with none kept.
|
||||
pub fn set_jukebox_playing(m: &mut Mob, jukebox: [i32; 3], playing: bool) {
|
||||
let Some(kept) = m.extra.allay.as_ref().map(|st| st.jukebox) else {
|
||||
return;
|
||||
};
|
||||
if playing {
|
||||
if !m.extra.flag {
|
||||
set_jukebox(m, Some(jukebox));
|
||||
set_dancing(m, true);
|
||||
}
|
||||
} else if kept.is_none() || kept == Some(jukebox) {
|
||||
set_jukebox(m, None);
|
||||
set_dancing(m, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_jukebox(m: &mut Mob, jukebox: Option<[i32; 3]>) {
|
||||
if let Some(st) = m.extra.allay.as_mut() {
|
||||
st.jukebox = jukebox;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Allay.aiStep`'s `isDancing() && shouldStopDancing() && tickCount % 20 == 0`:
|
||||
/// the dance ends when no jukebox is kept, it is not within 10 blocks of the
|
||||
/// allay (`closerToCenterThan` its centre) or is no jukebox any more.
|
||||
fn stop_dancing_away(m: &mut Mob, w: &MobWorld) {
|
||||
if !m.extra.flag || !m.age.is_multiple_of(20) {
|
||||
return;
|
||||
}
|
||||
let kept = m.extra.allay.as_ref().and_then(|st| st.jukebox);
|
||||
let stays = kept.is_some_and(|[x, y, z]| {
|
||||
let centre = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5);
|
||||
centre.distance_squared(m.position) < f64::from(JUKEBOX_LISTENER_RADIUS as i32).powi(2)
|
||||
&& &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:jukebox"
|
||||
});
|
||||
if !stays {
|
||||
set_dancing(m, false);
|
||||
set_jukebox(m, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.getPickupReach` (1, 1, 1) around the box: `Mob.aiStep`'s looting loop
|
||||
@@ -103,6 +163,8 @@ pub struct State {
|
||||
/// None for a memory that never expires. The memory itself is
|
||||
/// `Extra::tame` and `Extra::owner`.
|
||||
pub liked_player_ttl: Option<i64>,
|
||||
/// `Allay.jukeboxPos`: the jukebox the dance started from. Not saved.
|
||||
pub jukebox: Option<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// The pocket is independent of the main-hand filter.
|
||||
@@ -410,6 +472,10 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if let Some(info) = tick.received {
|
||||
receive_vibration(m, dimension(w), info);
|
||||
}
|
||||
// Allay.tick, after the vibration ticker.
|
||||
if super::goals::should_panic(m) {
|
||||
set_dancing(m, false);
|
||||
}
|
||||
}
|
||||
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
|
||||
/// the one the move control set this tick, and no gravity applies, so an
|
||||
@@ -419,10 +485,12 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
|
||||
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
|
||||
true
|
||||
}
|
||||
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
|
||||
pub fn ai_step(m: &mut Mob) {
|
||||
/// `Allay.aiStep` / `tick`: healing, the dance's jukebox check, cooldowns, the
|
||||
/// hold and spin clocks.
|
||||
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
|
||||
tick_liked_player(m);
|
||||
passive_heal(m);
|
||||
stop_dancing_away(m, w);
|
||||
for i in [2, 3] {
|
||||
if m.extra.ints[i] > 0 {
|
||||
m.extra.ints[i] -= 1;
|
||||
@@ -614,14 +682,55 @@ mod allay_parity_tests {
|
||||
|
||||
#[test]
|
||||
fn a_no_ai_allay_does_not_dance_to_a_playing_jukebox() {
|
||||
// Beside a playing jukebox and at the edge of its 10-block reach.
|
||||
for distance_sq in [0.0, 1.5 * 1.5, 99.0, 100.0] {
|
||||
assert!(!dances(true, false, distance_sq), "NoAI at {distance_sq}");
|
||||
assert!(dances(false, false, distance_sq), "AI at {distance_sq}");
|
||||
}
|
||||
// Out of reach, or panicking: no allay dances.
|
||||
assert!(!dances(false, false, 100.5));
|
||||
assert!(!dances(false, true, 1.0));
|
||||
let mut mob = test_allay();
|
||||
mob.no_ai = true;
|
||||
set_jukebox_playing(&mut mob, [0, 64, 0], true);
|
||||
assert!(!mob.extra.flag, "NoAI");
|
||||
mob.no_ai = false;
|
||||
set_jukebox_playing(&mut mob, [0, 64, 0], true);
|
||||
assert!(mob.extra.flag, "AI");
|
||||
// A burning allay panics: it does not start dancing.
|
||||
let mut burning = test_allay();
|
||||
burning.fire_ticks = 20;
|
||||
set_jukebox_playing(&mut burning, [0, 64, 0], true);
|
||||
assert!(!burning.extra.flag);
|
||||
}
|
||||
|
||||
fn test_allay() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let kind_mut = std::sync::Arc::make_mut(&mut kind);
|
||||
kind_mut.species = crate::mobs::Species::Allay;
|
||||
kind_mut.id = "minecraft:allay".into();
|
||||
Mob::new(37, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
// Added after the baseline: the kept jukebox and the listener's reach.
|
||||
#[test]
|
||||
fn only_the_kept_jukebox_stops_the_dance() {
|
||||
let mut mob = test_allay();
|
||||
set_jukebox_playing(&mut mob, [3, 64, 0], true);
|
||||
// Another jukebox plays: the first one stays the dance's.
|
||||
set_jukebox_playing(&mut mob, [-4, 64, 0], true);
|
||||
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, Some([3, 64, 0]));
|
||||
set_jukebox_playing(&mut mob, [-4, 64, 0], false);
|
||||
assert!(mob.extra.flag, "a stop elsewhere does not end it");
|
||||
set_jukebox_playing(&mut mob, [3, 64, 0], false);
|
||||
assert!(!mob.extra.flag);
|
||||
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, None);
|
||||
// With none kept, any stop ends a dance.
|
||||
mob.extra.flag = true;
|
||||
set_jukebox_playing(&mut mob, [9, 64, 9], false);
|
||||
assert!(!mob.extra.flag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_jukebox_listener_hears_ten_blocks_from_the_eye_block() {
|
||||
let mob = test_allay();
|
||||
let eye = mob.eye_y().floor();
|
||||
// Block distance squared from the eye's block: 100 is heard, 101 not.
|
||||
assert!(hears_jukebox(&mob, [10.5, eye, 0.5]));
|
||||
assert!(!hears_jukebox(&mob, [10.5, eye + 1.0, 0.5]));
|
||||
assert!(hears_jukebox(&mob, [6.5, eye + 8.0, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -730,6 +730,21 @@ impl Goal for SquidFlee {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
|
||||
/// an axolotl with a custom name is never removed for distance, and one let out
|
||||
/// of a bucket is persistent before it (`isPersistenceRequired`).
|
||||
pub fn named_keeps_it(m: &Mob) -> bool {
|
||||
m.custom_name.is_some()
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Tadpole
|
||||
| Species::Axolotl
|
||||
)
|
||||
}
|
||||
/// `AbstractSchoolingFish`: cod, salmon and tropical fish.
|
||||
fn schooling(s: Species) -> bool {
|
||||
matches!(s, Species::Cod | Species::Salmon | Species::TropicalFish)
|
||||
|
||||
@@ -12,10 +12,7 @@ use super::{
|
||||
goal_clone,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
pub const IDLE: u8 = 0;
|
||||
pub const ROLLING: u8 = 1;
|
||||
@@ -86,18 +83,20 @@ struct BabyFollowAdult {
|
||||
const FOLLOW_MIN: f64 = 5.0;
|
||||
const FOLLOW_MAX: f64 = 16.0;
|
||||
const FOLLOW_SPEED: f64 = 1.25;
|
||||
/// The armadillo's `FOLLOW_RANGE` attribute, the sensor's reach.
|
||||
const FOLLOW_RANGE: f64 = 16.0;
|
||||
impl BabyFollowAdult {
|
||||
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`.
|
||||
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`, of the living
|
||||
/// entities `NearestLivingEntitySensor.doTick` finds: those whose boxes meet the baby's box
|
||||
/// inflated by its `FOLLOW_RANGE` (16, `Mob.createMobAttributes`).
|
||||
fn nearest_adult(m: &mut Mob, cx: &mut Cx) -> Option<u32> {
|
||||
let species = m.species();
|
||||
let p = m.position;
|
||||
let area = Aabb::new(
|
||||
Vec3::new(p.x - FOLLOW_MAX, p.y - FOLLOW_MAX, p.z - FOLLOW_MAX),
|
||||
Vec3::new(p.x + FOLLOW_MAX, p.y + FOLLOW_MAX, p.z + FOLLOW_MAX),
|
||||
);
|
||||
let area = m
|
||||
.bounds()
|
||||
.inflate(Vec3::new(FOLLOW_RANGE, FOLLOW_RANGE, FOLLOW_RANGE));
|
||||
let ids: Vec<(u32, f64)> =
|
||||
cx.w.others_near(area)
|
||||
.filter(|s| s.alive && !s.baby && s.species == species)
|
||||
.filter(|s| s.alive && !s.baby && s.species == species && s.bounds.intersects(area))
|
||||
.map(|s| (s.id, m.distance_sqr(s.position)))
|
||||
.collect();
|
||||
let mut best: Option<(u32, f64)> = None;
|
||||
@@ -780,6 +779,29 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_follows_only_adults_its_sensor_box_meets() {
|
||||
// NearestLivingEntitySensor.doTick: the entities whose boxes meet the baby's box inflated
|
||||
// by FOLLOW_RANGE (16). Both adults are within BabyFollowAdult's 17 blocks. The baby's box
|
||||
// (0.35 to 0.7 wide, by its age scale) inflated reaches x = 16.675 to 16.85: the adult at
|
||||
// x = 17.0 (0.7 wide, its box from 16.65) is found, the one at x = 17.2 (from 16.85) is
|
||||
// not, though a scan of every mob returns it.
|
||||
for (x, follows) in [(17.0, true), (17.2, false)] {
|
||||
let Some([mut baby, adult]) = real([[0.5, 64.0, 0.5], [x, 64.0, 0.5]]) else {
|
||||
return;
|
||||
};
|
||||
baby.extra.age = -24000;
|
||||
crate::mobs::world::with_test_world(&[adult.seen(None)], |w, _| {
|
||||
run_goals(&mut baby, w, 1);
|
||||
assert_eq!(
|
||||
baby.ai.goals.is_running("BabyFollowAdult"),
|
||||
follows,
|
||||
"x = {x}"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_stays_when_the_adult_is_within_five_blocks() {
|
||||
// BabyFollowAdult: `!closerThan(adult, 5)`.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use super::*;
|
||||
|
||||
/// A cow in love at (0.5, 64, 0.5) and single in-love cows at `mates`, on
|
||||
/// a flat grass shelf, one tick of its goals: whether its `Breed` goal
|
||||
/// took a mate, and the block it walks to. `None` without the vanilla
|
||||
/// data.
|
||||
fn courtship(mates: &[[f64; 3]]) -> Option<(bool, Option<[i32; 3]>)> {
|
||||
// A mate's `Seen` needs its kind, so two loads.
|
||||
let mut kind = None;
|
||||
crate::mobs::world::with_built_test_world(&[], shelf, |_, types| {
|
||||
kind = Some(Arc::new(types.types["minecraft:cow"].clone()));
|
||||
});
|
||||
let kind = kind?;
|
||||
let seen: Vec<Seen> = mates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, [x, y, z])| {
|
||||
let mut p = Mob::new(i as u32 + 2, kind.clone(), Vec3::new(*x, *y, *z), 0.0);
|
||||
p.extra.in_love = 600;
|
||||
p.seen(None)
|
||||
})
|
||||
.collect();
|
||||
let mut out = None;
|
||||
crate::mobs::world::with_built_test_world(&seen, shelf, |w, _| {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.in_love = 600;
|
||||
m.on_ground = true;
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
m.ai.goals = goals;
|
||||
out = Some((m.ai.goals.is_running("Breed"), m.ai.nav.target_pos));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A flat grass shelf the cows stand and walk on.
|
||||
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
for cx in -3..3 {
|
||||
for cz in -3..3 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -16..=16 {
|
||||
for z in -16..=16 {
|
||||
world.set(x, 63, z, grass).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BreedGoal.getFreePartner` searches the box `getBoundingBox().inflate(8.0)`
|
||||
/// but keeps only what `PARTNER_TARGETING` (`forNonCombat().range(8.0)`)
|
||||
/// passes, and `TargetingConditions.test` rejects a target whose
|
||||
/// `distanceToSqr` is above 8 squared.
|
||||
#[test]
|
||||
fn a_mate_outside_the_eight_block_sphere_is_ignored() {
|
||||
// 7,0,5 apart: squared 74, inside the box, outside the sphere.
|
||||
let Some((running, target)) = courtship(&[[7.5, 64.0, 5.5]]) else {
|
||||
return;
|
||||
};
|
||||
assert!(!running, "a mate 8.6 blocks away is out of range");
|
||||
assert_eq!(target, None, "it does not walk to it");
|
||||
// Just outside: 8 on x and 0.6 on z, squared 64.36.
|
||||
let Some((running, target)) = courtship(&[[8.5, 64.0, 1.1]]) else {
|
||||
return;
|
||||
};
|
||||
assert!(!running, "a mate just past eight blocks is out of range");
|
||||
assert_eq!(target, None, "it does not walk to it");
|
||||
}
|
||||
|
||||
/// The other half of the same predicate: the sphere keeps a diagonal mate
|
||||
/// and one exactly eight blocks away, and the nearest of two still wins.
|
||||
#[test]
|
||||
fn a_mate_inside_the_eight_block_sphere_is_courted() {
|
||||
// 5,0,5 apart: squared 50.
|
||||
let Some((running, target)) = courtship(&[[5.5, 64.0, 5.5]]) else {
|
||||
return;
|
||||
};
|
||||
assert!(running, "a diagonal mate 7.1 blocks away is in range");
|
||||
let [x, _, z] = target.expect("it walks toward its mate");
|
||||
assert!((x, z) == (5, 5), "it heads for its mate, got {x} {z}");
|
||||
// Exactly eight: squared 64 is not above the limit.
|
||||
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5]]) else {
|
||||
return;
|
||||
};
|
||||
assert!(running, "a mate exactly eight blocks away is in range");
|
||||
let [x, _, z] = target.expect("it walks toward its mate");
|
||||
assert!((x, z) == (8, 0), "it heads for its mate, got {x} {z}");
|
||||
// Two mates: the nearer (5,5) wins over the one at exactly eight.
|
||||
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5], [5.5, 64.0, 5.5]]) else {
|
||||
return;
|
||||
};
|
||||
assert!(running);
|
||||
let [x, _, z] = target.expect("it walks toward its mate");
|
||||
assert!((x, z) == (5, 5), "the nearest mate, got {x} {z}");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use super::*;
|
||||
|
||||
/// Two llamas in love, two blocks apart on a flat grass shelf, ticked by
|
||||
/// the AI until the first breeds: its own events, with the `mob_drops`
|
||||
/// game rule at `drops`. `None` without the vanilla data.
|
||||
fn llama_events(drops: bool) -> Option<Vec<MobEvent>> {
|
||||
// The partner's `Seen` needs its kind, so two loads.
|
||||
let mut kind = None;
|
||||
crate::mobs::world::with_built_test_world_rules(&[], true, drops, shelf, |_, types| {
|
||||
kind = Some(Arc::new(types.types["minecraft:llama"].clone()));
|
||||
});
|
||||
let kind = kind?;
|
||||
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
|
||||
// AbstractHorse.canParent: only tame llamas breed.
|
||||
partner.extra.tame = true;
|
||||
partner.extra.in_love = 600;
|
||||
let seen = [partner.seen(None)];
|
||||
let mut out = None;
|
||||
crate::mobs::world::with_built_test_world_rules(&seen, true, drops, shelf, |w, _| {
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.in_love = 600;
|
||||
m.extra.tame = true;
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..600 {
|
||||
crate::mobs::ai::tick(&mut m, w, &mut lr, &mut events);
|
||||
if events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. }))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
out = Some(events);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A flat grass shelf two llamas can stand on.
|
||||
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
for cx in -3..3 {
|
||||
for cz in -3..3 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -8..=8 {
|
||||
for z in -8..=8 {
|
||||
world.set(x, 63, z, grass).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `finalizeSpawnChildFromBreeding` makes the experience draw only inside
|
||||
/// the `mob_drops` check, from the breeding parent's own random: the
|
||||
/// breed goal pushes one 1-7 orb with the rule on, none with it off, and
|
||||
/// the pair breeds either way.
|
||||
#[test]
|
||||
fn the_breed_xp_follows_the_mob_drops_rule() {
|
||||
let Some(on) = llama_events(true) else {
|
||||
return;
|
||||
};
|
||||
let Some(off) = llama_events(false) else {
|
||||
return;
|
||||
};
|
||||
let orb = |events: &[MobEvent]| {
|
||||
events.iter().find_map(|e| match e {
|
||||
MobEvent::Experience { amount, .. } => Some(*amount),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
orb(&on).is_some_and(|a| (1..=7).contains(&a)),
|
||||
"mob_drops on: one orb of 1-7, got {:?}",
|
||||
orb(&on)
|
||||
);
|
||||
assert!(orb(&off).is_none(), "mob_drops off: no experience draw");
|
||||
assert!(
|
||||
on.iter()
|
||||
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
|
||||
"mob_drops on: the pair breeds"
|
||||
);
|
||||
assert!(
|
||||
off.iter()
|
||||
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
|
||||
"mob_drops off: the pair still breeds"
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `cat_collar` to `cat`).
|
||||
//!
|
||||
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
|
||||
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
|
||||
//! or old mob textures ([`LEGACY`]) every model stays vanilla's, and a file no layer uses isn't parsed.
|
||||
//!
|
||||
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
|
||||
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
|
||||
@@ -34,7 +34,7 @@ pub struct CemModels {
|
||||
layers: HashMap<String, CemLayer>,
|
||||
}
|
||||
|
||||
/// A model layer as a `.jem` reshapes it.
|
||||
/// A model layer as a `.jem` reshapes it, or as a pack's pre-1.21.5 mob texture needs it.
|
||||
pub struct CemLayer {
|
||||
pub model: Arc<Part>,
|
||||
/// The `.jem`'s animations, run after the vanilla animation.
|
||||
@@ -45,19 +45,34 @@ pub struct CemLayer {
|
||||
/// The `attachments` the pack's bones declare, by the bone's `cem#<n>` name: the item
|
||||
/// anchors a renderer looks up (`right_handheld_item`, `left_handheld_item`, `fox_item`).
|
||||
pub anchors: Vec<(String, String, [f32; 3])>,
|
||||
/// The packs' pre-1.21.5 textures this layer paints with, by the vanilla texture they
|
||||
/// stand for without its climate ([`legacy_stem`]): `entity/cow/mooshroom_red`
|
||||
/// is drawn as `entity/cow/red_mooshroom`.
|
||||
legacy: Vec<(&'static str, &'static str)>,
|
||||
}
|
||||
|
||||
impl CemLayer {
|
||||
/// What this layer draws in place of the vanilla `texture`: the `.jem`'s own, or the old
|
||||
/// name of a texture 1.21.5 renamed when the packs paint this layer with it.
|
||||
pub fn texture_for(&self, texture: &str) -> Option<&str> {
|
||||
self.texture.as_deref().or_else(|| {
|
||||
let stem = legacy_stem(texture);
|
||||
self.legacy
|
||||
.iter()
|
||||
.find(|(new, _)| *new == stem)
|
||||
.map(|(_, old)| *old)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
|
||||
const FALLBACK_STEPS: usize = 3;
|
||||
|
||||
impl CemModels {
|
||||
/// The `.jem` files under `root` applied to the layers of `types`.
|
||||
/// The `.jem` files under `root` applied to the layers of `types`, and the layers the
|
||||
/// packs' pre-1.21.5 mob textures paint ([`LEGACY`]).
|
||||
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
|
||||
let files = jem_files(root);
|
||||
if files.is_empty() {
|
||||
set_rule_species(Vec::new());
|
||||
return Self::default();
|
||||
}
|
||||
let (models, standing) = Self::from_files(root, &files, &vanilla_layers(types));
|
||||
set_rule_species(standing);
|
||||
models
|
||||
@@ -104,6 +119,8 @@ impl CemModels {
|
||||
// Each file parsed once, when a layer first tries it.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut layers = HashMap::new();
|
||||
// The layers that took their own file (`pig.jem` for `pig#main`), for [`LEGACY`].
|
||||
let mut own = HashMap::new();
|
||||
// The species whose rules read the standing block, for the per-tick gate.
|
||||
let mut standing_species = Vec::new();
|
||||
for (key, mut candidates) in plan {
|
||||
@@ -137,9 +154,20 @@ impl CemModels {
|
||||
// only: the layer keeps its own texture.
|
||||
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
|
||||
layer.texture = None;
|
||||
} else if key == layer_key(name) {
|
||||
own.insert(key.clone(), (name, path));
|
||||
}
|
||||
layers.insert(key, layer);
|
||||
}
|
||||
let own: HashMap<String, (&str, &str, &EntityModel)> = own
|
||||
.into_iter()
|
||||
.filter_map(|(key, (name, path))| {
|
||||
Some((key, (name, path, parsed.get(path)?.as_ref()?)))
|
||||
})
|
||||
.collect();
|
||||
for family in LEGACY {
|
||||
family.layers(root, vanilla, &own, &mut layers);
|
||||
}
|
||||
(Self { layers }, standing_species)
|
||||
}
|
||||
|
||||
@@ -604,13 +632,10 @@ impl CemLayer {
|
||||
// beside the pack's own.
|
||||
hide_unnamed(node, &named);
|
||||
}
|
||||
// A replacing entry stands in for the vanilla part; an attached one is its own.
|
||||
let this = if p.attach {
|
||||
bone.name.clone()
|
||||
} else {
|
||||
target.clone()
|
||||
};
|
||||
entries.push((this, target, p.animations.as_slice()));
|
||||
// `this` is the entry's own bone, replacing or attached ("current custom model",
|
||||
// `cem_animation.txt`); `part` the vanilla part it hangs on. Conquest's pig writes
|
||||
// `this.tx` = its `translate`: on the vanilla head that flung the head 7 px off.
|
||||
entries.push((bone.name.clone(), target, p.animations.as_slice()));
|
||||
node.children.push(bone);
|
||||
}
|
||||
let names = super::cem_anim::Names {
|
||||
@@ -624,10 +649,137 @@ impl CemLayer {
|
||||
animation,
|
||||
texture: jem.texture.as_deref().and_then(atlas_name),
|
||||
anchors,
|
||||
legacy: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob 1.21.5 split by climate or renamed the texture of: a pack made before paints the
|
||||
/// adult and its climate variants with its one old texture (`entity/pig/pig`, 64x32), whose
|
||||
/// UVs the adult's model measures.
|
||||
struct Legacy {
|
||||
/// The adult's model; its climate variants', which the pack knew as the adult.
|
||||
model: &'static str,
|
||||
variants: &'static [&'static str],
|
||||
/// The vanilla textures without climate ([`legacy_stem`]), and their old names.
|
||||
textures: &'static [(&'static str, &'static str)],
|
||||
/// How many times taller the new texture is than the old: the texels the old model read
|
||||
/// kept their places.
|
||||
taller: f32,
|
||||
}
|
||||
|
||||
const LEGACY: &[Legacy] = &[
|
||||
Legacy {
|
||||
model: "pig",
|
||||
variants: &["cold_pig"],
|
||||
textures: &[("entity/pig/pig", "entity/pig/pig")],
|
||||
taller: 2.0,
|
||||
},
|
||||
Legacy {
|
||||
model: "cow",
|
||||
variants: &["cold_cow", "warm_cow"],
|
||||
textures: &[("entity/cow/cow", "entity/cow/cow")],
|
||||
taller: 2.0,
|
||||
},
|
||||
Legacy {
|
||||
model: "mooshroom",
|
||||
variants: &[],
|
||||
textures: &[
|
||||
("entity/cow/mooshroom_red", "entity/cow/red_mooshroom"),
|
||||
("entity/cow/mooshroom_brown", "entity/cow/brown_mooshroom"),
|
||||
],
|
||||
taller: 2.0,
|
||||
},
|
||||
Legacy {
|
||||
model: "chicken",
|
||||
variants: &["cold_chicken"],
|
||||
textures: &[("entity/chicken/chicken", "entity/chicken")],
|
||||
taller: 1.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// A vanilla mob texture without its climate: `entity/pig/pig_cold` is `entity/pig/pig`.
|
||||
fn legacy_stem(texture: &str) -> &str {
|
||||
["_temperate", "_warm", "_cold"]
|
||||
.iter()
|
||||
.find_map(|climate| texture.strip_suffix(climate))
|
||||
.unwrap_or(texture)
|
||||
}
|
||||
|
||||
impl Legacy {
|
||||
/// The family's layers (adult and climate variants) when the packs have an old texture of
|
||||
/// it: the adult's vanilla model, reshaped by its own `.jem` when there is one without a
|
||||
/// texture of its own, read in the old layout. Without this a pig drew the 64x64 texture
|
||||
/// through a `.jem`'s 64x32 UVs, and cold pigs and mooshrooms vanilla's textures.
|
||||
fn layers(
|
||||
&self,
|
||||
root: &Path,
|
||||
vanilla: &HashMap<&str, (&Arc<Part>, &str)>,
|
||||
own: &HashMap<String, (&str, &str, &EntityModel)>,
|
||||
layers: &mut HashMap<String, CemLayer>,
|
||||
) {
|
||||
// `root` holds vanilla's files too, which never had the old names.
|
||||
let legacy: Vec<_> = self
|
||||
.textures
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(_, old)| {
|
||||
root.join(format!("assets/minecraft/textures/{old}.png"))
|
||||
.is_file()
|
||||
})
|
||||
.collect();
|
||||
let adult = format!("{}#main", self.model);
|
||||
let Some(&(part, _)) = vanilla.get(adult.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let jem = own.get(&adult);
|
||||
if legacy.is_empty() || jem.is_some_and(|(_, _, jem)| jem.texture.is_some()) {
|
||||
return;
|
||||
}
|
||||
let rules = jem.map(|&(_, path, _)| load_rules(root, path));
|
||||
for model in std::iter::once(self.model).chain(self.variants.iter().copied()) {
|
||||
let key = format!("{model}#main");
|
||||
// A variant with a file of its own was made after 1.21.5.
|
||||
if !vanilla.contains_key(key.as_str()) || key != adult && own.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let tree = (**part).clone();
|
||||
let mut layer = match (jem, &rules) {
|
||||
(Some(&(name, _, jem)), Some(rules)) => CemLayer::build(&tree, jem, name, rules),
|
||||
_ => CemLayer {
|
||||
model: Arc::new(tree),
|
||||
animation: None,
|
||||
texture: None,
|
||||
anchors: Vec::new(),
|
||||
legacy: Vec::new(),
|
||||
},
|
||||
};
|
||||
old_layout(Arc::make_mut(&mut layer.model), self.taller);
|
||||
layer.legacy = legacy.clone();
|
||||
layers.insert(key, layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The vanilla parts of `part` (not a `.jem`'s `cem#` bones) reading a texture `taller` times
|
||||
/// shorter. Faces past its bottom belong to parts newer than it (the 1.21.5 cow's muzzle):
|
||||
/// the model it was painted for had none.
|
||||
fn old_layout(part: &mut Part, taller: f32) {
|
||||
if part.name.starts_with("cem#") {
|
||||
return;
|
||||
}
|
||||
for polygon in &mut part.polygons {
|
||||
for vertex in &mut polygon.vertices {
|
||||
vertex[4] *= taller;
|
||||
}
|
||||
}
|
||||
part.polygons
|
||||
.retain(|p| p.vertices.iter().all(|v| v[4] <= 1.0));
|
||||
for child in &mut part.children {
|
||||
old_layout(child, taller);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
|
||||
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
|
||||
if part.name == name {
|
||||
@@ -880,6 +1032,75 @@ mod tests {
|
||||
assert!(models.layer("sheep#main").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_1_21_5_pig_paints_with_its_old_texture() {
|
||||
// Conquest's pig: the head replaced, the legs left vanilla.
|
||||
let pig = r#"{"textureSize": [64, 32], "models": [{"part": "head", "translate": [5, 9.7, -11],
|
||||
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}]}]}"#;
|
||||
let mut leg = plane("right_hind_leg", vec![]);
|
||||
leg.polygons[0].vertices[2][4] = 0.3125; // v 20 of the 64x64 texture
|
||||
let tree = plane("root", vec![plane("head", vec![]), leg]);
|
||||
let files = [("optifine/cem/pig.jem", pig)];
|
||||
let new = models("legacy-new", &files, &["pig#main"], &tree);
|
||||
assert_eq!(
|
||||
new.layer("pig#main")
|
||||
.unwrap()
|
||||
.texture_for("entity/pig/pig_temperate"),
|
||||
None
|
||||
);
|
||||
let files = [files[0], ("textures/entity/pig/pig.png", "")];
|
||||
let old = models("legacy-old", &files, &["pig#main"], &tree);
|
||||
let layer = old.layer("pig#main").unwrap();
|
||||
assert_eq!(
|
||||
layer.texture_for("entity/pig/pig_warm"),
|
||||
Some("entity/pig/pig")
|
||||
);
|
||||
// The leg's v 20 is 20 of the old texture's 32; the pack's own box keeps its UVs.
|
||||
let leg = get(&layer.model, "right_hind_leg");
|
||||
assert_eq!(leg.polygons[0].vertices[2][4], 0.625);
|
||||
assert_eq!(get(&layer.model, "cem#0").polygons[0].vertices[0][4], 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_1_21_5_pack_paints_variants_with_the_adults_old_texture() {
|
||||
// A pig without a `.jem`, its head with a face past the old texture's bottom.
|
||||
let mut head = plane("head", vec![]);
|
||||
let mut newer = head.polygons[0].clone();
|
||||
newer.vertices[2][4] = 0.578125; // v 37 of 64: past the old 32
|
||||
head.polygons.push(newer);
|
||||
let tree = plane("root", vec![head]);
|
||||
let keys = ["pig#main", "cold_pig#main"];
|
||||
let files = [("textures/entity/pig/pig.png", "")];
|
||||
let pigs = models("legacy-family", &files, &keys, &tree);
|
||||
let cold = pigs.layer("cold_pig#main").unwrap();
|
||||
assert_eq!(
|
||||
cold.texture_for("entity/pig/pig_cold"),
|
||||
Some("entity/pig/pig")
|
||||
);
|
||||
assert_eq!(get(&cold.model, "head").polygons.len(), 1);
|
||||
// A cold pig with a file of its own was made for its own texture.
|
||||
let file = format!(r#"{{"models": [{{"part": "head", {BOX}}}]}}"#);
|
||||
let files = [files[0], ("optifine/cem/cold_pig.jem", file.as_str())];
|
||||
let pigs = models("legacy-own-variant", &files, &keys, &tree);
|
||||
let cold = pigs.layer("cold_pig#main").unwrap();
|
||||
assert_eq!(cold.texture_for("entity/pig/pig_cold"), None);
|
||||
|
||||
// Mooshrooms take each colour's old name.
|
||||
let files = [("textures/entity/cow/red_mooshroom.png", "")];
|
||||
let moosh = models("legacy-mooshroom", &files, &["mooshroom#main"], &tree);
|
||||
let layer = moosh.layer("mooshroom#main").unwrap();
|
||||
let red = layer.texture_for("entity/cow/mooshroom_red");
|
||||
assert_eq!(red, Some("entity/cow/red_mooshroom"));
|
||||
assert_eq!(layer.texture_for("entity/cow/mooshroom_brown"), None);
|
||||
// Without the old names nothing changes.
|
||||
let files = [("textures/entity/pig/pig_temperate.png", "")];
|
||||
assert!(
|
||||
models("legacy-modern", &files, &keys, &tree)
|
||||
.layer("pig#main")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
|
||||
/// A `.jem` with one entry on `part`, attached or replacing it.
|
||||
@@ -1018,6 +1239,24 @@ mod tests {
|
||||
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_is_a_replacing_entrys_own_bone() {
|
||||
// Conquest's pig: the head replaced, posed through `this`.
|
||||
let pig = r#"{"models": [{"part": "head", "translate": [5, 9.7, -11],
|
||||
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}],
|
||||
"animations": [{"this.tx": "7"}]}]}"#;
|
||||
let tree = plane("root", vec![plane("head", vec![])]);
|
||||
let files = [("optifine/cem/pig.jem", pig)];
|
||||
let models = models("this-bone", &files, &["pig#main"], &tree);
|
||||
let layer = models.layer("pig#main").unwrap();
|
||||
let mut poses = super::super::anim::Poses::new(&layer.model);
|
||||
let animation = layer.animation.as_ref().unwrap();
|
||||
animation.run_with(1, 1, |_| {}, &mut poses);
|
||||
// `this.tx` writes the pack's bone; the vanilla head it replaces stays put.
|
||||
assert_eq!(poses.get("cem#0")[0], 7.0);
|
||||
assert_eq!(poses.get("head")[0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_part_is_skipped_and_the_rest_applies() {
|
||||
let file =
|
||||
|
||||
@@ -444,7 +444,13 @@ impl Animation {
|
||||
}
|
||||
|
||||
/// `run` for the entity `id` in the frame `counter`, whose parameters `fill` writes.
|
||||
fn run_with(&self, id: u32, counter: u32, fill: impl FnOnce(&mut [f32]), poses: &mut Poses) {
|
||||
pub(super) fn run_with(
|
||||
&self,
|
||||
id: u32,
|
||||
counter: u32,
|
||||
fill: impl FnOnce(&mut [f32]),
|
||||
poses: &mut Poses,
|
||||
) {
|
||||
let preview = IN_GUI.get();
|
||||
SLOTS.with_borrow_mut(|slots| {
|
||||
slots.clear();
|
||||
|
||||
@@ -338,6 +338,11 @@ impl Mob {
|
||||
}
|
||||
/// LookControl.tick (and SmoothSwimmingLookControl).
|
||||
pub fn look_control_tick(&mut self) {
|
||||
// Phantom.PhantomLookControl.tick is empty: the pitch the move control
|
||||
// sets from the flight stays.
|
||||
if self.species() == super::Species::Phantom {
|
||||
return;
|
||||
}
|
||||
let look = self.ai.look.clone();
|
||||
let fox = self.species() == super::Species::Fox;
|
||||
// Fox.FoxLookControl: no turning in sleep.
|
||||
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user