mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
154
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92dab6cafc | ||
|
|
e11233f2dc | ||
|
|
c89cd3f9e1 | ||
|
|
75a016b1b0 | ||
|
|
691a39cab6 | ||
|
|
3c06105a0e | ||
|
|
726726a1fc | ||
|
|
82da4379bd | ||
|
|
7627e37dba | ||
|
|
cd8eb96cef | ||
|
|
829aec1dba | ||
|
|
82a6e896d1 | ||
|
|
0300ba0a76 | ||
|
|
3a3c182aed | ||
|
|
e76394c2cf | ||
|
|
d9cfca426c | ||
|
|
763d4868d7 | ||
|
|
110a92a1aa | ||
|
|
3a8d647c8c | ||
|
|
3c346ba1a4 | ||
|
|
b41db51bf9 | ||
|
|
498d9bebcf | ||
|
|
1ecc5a9bdc | ||
|
|
d05675d755 | ||
|
|
2568835cc6 | ||
|
|
f0cb1e42c0 | ||
|
|
38174db59b | ||
|
|
093f5efda8 | ||
|
|
b444a6b64f | ||
|
|
e8d192f2ac | ||
|
|
6ad39aa412 | ||
|
|
6342ce83b2 | ||
|
|
f4e5fbdfd0 | ||
|
|
6d5b520591 | ||
|
|
dff332b257 | ||
|
|
c1dc47e41f | ||
|
|
7311870047 | ||
|
|
595421d8ee | ||
|
|
7169886142 | ||
|
|
a8bf4c1474 | ||
|
|
cdcac3463e | ||
|
|
e71c626484 | ||
|
|
194ede9376 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
29ac1e17b9 | ||
|
|
115f5235b9 | ||
|
|
e2afd13715 | ||
|
|
00aba43d81 | ||
|
|
e893fd3eac | ||
|
|
ee986c3e62 | ||
|
|
19aabc08c2 | ||
|
|
ca3649534d | ||
|
|
4881a0862f | ||
|
|
8df100e589 | ||
|
|
a416f1dfcd | ||
|
|
4c52129f7d | ||
|
|
243d765452 | ||
|
|
ff52e244ae | ||
|
|
bb712cca1a | ||
|
|
1498fdecd3 | ||
|
|
8cf1e6b4d6 | ||
|
|
341a92902c | ||
|
|
e859476293 | ||
|
|
89235891ce | ||
|
|
2081a0dd95 | ||
|
|
3f1a50ccb2 | ||
|
|
8534f12db0 | ||
|
|
7e95a1f57f | ||
|
|
e0e4c5e9db | ||
|
|
0c77cd5898 | ||
|
|
4848a9eff5 | ||
|
|
95b0cb05e7 | ||
|
|
723c4e7c2b | ||
|
|
c32f43b4b0 | ||
|
|
75071c8b9b | ||
|
|
641a5859a2 | ||
|
|
b26a050b2b | ||
|
|
7d17f516e5 | ||
|
|
0f0ca381d4 | ||
|
|
e3d24ab1a5 | ||
|
|
63a6a92bef | ||
|
|
8902d94a19 | ||
|
|
ca6140d05e | ||
|
|
cf93d0dd3a | ||
|
|
ac0f7d6030 | ||
|
|
2e9f807458 | ||
|
|
a7f07a08de | ||
|
|
42026ce09f | ||
|
|
0e528f7287 | ||
|
|
8d7ca30ea4 | ||
|
|
e39ec11e59 | ||
|
|
6bb4c0eda9 | ||
|
|
2f06f532e5 | ||
|
|
ac0a2f1275 | ||
|
|
092dee299b | ||
|
|
963782a17b | ||
|
|
7ca4fd68bd | ||
|
|
23ca4a855a | ||
|
|
16172da6b7 | ||
|
|
5720b15eff | ||
|
|
2e754b1297 | ||
|
|
70c8d7089d | ||
|
|
1ffa9ed25f | ||
|
|
4643ea9357 | ||
|
|
547b1d4959 | ||
|
|
e2918de5aa | ||
|
|
e6b0a03270 | ||
|
|
38865e907e | ||
|
|
b7615761ee | ||
|
|
8f198d9cbd | ||
|
|
93e0536aca | ||
|
|
396353c6b1 | ||
|
|
4c28a9b5b9 | ||
|
|
1e789800af | ||
|
|
74e0245c47 | ||
|
|
e5eacec45c | ||
|
|
cf23e5001a | ||
|
|
0e9233b230 | ||
|
|
466201a864 | ||
|
|
ddb4f9df58 | ||
|
|
bd90908a72 | ||
|
|
1b4f056692 | ||
|
|
15d2dd04c4 | ||
|
|
3b03b98836 | ||
|
|
4e4767bbfe | ||
|
|
a2d34c7dbd | ||
|
|
e42acfc7ea | ||
|
|
9fd16efb45 | ||
|
|
a8d445f56c | ||
|
|
d82a6941d1 | ||
|
|
f7772b587f | ||
|
|
d1098c9a91 | ||
|
|
d50d56d837 | ||
|
|
8e2780a8f0 | ||
|
|
1feb0c7b0b | ||
|
|
8a5af429d7 | ||
|
|
2cd4ec9888 | ||
|
|
f0c1ec9d84 | ||
|
|
755b7800c1 | ||
|
|
30149bfb41 | ||
|
|
93ecc8d0c1 | ||
|
|
0031c67d04 | ||
|
|
2726614191 | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
5152376c4b | ||
|
|
8f331a10f5 | ||
|
|
cbba6ead3a | ||
|
|
0f8225e608 | ||
|
|
b2ba0cb395 | ||
|
|
72f748a785 | ||
|
|
9ad798b74b | ||
|
|
41fc640974 |
No files matched your search
@@ -13,4 +13,3 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -198,3 +198,5 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
Generated
+10
@@ -1796,11 +1796,21 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -34,6 +34,7 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
# Horizon governance
|
||||
|
||||
> 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).
|
||||
|
||||
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
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
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** |
|
||||
| **🏛️ 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
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
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.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
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.
|
||||
|
||||
### 🤝 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.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 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.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 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.
|
||||
- **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).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- 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;
|
||||
- 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.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
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:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 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.
|
||||
- **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
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -1441,6 +1441,16 @@ pub enum Rumble {
|
||||
},
|
||||
Totem,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
use crate::input_source::Feedback;
|
||||
match event {
|
||||
Feedback::BlockBroken => Rumble::BlockBroken,
|
||||
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
|
||||
Feedback::Totem => Rumble::Totem,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
|
||||
/// long it lasts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
|
||||
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
|
||||
//! types, never a controller's; the controller code answers.
|
||||
|
||||
/// Something that happened to the player that a device may let them feel (a controller's
|
||||
/// vibration). The device decides how, or whether.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Feedback {
|
||||
/// The player broke a block.
|
||||
BlockBroken,
|
||||
/// An explosion this far away (blocks) with this power.
|
||||
Explosion { distance: f64, radius: f32 },
|
||||
/// A totem of undying saved the player.
|
||||
Totem,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod input_source;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
|
||||
@@ -115,8 +115,7 @@ default = ["audio", "controllers", "horizon-extras"]
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
|
||||
@@ -76,6 +76,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Audio occlusion, attenuation and mixing benchmark.
|
||||
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
|
||||
use horizon_client_render::{
|
||||
audio::Audio,
|
||||
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
|
||||
};
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn main() {
|
||||
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
|
||||
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent"));
|
||||
audio.engine.loaded = true;
|
||||
audio.backend_live_for_bench();
|
||||
audio.record_heard = false;
|
||||
|
||||
// Register a sound event with attenuation distance 16
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Insert 1 second of audio into cache (48000 mono samples)
|
||||
let samples: Vec<f32> = (0..48_000)
|
||||
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
|
||||
.collect();
|
||||
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
|
||||
|
||||
// 1. Benchmark set_listener (called every client render frame)
|
||||
let listener_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for i in 0..listener_iterations {
|
||||
let angle = (i as f32) * 0.01;
|
||||
audio.set_listener(
|
||||
[
|
||||
100.0 + (angle.cos() as f64) * 5.0,
|
||||
64.0,
|
||||
200.0 + (angle.sin() as f64) * 5.0,
|
||||
],
|
||||
angle,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let listener_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
|
||||
listener_elapsed,
|
||||
listener_iterations,
|
||||
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
|
||||
);
|
||||
|
||||
// 1b. Benchmark set_listener stationary (lock elimination early-out)
|
||||
let stationary_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..stationary_iterations {
|
||||
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
|
||||
}
|
||||
let stationary_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
|
||||
stationary_elapsed,
|
||||
stationary_iterations,
|
||||
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
|
||||
);
|
||||
|
||||
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
|
||||
// 64 sounds within attenuation distance (<16 blocks)
|
||||
// 64 sounds outside attenuation distance (>16 blocks)
|
||||
for i in 0..128 {
|
||||
let dist = if i < 64 {
|
||||
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
|
||||
} else {
|
||||
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
|
||||
};
|
||||
let pos = [100.0 + dist, 64.0, 200.0];
|
||||
let instance = SoundInstance::new(
|
||||
"minecraft:bench.loop",
|
||||
crate_sound_source(i),
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_core::random::LegacyRandom::new(i as i64),
|
||||
true, // looping
|
||||
0,
|
||||
horizon_client_render::sound_engine::Attenuation::Linear,
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
audio.play_instance(instance, false);
|
||||
}
|
||||
|
||||
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
|
||||
let mut buffer = vec![0.0f32; 1024 * 2];
|
||||
let mix_iterations = 5_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..mix_iterations {
|
||||
audio.mix_buffer(&mut buffer, 2, 48_000);
|
||||
}
|
||||
let mix_elapsed = start.elapsed();
|
||||
println!(
|
||||
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
|
||||
mix_elapsed,
|
||||
mix_iterations,
|
||||
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
|
||||
);
|
||||
|
||||
// 4. Benchmark SoundEngine::tick with ticking mob sounds
|
||||
let mut mobs = Vec::new();
|
||||
for i in 0..64 {
|
||||
mobs.push(MobSound {
|
||||
id: 2000 + i,
|
||||
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
|
||||
speed: 0.25,
|
||||
baby: false,
|
||||
angry: i % 2 == 0,
|
||||
silent: false,
|
||||
attack_scale: 0.5,
|
||||
sniffer_digging: false,
|
||||
});
|
||||
}
|
||||
|
||||
let tick_ctx = TickContext {
|
||||
player_underwater: false,
|
||||
player_removed: false,
|
||||
vehicle: None,
|
||||
carts: Vec::new(),
|
||||
mobs: mobs.clone(),
|
||||
frozen: false,
|
||||
player_position: [100.0, 64.0, 200.0],
|
||||
player_speed_sqr: 0.1,
|
||||
player_fall_flying: false,
|
||||
ridden: None,
|
||||
camera_position: [100.0, 64.0, 200.0],
|
||||
};
|
||||
|
||||
let tick_iterations = 10_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..tick_iterations {
|
||||
audio.tick(false, &tick_ctx);
|
||||
}
|
||||
let tick_elapsed = start.elapsed();
|
||||
println!(
|
||||
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
|
||||
tick_elapsed,
|
||||
tick_iterations,
|
||||
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
|
||||
);
|
||||
}
|
||||
|
||||
fn crate_sound_source(i: u32) -> usize {
|
||||
match i % 6 {
|
||||
0 => 0, // master
|
||||
1 => 1, // music
|
||||
2 => 2, // records
|
||||
3 => 3, // weather
|
||||
4 => 4, // blocks
|
||||
_ => 5, // hostile
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -382,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -478,6 +478,7 @@ pub struct Audio {
|
||||
pub record_heard: bool,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub listener_pitch: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.gain = volume;
|
||||
c.pitch = pitch;
|
||||
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
|
||||
}
|
||||
}
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
if let Ok(m) = self.mixer.lock() {
|
||||
for &(h, c) in live {
|
||||
if m.channels
|
||||
.get(&c)
|
||||
.is_none_or(|ch| ch.state == State::Stopped)
|
||||
{
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopped.extend(live.iter().map(|&(h, _)| h));
|
||||
}
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
|
||||
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
|
||||
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let length = (right * right + up * up + forward * forward).sqrt();
|
||||
stereo_gains_with_length(right, up, forward, length)
|
||||
}
|
||||
#[inline]
|
||||
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
|
||||
if length <= f32::EPSILON {
|
||||
return stereo_gains(0.0, 0.0, 1.0);
|
||||
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
let elevation = (up / length).clamp(-1.0, 1.0).asin();
|
||||
let u = (up / length).clamp(-1.0, 1.0);
|
||||
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
|
||||
let mut azimuth = right.atan2(forward);
|
||||
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
|
||||
azimuth = (azimuth.abs() * 1.5)
|
||||
.min(std::f32::consts::FRAC_PI_2)
|
||||
.copysign(azimuth);
|
||||
}
|
||||
let side = azimuth.sin() * elevation.cos();
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
let (sin_az, cos_az) = azimuth.sin_cos();
|
||||
let side = sin_az * cos_elev;
|
||||
let front = cos_az * cos_elev * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// and OpenAL's right = forward x up.
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let (yaw_sin, yaw_cos) = yaw.sin_cos();
|
||||
let (pitch_sin, pitch_cos) = pitch.sin_cos();
|
||||
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
|
||||
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
for (id, c) in &mut mixer.channels {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let camera = if !shared {
|
||||
let cam = &mixer.cameras[0];
|
||||
if c.relative || cam.2 == c.world {
|
||||
Some((cam.0, cam.1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1))
|
||||
};
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
let d_sqr = length_sq(d);
|
||||
|
||||
let (gain, [left, right]) = if camera.is_none() {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else if direct {
|
||||
let gain = c.gain.clamp(0.0, 1.0);
|
||||
(gain, [gain, gain])
|
||||
} else {
|
||||
let mut zero = false;
|
||||
if let Some(max) = c.linear {
|
||||
let max_d = max as f64;
|
||||
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
|
||||
zero = true;
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
if zero {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else {
|
||||
let distance = d_sqr.sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
|
||||
} else {
|
||||
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
|
||||
};
|
||||
if shared {
|
||||
let middle = (left + right) * 0.5;
|
||||
left = middle + (left - middle) * 0.5;
|
||||
right = middle + (right - middle) * 0.5;
|
||||
}
|
||||
(gain, [left * gain, right * gain])
|
||||
}
|
||||
};
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
let inaudible = if channels >= 2 {
|
||||
left == 0.0 && right == 0.0
|
||||
} else {
|
||||
gain == 0.0
|
||||
};
|
||||
if inaudible {
|
||||
let buffer_frames = data.len() / channels;
|
||||
// As the loop below: a sound of one frame or none stops, looping or not.
|
||||
if !c.looping || frames <= 1 {
|
||||
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
|
||||
// reaches the end, as in the loop below.
|
||||
let needed = frames as f64 - 1.0 - c.position;
|
||||
if needed <= 0.0 {
|
||||
c.state = State::Stopped;
|
||||
} else if step > 0.0 {
|
||||
let steps_to_stop = needed / step;
|
||||
if (buffer_frames as f64) >= steps_to_stop {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.position += step * (buffer_frames as f64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..buffer_frames {
|
||||
let i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
c.position = 0.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
|
||||
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -1412,6 +1482,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: 1.0,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -1535,8 +1606,12 @@ impl Audio {
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
|
||||
return;
|
||||
}
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
self.listener_pitch = pitch;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
@@ -1576,6 +1651,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: self.master,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -2109,6 +2185,25 @@ impl Audio {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
|
||||
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
|
||||
if let Ok(mut c) = self.backend.cache.lock() {
|
||||
c.insert(
|
||||
key.to_owned(),
|
||||
Some((std::sync::Arc::new(samples), rate, channels)),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn backend_live_for_bench(&mut self) {
|
||||
self.backend.live = true;
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -2613,6 +2708,25 @@ mod tests {
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
/// A sound of one frame or none, heard or not, looping or not, stops at its first
|
||||
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
|
||||
#[test]
|
||||
fn sounds_too_short_to_mix_stop_even_unheard() {
|
||||
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
|
||||
source.gain = 0.0;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
mixer.channels[&1].state,
|
||||
State::Stopped,
|
||||
"{samples:?} {looping}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -569,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn field(
|
||||
&mut self,
|
||||
root: horizon_polytone::expr::Root,
|
||||
field: horizon_polytone::expr::Field,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
if root != horizon_polytone::expr::Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_polytone::expr::Value::Num(match field {
|
||||
F::X => f64::from(self.at[0]),
|
||||
F::Y => f64::from(self.at[1]),
|
||||
F::Z => f64::from(self.at[2]),
|
||||
F::Temperature => f64::from(self.biome.temperature),
|
||||
F::Downfall => f64::from(self.biome.downfall),
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
|
||||
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
@@ -490,9 +494,7 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
|
||||
pub night_vision: f32,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -495,6 +515,7 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -611,6 +632,38 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: crate::pack_palette::Environment,
|
||||
|
||||
@@ -201,6 +201,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
|
||||
pub use screenshot_save::Saved as SavedScreenshot;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -236,6 +237,7 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod screenshot_save;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
@@ -1084,6 +1086,9 @@ pub struct ScreenshotRequest {
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
|
||||
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
|
||||
pub background: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
@@ -1578,6 +1583,9 @@ pub struct Renderer {
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
|
||||
pub screenshot_hdr: Option<std::path::PathBuf>,
|
||||
/// `background` screenshots written since the caller last took them.
|
||||
pub saved_screenshots: Vec<SavedScreenshot>,
|
||||
screenshot_saves: screenshot_save::Saves,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
@@ -5077,6 +5085,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
let screenshot_saves = screenshot_save::Saves::new(&device);
|
||||
Ok(Self {
|
||||
display: None,
|
||||
reading: display,
|
||||
@@ -5348,6 +5357,8 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
saved_screenshots: Vec::new(),
|
||||
screenshot_saves,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -7041,8 +7052,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7975,6 +7988,8 @@ impl Renderer {
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let saved = self.screenshot_saves.finished();
|
||||
self.saved_screenshots.extend(saved);
|
||||
if let Some(request) = self.capture.take() {
|
||||
let result = self.write_capture(&request, frame);
|
||||
self.capture_result = Some(result.map_err(|e| e.to_string()));
|
||||
@@ -8319,9 +8334,7 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -12044,24 +12057,18 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
self.screenshot_hdr = None;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let hdr = hdr_readback
|
||||
.filter(|_| self.hdr.is_some())
|
||||
.map(|b| (hdr_screenshot_path(&request.path), b));
|
||||
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
|
||||
if request.background {
|
||||
self.screenshot_saves.start(shot, buffer);
|
||||
} else {
|
||||
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
|
||||
self.screenshot_hdr = saved.hdr;
|
||||
self.screenshot_result = Some(saved.result);
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The frame's own waits for the submit thread (its queue writes and passes).
|
||||
@@ -12664,96 +12671,22 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
if self.hdr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct Hdr {
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
|
||||
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -166,7 +169,22 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
|
||||
],
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
|
||||
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
|
||||
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
|
||||
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
|
||||
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
|
||||
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a frame's readback holds and where its PNG goes.
|
||||
pub(super) struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// `ScreenshotRequest::size`: the world icon's square.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
|
||||
pub frame: [u32; 2],
|
||||
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
|
||||
pub bgra: bool,
|
||||
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
|
||||
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
}
|
||||
impl Shot {
|
||||
pub fn new(
|
||||
request: &ScreenshotRequest,
|
||||
frame: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
frame,
|
||||
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<[u32; 2], String>,
|
||||
pub hdr: Option<PathBuf>,
|
||||
}
|
||||
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
|
||||
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
|
||||
let (sender, receiver) = channel();
|
||||
map(buffer, &sender);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &sender);
|
||||
}
|
||||
let waited = device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string());
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
|
||||
write(shot, buffer, mapped)
|
||||
}
|
||||
/// Screenshots being written on their thread.
|
||||
pub(super) struct Saves {
|
||||
device: wgpu::Device,
|
||||
sender: Sender<Saved>,
|
||||
receiver: Receiver<Saved>,
|
||||
in_flight: usize,
|
||||
}
|
||||
impl Saves {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let (sender, receiver) = channel();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
sender,
|
||||
receiver,
|
||||
in_flight: 0,
|
||||
}
|
||||
}
|
||||
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
|
||||
/// PNG on a thread of its own once the GPU has finished the frame.
|
||||
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
|
||||
let (mapped, on_mapped) = channel();
|
||||
map(&buffer, &mapped);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &mapped);
|
||||
}
|
||||
let done = self.sender.clone();
|
||||
let path = shot.path.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Screenshot writer".into())
|
||||
.spawn(move || {
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let ready = wait_mapped(&on_mapped, count);
|
||||
let _ = done.send(write(shot, &buffer, ready));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = self.sender.send(Saved {
|
||||
path,
|
||||
result: Err(format!("no thread to write it: {e}")),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.in_flight += 1;
|
||||
}
|
||||
/// The screenshots written since the last call. Polls the device so a finished frame's
|
||||
/// mapping reaches its writer: the render thread never waits for it.
|
||||
pub fn finished(&mut self) -> Vec<Saved> {
|
||||
if self.in_flight == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
let saved: Vec<Saved> = self.receiver.try_iter().collect();
|
||||
self.in_flight -= saved.len();
|
||||
saved
|
||||
}
|
||||
}
|
||||
impl Drop for Saves {
|
||||
/// A screenshot taken right before quitting is still written whole.
|
||||
fn drop(&mut self) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while self.in_flight > 0 && std::time::Instant::now() < deadline {
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
log_saved(&saved);
|
||||
self.in_flight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_saved(saved: &Saved) {
|
||||
match &saved.result {
|
||||
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
|
||||
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
|
||||
}
|
||||
}
|
||||
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
|
||||
let mapped = mapped.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = mapped.send(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
}
|
||||
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
|
||||
/// (a lost device) gives up after a while instead of keeping its thread.
|
||||
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
|
||||
for _ in 0..count {
|
||||
mapped
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "the frame never came back from the GPU".to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
|
||||
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
|
||||
let mut hdr_written = None;
|
||||
let result = mapped.and_then(|()| {
|
||||
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
|
||||
let pixels = read_rows(hdr_buffer, shot.frame);
|
||||
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
hdr_written = Some(hdr_path.clone());
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
let pixels = read_rows(buffer, shot.frame)?;
|
||||
write_png(&shot, &pixels)
|
||||
});
|
||||
Saved {
|
||||
path: shot.path,
|
||||
result,
|
||||
hdr: hdr_written,
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
|
||||
/// row; unmaps it.
|
||||
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
|
||||
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
|
||||
let [frame_w, frame_h] = shot.frame;
|
||||
let mut pixels = Vec::with_capacity(frame.len() * 4);
|
||||
for px in frame {
|
||||
let (r, b) = if shot.bgra {
|
||||
(px[2], px[0])
|
||||
} else {
|
||||
(px[0], px[2])
|
||||
};
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match shot.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, frame_w, frame_h, size),
|
||||
),
|
||||
None => (frame_w, frame_h, pixels),
|
||||
};
|
||||
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
|
||||
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.and_then(|mut w| w.write_image_data(&pixels))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_png(
|
||||
path: &std::path::Path,
|
||||
[width, height]: [u32; 2],
|
||||
frame: &[[u8; 4]],
|
||||
) -> Result<(), String> {
|
||||
let pixels = hdr10_to_rgb16(frame);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
|
||||
.and_then(|encoder| encoder.write_header())
|
||||
.and_then(|mut writer| {
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)
|
||||
});
|
||||
written.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_background_save_writes_the_png_off_the_render_thread() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
let [w, h] = [3u32, 2];
|
||||
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
|
||||
let row = padded_row(w) as usize;
|
||||
let mut bytes = vec![0u8; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
|
||||
}
|
||||
}
|
||||
let source = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
},
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
let request = ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: true,
|
||||
};
|
||||
let mut saves = Saves::new(&device);
|
||||
saves.start(
|
||||
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
|
||||
buffer,
|
||||
);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let saved = loop {
|
||||
if let Some(saved) = saves.finished().pop() {
|
||||
break saved;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "never written");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(saved.path, path);
|
||||
assert_eq!(saved.result, Ok([w, h]));
|
||||
let decoder =
|
||||
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut out = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut out).unwrap();
|
||||
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
|
||||
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -1110,7 +1110,8 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1141,6 +1142,9 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -162,10 +162,38 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) | added::Row::Field { .. } => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -177,7 +205,10 @@ impl OptionsUi {
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -461,4 +492,84 @@ mod tests {
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ impl PackFormats for Formats {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
|
||||
@@ -15,6 +15,12 @@ pub trait PackFormats: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
|
||||
@@ -59,6 +59,24 @@ pub struct PackColors {
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
@@ -72,6 +90,7 @@ impl Default for PackColors {
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +801,26 @@ impl Host for Ctx<'_, '_> {
|
||||
_ => Err(format!("{name} is not available here")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A particle's own fields, as `particle_method` answers them.
|
||||
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
let Subject::Particle(p) = &self.subject else {
|
||||
return None;
|
||||
};
|
||||
if root != Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(V::Num(match field {
|
||||
F::Age => f64::from(p.age),
|
||||
F::Life => f64::from(p.lifetime),
|
||||
F::Custom => *self.custom,
|
||||
F::X => p.position[0],
|
||||
F::Y => p.position[1],
|
||||
F::Z => p.position[2],
|
||||
F::Temperature | F::Downfall => return None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
|
||||
|
||||
@@ -337,6 +337,13 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
|
||||
@@ -913,6 +913,20 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Update volume, pitch, and position in a single backend call.
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
self.set_volume(channel, volume);
|
||||
self.set_pitch(channel, pitch);
|
||||
self.set_position(channel, position);
|
||||
}
|
||||
/// Filter which handles have stopped playing in a single pass.
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
for &(h, c) in live {
|
||||
if self.stopped(c) {
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
@@ -1494,13 +1508,14 @@ impl SoundEngine {
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
|
||||
);
|
||||
for (h, c) in live.drain(..) {
|
||||
if backend.stopped(c) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
let mut stopped = std::mem::take(&mut self.mapped_scratch);
|
||||
stopped.clear();
|
||||
backend.filter_stopped(&live, &mut stopped);
|
||||
for h in stopped.drain(..) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
// Released handles nobody maps any more.
|
||||
let mut mapped = std::mem::take(&mut self.mapped_scratch);
|
||||
let mut mapped = stopped;
|
||||
mapped.clear();
|
||||
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
|
||||
self.handles
|
||||
@@ -1550,9 +1565,7 @@ impl SoundEngine {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
backend.set_pitch(c, pitch);
|
||||
backend.set_position(c, position);
|
||||
backend.update_channel(c, volume, pitch, position);
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
if instance.looping
|
||||
|
||||
@@ -1251,6 +1251,9 @@ pub struct EditBox {
|
||||
/// `getMessage`: what the narrator calls the box ("<message> edit box:
|
||||
/// <value>").
|
||||
pub message: String,
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
|
||||
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
|
||||
pub invert_highlighted_text_color: bool,
|
||||
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
|
||||
/// round the frame, not over the text.
|
||||
pub frame: Option<[f32; 4]>,
|
||||
@@ -1543,6 +1546,7 @@ impl EditBox {
|
||||
text_color_uneditable: rgba(112, 112, 112, 255),
|
||||
editable: true,
|
||||
message: String::new(),
|
||||
invert_highlighted_text_color: true,
|
||||
frame: None,
|
||||
}
|
||||
}
|
||||
@@ -1918,7 +1922,11 @@ impl EditBox {
|
||||
let right = self.x + self.width;
|
||||
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
|
||||
let (a, b) = (x0.min(x1), x0.max(x1));
|
||||
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(a, ty - 1.0, b - a, 11.0),
|
||||
self.invert_highlighted_text_color,
|
||||
);
|
||||
}
|
||||
if show_cursor {
|
||||
if insert {
|
||||
@@ -2628,6 +2636,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
|
||||
/// unless the box asks it not to (the anvil's rename box).
|
||||
#[test]
|
||||
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
|
||||
assert!(edit.invert_highlighted_text_color);
|
||||
edit.invert_highlighted_text_color = false;
|
||||
edit.set_value("name");
|
||||
edit.set_cursor(0, false);
|
||||
edit.set_cursor(4, true);
|
||||
assert_eq!(edit.selected_text(), "name");
|
||||
assert!(!edit.invert_highlighted_text_color);
|
||||
}
|
||||
|
||||
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
|
||||
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
|
||||
/// copies without taking the text out. A value and the caret still show,
|
||||
|
||||
@@ -119,10 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
|
||||
@@ -102,8 +102,6 @@ pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
pub mod official_servers;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
@@ -171,6 +169,7 @@ pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::gameplay_guide;
|
||||
pub use horizon_client_input::held;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::input_source;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
|
||||
@@ -23,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -373,6 +373,7 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -402,6 +403,7 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_settings;
|
||||
mod render_tick;
|
||||
mod replay;
|
||||
mod repro;
|
||||
mod resource_load;
|
||||
mod resource_loader;
|
||||
@@ -2882,19 +2884,9 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
|
||||
pub(super) replay: replay::Replay,
|
||||
pub(super) studio: studio::Studio,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5026,7 +5018,7 @@ impl Game<'_> {
|
||||
if canceled {
|
||||
return Ok(false);
|
||||
}
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
|
||||
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
|
||||
let broken = state;
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let state = self.crack_mined_pot(pos, state);
|
||||
@@ -5845,7 +5837,7 @@ impl Game<'_> {
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
self.pad.guide_shown.clear();
|
||||
self.input_hud_begin();
|
||||
let mut batch = HudBatch::new(size);
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
|
||||
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
|
||||
@@ -5942,8 +5934,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
let show_hud = !self.ui.hide_gui && !self.replay.playing();
|
||||
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
|
||||
// defers the subtitles over itself, and keeps them while F1 hides the
|
||||
// rest of the HUD.
|
||||
@@ -6282,23 +6273,7 @@ impl Game<'_> {
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
}
|
||||
self.draw_studio_bars(&mut batch);
|
||||
if show_hud {
|
||||
self.draw_pad_guide(&mut batch, guide_from);
|
||||
}
|
||||
@@ -6521,12 +6496,12 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.flying() {
|
||||
return camera_rotation(
|
||||
perspective::CameraType::FirstPerson,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
|
||||
}
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
@@ -6733,11 +6708,7 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.clock.camera_fraction()
|
||||
};
|
||||
let player_alpha = self.own_player_alpha();
|
||||
self.player_model_geometry(camera, player_alpha, &mut out);
|
||||
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
@@ -7466,7 +7437,6 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9236,7 +9206,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
if self.shows_panorama() || self.studio.flying() {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9414,14 +9384,7 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
self.studio_camera_path(&mut g);
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
@@ -9443,7 +9406,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio_freecam.active {
|
||||
let view = if self.studio.flying() {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9569,7 +9532,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10074,7 +10037,7 @@ impl Game<'_> {
|
||||
)
|
||||
)
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -10462,21 +10425,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
if self.studio_mouse_dragged(b.width) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
@@ -10527,7 +10476,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
if self.screen == Screen::Playing && self.replay.playing() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10804,90 +10753,7 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio_freecam.active || self.studio_replay.is_some())
|
||||
&& button == MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch =
|
||||
HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio_timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
} else if self.studio_rmb_down {
|
||||
self.studio_rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = HudBatch::new(size);
|
||||
let had_open_menu = self.studio_topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio_topbar.handle_mouse_click(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
);
|
||||
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
return Ok(());
|
||||
}
|
||||
if self.studio_timeline.visible {
|
||||
let action = self.studio_timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return Ok(());
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.studio_mouse_input(pressed, button) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11357,24 +11223,7 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
if self.studio_freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
|
||||
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
|
||||
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
fast,
|
||||
elapsed.as_secs_f64().min(0.1),
|
||||
);
|
||||
}
|
||||
self.studio_fly(elapsed);
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
// other screens use the same keys for their own navigation.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -11447,7 +11296,7 @@ impl Game<'_> {
|
||||
self.extras_frame(elapsed);
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.input_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
@@ -11559,17 +11408,18 @@ impl Game<'_> {
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
let saved = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.saved_screenshots))
|
||||
.unwrap_or_default();
|
||||
for saved in saved {
|
||||
self.finish_screenshot(saved);
|
||||
}
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.and_then(|r| r.screenshot_result.take());
|
||||
if let Some(result) = &finished
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
@@ -11582,7 +11432,6 @@ impl Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) =
|
||||
self.renderer.as_mut().and_then(|r| r.capture_result.take())
|
||||
@@ -11591,12 +11440,6 @@ impl Game<'_> {
|
||||
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
|
||||
let _ = reply.send(value.to_string());
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
|
||||
@@ -12157,16 +12000,8 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
replay: Default::default(),
|
||||
studio: Default::default(),
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
@@ -12665,10 +12500,8 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
|
||||
@@ -848,34 +848,20 @@ impl Game<'_> {
|
||||
self.armor_stands[i].last_hit = time;
|
||||
return true;
|
||||
}
|
||||
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
|
||||
let a = self.armor_stands.remove(i);
|
||||
let (width, height) = stand_box(&a);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
|
||||
let stand = ItemStack {
|
||||
item: Arc::from("minecraft:armor_stand"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stand, center, [0.0; 3], 10);
|
||||
for stack in a.equipment.into_iter().flatten() {
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, center, v, 10);
|
||||
// brokenByPlayer: the stand item, with the stand's name, pops at its
|
||||
// block, then brokenByAnything: the loot table and the equipment.
|
||||
let (width, height) = stand_box(&self.armor_stands[i]);
|
||||
let p = self.armor_stands[i].position;
|
||||
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
|
||||
if let Some(name) = &self.armor_stands[i].custom_name {
|
||||
stand.data_mut().custom_name =
|
||||
Some(horizon_client::scoreboard::component_tag(&name.component()));
|
||||
}
|
||||
self.pop_resource(pos, stand);
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.stand_game_event("minecraft:entity_die", at, by);
|
||||
self.world
|
||||
.modified
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
true
|
||||
}
|
||||
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
|
||||
@@ -1202,6 +1188,8 @@ impl Game<'_> {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let a_id = a.id;
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
@@ -1210,8 +1198,6 @@ impl Game<'_> {
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
@@ -42,12 +42,25 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let tickers = self.world.block_entity_tickers();
|
||||
let mut last_chunk = (i32::MIN, i32::MIN);
|
||||
let mut last_chunk_ticking = false;
|
||||
for slot in tickers.iter().copied() {
|
||||
let pos = slot.0;
|
||||
let chunk = (pos.0 >> 4, pos.2 >> 4);
|
||||
let chunk_ticking = if chunk == last_chunk {
|
||||
last_chunk_ticking
|
||||
} else {
|
||||
last_chunk = chunk;
|
||||
last_chunk_ticking = ticking(pos.0, pos.2);
|
||||
last_chunk_ticking
|
||||
};
|
||||
if !chunk_ticking {
|
||||
continue;
|
||||
}
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
continue;
|
||||
@@ -210,8 +223,14 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
|
||||
let has_pending = !self.world.entities_pending.is_empty();
|
||||
let pending = has_pending.then(|| self.world.entities_pending.clone());
|
||||
move |x, z| {
|
||||
levels.block_ticking_at(x, z)
|
||||
&& pending
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
|
||||
}
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
|
||||
@@ -255,8 +255,8 @@ pub struct UiState {
|
||||
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
|
||||
/// set by `hud_layers` before anything draws.
|
||||
pub room: horizon_client::hud_kit::room::Room,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
pub screenshot_message: Option<std::path::PathBuf>,
|
||||
/// F2 screenshots not written yet: each reports in chat once its PNG is.
|
||||
pub screenshot_messages: Vec<std::path::PathBuf>,
|
||||
/// The options screens.
|
||||
pub options: horizon_client::options_ui::OptionsUi,
|
||||
/// The prepared resources before resource packs are layered on.
|
||||
@@ -1982,15 +1982,17 @@ impl Game<'_> {
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
let name = screenshot_name(&dir);
|
||||
// A screenshot still being written has no file yet: its name is taken all the same.
|
||||
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
|
||||
let renderer = self.renderer.as_mut()?;
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
background: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
self.ui.screenshot_messages.push(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
@@ -2014,12 +2016,23 @@ impl Game<'_> {
|
||||
self.take_screenshot();
|
||||
true
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
|
||||
let Some(path) = self.ui.screenshot_message.take() else {
|
||||
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
|
||||
/// icon's (`tick_world_icon`): a failure tries again a second later, as
|
||||
/// `tryTakeScreenshotIfNeeded` does.
|
||||
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
|
||||
let Some(at) = self
|
||||
.ui
|
||||
.screenshot_messages
|
||||
.iter()
|
||||
.position(|p| *p == saved.path)
|
||||
else {
|
||||
if let Err(error) = saved.result {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
return;
|
||||
};
|
||||
let message = match result {
|
||||
let path = self.ui.screenshot_messages.remove(at);
|
||||
let message = match &saved.result {
|
||||
Ok(_) => {
|
||||
log::info!("Saved screenshot to {}", path.display());
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
@@ -2055,7 +2068,7 @@ fn chat_box_text(text: &str) -> String {
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
let mut n = 1;
|
||||
@@ -2065,7 +2078,8 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
} else {
|
||||
format!("{base}_{n}.png")
|
||||
};
|
||||
if !dir.join(&name).exists() {
|
||||
let path = dir.join(&name);
|
||||
if !path.exists() && !pending.contains(&path) {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2107,6 +2121,25 @@ mod tests {
|
||||
assert!(should_discard_draft(ChatExit::Done, true));
|
||||
}
|
||||
|
||||
/// `Screenshot.getFile` picks the first free name of the second; an F2
|
||||
/// screenshot still being written on its thread has no file yet, so a
|
||||
/// second press in the same second takes the next name, not the same one.
|
||||
#[test]
|
||||
fn a_screenshot_being_written_keeps_its_name() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let first = screenshot_name(&dir, &[]);
|
||||
let second = screenshot_name(&dir, &[dir.join(&first)]);
|
||||
assert_ne!(first, second);
|
||||
// Unless the clock turned to the next second in between.
|
||||
let same_second = second.starts_with(first.trim_end_matches(".png"));
|
||||
assert!(
|
||||
!same_second || second == first.replace(".png", "_2.png"),
|
||||
"{second}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
|
||||
@@ -3831,12 +3831,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
|
||||
level, pos, yaw, pitch,
|
||||
));
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
|
||||
@@ -644,7 +644,7 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
@@ -800,7 +800,7 @@ impl Game<'_> {
|
||||
// controller still holding one keeps it.
|
||||
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
|
||||
if !self.pad.holds_action(&self.settings.controller, name) {
|
||||
if !self.input_holds(name) {
|
||||
self.toggles.key(
|
||||
slot,
|
||||
horizon_client::keymapping::KeyAction::Release,
|
||||
@@ -1039,6 +1039,7 @@ impl Game<'_> {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
|
||||
@@ -20,13 +20,17 @@ use horizon_cosmetics::follow::Follow;
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{
|
||||
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
|
||||
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
|
||||
const MARK: Mark = &horizon_cosmetics::mark::MARK;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
@@ -55,7 +59,8 @@ impl Baked {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
client: Option<horizon_cosmetics::Cosmetics>,
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
@@ -65,16 +70,6 @@ pub(super) struct Cosmetics {
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// Companions, trails and auras.
|
||||
fx: RefCell<Fx>,
|
||||
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
|
||||
@@ -405,17 +400,13 @@ fn slot_image(item: &Item) -> Vec<u8> {
|
||||
|
||||
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
|
||||
/// what is worn on the back. A name tag style is never drawn on the body.
|
||||
fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
fn covered(gear: Gear, slot: Slot) -> bool {
|
||||
match slot {
|
||||
Slot::Head => m.extra.head.is_some(),
|
||||
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
|
||||
Slot::Head => gear.head,
|
||||
Slot::Back => gear.back,
|
||||
Slot::Shoulder => false,
|
||||
// A chestplate, not an elytra.
|
||||
Slot::Torso => m
|
||||
.extra
|
||||
.chest
|
||||
.as_deref()
|
||||
.is_some_and(|c| c != "minecraft:elytra"),
|
||||
Slot::Torso => gear.chest,
|
||||
Slot::NameTag | Slot::Emote => true,
|
||||
// On no bone: drawn by a pass of their own, never on the body.
|
||||
Slot::Companion | Slot::Trail | Slot::Aura => true,
|
||||
@@ -427,12 +418,12 @@ fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
/// (`worn` is None), another player's at "name tags only" or hidden, or every item `covered` (a
|
||||
/// helmet over a hat). One max over at most five items, read when the item was parsed.
|
||||
fn body_top(
|
||||
extras: &horizon_client::extras::Extras,
|
||||
others: Others,
|
||||
own: bool,
|
||||
worn: Option<&horizon_cosmetics::Worn>,
|
||||
covered: impl Fn(Slot) -> bool,
|
||||
) -> Option<f32> {
|
||||
if !own && extras.effective_others() != Others::Shown {
|
||||
if !own && others != Others::Shown {
|
||||
return None;
|
||||
}
|
||||
worn?
|
||||
@@ -449,115 +440,424 @@ pub(super) fn enabled() -> bool {
|
||||
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
|
||||
}
|
||||
|
||||
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
|
||||
/// service's client, the emote feed and what the account tells the service.
|
||||
#[derive(Default)]
|
||||
struct Seat {
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
|
||||
local: bool,
|
||||
/// Show my cosmetics on its way to the service.
|
||||
mine: AccountSync,
|
||||
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
|
||||
listed: AccountSync,
|
||||
/// Whether the signed-in account owns a cosmetic.
|
||||
owner: OwnerCheck,
|
||||
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
|
||||
feed: Option<horizon_cosmetics::Feed>,
|
||||
/// My emote on its way to the service.
|
||||
emote_out: EmoteOut,
|
||||
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
|
||||
said: Said,
|
||||
/// Kept between ticks so a tick allocates nothing: the player models in view.
|
||||
models: Vec<PlayerModel>,
|
||||
/// The world is the window's own single-player one (`Frame::solo`).
|
||||
solo: bool,
|
||||
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
|
||||
/// held so the address isn't reused while it is there.
|
||||
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
|
||||
last_sprite: u64,
|
||||
/// Sprites drawing missed, put in the atlas next frame.
|
||||
wanted: Vec<u64>,
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
/// step 5a) and the player's emote with them (step 4).
|
||||
#[derive(Default)]
|
||||
struct Said {
|
||||
mine: bool,
|
||||
listed: bool,
|
||||
/// Other players' cosmetics are shown (not "name tags only", not hidden).
|
||||
others_shown: bool,
|
||||
/// Other players' cosmetics are hidden (not even name tag styles).
|
||||
others_hidden: bool,
|
||||
/// Horizon's mark by Horizon players' names.
|
||||
marks: bool,
|
||||
/// Cosmetics in single player: the signed-in account's on the player's own model.
|
||||
solo: bool,
|
||||
/// My emote pulled the camera back: when it started (the own model's age).
|
||||
pull: Option<f32>,
|
||||
/// My emote while it plays: its id and whether it loops.
|
||||
playing: Option<(String, bool)>,
|
||||
/// A folder's first emote, which mannequins play.
|
||||
first: Option<Arc<Item>>,
|
||||
}
|
||||
|
||||
/// One [`Seat`] per splitscreen player.
|
||||
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
|
||||
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
|
||||
let seat = horizon_hooks::seat();
|
||||
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if all.len() <= seat {
|
||||
all.resize_with(seat + 1, Seat::default);
|
||||
}
|
||||
f(&mut all[seat])
|
||||
}
|
||||
|
||||
/// The cosmetics' hooks, registered when [`enabled`].
|
||||
pub(super) struct Hooks;
|
||||
pub(super) static HOOKS: Hooks = Hooks;
|
||||
|
||||
impl horizon_hooks::Hooks for Hooks {
|
||||
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
|
||||
service(|s| {
|
||||
s.solo = frame.solo;
|
||||
s.frame();
|
||||
s.put_sprites(sprites);
|
||||
});
|
||||
}
|
||||
|
||||
fn tick(&self, models: &mut dyn PlayerModels) {
|
||||
service(|s| s.tick(models));
|
||||
}
|
||||
|
||||
fn name_decoration(
|
||||
&self,
|
||||
model: &PlayerModel,
|
||||
sprites: &dyn Sprites,
|
||||
) -> Option<NameDecoration> {
|
||||
service(|s| s.name_decoration(model, sprites))
|
||||
}
|
||||
|
||||
fn player_mark(&self, uuid: u128) -> Option<Mark> {
|
||||
service(|s| {
|
||||
let client = s.client.as_ref().filter(|_| s.said.marks)?;
|
||||
client.frame().horizon(uuid).then_some(MARK)
|
||||
})
|
||||
}
|
||||
|
||||
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
|
||||
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
|
||||
let start = service(|s| s.said.pull)?;
|
||||
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
|
||||
Some(CameraOverride::new(0.35 + 0.65 * t * t * (3.0 - 2.0 * t)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// Once a frame: starts the service's client on first use, asks whether the account owns a
|
||||
/// cosmetic, and tells the service what the account shows.
|
||||
fn frame(&mut self) {
|
||||
if self.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
self.client = Some(Arc::new(client));
|
||||
self.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
self.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
}
|
||||
}
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if self.local {
|
||||
self.owner.owns = true;
|
||||
return;
|
||||
}
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut self.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
|
||||
{
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
self.mine.settle(None);
|
||||
self.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy = self.owner.asking.is_some()
|
||||
|| self.mine.sending.is_some()
|
||||
|| self.listed.sending.is_some();
|
||||
let said = &self.said;
|
||||
if let Some(done) = self
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
self.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => self.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
self.emote_out.retry = backoff(self.emote_out.retry);
|
||||
self.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = said.playing.as_ref();
|
||||
let looping = playing.is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
|
||||
let o = &mut self.emote_out;
|
||||
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
let wish = wish.map(str::to_owned);
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if self.mine.due(said.mine) {
|
||||
self.mine.start(said.mine, send_mine);
|
||||
} else if self.listed.due(said.listed) {
|
||||
self.listed.start(said.listed, send_listed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
|
||||
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
|
||||
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
|
||||
fn name_decoration(
|
||||
&mut self,
|
||||
m: &PlayerModel,
|
||||
sprites: &dyn Sprites,
|
||||
) -> Option<NameDecoration> {
|
||||
let said = &self.said;
|
||||
let own = m.kind == Kind::Own;
|
||||
let styles = if own { said.mine } else { !said.others_hidden };
|
||||
if !(styles || said.marks) {
|
||||
return None;
|
||||
}
|
||||
let uuid = match m.kind {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
|
||||
horizon_client::auth::current().map(|s| s.uuid)
|
||||
}),
|
||||
Kind::Mannequin if self.local => Some(u128::from(m.id)),
|
||||
Kind::Player => m.uuid,
|
||||
_ => None,
|
||||
}?;
|
||||
let mut frame = self.client.as_ref()?.frame();
|
||||
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
|
||||
let worn = if styles { frame.worn(uuid) } else { None };
|
||||
drop(frame);
|
||||
let others = if said.others_shown {
|
||||
Others::Shown
|
||||
} else {
|
||||
Others::NameTags
|
||||
};
|
||||
let top = body_top(others, own, worn.as_ref(), |slot| covered(m.gear, slot))
|
||||
.filter(|_| !m.invisible);
|
||||
let badge = worn.and_then(|w| self.badge(&w, sprites));
|
||||
Some(NameDecoration::new(mark, badge, top))
|
||||
}
|
||||
|
||||
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
|
||||
/// until the badge is in the atlas (it is put there next frame).
|
||||
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
let next = self.last_sprite + 1;
|
||||
let (id, _) = *self
|
||||
.sprites
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| (next, item.clone()));
|
||||
self.last_sprite = self.last_sprite.max(id);
|
||||
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
|
||||
if !self.wanted.contains(&id) {
|
||||
self.wanted.push(id);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
// The mark sits in the slot's top left corner.
|
||||
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
|
||||
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
|
||||
Some(Badge::new(
|
||||
[u0, v0, u1, v1],
|
||||
u32::from_be_bytes([0, r, g, b]),
|
||||
))
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
|
||||
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
|
||||
for id in self.wanted.drain(..) {
|
||||
let item = self.sprites.values().find(|(i, _)| *i == id);
|
||||
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
|
||||
store.put(id, slot_image(item));
|
||||
}
|
||||
}
|
||||
self.sprites
|
||||
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
fn tick(&mut self, models: &mut dyn PlayerModels) {
|
||||
let mut seen = std::mem::take(&mut self.models);
|
||||
seen.clear();
|
||||
models.for_each(&mut |m| {
|
||||
if m.kind != Kind::Own {
|
||||
seen.push(m);
|
||||
}
|
||||
});
|
||||
self.play_others(models, &mut seen);
|
||||
self.models = seen;
|
||||
}
|
||||
|
||||
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
|
||||
let shown = self.said.others_shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.local && shown {
|
||||
let Some(first) = &self.said.first else {
|
||||
return;
|
||||
};
|
||||
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
|
||||
let age = m.age as f32;
|
||||
let now = emotes::pose_playing(models.body_pose(m.id));
|
||||
if now.is_none_or(|p| p.at(age).is_none()) {
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_model_playing(models, m.id, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in view.iter() {
|
||||
emotes::set_model_playing(models, m.id, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
|
||||
drop(frame);
|
||||
feed.ask(view.iter().filter_map(|m| m.uuid));
|
||||
let now = Instant::now();
|
||||
for m in view.iter() {
|
||||
let Some(uuid) = m.uuid else { continue };
|
||||
let seen = feed.playing(uuid);
|
||||
let age = m.age as f32;
|
||||
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if m.moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !m.moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Once a frame, before drawing: starts the service's client on first use and puts the
|
||||
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
|
||||
/// the emote changes).
|
||||
pub(super) fn cosmetics_say(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let first = if self.cosmetics.local {
|
||||
service(|s| s.said.first.is_none())
|
||||
.then(|| self.owned_emotes().first().cloned())
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let extras = &self.settings.extras;
|
||||
let playing = self.cosmetics.emotes.sending();
|
||||
let pull = self.cosmetics.emotes.pull();
|
||||
service(|s| {
|
||||
let said = &mut s.said;
|
||||
said.mine = extras.cosmetics_mine;
|
||||
said.listed = extras.horizon_listed;
|
||||
said.others_shown = extras.effective_others() == Others::Shown;
|
||||
said.others_hidden = extras.effective_others() == Others::Hidden;
|
||||
said.marks = extras.horizon_marks;
|
||||
said.solo = extras.cosmetics_solo;
|
||||
said.pull = pull;
|
||||
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
|
||||
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
|
||||
}
|
||||
if first.is_some() {
|
||||
said.first = first;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
if c.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
c.client = Some(client);
|
||||
c.local = local;
|
||||
// Others' emotes come from the service only (a local folder has no one playing).
|
||||
if !local {
|
||||
c.feed = Some(horizon_cosmetics::Feed::new(http()));
|
||||
let owns = service(|s| {
|
||||
if c.client.is_none() {
|
||||
c.client = s.client.clone();
|
||||
c.local = s.local;
|
||||
}
|
||||
}
|
||||
s.owner.owns
|
||||
});
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = owns;
|
||||
c.frame.set(c.frame.get().wrapping_add(1));
|
||||
c.fx.get_mut().frame(c.frame.get());
|
||||
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
|
||||
if c.local {
|
||||
c.owner.owns = true;
|
||||
} else {
|
||||
// Whether the account owns a cosmetic: what the last run learnt, then the service.
|
||||
let o = &mut c.owner;
|
||||
if !o.started {
|
||||
o.started = true;
|
||||
o.owns = account()
|
||||
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
|
||||
}
|
||||
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
o.asking = None;
|
||||
match done {
|
||||
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
|
||||
Err(_) => o.retry = backoff(o.retry),
|
||||
}
|
||||
}
|
||||
// One sign-in at a time: Mojang keeps one joined server id per account.
|
||||
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
|
||||
let (tx, rx) = channel();
|
||||
o.asking = Some(rx);
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics account".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(check_owner());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = c.owner.owns;
|
||||
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
|
||||
// Show me as a Horizon player: also said again every hour while on.
|
||||
if !c.local {
|
||||
let every = horizon_cosmetics::account::PRESENCE_EVERY;
|
||||
c.mine.settle(None);
|
||||
c.listed.settle(Some(every));
|
||||
// One sign-in at a time, as for the ownership check above.
|
||||
let busy =
|
||||
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
|
||||
let extras = &self.settings.extras;
|
||||
if let Some(done) = c
|
||||
.emote_out
|
||||
.sending
|
||||
.as_ref()
|
||||
.and_then(|rx| rx.try_recv().ok())
|
||||
{
|
||||
c.emote_out.sending = None;
|
||||
match done {
|
||||
Ok(()) => c.emote_out.retry = None,
|
||||
Err(_) => {
|
||||
c.emote_out.retry = backoff(c.emote_out.retry);
|
||||
c.emote_out.sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = c.emotes.sending();
|
||||
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
|
||||
let o = &mut c.emote_out;
|
||||
let changed = o.sent.as_ref() != Some(&wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
|
||||
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
|
||||
// was sent: the service ends a one-shot by itself, a loop within a minute).
|
||||
let quiet = wish.is_none() && o.sent.is_none();
|
||||
let emote_due = !quiet && waited && (changed || again);
|
||||
// One sign-in at a time, the emote's included.
|
||||
if busy || o.sending.is_some() {
|
||||
} else if emote_due {
|
||||
o.sent = Some(wish.clone());
|
||||
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
|
||||
let (tx, rx) = channel();
|
||||
o.sending = Some(rx);
|
||||
let kept = o.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emote".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if c.mine.due(extras.cosmetics_mine) {
|
||||
c.mine.start(extras.cosmetics_mine, send_mine);
|
||||
} else if c.listed.due(extras.horizon_listed) {
|
||||
c.listed.start(extras.horizon_listed, send_listed);
|
||||
}
|
||||
}
|
||||
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
|
||||
for item in uploads {
|
||||
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
|
||||
@@ -572,88 +872,6 @@ impl Game<'_> {
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
|
||||
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
|
||||
pub(super) fn tick_others_emotes(&mut self) {
|
||||
let shown = self.settings.extras.effective_others() == Others::Shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.cosmetics.local && shown {
|
||||
let Some(first) = self.owned_emotes().first().cloned() else {
|
||||
return;
|
||||
};
|
||||
for m in self.mobs.iter_mut() {
|
||||
let age = m.age as f32;
|
||||
if &*m.kind.id == "minecraft:mannequin"
|
||||
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
|
||||
{
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: first.clone(),
|
||||
// Out of step, so a crowd doesn't move as one.
|
||||
start: age - (m.id % 40) as f32,
|
||||
stop: None,
|
||||
};
|
||||
emotes::set_playing(m, Some(playing));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
|
||||
return;
|
||||
};
|
||||
if !shown {
|
||||
feed.ask([]);
|
||||
for m in self.mobs.iter_mut() {
|
||||
emotes::set_playing(m, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut frame = client.frame();
|
||||
let mut horizon = Vec::new();
|
||||
for m in self.mobs.iter() {
|
||||
if &*m.kind.id == "minecraft:player"
|
||||
&& let Some(uuid) = self.remote_player_uuid(m.id)
|
||||
&& frame.horizon(uuid)
|
||||
{
|
||||
horizon.push((m.id, uuid));
|
||||
}
|
||||
}
|
||||
drop(frame);
|
||||
feed.ask(horizon.iter().map(|(_, u)| *u));
|
||||
let now = Instant::now();
|
||||
for (id, uuid) in horizon {
|
||||
let seen = feed.playing(uuid);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let age = m.age as f32;
|
||||
let moved = {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
dx * dx + dz * dz > 1e-4
|
||||
};
|
||||
let mut next = emotes::playing(m).cloned();
|
||||
match (seen, &mut next) {
|
||||
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
|
||||
if moved && p.stop.is_none() {
|
||||
p.stop = Some(age);
|
||||
}
|
||||
}
|
||||
(Some(seen), slot) if !moved => {
|
||||
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
|
||||
*slot = Some(horizon_cosmetics::Playing {
|
||||
item: seen.item,
|
||||
start: age - ticks,
|
||||
stop: None,
|
||||
});
|
||||
}
|
||||
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
|
||||
_ => {}
|
||||
}
|
||||
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
|
||||
pub(super) fn refresh_owned_emotes(&mut self) {
|
||||
@@ -669,7 +887,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
e.loading = Some(rx);
|
||||
let kept = self.cosmetics.emote_out.kept.clone();
|
||||
let kept = service(|s| s.emote_out.kept.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emotes owned".into())
|
||||
.spawn(move || {
|
||||
@@ -678,81 +896,6 @@ impl Game<'_> {
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
|
||||
pub(super) fn cosmetics_local(&self) -> bool {
|
||||
self.cosmetics.local
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
|
||||
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
|
||||
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
|
||||
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
let mut marks = super::names::Marks::default();
|
||||
let extras = &self.settings.extras;
|
||||
let styles = if own {
|
||||
extras.cosmetics_mine
|
||||
} else {
|
||||
extras.effective_others() != Others::Hidden
|
||||
};
|
||||
if !(styles || extras.horizon_marks) {
|
||||
return marks;
|
||||
}
|
||||
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
|
||||
else {
|
||||
return marks;
|
||||
};
|
||||
let mut frame = client.frame();
|
||||
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
|
||||
let worn = if styles { frame.worn(uuid) } else { None };
|
||||
drop(frame);
|
||||
if !horizon_client::mobs::trader::is_invisible(m) {
|
||||
marks.top = body_top(extras, own, worn.as_ref(), |slot| covered(m, slot));
|
||||
}
|
||||
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
|
||||
marks
|
||||
}
|
||||
|
||||
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
|
||||
pub(super) fn horizon_players(
|
||||
&self,
|
||||
uuids: impl Iterator<Item = u128>,
|
||||
) -> std::collections::HashSet<u128> {
|
||||
match &self.cosmetics.client {
|
||||
Some(c) if self.settings.extras.horizon_marks => {
|
||||
let mut frame = c.frame();
|
||||
uuids.filter(|u| frame.horizon(*u)).collect()
|
||||
}
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
|
||||
/// until the badge is in the atlas.
|
||||
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let entry = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
return None;
|
||||
};
|
||||
if entry.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&entry.location);
|
||||
}
|
||||
// The mark sits in the slot's top left corner.
|
||||
let mut mark = *slot;
|
||||
mark.u1 = mark.u0 + (slot.u1 - slot.u0) * item.texture.width as f32 / 64.0;
|
||||
mark.v1 = mark.v0 + (slot.v1 - slot.v0) * item.texture.height as f32 / 64.0;
|
||||
Some(super::names::TagStyle {
|
||||
mark,
|
||||
line: u32::from_be_bytes([0, r, g, b]),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
|
||||
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
|
||||
let client = self.cosmetics.client.as_ref()?;
|
||||
@@ -844,7 +987,7 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if emoting.is_none() && covered(m, item.slot) {
|
||||
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
@@ -962,7 +1105,7 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::extras::Extras;
|
||||
use std::sync::Mutex;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
|
||||
/// A name tag style with a 1×1 mark.
|
||||
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
|
||||
@@ -1150,13 +1293,18 @@ mod tests {
|
||||
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
|
||||
let top_of = |extras: &Extras, patience| {
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
|
||||
body_top(extras, false, worn.as_ref(), |_| false)
|
||||
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
|
||||
};
|
||||
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
|
||||
let (worn, _) = other_player(&shown, long);
|
||||
assert!(worn.is_some());
|
||||
assert_eq!(
|
||||
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
|
||||
tag(body_top(
|
||||
shown.effective_others(),
|
||||
false,
|
||||
worn.as_ref(),
|
||||
|_| false
|
||||
)),
|
||||
VANILLA
|
||||
);
|
||||
// A small hat under the tag: vanilla's height too.
|
||||
@@ -1170,7 +1318,9 @@ mod tests {
|
||||
);
|
||||
// A helmet over it: vanilla's height.
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
|
||||
let helmet = body_top(&shown, false, worn.as_ref(), |slot| slot == Slot::Head);
|
||||
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
|
||||
slot == Slot::Head
|
||||
});
|
||||
assert_eq!(tag(helmet), VANILLA);
|
||||
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
|
||||
for others in [Others::NameTags, Others::Hidden] {
|
||||
@@ -1186,4 +1336,66 @@ mod tests {
|
||||
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Player models as the hooks see them, with the pose laid over each.
|
||||
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
|
||||
|
||||
impl PlayerModels for View {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
self.0.iter().for_each(|(m, _)| f(*m));
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
|
||||
*p = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
|
||||
let item = Arc::new(
|
||||
Item::parse(
|
||||
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
|
||||
"emote": {"length": 20, "blend": 0, "tracks": [
|
||||
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut service = Seat {
|
||||
local: true,
|
||||
said: Said {
|
||||
others_shown: true,
|
||||
first: Some(item.clone()),
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
};
|
||||
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
|
||||
let mut view = View(vec![
|
||||
model(1, Kind::Own, None),
|
||||
model(41, Kind::Mannequin, None),
|
||||
model(2, Kind::Player, Some(7)),
|
||||
]);
|
||||
service.tick(&mut view);
|
||||
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
|
||||
assert!(Arc::ptr_eq(&playing.item, &item));
|
||||
// Out of step by its id: 50 - 41 % 40.
|
||||
assert_eq!(playing.start, 49.0);
|
||||
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
|
||||
// Still playing: the same pose stays.
|
||||
let first = view.0[1].1.clone().unwrap();
|
||||
service.tick(&mut view);
|
||||
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
|
||||
// Others' cosmetics not shown: a new mannequin plays nothing.
|
||||
service.said.others_shown = false;
|
||||
view.0.push(model(3, Kind::Mannequin, None));
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_cosmetics::account::OwnedEmote;
|
||||
use horizon_cosmetics::{Item, Playing, Slot};
|
||||
use horizon_hooks::players::PlayerModels;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
|
||||
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
|
||||
|
||||
impl Emotes {
|
||||
/// The emote to tell the service about, while it plays: its id and whether it loops.
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
|
||||
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
|
||||
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
|
||||
Some((p.item.id.clone(), looping))
|
||||
Some((&p.item.id, looping))
|
||||
}
|
||||
|
||||
/// When the emote that pulled the camera back started (the own model's age), while it plays.
|
||||
pub(in crate::game) fn pull(&self) -> Option<f32> {
|
||||
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,23 +65,48 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
|
||||
/// The emote a model's pose plays, if it is one.
|
||||
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**pose?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
let same = match (playing(m), &next) {
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
if let Some(pose) = replaced(m.emote.as_ref(), next) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes model `id` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_model_playing(
|
||||
models: &mut dyn PlayerModels,
|
||||
id: u32,
|
||||
next: Option<Playing>,
|
||||
) {
|
||||
if let Some(pose) = replaced(models.body_pose(id), next) {
|
||||
models.set_body_pose(id, pose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +260,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -250,20 +281,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_emote();
|
||||
self.tick_others_emotes();
|
||||
}
|
||||
|
||||
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
|
||||
/// out over the emote's first quarter second.
|
||||
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
|
||||
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
|
||||
return 1.0;
|
||||
};
|
||||
if self.cosmetics.emotes.camera_from.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
|
||||
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ impl Game<'_> {
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
|| self.pad.holds_action(&self.settings.controller, mapping)
|
||||
|| self.input_holds(mapping)
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
|
||||
@@ -2317,7 +2317,7 @@ impl Game<'_> {
|
||||
a.spawn_velocity = Some(p.velocity);
|
||||
a.spawn_rot = Some((p.pitch, p.yaw));
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.creatures.projectiles.put_back(p);
|
||||
self.native_projectile_launch(player, &name, id, kind);
|
||||
}
|
||||
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
|
||||
@@ -6949,7 +6949,7 @@ impl Game<'_> {
|
||||
{
|
||||
m.heal(25.0);
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
|
||||
}
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
|
||||
@@ -280,25 +280,18 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
|
||||
// posed on the GPU with the mob's own jobs: culled with it.
|
||||
if !layers_only
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& horizon_hooks::engine::any()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
if let Some((lo, hi)) =
|
||||
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
|
||||
@@ -353,7 +353,7 @@ impl Game<'_> {
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
Some((p, yaw)) => format!(
|
||||
Some((p, yaw, _pitch)) => format!(
|
||||
"respawn {} {} {} {}",
|
||||
num(p.x),
|
||||
num(p.y),
|
||||
@@ -579,6 +579,7 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos: [p(0), p(1), p(2)],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -739,7 +739,8 @@ impl Game<'_> {
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
// This existing owner transition adapter carries yaw only; its pitch path remains open.
|
||||
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
target = fallback_spawn(self.spawn, self.level.world_spawn);
|
||||
|
||||
@@ -1008,7 +1008,10 @@ impl Game<'_> {
|
||||
p.position.z - center[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
|
||||
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
|
||||
distance,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
|
||||
@@ -70,9 +70,10 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw)) = actual {
|
||||
if let Some((at, yaw, pitch)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
if self.studio_freecam.active {
|
||||
return self.studio_freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio.flying() {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
}
|
||||
let fov = self.view.calculate_fov(
|
||||
partial,
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Game<'_> {
|
||||
// replay player's hands remain visible in first person; freecam has
|
||||
// no player-view hand.
|
||||
if !fov::studio_draws_hand(
|
||||
self.studio_freecam.active,
|
||||
self.studio.flying(),
|
||||
self.ui.hands_drawn(
|
||||
self.spectator(),
|
||||
self.sleeping.is_some(),
|
||||
|
||||
@@ -4190,8 +4190,8 @@ impl Game<'_> {
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
let at = found.map_or(world, |(at, yaw, pitch)| {
|
||||
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -118,35 +118,79 @@ impl View {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
|
||||
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
match (self.kind, face) {
|
||||
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
|
||||
(Kind::Furnace, Some(Direction::Up)) => vec![0],
|
||||
(Kind::Furnace, Some(_)) => vec![1],
|
||||
(Kind::Brewing, Some(Direction::Up)) => vec![3],
|
||||
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
|
||||
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
|
||||
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
|
||||
(Kind::Composter(_), _) => vec![],
|
||||
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
|
||||
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
|
||||
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
|
||||
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
|
||||
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
|
||||
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
|
||||
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
|
||||
(Kind::Composter(_), _) => Slots::Slice(&[]),
|
||||
(Kind::Mod, Some(face)) => self
|
||||
.container
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
|
||||
_ => (0..self.len()).collect(),
|
||||
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
|
||||
_ => Slots::Range(0..self.len()),
|
||||
}
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
|
||||
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face_iter(face)
|
||||
} else {
|
||||
Slots::Range(0..self.len())
|
||||
}
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
self.slots_for_face_iter(face).collect()
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`.
|
||||
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face(face)
|
||||
} else {
|
||||
(0..self.len()).collect()
|
||||
}
|
||||
self.slots_to_add_iter(face).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Slots<'a> {
|
||||
Range(std::ops::Range<usize>),
|
||||
Slice(&'static [usize]),
|
||||
Mod(&'a [usize]),
|
||||
}
|
||||
|
||||
impl Iterator for Slots<'_> {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
match self {
|
||||
Slots::Range(r) => r.next(),
|
||||
Slots::Slice(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
Slots::Mod(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = match self {
|
||||
Slots::Range(r) => r.len(),
|
||||
Slots::Slice(s) => s.len(),
|
||||
Slots::Mod(s) => s.len(),
|
||||
};
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Slots<'_> {}
|
||||
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
"down" => Direction::Down,
|
||||
@@ -180,8 +224,6 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The stack of a full composter's `OutputContainer`.
|
||||
bone_meal: ItemStack,
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -346,7 +388,7 @@ impl Containers<'_> {
|
||||
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer holds one bone meal; InputContainer composts at once.
|
||||
return (level == 8 && slot == 0).then_some(&self.bone_meal);
|
||||
return (level == 8 && slot == 0).then_some(bone_meal_stack());
|
||||
}
|
||||
let (pos, i) = view.locate(slot)?;
|
||||
self.world
|
||||
@@ -452,7 +494,7 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.isFullContainer`.
|
||||
fn is_full(&self, view: &View, face: Direction) -> bool {
|
||||
view.slots_for_face(Some(face)).into_iter().all(|i| {
|
||||
view.slots_for_face_iter(Some(face)).all(|i| {
|
||||
self.insertion_item(view, i)
|
||||
.is_some_and(|s| s.count >= self.max_stack(&s))
|
||||
})
|
||||
@@ -498,6 +540,9 @@ impl ItemColumns {
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
if items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (len, last) = (items.len(), items.last().map(|i| i.id));
|
||||
let stale = self
|
||||
.seen
|
||||
@@ -556,13 +601,14 @@ impl StampedItemColumns {
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal() -> ItemStack {
|
||||
ItemStack {
|
||||
fn bone_meal_stack() -> &'static ItemStack {
|
||||
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
|
||||
item: "minecraft:bone_meal".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
});
|
||||
&STACK
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
@@ -579,7 +625,6 @@ pub(super) fn container_signal(
|
||||
items,
|
||||
registry,
|
||||
brewing: None,
|
||||
bone_meal: bone_meal(),
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
@@ -635,7 +680,6 @@ impl Game<'_> {
|
||||
items: self.items(),
|
||||
registry: self.pack.assets.registry,
|
||||
brewing: Some(&self.stations().brewing),
|
||||
bone_meal: bone_meal(),
|
||||
}
|
||||
}
|
||||
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
|
||||
@@ -847,7 +891,7 @@ impl Game<'_> {
|
||||
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
|
||||
}
|
||||
let mut rest = Some(stack);
|
||||
for slot in view.slots_to_add(face) {
|
||||
for slot in view.slots_to_add_iter(face) {
|
||||
let Some(stack) = rest.take() else { break };
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
|
||||
}
|
||||
@@ -880,32 +924,31 @@ impl Game<'_> {
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
let waiting = {
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
state.cooldown > 0
|
||||
};
|
||||
if waiting {
|
||||
// Every hopper keeps its cooldown and the tick it last ticked
|
||||
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
|
||||
// receives an item reads it for its cooldown (8, or 7 once it ticked).
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
if state.cooldown > 0 {
|
||||
return;
|
||||
}
|
||||
state.cooldown = 0;
|
||||
// Most ticks only advance the cooldown: the block is checked when the
|
||||
// hopper is about to move, not on every tick it waits.
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:hopper";
|
||||
if !hopper {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
};
|
||||
if info.name != "minecraft:hopper" {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
}
|
||||
self.hopper_try_move_items(pos);
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
@@ -917,23 +960,69 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
|
||||
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let enabled = c.prop(state, "enabled") != Some("false");
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
}
|
||||
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
return;
|
||||
};
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
}
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return;
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
|
||||
if !full {
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
if items.len() != HOPPER_SLOTS {
|
||||
return;
|
||||
}
|
||||
let is_empty = items
|
||||
.iter()
|
||||
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
let has_empty_slot = items
|
||||
.iter()
|
||||
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
// Fast path for empty hopper with no source container and no item entities:
|
||||
// if empty, cannot eject; if no container above, cannot suck container;
|
||||
// if no dropped items nearby, cannot suck entities -> nothing can happen!
|
||||
if is_empty
|
||||
&& self.world.block_entities.get(&above).is_none()
|
||||
&& self.carts.is_empty()
|
||||
&& self.boats.is_empty()
|
||||
&& (self.dropped.is_empty()
|
||||
|| self
|
||||
.item_columns
|
||||
.as_mut()
|
||||
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(above.0, above.1, above.2))
|
||||
.is_none_or(|s| s.name != "minecraft:composter")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let view = View {
|
||||
parts: vec![(pos, HOPPER_SLOTS)],
|
||||
kind: Kind::Hopper,
|
||||
container: None,
|
||||
};
|
||||
let mut changed = false;
|
||||
if !is_empty {
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
}
|
||||
let is_full = !changed && !has_empty_slot && {
|
||||
let c = self.containers();
|
||||
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
|
||||
};
|
||||
if !is_full {
|
||||
changed |= self.hopper_suck(pos, &view);
|
||||
}
|
||||
if changed {
|
||||
@@ -995,19 +1084,20 @@ impl Game<'_> {
|
||||
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
|
||||
/// item entity was taken whole.
|
||||
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return false;
|
||||
}
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
if c.prop(state, "enabled") == Some("false") {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
return false;
|
||||
};
|
||||
if c.prop(state, "enabled") == Some("false")
|
||||
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
@@ -1043,8 +1133,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
@@ -1156,6 +1245,9 @@ impl Game<'_> {
|
||||
if blocked {
|
||||
return false;
|
||||
}
|
||||
if self.dropped.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Hopper.SUCK_AABB: the hopper bowl and the block above.
|
||||
let (min, max) = (
|
||||
[
|
||||
@@ -1214,7 +1306,7 @@ impl Game<'_> {
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let bone_meal = bone_meal_stack().clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
@@ -1244,13 +1336,15 @@ impl Game<'_> {
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
|
||||
{
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
@@ -1269,7 +1363,9 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
Some(rest) => {
|
||||
self.put_back(source, slot, rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -1279,6 +1375,9 @@ impl Game<'_> {
|
||||
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
|
||||
/// picked at random.
|
||||
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
|
||||
if self.carts.is_empty() && self.boats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let meets = |b: horizon_core::geometry::Aabb| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < at[0] + 0.5
|
||||
|
||||
@@ -61,46 +61,49 @@ impl Game<'_> {
|
||||
/// that are gone, and read what the instances follow.
|
||||
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
|
||||
let game_time = self.world.game_time;
|
||||
let mobs: Vec<MobSound> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Bee
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Sniffer
|
||||
)
|
||||
})
|
||||
.map(|m| MobSound {
|
||||
let mut mobs = Vec::new();
|
||||
let mut bee_indices = Vec::new();
|
||||
|
||||
for m in &self.mobs {
|
||||
if m.removed {
|
||||
continue;
|
||||
}
|
||||
let species = m.species();
|
||||
if !matches!(
|
||||
species,
|
||||
Species::Bee | Species::Guardian | Species::ElderGuardian | Species::Sniffer
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let is_bee = species == Species::Bee;
|
||||
let sound = MobSound {
|
||||
id: m.id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: m.velocity.x.hypot(m.velocity.z),
|
||||
baby: m.is_baby(),
|
||||
// NeutralMob.isAngry: the anger end time is ahead.
|
||||
angry: m.species() == Species::Bee
|
||||
angry: is_bee
|
||||
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
// SnifferSoundInstance.tick.
|
||||
sniffer_digging: m.species() == Species::Sniffer
|
||||
sniffer_digging: species == Species::Sniffer
|
||||
&& m.ai.target.is_none()
|
||||
&& sniffer::can_play_digging_sound(m),
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
if is_bee {
|
||||
bee_indices.push(mobs.len());
|
||||
}
|
||||
mobs.push(sound);
|
||||
}
|
||||
self.guardian_sounds
|
||||
.retain(|id| mobs.iter().any(|m| m.id == *id));
|
||||
self.bee_sounds
|
||||
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
for sound in &mobs {
|
||||
let is_bee = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == sound.id && m.species() == Species::Bee);
|
||||
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
for &idx in &bee_indices {
|
||||
let sound = &mobs[idx];
|
||||
if self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
self.bee_sounds.insert(sound.id, sound.angry);
|
||||
let instance = SoundInstance::bee(sound, sound.angry, random_source::create());
|
||||
self.audio.play_instance(instance, false);
|
||||
|
||||
@@ -300,11 +300,7 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let marks = if player || self.cosmetics_local() {
|
||||
self.cosmetics_marks(m, Some(m.id) == own)
|
||||
} else {
|
||||
Marks::default()
|
||||
};
|
||||
let marks = self.hooks_name_marks(m, Some(m.id) == own);
|
||||
let at = match marks.top {
|
||||
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
|
||||
None => p,
|
||||
@@ -770,10 +766,10 @@ impl Game<'_> {
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if n == last && marks.horizon {
|
||||
if let Some(mark) = marks.mark.filter(|_| n == last) {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
|
||||
for &(mx, my, w, h, argb) in mark {
|
||||
let a = text_alpha * (argb >> 24) / 255;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
@@ -937,10 +933,11 @@ struct NameTag {
|
||||
marks: Marks,
|
||||
}
|
||||
|
||||
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
|
||||
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
|
||||
/// before it (Horizon's: they play Horizon) and a name tag style.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Marks {
|
||||
pub horizon: bool,
|
||||
pub mark: Option<horizon_hooks::players::Mark>,
|
||||
pub style: Option<TagStyle>,
|
||||
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
|
||||
/// goes up over a tall one (`cosmetic_lift`).
|
||||
|
||||
@@ -471,6 +471,7 @@ pub(crate) mod tests {
|
||||
dim: game.dim,
|
||||
pos: [1, 2, 3],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
let spawn = Vec3::new(1.0, 2.0, 3.0);
|
||||
|
||||
@@ -46,6 +46,8 @@ pub(super) struct SpawnScratch {
|
||||
/// dx/dz order, that no earlier player's square holds.
|
||||
segments: Vec<Vec<(i32, i32)>>,
|
||||
stale: Vec<bool>,
|
||||
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
|
||||
pub(super) spawn_players: Vec<SpawnPlayer>,
|
||||
}
|
||||
impl SpawnScratch {
|
||||
fn chunk(p: &LevelPlayer) -> (i32, i32) {
|
||||
@@ -128,6 +130,20 @@ impl SpawnScratch {
|
||||
self.prepare(players);
|
||||
self.candidates.clear();
|
||||
let mut count = 0;
|
||||
if players.len() == 1 {
|
||||
let p = &players[0];
|
||||
count = self.segments.first().map_or(0, |s| s.len());
|
||||
if !p.spectator
|
||||
&& let Some(segment) = self.segments.first()
|
||||
{
|
||||
for &c in segment {
|
||||
if close_enough_for_spawning(p, c) && keep(c) {
|
||||
self.candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count as i32;
|
||||
}
|
||||
for (i, segment) in self.segments.iter().enumerate() {
|
||||
count += segment.len();
|
||||
let near = &self.neighbours[i];
|
||||
@@ -650,8 +666,10 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
|
||||
types
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
pub(super) fn natural_level_with_players(
|
||||
&self,
|
||||
players: Vec<LevelPlayer>,
|
||||
) -> GameLevel<'_, 'a> {
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
@@ -670,6 +688,9 @@ impl<'a> Game<'a> {
|
||||
costed: spawn_cost_types(world_biomes!(self)),
|
||||
}
|
||||
}
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
self.natural_level_with_players(self.level_players())
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
/// caller adds it with [`Game::add_spawned`].
|
||||
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
|
||||
@@ -773,6 +794,8 @@ impl<'a> Game<'a> {
|
||||
scratch: &mut SpawnScratch,
|
||||
) -> i32 {
|
||||
let levels = self.tickets.levels();
|
||||
// Recomputed each pass: a chunk's ticket level and entity loading change without the
|
||||
// players moving or the chunk count changing.
|
||||
scratch.collect(players, |(cx, cz)| {
|
||||
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
|
||||
})
|
||||
@@ -819,31 +842,28 @@ impl<'a> Game<'a> {
|
||||
{
|
||||
let level = self.natural_level();
|
||||
laps.lap("sn_level");
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
})
|
||||
.collect();
|
||||
let players = players
|
||||
.iter()
|
||||
.map(|p| SpawnPlayer {
|
||||
let counted = self.mobs.iter().map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
});
|
||||
scratch.spawn_players.clear();
|
||||
scratch
|
||||
.spawn_players
|
||||
.extend(players.iter().map(|p| SpawnPlayer {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
spectator: p.spectator,
|
||||
})
|
||||
.collect();
|
||||
}));
|
||||
let spawn_players = std::mem::take(&mut scratch.spawn_players);
|
||||
laps.lap("sn_materialize");
|
||||
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
|
||||
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
laps.lap("sn_state");
|
||||
@@ -854,7 +874,8 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
laps.lap("sn_categories");
|
||||
// Util.shuffle.
|
||||
// Util.shuffle, every pass: it draws from the level's random even when no
|
||||
// category may spawn.
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
scratch.candidates.swap(i, j);
|
||||
@@ -885,6 +906,7 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
}
|
||||
}
|
||||
scratch.spawn_players = state.into_players();
|
||||
}
|
||||
spawn_selection::attempt_counters(true);
|
||||
laps.lap("sn_attempts");
|
||||
|
||||
@@ -185,7 +185,7 @@ impl Game<'_> {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
|
||||
self.ui.options = ui;
|
||||
self.show_calibration((batch.width, batch.height));
|
||||
|
||||
@@ -679,26 +679,6 @@ impl Game<'_> {
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
}
|
||||
|
||||
/// Whether `key` is held, a movement key by the left stick too: as the keys the stick holds
|
||||
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
|
||||
/// the movement itself.
|
||||
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
|
||||
if self.keys.raw(&key) {
|
||||
return true;
|
||||
}
|
||||
let Some(stick) = self.pad_move() else {
|
||||
return false;
|
||||
};
|
||||
let [forward, backward, left, right] = gamepad::eight_direction_keys(stick);
|
||||
match key {
|
||||
KeyCode::KeyW => forward,
|
||||
KeyCode::KeyS => backward,
|
||||
KeyCode::KeyA => left,
|
||||
KeyCode::KeyD => right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The right stick turns the view, once per frame (`Entity.turn`).
|
||||
pub(super) fn pad_look(&mut self, seconds: f32) {
|
||||
let settings = &self.settings.controller;
|
||||
|
||||
@@ -280,11 +280,11 @@ impl Game<'_> {
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.flying() {
|
||||
return Some((
|
||||
self.studio_freecam.position,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
self.studio.freecam.position,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
));
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
@@ -311,8 +311,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let mut view = self.view;
|
||||
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
|
||||
let pull = self.emote_camera_pull(partial);
|
||||
// A hook may pull the camera back (an emote, from the player's face).
|
||||
let pull = self.hooks_camera_distance(partial);
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
@@ -643,8 +643,9 @@ impl Game<'_> {
|
||||
/// The model's old pitch is the player's `xRotO`, which the turn moves
|
||||
/// with `xRot` (`turn_rotation`).
|
||||
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam
|
||||
if self.studio.flying() {
|
||||
self.studio
|
||||
.freecam
|
||||
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
|
||||
return;
|
||||
}
|
||||
@@ -697,7 +698,7 @@ impl Game<'_> {
|
||||
/// `LevelExtractor`'s skip of the camera entity: the player's own body
|
||||
/// is drawn unless the camera is in first person, or while it sleeps.
|
||||
pub(super) fn own_body_in_view(&self) -> bool {
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.flying()
|
||||
}
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
|
||||
@@ -101,7 +101,7 @@ impl Game<'_> {
|
||||
};
|
||||
// A controller's stick click acts as the key without setting the modifier: it is held
|
||||
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
|
||||
let pad = self.pad.holds_action(&self.settings.controller, mapping);
|
||||
let pad = self.input_holds(mapping);
|
||||
if !held && !pad {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
self.sync_toggle(slot);
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.replay.playing() {
|
||||
if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
|
||||
if !studio_freecam_movement_key(key) || !self.studio.flying() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -352,9 +352,7 @@ impl Game<'_> {
|
||||
_ => return false,
|
||||
};
|
||||
match source {
|
||||
Source::Keyboard | Source::Agent => {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
Source::Keyboard | Source::Agent => self.input_holds(mapping),
|
||||
Source::Pad => {
|
||||
let keys = &self.settings.keys;
|
||||
self.physical_buttons
|
||||
@@ -450,6 +448,79 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The input source (docs/layers.md, "Controllers, splitscreen, replays and UI pages"): what
|
||||
/// gameplay asks of a device beyond the keyboard and mouse, and tells it. Gameplay calls these,
|
||||
/// never the controller modules; today `pad` answers.
|
||||
impl Game<'_> {
|
||||
/// The analog move vector (`[left, forward]`, -1..1) while a device moves the player.
|
||||
pub(super) fn input_move(&self) -> Option<[f32; 2]> {
|
||||
self.pad_move()
|
||||
}
|
||||
|
||||
/// Whether a device holds the key mapping named `mapping` (`key.use`).
|
||||
pub(super) fn input_holds(&self, mapping: &str) -> bool {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
|
||||
/// Turns the view by what the devices give this frame (`Entity.turn`).
|
||||
pub(super) fn input_look(&mut self, seconds: f32) {
|
||||
self.pad_look(seconds);
|
||||
}
|
||||
|
||||
/// The keyboard or mouse acted in play (a key or button pressed): it has control again, though
|
||||
/// only moving the mouse takes a pad out of use.
|
||||
pub(super) fn input_keyboard_used(&mut self) {
|
||||
self.pad.keyboard_took_over = true;
|
||||
}
|
||||
|
||||
/// Whether the pointer event being handled is one a device made (a pad's menu cursor
|
||||
/// clicking), not the mouse's.
|
||||
pub(super) fn input_made_pointer_event(&self) -> bool {
|
||||
self.pad.menu.synthetic
|
||||
}
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
|
||||
pub(super) fn input_player_replaced(&mut self) {
|
||||
self.pad.player_replaced();
|
||||
}
|
||||
|
||||
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
|
||||
/// hold). Asking answers once.
|
||||
pub(super) fn input_takes_stack_drop(&mut self) -> bool {
|
||||
std::mem::take(&mut self.pad.drop_stack)
|
||||
}
|
||||
|
||||
/// The HUD starts a frame: what a device drew on the last one is gone.
|
||||
pub(super) fn input_hud_begin(&mut self) {
|
||||
self.pad.guide_shown.clear();
|
||||
}
|
||||
|
||||
/// Something happened to the player that a device may let them feel.
|
||||
pub(super) fn input_feedback(&mut self, event: horizon_client::input_source::Feedback) {
|
||||
self.pad_rumble(event.into());
|
||||
}
|
||||
|
||||
/// Whether `key` is held, a movement key by the analog move vector too: as the keys it holds
|
||||
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
|
||||
/// the movement itself.
|
||||
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
|
||||
if self.keys.raw(&key) {
|
||||
return true;
|
||||
}
|
||||
let Some(stick) = self.input_move() else {
|
||||
return false;
|
||||
};
|
||||
let [forward, backward, left, right] = horizon_client::gamepad::eight_direction_keys(stick);
|
||||
match key {
|
||||
KeyCode::KeyW => forward,
|
||||
KeyCode::KeyS => backward,
|
||||
KeyCode::KeyA => left,
|
||||
KeyCode::KeyD => right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{studio_freecam_movement_key, studio_replay_shortcut};
|
||||
|
||||
@@ -10,41 +10,8 @@ impl Game<'_> {
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
self.direct_craft_stop();
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::KeyP => {
|
||||
self.pause_focus = None;
|
||||
self.screen = Screen::Paused;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
}
|
||||
KeyCode::KeyF => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::ToggleFreecam,
|
||||
),
|
||||
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
|
||||
KeyCode::KeyM => self.add_studio_marker(),
|
||||
KeyCode::Home => self.handle_studio_topbar_action(
|
||||
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
|
||||
),
|
||||
KeyCode::KeyL => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
}
|
||||
KeyCode::KeyK => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::AddCameraKeyframe,
|
||||
),
|
||||
KeyCode::Delete => {
|
||||
if let Some(index) = self.studio_timeline.selected_keyframe {
|
||||
self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.replay.playing() {
|
||||
self.studio_key(key);
|
||||
return Ok(());
|
||||
}
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
@@ -78,7 +45,7 @@ impl Game<'_> {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
if self.replay.playing() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -213,7 +180,7 @@ impl Game<'_> {
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
KeyCode::F5 if self.screen == Screen::Playing && !self.replay.playing() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
@@ -279,8 +246,7 @@ impl Game<'_> {
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all =
|
||||
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
|
||||
let all = drop_whole_stack(self.modifiers) || self.input_takes_stack_drop();
|
||||
self.drop_held(all);
|
||||
}
|
||||
KeyCode::Digit1
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
|
||||
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
|
||||
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
|
||||
//! registered nothing is built.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::join_seq::JoinList;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The models of the world a window shows: its own first, then the other players and mannequins.
|
||||
struct Models<'a> {
|
||||
own: Option<&'a mut Mob>,
|
||||
mobs: &'a mut JoinList<Mob>,
|
||||
remote: &'a super::remote::Remote,
|
||||
}
|
||||
|
||||
impl Models<'_> {
|
||||
fn find(&mut self, id: u32) -> Option<&mut Mob> {
|
||||
match self.own.as_deref_mut() {
|
||||
Some(m) if m.id == id => Some(m),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && kind(m).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of player model `m` is, if it is one (the window's own model aside).
|
||||
fn kind(m: &Mob) -> Option<Kind> {
|
||||
match &*m.kind.id {
|
||||
"minecraft:player" => Some(Kind::Player),
|
||||
"minecraft:mannequin" => Some(Kind::Mannequin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
|
||||
pub(super) fn gear(m: &Mob) -> Gear {
|
||||
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
|
||||
Gear::new(
|
||||
m.extra.head.is_some(),
|
||||
m.extra.chest.is_some() && !elytra,
|
||||
m.cape.is_some() || elytra,
|
||||
)
|
||||
}
|
||||
|
||||
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
|
||||
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
|
||||
}
|
||||
|
||||
impl PlayerModels for Models<'_> {
|
||||
fn connection(&self) -> u64 {
|
||||
self.remote.session.serial()
|
||||
}
|
||||
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
if let Some(m) = self.own.as_deref() {
|
||||
f(model(m, Kind::Own, Some(self.remote.profile())));
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if let Some(kind) = kind(m) {
|
||||
let uuid = (kind == Kind::Player)
|
||||
.then(|| self.remote.player_uuid(m.id))
|
||||
.flatten();
|
||||
f(model(m, kind, uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
match self.own.as_deref() {
|
||||
Some(m) if m.id == id => m.emote.as_ref(),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && kind(m).is_some())?
|
||||
.emote
|
||||
.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some(m) = self.find(id) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
|
||||
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::Cursor::new(&mut buf[..]);
|
||||
let _ = write!(out, "hooks/{id:x}");
|
||||
let len = out.position() as usize;
|
||||
std::str::from_utf8(&buf[..len]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
|
||||
struct HookSprites<'a, 'g>(&'a Game<'g>);
|
||||
|
||||
impl Sprites for HookSprites<'_, '_> {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
let mut buf = [0; 22];
|
||||
let at = location(id, &mut buf);
|
||||
let r = self.0.gui.atlas.sprites.get(at)?;
|
||||
// Kept this frame (`skin_slots.next_frame`).
|
||||
self.0.skin_downloads.borrow_mut().drawn(at);
|
||||
Some([r.u0, r.v0, r.u1, r.v1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a crate's frame hook puts its sprites.
|
||||
struct HookStore<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl SpriteStore for HookStore<'_, '_> {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.contains_key(location(id, &mut [0; 22]))
|
||||
}
|
||||
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
let at = location(id, &mut [0; 22]).to_owned();
|
||||
self.0.store_skin(at, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
|
||||
/// mesh, so the address isn't reused); render thread only.
|
||||
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
|
||||
Default::default();
|
||||
}
|
||||
|
||||
/// The game's model of `mesh`, made once.
|
||||
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
fn part(m: &Mesh) -> Part {
|
||||
Part {
|
||||
name: m.name.clone(),
|
||||
pose: m.pose,
|
||||
polygons: m
|
||||
.quads
|
||||
.iter()
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: m.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
MESHES.with_borrow_mut(|meshes| {
|
||||
let (_, part_of) = meshes
|
||||
.entry(Arc::as_ptr(mesh) as usize)
|
||||
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
|
||||
part_of.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
m.borrow_mut()
|
||||
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `Poses` as a crate poses a mesh's parts.
|
||||
struct PosesOf<'a>(&'a mut Poses);
|
||||
|
||||
impl PartPoses for PosesOf<'_> {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
m: &'a Mob,
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &'a mut Vec<Vertex>,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
/// The body as it is drawn, posed on the first `bone`.
|
||||
body: Option<PooledPoses>,
|
||||
poses: PooledPoses,
|
||||
}
|
||||
|
||||
impl DrawPlayer<'_, '_> {
|
||||
fn body(&self) -> &Part {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
fn pass(&self) -> Pass {
|
||||
self.pass
|
||||
}
|
||||
|
||||
fn ticks(&self) -> f32 {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
let data = self.game.res.mob_data();
|
||||
let state = anim::state(self.m, self.alpha);
|
||||
anim::setup(self.m, &state, &mut poses, &data.animations);
|
||||
self.body = Some(poses);
|
||||
}
|
||||
let poses = self.body.as_ref()?;
|
||||
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
|
||||
Some(at.to_cols_array_2d())
|
||||
}
|
||||
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.m.emote.as_ref()
|
||||
}
|
||||
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
HookSprites(self.game).get(id)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = self.bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: self.light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let poses = &self.poses;
|
||||
horizon_client::mobs::render_model(
|
||||
&model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
self.light,
|
||||
0,
|
||||
self.out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
pub(super) fn client_hooks_frame(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
/// Once a client tick in a world: the player's own emote, then the hooks.
|
||||
pub(super) fn client_hooks_tick(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
self.tick_emotes();
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
engine::tick(&mut Models {
|
||||
own: self.player_model.as_mut(),
|
||||
mobs: &mut self.mobs,
|
||||
remote,
|
||||
});
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
|
||||
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let mut marks = super::names::Marks::default();
|
||||
if !engine::any() {
|
||||
return marks;
|
||||
}
|
||||
let model = match (own, kind(m)) {
|
||||
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
|
||||
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
|
||||
(false, None) => return marks,
|
||||
};
|
||||
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
|
||||
marks.mark = d.mark;
|
||||
marks.top = d.top;
|
||||
marks.style = d.badge.map(|b| {
|
||||
let [u0, v0, u1, v1] = b.uv;
|
||||
super::names::TagStyle {
|
||||
mark: horizon_client::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
line: b.line,
|
||||
}
|
||||
});
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
|
||||
/// (an emote pulls it back), else 1.
|
||||
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
match &self.player_model {
|
||||
Some(m) if engine::any() => {
|
||||
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
|
||||
}
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
|
||||
let mut a = [0; 22];
|
||||
let mut b = [0; 22];
|
||||
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
|
||||
assert_eq!(location(10, &mut b), "hooks/a");
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ impl Game<'_> {
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.input_player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
self.eye = 1.62;
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Game<'_> {
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.player.is_some() || self.studio_freecam.active);
|
||||
&& (self.player.is_some() || self.studio.flying());
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
@@ -75,10 +75,7 @@ impl Game<'_> {
|
||||
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !studio_suppresses_focus_pause(
|
||||
self.studio_replay.is_some(),
|
||||
self.studio_freecam.active,
|
||||
)
|
||||
&& !studio_suppresses_focus_pause(self.replay.playing(), self.studio.flying())
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
@@ -149,7 +146,7 @@ impl Game<'_> {
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
@@ -353,9 +350,9 @@ impl Game<'_> {
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if self.studio_replay.is_some() || self.studio_freecam.active {
|
||||
if self.studio_up() {
|
||||
self.regrab_on_refocus = false;
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2433,7 +2433,7 @@ impl Game<'_> {
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.replay.playing() {
|
||||
return true;
|
||||
}
|
||||
self.remote
|
||||
@@ -3110,33 +3110,6 @@ impl Game<'_> {
|
||||
index.get(mobs, id as u32).map(mob_tracking_body)
|
||||
});
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.passenger_vehicles.retain(|_, v| *v != id as u32);
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Map a menu slot to Horizon's inventory screen slot ids.
|
||||
pub(super) fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
|
||||
let s = usize::try_from(slot).ok()?;
|
||||
@@ -3707,7 +3680,7 @@ impl Game<'_> {
|
||||
self.move_remote_interaction(p.id, p.position);
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
for &id in &p.ids {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(uuid) = r.level_players.remove(&id) {
|
||||
r.attachments.remove(&Holder::Player(uuid));
|
||||
@@ -3716,8 +3689,8 @@ impl Game<'_> {
|
||||
r.level_player_textures.remove(&id);
|
||||
r.attachments.remove(&Holder::Entity(id as u32));
|
||||
}
|
||||
self.remove_entity(id);
|
||||
}
|
||||
self.remove_entities(&p.ids);
|
||||
}
|
||||
C::MoveEntityPos(play::MoveEntityPos(m))
|
||||
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
|
||||
@@ -6176,7 +6149,7 @@ impl Game<'_> {
|
||||
player.spectator = self.spectator();
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.input_player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
@@ -6185,7 +6158,7 @@ impl Game<'_> {
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
}
|
||||
if self.studio_replay.is_none() {
|
||||
if !self.replay.playing() {
|
||||
self.previous_position = position;
|
||||
}
|
||||
self.yaw = yaw;
|
||||
@@ -6441,7 +6414,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let stick = self.pad_move();
|
||||
let stick = self.input_move();
|
||||
if self.player.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -6521,7 +6494,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
player.fluids.boat = boat;
|
||||
if !riding && self.studio_replay.is_none() {
|
||||
if !riding && !self.replay.playing() {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
let before = (
|
||||
@@ -6728,10 +6701,10 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
if self.studio_replay.is_none() {
|
||||
if !self.replay.playing() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.tick_emotes();
|
||||
self.client_hooks_tick();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -7269,15 +7242,15 @@ impl Game<'_> {
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
// Squid.aiStep follows LivingEntity's interpolation and head turn.
|
||||
// Squid.aiStep; WalkAnimationState.update from the horizontal distance.
|
||||
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
|
||||
// WalkAnimationState.update from the horizontal distance moved.
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
// Camel.updateWalkAnimation.
|
||||
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
|
||||
let d = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z) as f32;
|
||||
let (target, factor) = if m.species() == horizon_client::mobs::Species::Frog {
|
||||
(horizon_client::mobs::frog::walk_target(m, d), 0.4)
|
||||
} else if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
horizon_client::mobs::camel::walk_target(m, d)
|
||||
} else {
|
||||
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
|
||||
((d * 4.0).min(1.0), 0.4)
|
||||
};
|
||||
m.limb_speed += (target - m.limb_speed) * factor;
|
||||
m.limb_position += m.limb_speed;
|
||||
@@ -9525,7 +9498,7 @@ impl Game<'_> {
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
// Minecraft.hasControlDown: the physical keys, so a toggled
|
||||
// sprint on Left Control does not make Q drop the stack.
|
||||
let all = self.has_control_down() || std::mem::take(&mut self.pad.drop_stack);
|
||||
let all = self.has_control_down() || self.input_takes_stack_drop();
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE
|
||||
@@ -10528,7 +10501,7 @@ impl Game<'_> {
|
||||
/// A connection's screen is over the world: connecting, reconfiguring, or
|
||||
/// the level loading screen, a portal's included (`remote_overlay`).
|
||||
pub(super) fn remote_screen_up(&self) -> bool {
|
||||
self.studio_replay.is_none()
|
||||
!self.replay.playing()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -10612,7 +10585,7 @@ impl Game<'_> {
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.replay.playing() {
|
||||
return false;
|
||||
}
|
||||
use horizon_client::level_loading::Reason;
|
||||
@@ -10714,17 +10687,11 @@ impl Game<'_> {
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
/// The UUID of another player's entity in the level.
|
||||
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11505,6 +11472,11 @@ impl Remote {
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// Another player's profile UUID, by their entity id (None for the local player).
|
||||
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let uuid = *self.level_players.get(&(id as i32))?;
|
||||
(uuid != self.profile).then_some(uuid)
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
@@ -11642,6 +11614,9 @@ fn in_storage_range(view_distance: i32, center: (i32, i32), x: i32, z: i32) -> b
|
||||
x.abs_diff(center.0) <= range && z.abs_diff(center.1) <= range
|
||||
}
|
||||
|
||||
// A packet's removals (`RemoveEntities`), apart: this file may not grow.
|
||||
#[path = "remote_removal.rs"]
|
||||
mod removal;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -793,13 +793,17 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.tnts.retain(|t| t.id != id);
|
||||
self.falling.retain(|f| f.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.remove_remote_small_projectiles(id);
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_small_projectiles(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.potions.clouds.retain(|c| c.id != id);
|
||||
self.end.remove_remote_eye(id);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Removing entities a server dropped (`RemoveEntities`), out of remote.rs,
|
||||
//! which may not grow.
|
||||
use super::super::Game;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn remove_entity(&mut self, id: i32) {
|
||||
self.remove_entities(&[id]);
|
||||
}
|
||||
|
||||
/// `ClientboundRemoveEntitiesPacket`: a packet's ids go together, the big
|
||||
/// entity lists filtered once (#13007), each id's own cleanup in order.
|
||||
pub(super) fn remove_entities(&mut self, ids: &[i32]) {
|
||||
for &id in ids {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.crystals.retain(|c| c.id != id as u32);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_small_projectiles(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
let removed: horizon_client::fast_hash::FxHashSet<u32> = if ids.len() > 1 {
|
||||
ids.iter().map(|&id| id as u32).collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let keep = |id: u32| {
|
||||
if ids.len() > 1 {
|
||||
!removed.contains(&id)
|
||||
} else {
|
||||
ids.first().is_none_or(|&removed| id != removed as u32)
|
||||
}
|
||||
};
|
||||
self.mobs.retain(|m| keep(m.id));
|
||||
self.orbs.retain(|o| keep(o.id));
|
||||
self.dropped.retain(|d| keep(d.id));
|
||||
self.falling.retain(|f| keep(f.id));
|
||||
self.tnts.retain(|t| keep(t.id));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.passenger_vehicles.retain(|_, v| keep(*v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn remove_entities_filters_mixed_entity_lists() {
|
||||
use crate::game::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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 = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(client, "RemovalTest").unwrap();
|
||||
for (id, kind) in [
|
||||
(40, "minecraft:falling_block"),
|
||||
(41, "minecraft:falling_block"),
|
||||
(42, "minecraft:falling_block"),
|
||||
(43, "minecraft:tnt"),
|
||||
(44, "minecraft:tnt"),
|
||||
(45, "minecraft:experience_orb"),
|
||||
(46, "minecraft:experience_orb"),
|
||||
(47, "minecraft:zombie"),
|
||||
(48, "minecraft:zombie"),
|
||||
] {
|
||||
if matches!(kind, "minecraft:falling_block" | "minecraft:tnt") {
|
||||
assert!(game.spawn_remote_projectile(
|
||||
id,
|
||||
kind,
|
||||
[0.0, 64.0, 0.0],
|
||||
(0.0, 0.0),
|
||||
[0.0; 3],
|
||||
0
|
||||
));
|
||||
} else {
|
||||
game.spawn_entity(id, kind, [0.0, 64.0, 0.0], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
for id in [49, 50] {
|
||||
game.dropped.push(horizon_client::entities::ItemEntity::new(
|
||||
id,
|
||||
horizon_client::items::ItemStack::new("minecraft:sand", 1),
|
||||
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.handle_packet(C::RemoveEntities(play::RemoveEntities {
|
||||
ids: vec![40, 42, 43, 45, 47, 49],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [41]);
|
||||
assert_eq!(game.tnts.iter().map(|e| e.id).collect::<Vec<_>>(), [44]);
|
||||
assert_eq!(game.orbs.iter().map(|e| e.id).collect::<Vec<_>>(), [46]);
|
||||
assert_eq!(game.mobs.iter().map(|e| e.id).collect::<Vec<_>>(), [48]);
|
||||
assert_eq!(game.dropped.iter().map(|e| e.id).collect::<Vec<_>>(), [50]);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,7 @@ impl Game<'_> {
|
||||
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|
||||
night_vision: self.fog_night_vision(),
|
||||
environment: self.pack.polytone_colors.env,
|
||||
fluid_fog: self.pack.polytone_colors.fluid_fog,
|
||||
};
|
||||
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
|
||||
if !self.settings.horizon.fog {
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
//! Recording a session's packets as `.mcpr` and playing them back in a `Game`: the `/studio record`
|
||||
//! and `/studio replay` commands (chat and `horizon-ctl`), the replay's clock and its world. This is
|
||||
//! platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
|
||||
|
||||
use super::{Game, PendingWorldEntry, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
studio::{Metadata, ReplayDriver},
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// The packet replay a `Game` plays, if any, and its playback clock.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Replay {
|
||||
/// The packet replay playing, if any.
|
||||
pub(super) driver: Option<ReplayDriver>,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
pub(super) previous_camera_type: Option<super::perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
pub(super) standalone: bool,
|
||||
pub(super) last_tick: Option<std::time::Instant>,
|
||||
pub(super) virtual_tick_ms: f32,
|
||||
}
|
||||
|
||||
impl Replay {
|
||||
/// A replay plays: the world shown is the recording's.
|
||||
pub(super) fn playing(&self) -> bool {
|
||||
self.driver.is_some()
|
||||
}
|
||||
/// How far the replay's player is between its last two ticks, while a replay plays: it
|
||||
/// moves on the replay's clock, not the game's.
|
||||
pub(super) fn player_alpha(&self) -> Option<f64> {
|
||||
self.playing()
|
||||
.then(|| (f64::from(self.virtual_tick_ms) / 50.0).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// How far the window's own player model is between two ticks: on the replay's clock while a
|
||||
/// replay plays, else the game's.
|
||||
pub(super) fn own_player_alpha(&self) -> f64 {
|
||||
self.replay
|
||||
.player_alpha()
|
||||
.unwrap_or_else(|| self.clock.camera_fraction())
|
||||
}
|
||||
|
||||
/// In-game chat command handler: `/studio <record|replay> ...`.
|
||||
/// Returns true if the command was recognized and handled.
|
||||
pub(super) fn studio_command(&mut self, command: &str) -> bool {
|
||||
let Some(rest) = command.strip_prefix("studio") else {
|
||||
return false;
|
||||
};
|
||||
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
|
||||
return false;
|
||||
}
|
||||
let words: Vec<&str> = rest.split_whitespace().collect();
|
||||
if words.is_empty() {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
return true;
|
||||
}
|
||||
|
||||
match words[0] {
|
||||
"freecam" | "keyframe" | "path" if super::studio::EDITOR => {
|
||||
return self.studio_editor_command(&words);
|
||||
}
|
||||
"record" => match words.get(1).copied() {
|
||||
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let path = v["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
|
||||
}
|
||||
Ok(v) => {
|
||||
let error = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
Err(error) => {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
},
|
||||
Some("stop") => match self.stop_studio_recording() {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let path = v["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
|
||||
}
|
||||
Ok(v) => {
|
||||
let error = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
Err(error) => {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
},
|
||||
Some("status") => {
|
||||
let status = self.studio_recording_status();
|
||||
if status["active"].as_bool() == Some(true) {
|
||||
let elapsed =
|
||||
(status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
|
||||
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
|
||||
let size =
|
||||
(status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
|
||||
let path = status["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.record.status.active",
|
||||
&[&elapsed, &packets, &size, &path],
|
||||
));
|
||||
} else if let Some(error) = status["error"].as_str() {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.record.status.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
|
||||
},
|
||||
"replay" => match words.get(1).copied() {
|
||||
Some("open") => {
|
||||
let Some(path) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.open.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
match self.open_studio_replay(path) {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let duration_s = v["duration_ms"].as_f64().unwrap_or(0.0) / 1000.0;
|
||||
let packets = v["packet_count"].as_u64().unwrap_or(0);
|
||||
let duration = format!("{duration_s:.1}");
|
||||
let packets = packets.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.opened",
|
||||
&[path, &duration, &packets],
|
||||
));
|
||||
}
|
||||
Ok(v) => {
|
||||
let err = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.open_error",
|
||||
&[&err],
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
let error = e.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.open_error",
|
||||
&[&error],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(true);
|
||||
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(false);
|
||||
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("speed") => {
|
||||
let Some(spd_str) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.speed.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if let Ok(spd) = spd_str.parse::<f64>() {
|
||||
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Some(r.speed())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(speed) = new_speed {
|
||||
let speed = format!("{speed:.2}");
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.speed.set",
|
||||
&[&speed],
|
||||
));
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.speed.invalid",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
Some("seek") => {
|
||||
let Some(ms_str) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if let Ok(ms) = ms_str.parse::<u32>() {
|
||||
if self.replay.driver.is_some() {
|
||||
match self.seek_studio_replay(ms) {
|
||||
Ok(()) => {
|
||||
let actual_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(ms, |replay| ms.min(replay.duration_ms()));
|
||||
let ms_text = actual_ms.to_string();
|
||||
let seconds = format!("{:.1}", actual_ms as f32 / 1000.0);
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.seeked",
|
||||
&[&ms_text, &seconds],
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::Unsupported {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.backward_requires_standalone",
|
||||
&[],
|
||||
));
|
||||
} else {
|
||||
let error = e.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.seek.error",
|
||||
&[&error],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.invalid",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
let position = r.position_ms().to_string();
|
||||
let duration = r.duration_ms().to_string();
|
||||
let progress = format!("{:.1}", r.progress() * 100.0);
|
||||
let speed = format!("{:.2}", r.speed());
|
||||
let paused = r.is_paused().to_string();
|
||||
let finished = r.is_finished().to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.status",
|
||||
&[&position, &duration, &progress, &speed, &paused, &finished],
|
||||
));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("close") => {
|
||||
if self.close_studio_replay() {
|
||||
self.system_message(Text::translate("horizon.studio.replay.closed", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reset playback tick accumulator and align positions on seek.
|
||||
pub(super) fn reset_studio_playback_ticks(&mut self) {
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
if let Some(player) = &self.player {
|
||||
self.previous_position = player.position;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous = player.position;
|
||||
m.position = player.position;
|
||||
m.limb_speed = 0.0;
|
||||
m.previous_limb_speed = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct replay state at a target timestamp. Standalone replays can
|
||||
/// safely reset their synthetic remote; an in-world replay can only seek
|
||||
/// forward because its pre-replay world must remain intact.
|
||||
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
|
||||
let current_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms);
|
||||
let packets = if self.replay.standalone {
|
||||
let packets = self
|
||||
.replay
|
||||
.driver
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_and_collect(target_ms)?;
|
||||
self.init_remote_for_replay()
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
packets
|
||||
} else {
|
||||
if target_ms < current_ms {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"backward seek requires a standalone replay",
|
||||
));
|
||||
}
|
||||
self.replay
|
||||
.driver
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_forward(target_ms)?
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(packets);
|
||||
self.reset_studio_playback_ticks();
|
||||
self.replay.last_tick = Some(std::time::Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch_studio_replay_packets(
|
||||
&mut self,
|
||||
packets: Vec<horizon_client::studio::mcpr::TimestampedPacket>,
|
||||
) {
|
||||
for pkt in packets {
|
||||
match horizon_protocol::packets::play::Clientbound::decode(pkt.id, &pkt.payload) {
|
||||
Ok(packet) => {
|
||||
let _ = self.handle_packet(packet);
|
||||
}
|
||||
Err(e) => {
|
||||
let name = horizon_protocol::ids::name(
|
||||
horizon_protocol::State::Play,
|
||||
horizon_protocol::Direction::Clientbound,
|
||||
pkt.id,
|
||||
)
|
||||
.unwrap_or("?");
|
||||
log::debug!(
|
||||
"Studio replay skipped packet {name} (id {:#x}): {e}",
|
||||
pkt.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
|
||||
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
|
||||
let Some(driver) = self.replay.driver.as_mut() else {
|
||||
self.replay.last_tick = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let delta = match self.replay.last_tick {
|
||||
Some(last) => now
|
||||
.saturating_duration_since(last)
|
||||
.min(Duration::from_millis(250)),
|
||||
None => Duration::from_millis(16),
|
||||
};
|
||||
self.replay.last_tick = Some(now);
|
||||
|
||||
let ready_packets = match driver.advance(delta) {
|
||||
Ok(pkts) => pkts,
|
||||
Err(e) => {
|
||||
log::warn!("Studio replay advance error: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(ready_packets);
|
||||
let paused = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.is_some_and(|replay| replay.is_paused());
|
||||
if self.player_model.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
if !paused {
|
||||
let speed = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(1.0, |r| r.speed() as f32)
|
||||
.max(0.1);
|
||||
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.replay.virtual_tick_ms >= 50.0 {
|
||||
self.replay.virtual_tick_ms -= 50.0;
|
||||
if let Some(player) = &self.player {
|
||||
let current_pos = player.position;
|
||||
let (dx, dz) = (
|
||||
current_pos.x - self.previous_position.x,
|
||||
current_pos.z - self.previous_position.z,
|
||||
);
|
||||
let walked = (dx * dx + dz * dz).sqrt() as f32;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous = self.previous_position;
|
||||
m.position = current_pos;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
let target_speed = (walked * 4.0).min(1.0);
|
||||
m.limb_speed += (target_speed - m.limb_speed) * 0.4;
|
||||
if m.limb_speed < 0.001 {
|
||||
m.limb_speed = 0.0;
|
||||
}
|
||||
m.limb_position += m.limb_speed;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_yaw = m.head_yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
m.yaw = self.yaw;
|
||||
m.head_yaw = self.yaw;
|
||||
m.head_pitch = self.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
if dx * dx + dz * dz > 0.0025 {
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing =
|
||||
((self.yaw - walk + 180.0).rem_euclid(360.0) - 180.0).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
let diff = (target - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
m.body_yaw += diff * 0.3;
|
||||
let body_diff = (self.yaw - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if body_diff.abs() > 50.0 {
|
||||
m.body_yaw += body_diff - body_diff.signum() * 50.0;
|
||||
}
|
||||
m.age = m.age.wrapping_add(1);
|
||||
}
|
||||
self.previous_position = current_pos;
|
||||
}
|
||||
}
|
||||
} else if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
}
|
||||
self.studio_replay_advanced();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Control socket command handler for `studio`.
|
||||
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
|
||||
if words.first().copied() != Some("studio") {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("record") => match words.get(2).copied() {
|
||||
Some("start") => {
|
||||
let name = words.get(3).copied();
|
||||
let res = self.start_studio_recording(name)?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("stop") => {
|
||||
let res = self.stop_studio_recording()?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("status") => {
|
||||
let res = self.studio_recording_status();
|
||||
Ok(Some(res))
|
||||
}
|
||||
_ => Ok(Some(json!({ "error": "unknown studio record subcommand" }))),
|
||||
},
|
||||
Some("replay") => match words.get(2).copied() {
|
||||
Some("open") => {
|
||||
let path = words.get(3).ok_or("missing path for studio replay open")?;
|
||||
let res = self.open_studio_replay(path)?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(true);
|
||||
Ok(Some(json!({ "ok": true, "paused": true })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(false);
|
||||
Ok(Some(json!({ "ok": true, "paused": false })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("speed") => {
|
||||
let spd_str = words.get(3).ok_or("missing speed value")?;
|
||||
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("seek") => {
|
||||
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
|
||||
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
|
||||
let pos = if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.seek(ms).map_err(|e| format!("{e}"))?;
|
||||
Some(r.position_ms())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(pos) = pos {
|
||||
self.reset_studio_playback_ticks();
|
||||
Ok(Some(json!({ "ok": true, "position_ms": pos })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"active": true,
|
||||
"position_ms": r.position_ms(),
|
||||
"duration_ms": r.duration_ms(),
|
||||
"progress": r.progress(),
|
||||
"speed": r.speed(),
|
||||
"paused": r.is_paused(),
|
||||
"finished": r.is_finished(),
|
||||
})))
|
||||
} else {
|
||||
Ok(Some(json!({ "ok": true, "active": false })))
|
||||
}
|
||||
}
|
||||
Some("close") => {
|
||||
let closed = self.close_studio_replay();
|
||||
Ok(Some(json!({ "ok": true, "closed": closed })))
|
||||
}
|
||||
_ => Ok(Some(json!({ "error": "unknown studio replay subcommand" }))),
|
||||
},
|
||||
Some("screen") if super::studio::EDITOR => self.studio_screen_control(words),
|
||||
_ => Ok(Some(json!({ "error": "unknown studio command" }))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Ok(json!({ "ok": false, "error": "no active world session" }));
|
||||
};
|
||||
if r.session.is_recording() {
|
||||
return Ok(json!({ "ok": false, "error": "recording already active" }));
|
||||
}
|
||||
let _ = std::fs::create_dir_all("recordings");
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let filename = match name {
|
||||
Some(n) if !n.is_empty() => format!("{}_{}.mcpr", n, ts),
|
||||
_ => format!("recording_{}.mcpr", ts),
|
||||
};
|
||||
let path = PathBuf::from("recordings").join(filename);
|
||||
let server_address = if r.session.address.is_empty() {
|
||||
"local".to_owned()
|
||||
} else {
|
||||
r.session.address.clone()
|
||||
};
|
||||
let is_singleplayer = server_address == "local";
|
||||
let meta = Metadata::new(
|
||||
server_address,
|
||||
is_singleplayer,
|
||||
horizon_core::release::VERSION.into(),
|
||||
);
|
||||
match horizon_client::studio::StudioRecorder::start(path.clone(), meta) {
|
||||
Ok(recorder) => {
|
||||
r.session.start_recording(recorder, |recorder| {
|
||||
self.dump_world_snapshot_to_recorder(recorder);
|
||||
});
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string() }))
|
||||
}
|
||||
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Ok(json!({ "ok": false, "error": "no active world session" }));
|
||||
};
|
||||
match r.session.stop_recording() {
|
||||
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
|
||||
Some(Err(e)) => Ok(json!({ "ok": false, "error": e.to_string() })),
|
||||
None => Ok(json!({ "ok": false, "error": "no active recording" })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn studio_recording_status(&self) -> Value {
|
||||
if let Some(r) = self.remote.as_ref()
|
||||
&& let Some(status) = r.session.recorder_status()
|
||||
{
|
||||
json!({
|
||||
"ok": true,
|
||||
"active": status.active,
|
||||
"path": status.path.display().to_string(),
|
||||
"elapsed_ms": status.elapsed.as_millis(),
|
||||
"packet_count": status.packet_count,
|
||||
"bytes_written": status.bytes_written,
|
||||
})
|
||||
} else {
|
||||
json!({ "ok": true, "active": false })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
|
||||
if let Err(error) = self.save_studio_camera_track() {
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
// The recordings browser can be opened while the title screen is
|
||||
// available but the slower world tables are still loading. Replay
|
||||
// setup builds the normal client registries, which require `rest`.
|
||||
if self.remote.is_none() && self.res.rest.get().is_none() {
|
||||
let pending = PendingWorldEntry::StudioReplay(PathBuf::from(path));
|
||||
if self.defer_world_entry(pending)? {
|
||||
return Ok(json!({ "ok": true, "pending": true }));
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
}
|
||||
|
||||
// Validate and index the archive before replacing the title menu or active world. A
|
||||
// temp-file, metadata, or protocol error must leave the current screen usable.
|
||||
let driver = match ReplayDriver::open(Path::new(path)) {
|
||||
Ok(driver) => driver,
|
||||
Err(error) => {
|
||||
log::error!("Failed to open studio replay '{path}': {error}");
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
};
|
||||
|
||||
let standalone = self.replay.driver.is_none() && self.remote.is_none();
|
||||
if self.remote.is_none()
|
||||
&& let Err(error) = self.init_remote_for_replay()
|
||||
{
|
||||
log::error!("Failed to initialize studio replay session: {error}");
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
|
||||
let duration_ms = driver.duration_ms();
|
||||
let packets = driver.packet_count();
|
||||
self.studio_replay_opened(&driver);
|
||||
if self.replay.driver.is_none() {
|
||||
self.replay.previous_camera_type = Some(self.camera_type);
|
||||
self.replay.standalone = standalone;
|
||||
}
|
||||
self.replay.driver = Some(driver);
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
self.title = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
self.dragging = false;
|
||||
// Apply timestamp-zero login, player pose, and chunk packets before the first rendered
|
||||
// frame so playback opens at the recorded player's viewpoint.
|
||||
let _ = self.advance_studio_replay();
|
||||
self.reset_studio_playback_ticks();
|
||||
log::info!("Studio: Opened replay '{path}' ({duration_ms} ms, {packets} packets)");
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"duration_ms": duration_ms,
|
||||
"packet_count": packets,
|
||||
}))
|
||||
}
|
||||
pub(super) fn close_studio_replay(&mut self) -> bool {
|
||||
self.studio_replay_closing();
|
||||
let closed = self.replay.driver.take().is_some();
|
||||
if !closed {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A standalone replay owns a synthetic Remote connection. Treat that
|
||||
// connection as replay state even if the saved origin flag was lost;
|
||||
// sending it through leave_server() opens the Multiplayer screen.
|
||||
let standalone = self.replay.standalone
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.brand == "horizon-studio");
|
||||
self.replay.standalone = false;
|
||||
if let Some(camera_type) = self.replay.previous_camera_type.take() {
|
||||
self.camera_type = camera_type;
|
||||
}
|
||||
self.replay.last_tick = None;
|
||||
self.studio_replay_closed();
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
self.capture(false);
|
||||
if standalone {
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
remote.session.disconnect();
|
||||
}
|
||||
self.using = None;
|
||||
self.end_mod_use();
|
||||
if let Err(error) = self.finish_close_world() {
|
||||
log::error!("Failed to close standalone Studio replay cleanly: {error}");
|
||||
}
|
||||
self.reset_studio_hand_state();
|
||||
// A title-screen replay has no previous world perspective to
|
||||
// restore. Start the next world in first person with visible hands.
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.mode = horizon_client::title::Mode::Main;
|
||||
}
|
||||
} else if self.integrated.is_none() && self.lan.is_none() {
|
||||
let _ = self.close_world();
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.mode = horizon_client::title::Mode::Main;
|
||||
}
|
||||
}
|
||||
closed
|
||||
}
|
||||
/// Dump current player pose, dimension login, center chunk, and resident loaded chunks
|
||||
/// into the recording stream at timestamp 0ms so replays accurately reconstruct the world.
|
||||
pub(super) fn dump_world_snapshot_to_recorder(
|
||||
&self,
|
||||
recorder: &horizon_client::studio::StudioRecorder,
|
||||
) {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let record_cb = |pkt: horizon_protocol::packets::play::Clientbound| {
|
||||
let encoded = pkt.encode();
|
||||
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
|
||||
if let Ok(id) = reader.varint() {
|
||||
let payload = reader.rest();
|
||||
recorder.record_packet_at(0, id, payload);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. C::Login packet baseline
|
||||
let login = horizon_protocol::packets::play::Clientbound::Login(
|
||||
horizon_protocol::packets::play::Login {
|
||||
player_id: r.entity_id,
|
||||
hardcore: self.level.hardcore,
|
||||
levels: vec!["minecraft:overworld".into()],
|
||||
max_players: 20,
|
||||
chunk_radius: self.settings.render_distance,
|
||||
simulation_distance: self.settings.simulation_distance,
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: true,
|
||||
limited_crafting: false,
|
||||
spawn: horizon_protocol::packets::play::SpawnInfo {
|
||||
dimension_type: 0,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
seed: 0,
|
||||
game_type: 0,
|
||||
previous_game_type: None,
|
||||
debug: false,
|
||||
flat: false,
|
||||
last_death: None,
|
||||
portal_cooldown: 0,
|
||||
sea_level: 63,
|
||||
},
|
||||
online_mode: false,
|
||||
enforces_secure_chat: false,
|
||||
},
|
||||
);
|
||||
record_cb(login);
|
||||
|
||||
// 2. Center chunk and player position
|
||||
let (pos, yaw, pitch) = if let Some(p) = &self.player {
|
||||
(
|
||||
glam::DVec3::new(p.position.x, p.position.y, p.position.z),
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
)
|
||||
} else {
|
||||
(glam::DVec3::ZERO, self.yaw, self.pitch)
|
||||
};
|
||||
let chunk_x = (pos.x.floor() as i32) >> 4;
|
||||
let chunk_z = (pos.z.floor() as i32) >> 4;
|
||||
|
||||
let center_chunk = horizon_protocol::packets::play::Clientbound::SetChunkCacheCenter(
|
||||
horizon_protocol::packets::play::SetChunkCacheCenter {
|
||||
x: chunk_x,
|
||||
z: chunk_z,
|
||||
},
|
||||
);
|
||||
record_cb(center_chunk);
|
||||
|
||||
let player_pos = horizon_protocol::packets::play::Clientbound::PlayerPosition(
|
||||
horizon_protocol::packets::play::PlayerPosition {
|
||||
teleport: 0,
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
delta: [0.0, 0.0, 0.0],
|
||||
yaw,
|
||||
pitch,
|
||||
relatives: 0,
|
||||
},
|
||||
);
|
||||
record_cb(player_pos);
|
||||
|
||||
// 3. Encode resident loaded chunks into LevelChunkWithLight packets
|
||||
let biome_names: Vec<String> = world_biomes!(self)
|
||||
.list
|
||||
.iter()
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
let registry: Vec<String> = r
|
||||
.registries
|
||||
.registries
|
||||
.get("minecraft:worldgen/biome")
|
||||
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
|
||||
.unwrap_or_else(|| biome_names.clone());
|
||||
|
||||
let encoder = crate::lan::chunks::ChunkEncoder::with_signs(
|
||||
self.res.facts().states.iter().map(|s| {
|
||||
(
|
||||
s.name.clone(),
|
||||
s.fluid_level > 0 || s.waterlogged,
|
||||
s.sign_form(),
|
||||
)
|
||||
}),
|
||||
&r.builtin,
|
||||
&biome_names,
|
||||
®istry,
|
||||
);
|
||||
|
||||
let mut count = 0;
|
||||
for chunk in self.world.chunks.values() {
|
||||
let level_chunk = encoder.encode(chunk, &self.world.block_entities);
|
||||
record_cb(
|
||||
horizon_protocol::packets::play::Clientbound::LevelChunkWithLight(level_chunk),
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
log::info!(
|
||||
"Studio Recorder: Dumped initial world snapshot ({count} chunks) at timestamp 0ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,13 @@ pub(super) struct RespawnConfig {
|
||||
pub dim: Dim,
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
/// block, yaw, pitch and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
@@ -43,19 +44,37 @@ impl RespawnConfig {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
pitch: 0.0,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
/// `SetSpawnCommand`: the forced literal, with its already parsed angles.
|
||||
pub(super) fn forced(dim: Dim, pos: [i32; 3], yaw: f32, pitch: f32) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw,
|
||||
pitch,
|
||||
forced: true,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, pitch, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
pitch: *pitch,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
(
|
||||
self.dim.id().to_owned(),
|
||||
self.pos,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.forced,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +199,7 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
// RespawnConfig.isSamePosition: dimension and block.
|
||||
@@ -478,14 +498,14 @@ impl Game<'_> {
|
||||
rule.can_set_spawn.test(self.dark_outside())
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: the position and yaw, using an anchor's charge; a bed's
|
||||
/// dimension: position and angles, using an anchor's charge; a bed's
|
||||
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
|
||||
/// holds if a player fits in it; anything else is gone.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
consume: bool,
|
||||
) -> Result<Option<(Vec3, f32)>> {
|
||||
) -> Result<Option<(Vec3, f32, f32)>> {
|
||||
let state = self.world.block(c.pos[0], c.pos[1], c.pos[2]);
|
||||
if !(self.is_anchor(state)
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
@@ -495,7 +515,7 @@ impl Game<'_> {
|
||||
if self.is_bed(state) && self.bed_sets_spawn(state) {
|
||||
let facing = self.bed_forward(state);
|
||||
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
@@ -503,7 +523,7 @@ impl Game<'_> {
|
||||
&& self.possible_to_respawn_in(state)
|
||||
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
|
||||
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
return Ok(free.then_some((at, c.yaw, c.pitch)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
@@ -513,7 +533,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.level_set(c.pos, next)?;
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,3 +580,354 @@ mod tests {
|
||||
assert!((look_at_yaw(east, head) - 90.0).abs() < 1.0e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod connected_spawnpoint_rotation_tests {
|
||||
use crate::game::{Game, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig, playerdata},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn command(game: &mut Game<'_>, text: &str) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
game.ui.registries.permission = 4;
|
||||
let dispatcher = horizon_client::command::vanilla::build_for(false, true);
|
||||
let parsed = dispatcher.parse(text, &game.ui.registries);
|
||||
let (ctx, _) = parsed
|
||||
.executable()
|
||||
.expect("actual registered dedicated command");
|
||||
let (root, ctx) = (parsed.context.clone(), ctx.clone());
|
||||
assert_eq!(game.run_chain(&root, &ctx).unwrap(), 1, "{text}");
|
||||
assert!(
|
||||
!game.chat_capture.take().unwrap().is_empty(),
|
||||
"actual command feedback {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn command_forced_free_point_survives_real_guest_death_and_respawn_packet() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.set_allow_commands(true);
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-forced-spawn-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
// Fixture setup through the existing column insertion API; admit every lit column below.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.streaming
|
||||
.insert_remote(&mut game.world, Chunk::empty(x, z));
|
||||
assert!(game.streaming.is_lit(x, z));
|
||||
}
|
||||
}
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let air = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.unwrap();
|
||||
game.world.set(12, 99, 12, stone).unwrap();
|
||||
for y in [100, 101] {
|
||||
game.world.set(12, y, 12, air).unwrap();
|
||||
}
|
||||
assert_eq!(game.world.block(12, 99, 12), stone);
|
||||
assert!(game.world.loaded(12, 12));
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.lan_host(
|
||||
LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
listen: false,
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, String::new(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 7,
|
||||
uuid: 77,
|
||||
name: "Alice".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:7".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
7,
|
||||
[4.5, 100.0, 4.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.health = 20.0;
|
||||
guest.game_mode = 0;
|
||||
guest.yaw = 0.0;
|
||||
guest.pitch = 0.0;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
command(&mut game, "spawnpoint Alice 12 100 12 45 10");
|
||||
let host = game.lan.as_ref().unwrap();
|
||||
assert!(host.owner.is_none());
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(
|
||||
(guest.uuid, guest.health, guest.game_mode, guest.position),
|
||||
(77, 20.0, 0, [4.5, 100.0, 4.5])
|
||||
);
|
||||
let tag = playerdata::player_tag(guest);
|
||||
let respawn = tag
|
||||
.get("respawn")
|
||||
.expect("actual serialized personal configuration")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
respawn.get("yaw"),
|
||||
Some(&horizon_world::nbt::Tag::Float(45f32.to_bits()))
|
||||
);
|
||||
assert_eq!(
|
||||
respawn.get("pos"),
|
||||
Some(&horizon_world::nbt::Tag::IntArray(vec![12, 100, 12]))
|
||||
);
|
||||
command(&mut game, "kill Alice");
|
||||
game.tick_lan(false);
|
||||
let mut host = game.lan.take().unwrap();
|
||||
assert_eq!(host.hub.guests[0].uuid, 77);
|
||||
assert_eq!(host.hub.guests[0].health, 0.0);
|
||||
assert!(
|
||||
host.hub.guests[0].dead,
|
||||
"actual command death before request"
|
||||
);
|
||||
for _ in rx.try_iter() {}
|
||||
game.lan_guest_packet(
|
||||
&mut host,
|
||||
7,
|
||||
S::ClientCommand(play::ClientCommand { action: 0 }),
|
||||
);
|
||||
game.tick_server_travel(&mut host);
|
||||
assert!(
|
||||
!game.on_server_trip(7),
|
||||
"admitted lit columns permit actual arrival"
|
||||
);
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(guest.uuid, 77);
|
||||
assert_eq!(guest.health, 20.0);
|
||||
assert!(!guest.dead);
|
||||
assert_eq!(guest.position, [12.5, 100.1, 12.5]);
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
let physical = [guest.yaw.to_bits(), guest.pitch.to_bits()];
|
||||
let awaiting_id = guest
|
||||
.awaiting_teleport
|
||||
.expect("actual final arrival awaits its acknowledgement")
|
||||
.0;
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(p) => packets.push(p),
|
||||
Out::Packets(p) => packets.extend(p),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut positions = Vec::new();
|
||||
let mut respawns = 0;
|
||||
for p in packets {
|
||||
println!("complete actual Hub frame {p:?}");
|
||||
let mut r = horizon_protocol::buf::Reader::new(&p);
|
||||
let id = r.varint().unwrap();
|
||||
match C::decode(id, r.rest()).unwrap() {
|
||||
C::PlayerPosition(p) => positions.push(p),
|
||||
C::Respawn(_) => respawns += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(respawns, 1);
|
||||
assert!(!positions.is_empty());
|
||||
println!("all actual arrival positions {positions:?}");
|
||||
let selected: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|p| p.teleport == awaiting_id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
selected.len(),
|
||||
1,
|
||||
"select by actual authoritative pending id, never expected position/pitch"
|
||||
);
|
||||
let p = selected[0];
|
||||
assert_eq!(p.position, [12.5, 100.1, 12.5]);
|
||||
// All successful command/death/arrival/identity/world controls precede the intended regression.
|
||||
assert_eq!(
|
||||
(
|
||||
respawn.get("pitch"),
|
||||
physical,
|
||||
[p.yaw.to_bits(), p.pitch.to_bits()]
|
||||
),
|
||||
(
|
||||
Some(&horizon_world::nbt::Tag::Float(10f32.to_bits())),
|
||||
[45f32.to_bits(), 10f32.to_bits()],
|
||||
[45f32.to_bits(), 10f32.to_bits()]
|
||||
)
|
||||
);
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pitch_preservation_controls {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrapped_existing_callers_default_to_positive_zero_pitch() {
|
||||
let c = RespawnConfig::wrapped(Dim::Overworld, [5, 100, 5], 45.0, false);
|
||||
assert_eq!(c.yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(c.pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(!c.forced);
|
||||
}
|
||||
|
||||
/// Added source/model controls; these do not add a connected bed/anchor runtime claim.
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn bed_anchor_facing_and_invalid_personal_fallback_ignore_stored_pitch() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
game.world.set(x, 99, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let state = |game: &Game<'_>, name: &str, properties: &[(&str, &str)]| {
|
||||
let registry = game.pack.assets.registry;
|
||||
let id = registry.default_state(name).unwrap();
|
||||
let mut value = registry.state(id).unwrap().clone();
|
||||
for (key, value_) in properties {
|
||||
value.properties.insert((*key).into(), (*value_).into());
|
||||
}
|
||||
registry.resolve(&value).unwrap()
|
||||
};
|
||||
let head = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "head")],
|
||||
);
|
||||
let foot = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "foot")],
|
||||
);
|
||||
game.world.set(5, 100, 5, head).unwrap();
|
||||
game.world.set(5, 100, 6, foot).unwrap();
|
||||
assert!(game.is_bed(head) && game.bed_sets_spawn(head));
|
||||
let bed = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [5, 100, 5],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(bed, false)
|
||||
.unwrap()
|
||||
.expect("valid bed geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..bed }, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, bed.pos).to_bits());
|
||||
|
||||
// Existing built-in dimension facts select the anchor rule; no new world or actor API.
|
||||
game.dim = Dim::Nether;
|
||||
let anchor = state(&game, "minecraft:respawn_anchor", &[("charges", "2")]);
|
||||
game.world.set(10, 100, 10, anchor).unwrap();
|
||||
assert!(game.anchor_works() && game.is_anchor(anchor));
|
||||
let c = RespawnConfig {
|
||||
dim: Dim::Nether,
|
||||
pos: [10, 100, 10],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(c, false)
|
||||
.unwrap()
|
||||
.expect("valid anchor geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..c }, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, c.pos).to_bits());
|
||||
assert_eq!(
|
||||
game.world.block(10, 100, 10),
|
||||
anchor,
|
||||
"existing forced-charge behavior"
|
||||
);
|
||||
|
||||
game.dim = Dim::Overworld;
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.world.set(12, 100, 12, stone).unwrap();
|
||||
let invalid = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [12, 100, 12],
|
||||
yaw: 45.0,
|
||||
pitch: 10.0,
|
||||
forced: true,
|
||||
};
|
||||
game.respawn_config = Some(invalid);
|
||||
assert!(game.resolve_server_respawn(invalid).is_none());
|
||||
assert!(game.respawn_config.is_none());
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,7 @@ impl Game<'_> {
|
||||
path,
|
||||
size: Some([64, 64]),
|
||||
scaled: false,
|
||||
background: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -823,7 +823,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.cosmetics_frame();
|
||||
self.client_hooks_frame();
|
||||
}
|
||||
/// `Hud.extractTabList`: the player list while the player-list key is
|
||||
/// held. A local world shows it with a joined LAN guest or a `list`
|
||||
@@ -900,10 +900,12 @@ impl Game<'_> {
|
||||
let board = &self.score.board;
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let up = |id: u128| upside_down.contains(&id);
|
||||
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
|
||||
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
|
||||
let horizon_mark = super::cosmetics::MARK;
|
||||
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
|
||||
// A hook's mark before a name (Horizon's: they play Horizon).
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let marked = horizon_hooks::engine::player_mark;
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board,
|
||||
|
||||
@@ -138,10 +138,7 @@ impl Game<'_> {
|
||||
return Err("a screenshot sequence is already running".into());
|
||||
}
|
||||
let renderer = self.renderer.as_ref().ok_or("no renderer yet")?;
|
||||
if self.pending_screenshot.is_some()
|
||||
|| self.ui.screenshot_message.is_some()
|
||||
|| renderer.screenshot.is_some()
|
||||
{
|
||||
if self.pending_screenshot.is_some() || renderer.screenshot.is_some() {
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
if let Some(refusal) = self.sequence_refusal() {
|
||||
@@ -233,6 +230,7 @@ impl Game<'_> {
|
||||
path,
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.screenshot_result = None;
|
||||
}
|
||||
|
||||
@@ -644,11 +644,14 @@ impl Game<'_> {
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten, and the
|
||||
/// caller tells the player (`Hub::missing_respawn_block`).
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
pub(super) fn resolve_server_respawn(
|
||||
&mut self,
|
||||
config: RespawnConfig,
|
||||
) -> Option<(Vec3, f32, f32)> {
|
||||
let [x, y, z] = config.pos;
|
||||
let anchor = self.is_anchor(self.world.block(x, y, z));
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw))) => {
|
||||
Ok(Some((at, yaw, pitch))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
@@ -658,7 +661,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw))
|
||||
Some((at, yaw, pitch))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let mut cancel = false;
|
||||
@@ -796,7 +799,10 @@ impl Game<'_> {
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some(found)) => found,
|
||||
Some(Some((at, yaw, pitch))) => {
|
||||
host.hub.guests[i].pitch = pitch;
|
||||
(at, yaw)
|
||||
}
|
||||
_ => {
|
||||
host.hub.missing_respawn_block(trip.conn);
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
|
||||
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
|
||||
.get(i.wrapping_sub(1))
|
||||
.map(|g| g.options.clone()),
|
||||
);
|
||||
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
self.turn = i;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ impl Split<'_> {
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
let mut settings = self.host.settings.clone();
|
||||
if pad.is_some() {
|
||||
// The Controller setting is P1's own: a player who pressed a pad to join plays
|
||||
@@ -135,6 +136,7 @@ impl Split<'_> {
|
||||
}
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
@@ -299,16 +301,31 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
let made = if options.exists() {
|
||||
None
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
let mut new = self.host.settings.clone();
|
||||
// The Controller setting is P1's, as on the profile page (`join`).
|
||||
new.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// On P1's seat, so the crates' settings stores write P1's values too.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
new.save(&options);
|
||||
new
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
};
|
||||
// The crates' stores load into the guest's seat, before its turn starts below.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
|
||||
let mut settings = match made {
|
||||
Some(new) => {
|
||||
horizon_client::settings::Settings::load_stores(&options);
|
||||
new
|
||||
}
|
||||
None => horizon_client::settings::Settings::load(&options),
|
||||
};
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
|
||||
settings.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
|
||||
|
||||
@@ -1268,7 +1268,7 @@ impl Loading {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
self.menu
|
||||
.options
|
||||
.draw(&self.settings, output, batch, &gui.atlas, self.menu.cursor);
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(super) fn rename_box() -> horizon_client::ui::EditBox {
|
||||
edit.text_color = u32::MAX;
|
||||
// AnvilScreen also sets `setTextColorUneditable(-1)`: white either way.
|
||||
edit.text_color_uneditable = u32::MAX;
|
||||
// AnvilScreen sets `setInvertHighlightedTextColor(false)`.
|
||||
edit.invert_highlighted_text_color = false;
|
||||
edit.focused = true;
|
||||
edit.editable = false;
|
||||
edit
|
||||
@@ -40,6 +42,7 @@ mod tests {
|
||||
// AnvilScreen.init: no input yet, so the box is read-only but focused.
|
||||
let station_box = rename_box();
|
||||
assert!(station_box.focused && !station_box.editable);
|
||||
assert!(!station_box.invert_highlighted_text_color);
|
||||
|
||||
// slotChanged with an item: editable, and it shows the item's name.
|
||||
let mut station = Station::new_kind(Kind::Anvil, [0; 3]);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -149,7 +149,7 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play_unshared("minecraft:item.totem.use", Some(body.bounds.feet), 1.0, 1.0);
|
||||
if target == TrackingTarget::LocalPlayer {
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
|
||||
self.input_feedback(horizon_client::input_source::Feedback::Totem);
|
||||
// findTotem, then LocalPlayer.displayItemActivation. The offsets
|
||||
// come from the player's own random in vanilla, not the level's:
|
||||
// the client-only particle random stands in for it. The server
|
||||
|
||||
@@ -298,7 +298,7 @@ impl super::Game<'_> {
|
||||
.into_iter()
|
||||
.any(|action| self.keys.held(action))
|
||||
|| self
|
||||
.pad_move()
|
||||
.input_move()
|
||||
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
|
||||
{
|
||||
self.tutorial.input();
|
||||
|
||||
@@ -107,9 +107,9 @@ impl Game<'_> {
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
}
|
||||
) && !self.pad.menu.synthetic
|
||||
) && !self.input_made_pointer_event()
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.studio_replay.is_some() {
|
||||
if self.replay.playing() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -165,6 +165,22 @@ impl<T: Joined> JoinList<T> {
|
||||
self.push(entity);
|
||||
}
|
||||
}
|
||||
/// An entity that never left its level keeps its place in
|
||||
/// `EntityTickList`: one a pass took out and puts back (an arrow bouncing
|
||||
/// off) goes back before the first entity that joined after it. One that
|
||||
/// has not joined yet is appended and numbered, as by `push`.
|
||||
pub fn put_back(&mut self, entity: T) {
|
||||
let seq = entity.join_seq();
|
||||
let at = if seq.is_joined() {
|
||||
self.list
|
||||
.iter()
|
||||
.position(|q| q.join_seq() > seq)
|
||||
.unwrap_or(self.list.len())
|
||||
} else {
|
||||
self.list.len()
|
||||
};
|
||||
self.insert(at, entity);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a JoinList<T> {
|
||||
@@ -193,6 +209,33 @@ impl<T> IntoIterator for JoinList<T> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct E {
|
||||
join: JoinSeq,
|
||||
}
|
||||
crate::impl_joined!(E);
|
||||
|
||||
/// A numbered entity put back lands by its join number; a new one goes last.
|
||||
#[test]
|
||||
fn put_back_keeps_the_join_order() {
|
||||
let mut list = JoinList::default();
|
||||
for _ in 0..3 {
|
||||
list.push(E {
|
||||
join: JoinSeq::UNJOINED,
|
||||
});
|
||||
}
|
||||
let first = list.remove(0);
|
||||
let first_seq = first.join;
|
||||
list.put_back(first);
|
||||
assert_eq!(list[0].join, first_seq, "back before the later joins");
|
||||
list.put_back(E {
|
||||
join: JoinSeq::UNJOINED,
|
||||
});
|
||||
let seqs: Vec<_> = list.iter().map(|e| e.join).collect();
|
||||
assert!(seqs.windows(2).all(|w| w[0] < w[1]), "{seqs:?}");
|
||||
assert_eq!(seqs.len(), 4);
|
||||
}
|
||||
|
||||
/// Whatever can change a list changes its stamp; only reading leaves it.
|
||||
#[test]
|
||||
fn a_stamp_holds_until_the_list_can_have_changed() {
|
||||
|
||||
@@ -173,10 +173,10 @@ pub struct Guest {
|
||||
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
|
||||
/// None for the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point outside the Overworld (a respawn anchor, or one
|
||||
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
|
||||
/// forced, as the player's save keeps it (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// A block-resolved personal respawn point: its dimension, block, yaw,
|
||||
/// pitch and whether it is forced, as the player's save keeps it
|
||||
/// (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
|
||||
@@ -343,7 +343,7 @@ struct Kept {
|
||||
effects: Option<crate::effects::PlayerEffects>,
|
||||
game_mode: Option<(i32, bool, f32)>,
|
||||
position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, f32, bool)>)>,
|
||||
recipes: Option<crate::recipe_book::Settings>,
|
||||
/// The body's values: its fall goes with the position, its air and fire with the
|
||||
/// health.
|
||||
|
||||
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
|
||||
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
|
||||
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
|
||||
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
|
||||
/// ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
|
||||
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
|
||||
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -246,7 +246,9 @@ impl Goal for DefendVillage {
|
||||
// wider, as the box test is strict).
|
||||
let near = b.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
// Villager.getPlayerReputation of the player this golem sees: the
|
||||
// villager's gossips about that player's own UUID.
|
||||
// villager's gossips about that player's own UUID. The villagers come
|
||||
// through the same `attackTargeting` (`forCombat().range(64)`), so the
|
||||
// golem must see them too.
|
||||
let bad = cx.w.others_near(near).any(|s| {
|
||||
s.species == Species::Villager
|
||||
&& s.alive
|
||||
@@ -255,6 +257,7 @@ impl Goal for DefendVillage {
|
||||
.is_some_and(|v| v.gossips.reputation(p.uuid) <= -100)
|
||||
&& b.contains(s.position)
|
||||
&& s.position.distance_squared(m.position) <= 64.0 * 64.0
|
||||
&& m.sees(cx.w, Who::Mob(s.id))
|
||||
});
|
||||
bad && m.sees(cx.w, Who::Player)
|
||||
}
|
||||
@@ -322,6 +325,39 @@ impl Goal for MoveTowardsTarget {
|
||||
m.nav_stop();
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// `IronGolem.mobInteract`: the repair sound's pitch comes from the
|
||||
/// golem's own random, not the level's.
|
||||
pub fn golem_repair_pitch(&mut self) -> f32 {
|
||||
1.0 + (self.random.next_float() - self.random.next_float()) * 0.2
|
||||
}
|
||||
}
|
||||
/// `Level.isBrightOutside`: a fixed-time dimension (the End) is never bright.
|
||||
fn bright_outside(w: &MobWorld) -> bool {
|
||||
w.daylight && !w.dimension_type.has_fixed_time
|
||||
}
|
||||
/// `OfferFlowerGoal.getNearestEntity`: the nearest of
|
||||
/// #candidate_for_iron_golem_gift (villagers and copper golems) in the golem's
|
||||
/// box inflated by (6, 2, 6) that passes `forNonCombat().range(6.0)`: within
|
||||
/// six blocks and in sight.
|
||||
fn gift_candidate(m: &mut Mob, w: &MobWorld) -> Option<u32> {
|
||||
let area = m.bounds().inflate(Vec3::new(6.0, 2.0, 6.0));
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for s in w.others_near(area) {
|
||||
if !matches!(s.species, Species::Villager | Species::CopperGolem)
|
||||
|| !s.alive
|
||||
|| !area.intersects(s.bounds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = s.position.distance_squared(m.position);
|
||||
if d > 36.0 || best.is_some_and(|(b, _)| d >= b) || !m.sees(w, Who::Mob(s.id)) {
|
||||
continue;
|
||||
}
|
||||
best = Some((d, s.id));
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
/// `OfferFlowerGoal`: by day, now and then, hold out a poppy to a villager.
|
||||
#[derive(Clone, Debug)]
|
||||
struct OfferFlower {
|
||||
@@ -334,16 +370,10 @@ impl Goal for OfferFlower {
|
||||
MOVE | LOOK
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
if !cx.w.daylight || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
|
||||
if !bright_outside(cx.w) || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
|
||||
return false;
|
||||
}
|
||||
// The nearest of #candidate_for_iron_golem_gift: villagers and copper golems.
|
||||
self.villager =
|
||||
cx.w.nearby(m.position, 6.0, |s| {
|
||||
matches!(s.species, Species::Villager | Species::CopperGolem) && s.alive
|
||||
})
|
||||
.map(|s| s.id)
|
||||
.next();
|
||||
self.villager = gift_candidate(m, cx.w);
|
||||
self.villager.is_some()
|
||||
}
|
||||
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
|
||||
@@ -612,6 +642,78 @@ mod tests {
|
||||
Mob::new(id, Arc::new(kind), at, 0.0)
|
||||
}
|
||||
|
||||
fn villager_at(id: u32, at: Vec3) -> crate::mobs::Seen {
|
||||
mob(id, Species::Villager, at).seen(None)
|
||||
}
|
||||
|
||||
fn pick(w: &MobWorld) -> Option<u32> {
|
||||
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
|
||||
gift_candidate(&mut golem, w)
|
||||
}
|
||||
|
||||
fn with_chunks(world: &mut crate::world::World, _: &horizon_world::registry::BlockRegistry) {
|
||||
for cx in -2..2 {
|
||||
for cz in -2..2 {
|
||||
world.chunks.insert(
|
||||
(cx, cz),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `OfferFlowerGoal`: the search box is the golem's box inflated by
|
||||
/// (6, 2, 6) and the candidate must be within six blocks (Euclidean).
|
||||
#[test]
|
||||
fn the_poppy_goes_to_a_villager_in_the_box_within_six_blocks() {
|
||||
let cases = [
|
||||
(Vec3::new(4.5, 64.0, 0.5), true),
|
||||
// 6.5 blocks on one axis: outside the range.
|
||||
(Vec3::new(7.0, 64.0, 0.5), false),
|
||||
// About 8.5 blocks away, inside the per-axis box of six.
|
||||
(Vec3::new(6.0, 64.0, 6.0), false),
|
||||
// Five blocks above: outside the box's two-block rise.
|
||||
(Vec3::new(0.5, 69.0, 0.5), false),
|
||||
];
|
||||
for (at, offered) in cases {
|
||||
let villager = villager_at(2, at);
|
||||
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
|
||||
assert_eq!(pick(w).is_some(), offered, "villager at {at:?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidate must also be in sight, and a fixed-time dimension is
|
||||
/// never bright outside.
|
||||
#[test]
|
||||
fn the_poppy_needs_sight_and_a_dimension_with_a_day() {
|
||||
let villager = villager_at(2, Vec3::new(4.5, 64.0, 0.5));
|
||||
let wall = |world: &mut crate::world::World, r: &horizon_world::registry::BlockRegistry| {
|
||||
with_chunks(world, r);
|
||||
let stone = r.default_state("minecraft:stone").unwrap();
|
||||
for y in 62..70 {
|
||||
for z in -6..8 {
|
||||
world.set(2, y, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::mobs::world::with_built_test_world(std::slice::from_ref(&villager), wall, |w, _| {
|
||||
assert_eq!(pick(w), None, "a villager behind a wall");
|
||||
});
|
||||
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
|
||||
assert!(bright_outside(w));
|
||||
assert_eq!(pick(w), Some(2));
|
||||
let mut end = w.clone();
|
||||
end.dimension_type =
|
||||
horizon_world::dimension_type::DimensionFacts::vanilla("minecraft:the_end")
|
||||
.unwrap();
|
||||
assert!(!bright_outside(&end));
|
||||
let mut night = w.clone();
|
||||
night.daylight = false;
|
||||
assert!(!bright_outside(&night));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snow_trail_posts_block_place_with_golem_and_snow_state() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Animation {
|
||||
/// KeyframeAnimation.apply(millis, scale): offsets added to each bone's pose.
|
||||
pub fn apply(&self, millis: i64, scale: f32, poses: &mut super::anim::Poses) {
|
||||
let mut seconds = millis as f32 / 1000.0;
|
||||
if self.looping {
|
||||
if self.looping && self.length > 0.0 {
|
||||
seconds %= self.length;
|
||||
}
|
||||
for (bone, channels) in &self.bones {
|
||||
@@ -231,4 +231,34 @@ mod tests {
|
||||
];
|
||||
assert!((sample(&k, 1.0, 1.0)[0] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn looping_animation_applies_walk_without_overflow() {
|
||||
let anim = Animation {
|
||||
length: 1.25,
|
||||
looping: true,
|
||||
bones: vec![(
|
||||
"body".to_string(),
|
||||
vec![Channel {
|
||||
target: Target::Position,
|
||||
keyframes: vec![
|
||||
Keyframe {
|
||||
time: 0.0,
|
||||
pre: [0.0; 3],
|
||||
post: [0.0, 1.0, 0.0],
|
||||
catmull: false,
|
||||
},
|
||||
Keyframe {
|
||||
time: 1.25,
|
||||
pre: [0.0, 1.0, 0.0],
|
||||
post: [0.0, 1.0, 0.0],
|
||||
catmull: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
)],
|
||||
};
|
||||
let mut poses = super::super::anim::Poses::default();
|
||||
anim.apply_walk(50.0, 1.0, 1.0, 2.5, &mut poses);
|
||||
assert!((poses.get("body")[1] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user