mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cea8c2ee6 | ||
|
|
0694db7dcb | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b |
No files matched your search
@@ -13,3 +13,4 @@
|
||||
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,5 +198,3 @@ 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
+2
-11
@@ -1461,6 +1461,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1796,27 +1797,17 @@ 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"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -34,7 +34,6 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
-483
@@ -1,483 +0,0 @@
|
||||
# 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,8 +135,6 @@ 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,10 +15,6 @@ 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";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -115,7 +115,8 @@ 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 still in this crate (docs/forking.md, docs/layers.md): the features below.
|
||||
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
|
||||
@@ -386,7 +386,6 @@ 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,7 +207,6 @@ 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"));
|
||||
@@ -252,7 +251,6 @@ 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,7 +167,6 @@ 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();
|
||||
@@ -337,7 +336,6 @@ 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();
|
||||
|
||||
@@ -112,11 +112,7 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button { label } => 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))
|
||||
@@ -494,7 +490,9 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,6 @@ 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`.
|
||||
@@ -399,24 +397,6 @@ 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;
|
||||
@@ -515,7 +495,6 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -632,38 +611,6 @@ 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,7 +201,6 @@ 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;
|
||||
@@ -237,7 +236,6 @@ 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};
|
||||
@@ -1086,9 +1084,6 @@ 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
|
||||
@@ -1583,9 +1578,6 @@ 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,
|
||||
@@ -5085,7 +5077,6 @@ 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,
|
||||
@@ -5357,8 +5348,6 @@ 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,
|
||||
@@ -7052,10 +7041,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
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)
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7988,8 +7975,6 @@ 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()));
|
||||
@@ -8334,7 +8319,9 @@ 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.shader_pack_kind().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");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -12057,18 +12044,24 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
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_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()
|
||||
),
|
||||
}
|
||||
}
|
||||
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).
|
||||
@@ -12671,22 +12664,96 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
self.screenshot_result = Some(Err(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),
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
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,9 +144,6 @@ 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 {
|
||||
@@ -169,22 +166,7 @@ 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()
|
||||
@@ -291,7 +273,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -1110,8 +1110,7 @@ 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. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1142,9 +1141,6 @@ 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,49 +31,6 @@ 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();
|
||||
|
||||
@@ -162,38 +162,10 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
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
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -205,10 +177,7 @@ 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, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -492,84 +461,4 @@ 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,14 +7,6 @@ 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);
|
||||
@@ -37,10 +29,6 @@ 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,24 +59,6 @@ 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 {
|
||||
@@ -90,7 +72,6 @@ impl Default for PackColors {
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,13 +337,6 @@ 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()
|
||||
|
||||
@@ -72,6 +72,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -287,7 +287,6 @@
|
||||
"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,6 +102,8 @@ 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;
|
||||
|
||||
@@ -23,9 +23,8 @@ 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`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -373,7 +373,6 @@ 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)]
|
||||
@@ -403,7 +402,6 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_settings;
|
||||
mod render_tick;
|
||||
mod replay;
|
||||
mod repro;
|
||||
mod resource_load;
|
||||
mod resource_loader;
|
||||
@@ -2884,9 +2882,19 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// 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,
|
||||
/// 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,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5934,7 +5942,8 @@ 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 show_hud = !self.ui.hide_gui && !self.replay.playing();
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
// 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.
|
||||
@@ -6273,7 +6282,23 @@ impl Game<'_> {
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
self.draw_studio_bars(&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,
|
||||
);
|
||||
}
|
||||
if show_hud {
|
||||
self.draw_pad_guide(&mut batch, guide_from);
|
||||
}
|
||||
@@ -6496,12 +6521,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.flying() {
|
||||
if self.studio_freecam.active {
|
||||
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.
|
||||
@@ -6708,7 +6733,11 @@ 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 = self.own_player_alpha();
|
||||
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()
|
||||
};
|
||||
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() {
|
||||
@@ -7437,6 +7466,7 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9206,7 +9236,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.flying() {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9384,7 +9414,14 @@ 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());
|
||||
self.studio_camera_path(&mut g);
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
@@ -9406,7 +9443,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio.flying() {
|
||||
let view = if self.studio_freecam.active {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9532,7 +9569,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10425,7 +10462,21 @@ 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.studio_mouse_dragged(b.width) {
|
||||
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;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
@@ -10476,7 +10527,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.replay.playing() => {}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10753,7 +10804,90 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if self.studio_mouse_input(pressed, button) {
|
||||
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() {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11223,7 +11357,24 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
self.studio_fly(elapsed);
|
||||
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),
|
||||
);
|
||||
}
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
// other screens use the same keys for their own navigation.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -11408,18 +11559,17 @@ impl Game<'_> {
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
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 mut consumed = false;
|
||||
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()
|
||||
{
|
||||
@@ -11432,6 +11582,7 @@ impl Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) =
|
||||
self.renderer.as_mut().and_then(|r| r.capture_result.take())
|
||||
@@ -11440,6 +11591,12 @@ 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);
|
||||
@@ -12000,8 +12157,16 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
replay: Default::default(),
|
||||
studio: Default::default(),
|
||||
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,
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
@@ -12500,8 +12665,10 @@ 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();
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
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());
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
|
||||
@@ -848,20 +848,34 @@ impl Game<'_> {
|
||||
self.armor_stands[i].last_hit = time;
|
||||
return true;
|
||||
}
|
||||
// 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.break_armor_stand_with_source(i, by);
|
||||
// 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);
|
||||
}
|
||||
// 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));
|
||||
true
|
||||
}
|
||||
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
|
||||
@@ -1188,8 +1202,6 @@ 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);
|
||||
@@ -1198,6 +1210,8 @@ 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,25 +42,12 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
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() {
|
||||
for slot in self.world.block_entity_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;
|
||||
@@ -223,14 +210,8 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
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)))
|
||||
}
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.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,
|
||||
/// F2 screenshots not written yet: each reports in chat once its PNG is.
|
||||
pub screenshot_messages: Vec<std::path::PathBuf>,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
pub screenshot_message: Option<std::path::PathBuf>,
|
||||
/// The options screens.
|
||||
pub options: horizon_client::options_ui::OptionsUi,
|
||||
/// The prepared resources before resource packs are layered on.
|
||||
@@ -1982,17 +1982,15 @@ impl Game<'_> {
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
// 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 name = screenshot_name(&dir);
|
||||
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_messages.push(path.clone());
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
@@ -2016,23 +2014,12 @@ impl Game<'_> {
|
||||
self.take_screenshot();
|
||||
true
|
||||
}
|
||||
/// 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}");
|
||||
}
|
||||
/// 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 {
|
||||
return;
|
||||
};
|
||||
let path = self.ui.screenshot_messages.remove(at);
|
||||
let message = match &saved.result {
|
||||
let message = match result {
|
||||
Ok(_) => {
|
||||
log::info!("Saved screenshot to {}", path.display());
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
@@ -2068,7 +2055,7 @@ fn chat_box_text(text: &str) -> String {
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> 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;
|
||||
@@ -2078,8 +2065,7 @@ pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) ->
|
||||
} else {
|
||||
format!("{base}_{n}.png")
|
||||
};
|
||||
let path = dir.join(&name);
|
||||
if !path.exists() && !pending.contains(&path) {
|
||||
if !dir.join(&name).exists() {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2121,25 +2107,6 @@ 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,9 +3831,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
|
||||
level, pos, yaw, pitch,
|
||||
));
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
|
||||
@@ -1039,7 +1039,6 @@ impl Game<'_> {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
|
||||
@@ -20,17 +20,13 @@ 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.
|
||||
const MARK: Mark = &horizon_cosmetics::mark::MARK;
|
||||
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
@@ -59,8 +55,7 @@ impl Baked {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
client: Option<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.
|
||||
@@ -70,6 +65,16 @@ 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`).
|
||||
@@ -400,13 +405,17 @@ 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(gear: Gear, slot: Slot) -> bool {
|
||||
fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
match slot {
|
||||
Slot::Head => gear.head,
|
||||
Slot::Back => gear.back,
|
||||
Slot::Head => m.extra.head.is_some(),
|
||||
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
|
||||
Slot::Shoulder => false,
|
||||
// A chestplate, not an elytra.
|
||||
Slot::Torso => gear.chest,
|
||||
Slot::Torso => m
|
||||
.extra
|
||||
.chest
|
||||
.as_deref()
|
||||
.is_some_and(|c| c != "minecraft:elytra"),
|
||||
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,
|
||||
@@ -418,12 +427,12 @@ fn covered(gear: Gear, 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(
|
||||
others: Others,
|
||||
extras: &horizon_client::extras::Extras,
|
||||
own: bool,
|
||||
worn: Option<&horizon_cosmetics::Worn>,
|
||||
covered: impl Fn(Slot) -> bool,
|
||||
) -> Option<f32> {
|
||||
if !own && others != Others::Shown {
|
||||
if !own && extras.effective_others() != Others::Shown {
|
||||
return None;
|
||||
}
|
||||
worn?
|
||||
@@ -440,424 +449,115 @@ 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<'_> {
|
||||
/// 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
|
||||
/// Once a frame, before drawing: starts the service's client on first use 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;
|
||||
let owns = service(|s| {
|
||||
if c.client.is_none() {
|
||||
c.client = s.client.clone();
|
||||
c.local = s.local;
|
||||
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()));
|
||||
}
|
||||
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);
|
||||
@@ -872,6 +572,88 @@ 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) {
|
||||
@@ -887,7 +669,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
e.loading = Some(rx);
|
||||
let kept = service(|s| s.emote_out.kept.clone());
|
||||
let kept = self.cosmetics.emote_out.kept.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Cosmetics emotes owned".into())
|
||||
.spawn(move || {
|
||||
@@ -896,6 +678,81 @@ 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()?;
|
||||
@@ -987,7 +844,7 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
|
||||
if emoting.is_none() && covered(m, item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
@@ -1105,7 +962,7 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::extras::Extras;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A name tag style with a 1×1 mark.
|
||||
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
|
||||
@@ -1293,18 +1150,13 @@ 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.effective_others(), false, worn.as_ref(), |_| false)
|
||||
body_top(extras, 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.effective_others(),
|
||||
false,
|
||||
worn.as_ref(),
|
||||
|_| false
|
||||
)),
|
||||
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
|
||||
VANILLA
|
||||
);
|
||||
// A small hat under the tag: vanilla's height too.
|
||||
@@ -1318,9 +1170,7 @@ 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.effective_others(), false, worn.as_ref(), |slot| {
|
||||
slot == Slot::Head
|
||||
});
|
||||
let helmet = body_top(&shown, 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] {
|
||||
@@ -1336,66 +1186,4 @@ 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,7 +10,6 @@ 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.
|
||||
@@ -38,15 +37,10 @@ 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<(&str, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, 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, 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)
|
||||
Some((p.item.id.clone(), looping))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,48 +59,23 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
/// 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()?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
(!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);
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +229,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -281,6 +250,20 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,18 +280,25 @@ 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);
|
||||
}
|
||||
// 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.
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& horizon_hooks::engine::any()
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& (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.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(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))),
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,9 +395,12 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -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, _pitch)) => format!(
|
||||
Some((p, yaw)) => format!(
|
||||
"respawn {} {} {} {}",
|
||||
num(p.x),
|
||||
num(p.y),
|
||||
@@ -579,7 +579,6 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos: [p(0), p(1), p(2)],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -739,8 +739,7 @@ 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) {
|
||||
// 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)? {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
target = fallback_spawn(self.spawn, self.level.world_spawn);
|
||||
|
||||
@@ -70,10 +70,9 @@ 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, pitch)) = actual {
|
||||
if let Some((at, yaw)) = 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.flying() {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio_freecam.active {
|
||||
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.flying(),
|
||||
self.studio_freecam.active,
|
||||
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, pitch)| {
|
||||
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -118,78 +118,34 @@ impl View {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
|
||||
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
match (self.kind, face) {
|
||||
(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::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::Mod, Some(face)) => self
|
||||
.container
|
||||
.as_ref()
|
||||
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
|
||||
_ => Slots::Range(0..self.len()),
|
||||
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
|
||||
_ => (0..self.len()).collect(),
|
||||
}
|
||||
}
|
||||
/// 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> {
|
||||
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)
|
||||
}
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face(face)
|
||||
} else {
|
||||
(0..self.len()).collect()
|
||||
}
|
||||
}
|
||||
#[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 {
|
||||
@@ -224,6 +180,8 @@ 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> {
|
||||
@@ -388,7 +346,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(bone_meal_stack());
|
||||
return (level == 8 && slot == 0).then_some(&self.bone_meal);
|
||||
}
|
||||
let (pos, i) = view.locate(slot)?;
|
||||
self.world
|
||||
@@ -494,7 +452,7 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.isFullContainer`.
|
||||
fn is_full(&self, view: &View, face: Direction) -> bool {
|
||||
view.slots_for_face_iter(Some(face)).all(|i| {
|
||||
view.slots_for_face(Some(face)).into_iter().all(|i| {
|
||||
self.insertion_item(view, i)
|
||||
.is_some_and(|s| s.count >= self.max_stack(&s))
|
||||
})
|
||||
@@ -540,9 +498,6 @@ 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
|
||||
@@ -601,14 +556,13 @@ impl StampedItemColumns {
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal_stack() -> &'static ItemStack {
|
||||
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
|
||||
fn bone_meal() -> ItemStack {
|
||||
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).
|
||||
@@ -625,6 +579,7 @@ 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(_)))
|
||||
@@ -680,6 +635,7 @@ 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 {
|
||||
@@ -891,7 +847,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_iter(face) {
|
||||
for slot in view.slots_to_add(face) {
|
||||
let Some(stack) = rest.take() else { break };
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
|
||||
}
|
||||
@@ -924,31 +880,32 @@ impl Game<'_> {
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
// 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 {
|
||||
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 {
|
||||
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 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" {
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:hopper";
|
||||
if !hopper {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
}
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
@@ -960,69 +917,23 @@ 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);
|
||||
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 {
|
||||
let c = self.containers();
|
||||
let enabled = c.prop(state, "enabled") != Some("false");
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
return;
|
||||
};
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
return;
|
||||
}
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &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 {
|
||||
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 {
|
||||
changed |= self.hopper_suck(pos, &view);
|
||||
}
|
||||
if changed {
|
||||
@@ -1084,20 +995,19 @@ 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_with_state(pos, state, &view);
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
@@ -1133,7 +1043,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
@@ -1245,9 +1156,6 @@ 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) = (
|
||||
[
|
||||
@@ -1306,7 +1214,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 = bone_meal_stack().clone();
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
@@ -1336,15 +1244,13 @@ impl Game<'_> {
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
@@ -1363,9 +1269,7 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(rest) => {
|
||||
self.put_back(source, slot, rest);
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -1375,9 +1279,6 @@ 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
|
||||
|
||||
@@ -300,7 +300,11 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let marks = self.hooks_name_marks(m, Some(m.id) == own);
|
||||
let marks = if player || self.cosmetics_local() {
|
||||
self.cosmetics_marks(m, Some(m.id) == own)
|
||||
} else {
|
||||
Marks::default()
|
||||
};
|
||||
let at = match marks.top {
|
||||
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
|
||||
None => p,
|
||||
@@ -766,10 +770,10 @@ impl Game<'_> {
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if let Some(mark) = marks.mark.filter(|_| n == last) {
|
||||
if n == last && marks.horizon {
|
||||
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 mark {
|
||||
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
|
||||
let a = text_alpha * (argb >> 24) / 255;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
@@ -933,11 +937,10 @@ struct NameTag {
|
||||
marks: Marks,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Marks {
|
||||
pub mark: Option<horizon_hooks::players::Mark>,
|
||||
pub horizon: bool,
|
||||
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,7 +471,6 @@ 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,8 +46,6 @@ 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) {
|
||||
@@ -130,20 +128,6 @@ 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];
|
||||
@@ -666,10 +650,8 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
|
||||
types
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level_with_players(
|
||||
&self,
|
||||
players: Vec<LevelPlayer>,
|
||||
) -> GameLevel<'_, 'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
@@ -688,9 +670,6 @@ 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> {
|
||||
@@ -794,8 +773,6 @@ 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)
|
||||
})
|
||||
@@ -842,28 +819,31 @@ impl<'a> Game<'a> {
|
||||
{
|
||||
let level = self.natural_level();
|
||||
laps.lap("sn_level");
|
||||
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 {
|
||||
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 {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
spectator: p.spectator,
|
||||
}));
|
||||
let spawn_players = std::mem::take(&mut scratch.spawn_players);
|
||||
})
|
||||
.collect();
|
||||
laps.lap("sn_materialize");
|
||||
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
|
||||
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
laps.lap("sn_state");
|
||||
@@ -874,8 +854,7 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
laps.lap("sn_categories");
|
||||
// Util.shuffle, every pass: it draws from the level's random even when no
|
||||
// category may spawn.
|
||||
// Util.shuffle.
|
||||
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);
|
||||
@@ -906,7 +885,6 @@ 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));
|
||||
|
||||
@@ -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.flying() {
|
||||
if self.studio_freecam.active {
|
||||
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;
|
||||
// A hook may pull the camera back (an emote, from the player's face).
|
||||
let pull = self.hooks_camera_distance(partial);
|
||||
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
|
||||
let pull = self.emote_camera_pull(partial);
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
@@ -643,9 +643,8 @@ 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.flying() {
|
||||
self.studio
|
||||
.freecam
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam
|
||||
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
|
||||
return;
|
||||
}
|
||||
@@ -698,7 +697,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.flying()
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
|
||||
}
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if self.screen == Screen::Playing && self.replay.playing() {
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
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.flying() {
|
||||
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,41 @@ 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.replay.playing() {
|
||||
self.studio_key(key);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
@@ -45,7 +78,7 @@ impl Game<'_> {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.replay.playing() && self.captured {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -180,7 +213,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.replay.playing() => {
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -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.flying());
|
||||
&& (self.player.is_some() || self.studio_freecam.active);
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
@@ -75,7 +75,10 @@ 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.replay.playing(), self.studio.flying())
|
||||
&& !studio_suppresses_focus_pause(
|
||||
self.studio_replay.is_some(),
|
||||
self.studio_freecam.active,
|
||||
)
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
@@ -146,7 +149,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;
|
||||
@@ -350,9 +353,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_up() {
|
||||
if self.studio_replay.is_some() || self.studio_freecam.active {
|
||||
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.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.remote
|
||||
@@ -3110,6 +3110,33 @@ 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()?;
|
||||
@@ -3680,7 +3707,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));
|
||||
@@ -3689,8 +3716,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))
|
||||
@@ -6158,7 +6185,7 @@ impl Game<'_> {
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
}
|
||||
if !self.replay.playing() {
|
||||
if self.studio_replay.is_none() {
|
||||
self.previous_position = position;
|
||||
}
|
||||
self.yaw = yaw;
|
||||
@@ -6494,7 +6521,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
player.fluids.boat = boat;
|
||||
if !riding && !self.replay.playing() {
|
||||
if !riding && self.studio_replay.is_none() {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
let before = (
|
||||
@@ -6701,10 +6728,10 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
if !self.replay.playing() {
|
||||
if self.studio_replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.client_hooks_tick();
|
||||
self.tick_emotes();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -7242,15 +7269,15 @@ impl Game<'_> {
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
// Squid.aiStep; WalkAnimationState.update from the horizontal distance.
|
||||
// Squid.aiStep follows LivingEntity's interpolation and head turn.
|
||||
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
|
||||
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)
|
||||
// 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)
|
||||
} else {
|
||||
((d * 4.0).min(1.0), 0.4)
|
||||
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
|
||||
};
|
||||
m.limb_speed += (target - m.limb_speed) * factor;
|
||||
m.limb_position += m.limb_speed;
|
||||
@@ -10501,7 +10528,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.replay.playing()
|
||||
self.studio_replay.is_none()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -10585,7 +10612,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.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
return false;
|
||||
}
|
||||
use horizon_client::level_loading::Reason;
|
||||
@@ -10687,11 +10714,17 @@ 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> {
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11472,11 +11505,6 @@ 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
|
||||
@@ -11614,9 +11642,6 @@ 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,17 +793,13 @@ 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);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! 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,7 +168,6 @@ 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 {
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_system::scrub::Scrubber;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
|
||||
@@ -29,13 +29,12 @@ 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, pitch and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
@@ -44,37 +43,19 @@ impl RespawnConfig {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
pitch: 0.0,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
/// `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 {
|
||||
pub(super) fn from_saved((dim, pos, yaw, 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.pitch,
|
||||
self.forced,
|
||||
)
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +180,6 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
// RespawnConfig.isSamePosition: dimension and block.
|
||||
@@ -498,14 +478,14 @@ impl Game<'_> {
|
||||
rule.can_set_spawn.test(self.dark_outside())
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: position and angles, using an anchor's charge; a bed's
|
||||
/// dimension: the position and yaw, 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, f32)>> {
|
||||
) -> Result<Option<(Vec3, 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)
|
||||
@@ -515,7 +495,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), 0.0)));
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
@@ -523,7 +503,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, c.pitch)));
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
@@ -533,7 +513,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.level_set(c.pos, next)?;
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,354 +560,3 @@ 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,7 +362,6 @@ 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.client_hooks_frame();
|
||||
self.cosmetics_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,12 +900,10 @@ impl Game<'_> {
|
||||
let board = &self.score.board;
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let up = |id: u128| upside_down.contains(&id);
|
||||
// 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;
|
||||
// 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);
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board,
|
||||
|
||||
@@ -138,7 +138,10 @@ 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() || renderer.screenshot.is_some() {
|
||||
if self.pending_screenshot.is_some()
|
||||
|| self.ui.screenshot_message.is_some()
|
||||
|| renderer.screenshot.is_some()
|
||||
{
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
if let Some(refusal) = self.sequence_refusal() {
|
||||
@@ -230,7 +233,6 @@ impl Game<'_> {
|
||||
path,
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.screenshot_result = None;
|
||||
}
|
||||
|
||||
@@ -644,14 +644,11 @@ 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, f32)> {
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, 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, pitch))) => {
|
||||
Ok(Some((at, yaw))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
@@ -661,7 +658,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw, pitch))
|
||||
Some((at, yaw))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let mut cancel = false;
|
||||
@@ -799,10 +796,7 @@ 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((at, yaw, pitch))) => {
|
||||
host.hub.guests[i].pitch = pitch;
|
||||
(at, yaw)
|
||||
}
|
||||
Some(Some(found)) => found,
|
||||
_ => {
|
||||
host.hub.missing_respawn_block(trip.conn);
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
|
||||
@@ -261,8 +261,6 @@ 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,7 +126,6 @@ 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
|
||||
@@ -136,7 +135,6 @@ 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.
|
||||
@@ -301,31 +299,16 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let made = if options.exists() {
|
||||
None
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} 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);
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
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);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.replay.playing() {
|
||||
if self.studio_replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -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 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)>,
|
||||
/// 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)>,
|
||||
/// 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, f32, bool)>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], 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,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// 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};
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -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 && self.length > 0.0 {
|
||||
if self.looping {
|
||||
seconds %= self.length;
|
||||
}
|
||||
for (bone, channels) in &self.bones {
|
||||
@@ -231,34 +231,4 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
borrow::{Borrow, Cow},
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
@@ -884,17 +879,13 @@ impl SpawnState {
|
||||
/// `NaturalSpawner.createState`: every non-persistent entity of a
|
||||
/// spawning category in a loaded chunk adds its biome's spawn charge, its
|
||||
/// category count and (for mobs) the local caps of the players near it.
|
||||
pub fn new<'a, L: NaturalLevel, I>(
|
||||
pub fn new<L: NaturalLevel>(
|
||||
l: &L,
|
||||
spawnable_chunks: i32,
|
||||
players: Vec<SpawnPlayer>,
|
||||
entities: I,
|
||||
entities: &[Counted],
|
||||
chunk_loaded: impl Fn(i32, i32) -> bool,
|
||||
) -> Self
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: std::borrow::Borrow<Counted<'a>>,
|
||||
{
|
||||
) -> Self {
|
||||
let mut state = Self {
|
||||
spawnable_chunks,
|
||||
counts: [0; 8],
|
||||
@@ -907,7 +898,6 @@ impl SpawnState {
|
||||
last_checked: None,
|
||||
};
|
||||
for e in entities {
|
||||
let e = e.borrow();
|
||||
if e.is_mob && e.persistent || e.category == Category::Misc {
|
||||
continue;
|
||||
}
|
||||
@@ -925,10 +915,6 @@ impl SpawnState {
|
||||
}
|
||||
state
|
||||
}
|
||||
/// Reclaims the player buffer for reuse across passes.
|
||||
pub fn into_players(self) -> Vec<SpawnPlayer> {
|
||||
self.players
|
||||
}
|
||||
fn add_charge(&mut self, pos: [i32; 3], charge: f64) {
|
||||
if charge != 0.0 {
|
||||
self.charges.push((pos, charge));
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::lan::discovery::Scanner;
|
||||
use crate::text::{DARK_GRAY, GRAY, Text, format_translation};
|
||||
use crate::title::confirm_top;
|
||||
use horizon_protocol::servers::{self, ServerEntry};
|
||||
pub use page::Form;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -139,8 +138,6 @@ pub enum Screen {
|
||||
relay: String,
|
||||
focus: u8,
|
||||
},
|
||||
/// A page a crate registered at the title screen's Friends icon, as a form.
|
||||
Page(Form),
|
||||
Disconnected {
|
||||
reason: String,
|
||||
/// Auto-reconnect's next attempt, until it is cancelled.
|
||||
@@ -149,21 +146,6 @@ pub enum Screen {
|
||||
lines: std::cell::Cell<usize>,
|
||||
},
|
||||
}
|
||||
mod page;
|
||||
|
||||
/// What the title screen's Friends icon opens: the page a crate registered there
|
||||
/// (`Place::Title`), else Join a Friend when an invite code can be joined. None: it is inactive.
|
||||
pub fn title_entry() -> Option<Screen> {
|
||||
match page::title_page() {
|
||||
Some(page) => Some(Screen::Page(Form::open(page))),
|
||||
None => crate::net::joins_friends().then(|| Screen::friend(true)),
|
||||
}
|
||||
}
|
||||
/// Whether the title screen's Friends icon opens something ([`title_entry`]).
|
||||
pub fn has_title_entry() -> bool {
|
||||
page::title_page().is_some() || crate::net::joins_friends()
|
||||
}
|
||||
|
||||
/// What a click asks the title screen to do.
|
||||
pub enum Outcome {
|
||||
None,
|
||||
@@ -194,7 +176,6 @@ impl Screen {
|
||||
Self::Direct { .. } => "direct_connect",
|
||||
Self::ConfirmDelete { .. } => "server_delete",
|
||||
Self::Friend { .. } => "friends_join",
|
||||
Self::Page(form) => form.id(),
|
||||
Self::Disconnected { .. } => "disconnected",
|
||||
}
|
||||
}
|
||||
@@ -763,7 +744,6 @@ impl Screen {
|
||||
53,
|
||||
),
|
||||
],
|
||||
Self::Page(form) => form.buttons(w, h),
|
||||
Self::Friend { code, .. } => vec![
|
||||
b(
|
||||
half(w) - 100.0,
|
||||
@@ -884,7 +864,6 @@ impl Screen {
|
||||
]),
|
||||
)
|
||||
}
|
||||
Self::Page(form) => form.narration(),
|
||||
Self::Friend { .. } => ScreenInfo::new(
|
||||
"multiplayer/friend",
|
||||
get("horizon.multiplayer.join_friend", "Join a Friend"),
|
||||
@@ -1339,7 +1318,6 @@ impl Screen {
|
||||
centered(batch, &line.plain(), top + 17.0 + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
}
|
||||
Self::Page(form) => form.draw(atlas, batch, caret, focused_at),
|
||||
Self::Friend {
|
||||
code, relay, focus, ..
|
||||
} => {
|
||||
@@ -1405,7 +1383,6 @@ impl Screen {
|
||||
/// selected server or LAN game, as a click on it does.
|
||||
pub fn confirm(&self) -> Outcome {
|
||||
match self {
|
||||
Self::Page(form) => form.confirm(),
|
||||
Self::Direct { address } if servers::parse_address(address).is_some() => {
|
||||
Outcome::Connect {
|
||||
address: direct_address(address),
|
||||
@@ -1454,9 +1431,6 @@ impl Screen {
|
||||
saves: &Path,
|
||||
) -> Outcome {
|
||||
let (w, h) = size;
|
||||
if let Self::Page(form) = self {
|
||||
return form.click(id, x, y);
|
||||
}
|
||||
match (&mut *self, id) {
|
||||
(
|
||||
Self::List {
|
||||
@@ -1849,7 +1823,6 @@ impl Screen {
|
||||
Self::Friend { focus: 0, .. } => 64.0,
|
||||
Self::Friend { focus: 1, .. } => 105.0,
|
||||
Self::Friend { .. } => return None,
|
||||
Self::Page(form) => return form.focused_box(),
|
||||
_ => return None,
|
||||
};
|
||||
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
|
||||
@@ -1878,13 +1851,6 @@ impl Screen {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether a press off the buttons may land on one of its text fields.
|
||||
pub fn has_fields(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Edit { .. } | Self::Friend { .. } | Self::Page(_)
|
||||
)
|
||||
}
|
||||
/// Join a friend, with the relay the player set before.
|
||||
pub fn friend(from_title: bool) -> Self {
|
||||
Self::Friend {
|
||||
@@ -1944,9 +1910,6 @@ impl Screen {
|
||||
}
|
||||
/// The focused text field, its id and maximum length (copy and paste).
|
||||
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
|
||||
if let Self::Page(form) = self {
|
||||
return form.focused_text();
|
||||
}
|
||||
let id = match self {
|
||||
Self::Edit { focus: 0, .. } => SERVER_NAME,
|
||||
Self::Edit { .. } => SERVER_ADDRESS,
|
||||
@@ -1971,9 +1934,6 @@ impl Screen {
|
||||
/// round to the first field. `button` is the focused button, if any; a field
|
||||
/// has the focus (`focus` below 2) only while no button does.
|
||||
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
|
||||
if let Self::Page(form) = self {
|
||||
return form.tab_dir(reverse, buttons, button);
|
||||
}
|
||||
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
|
||||
return;
|
||||
};
|
||||
@@ -2000,7 +1960,6 @@ impl Screen {
|
||||
pub fn escape(&mut self, saves: &Path) -> bool {
|
||||
match self {
|
||||
Self::List { .. }
|
||||
| Self::Page(_)
|
||||
| Self::Friend {
|
||||
from_title: true, ..
|
||||
} => true,
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
//! A page a crate registers at `horizon_hooks::pages::Place::Title`, which the title screen's
|
||||
//! Friends icon opens: a form drawn as Join a Friend and `ManageServerScreen` are. The title at
|
||||
//! the top, then the rows from y 53 (a field's grey label, its 200-wide `EditBox` 11 below, the
|
||||
//! next 41 lower; text 10 a line), and the page's buttons in a column under them from a quarter
|
||||
//! of the height + 114, 24 apart, the one that leaves it last.
|
||||
//!
|
||||
//! The engine keeps each field's text while the form is open (the title screen's caret edits
|
||||
//! it, as every field of its own) and tells the page each change before anything else reaches
|
||||
//! it ([`Act::Edit`]), then Enter in a field ([`Act::Submit`]) and the presses. A page joins a
|
||||
//! server with `horizon_hooks::pages::connect`.
|
||||
use super::{ButtonSpec, Outcome, centered_x, half, quarter};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use horizon_hooks::pages::{self, Act, Page, Place, Row};
|
||||
use std::cell::RefCell;
|
||||
use std::time::Instant;
|
||||
|
||||
/// The first button's id; button `k` of the page is `FIRST + k`.
|
||||
const FIRST: u8 = 100;
|
||||
/// The text a line of the form wraps at.
|
||||
const WIDTH: f32 = 300.0;
|
||||
|
||||
/// The page the title screen's Friends icon opens: the first registered there.
|
||||
pub(super) fn title_page() -> Option<&'static dyn Page> {
|
||||
pages::engine::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|page| page.place() == Place::Title)
|
||||
}
|
||||
|
||||
/// An open form: the page, and what the engine keeps while it is open.
|
||||
#[derive(Clone)]
|
||||
pub struct Form {
|
||||
page: &'static dyn Page,
|
||||
/// Each field's text, in the order the rows have them.
|
||||
texts: Vec<String>,
|
||||
/// The text the page last heard of each field.
|
||||
sent: RefCell<Vec<String>>,
|
||||
/// The focused field; `texts.len()` (or more) while a button has the focus.
|
||||
pub(super) focus: usize,
|
||||
/// Each field's box where the last draw put it.
|
||||
boxes: RefCell<Vec<[f32; 4]>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Form {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Form")
|
||||
.field("page", &self.page.id())
|
||||
.field("texts", &self.texts)
|
||||
.field("focus", &self.focus)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A button of the form: its row (None: the one that leaves), label key, and whether it acts.
|
||||
struct Button {
|
||||
row: Option<usize>,
|
||||
label: &'static str,
|
||||
active: bool,
|
||||
half: bool,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
/// The form of `page`, its fields holding the text its rows start with, the first focused.
|
||||
pub fn open(page: &'static dyn Page) -> Self {
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
let texts: Vec<String> = rows
|
||||
.iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Field { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
page,
|
||||
sent: RefCell::new(texts.clone()),
|
||||
boxes: RefCell::new(vec![[0.0; 4]; texts.len()]),
|
||||
texts,
|
||||
focus: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &'static str {
|
||||
self.page.id()
|
||||
}
|
||||
|
||||
/// The page's rows, after it heard every change to its fields.
|
||||
fn rows(&self) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
self.page.rows(&mut rows);
|
||||
let fields = field_rows(&rows);
|
||||
let mut sent = self.sent.borrow_mut();
|
||||
let mut told = false;
|
||||
for ((text, sent), row) in self.texts.iter().zip(sent.iter_mut()).zip(fields) {
|
||||
if text != sent {
|
||||
sent.clone_from(text);
|
||||
self.page.act(row, Act::Edit(text.clone()));
|
||||
told = true;
|
||||
}
|
||||
}
|
||||
if told {
|
||||
rows.clear();
|
||||
self.page.rows(&mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn buttons_of(rows: &[Row], back: &'static str) -> Vec<Button> {
|
||||
let mut buttons: Vec<Button> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(r, row)| match row {
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
..
|
||||
} => Some(Button {
|
||||
row: Some(r),
|
||||
label,
|
||||
active: *active,
|
||||
half: *half,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
buttons.push(Button {
|
||||
row: None,
|
||||
label: back,
|
||||
active: true,
|
||||
half: false,
|
||||
});
|
||||
buttons
|
||||
}
|
||||
|
||||
/// The id of the button that leaves: Cancel's or Done's, for the controller.
|
||||
fn back_id(&self) -> u8 {
|
||||
if self.page.back() == "gui.cancel" {
|
||||
57
|
||||
} else {
|
||||
50
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buttons(&self, w: f32, h: f32) -> Vec<ButtonSpec> {
|
||||
let rows = self.rows();
|
||||
let cx = half(w);
|
||||
let mut y = quarter(h) + 114.0;
|
||||
let mut beside = false;
|
||||
let mut out = Vec::new();
|
||||
for (k, b) in Self::buttons_of(&rows, self.page.back())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = match b.row {
|
||||
Some(_) => FIRST + k as u8,
|
||||
None => self.back_id(),
|
||||
};
|
||||
let label = crate::lang::get(b.label);
|
||||
let (x, bw) = match (b.half, beside) {
|
||||
(true, true) => {
|
||||
y -= 24.0;
|
||||
beside = false;
|
||||
(cx + 2.0, 98.0)
|
||||
}
|
||||
(true, false) => {
|
||||
beside = true;
|
||||
(cx - 100.0, 98.0)
|
||||
}
|
||||
(false, _) => {
|
||||
beside = false;
|
||||
(cx - 100.0, 200.0)
|
||||
}
|
||||
};
|
||||
out.push((x, y, bw, label, b.active, id));
|
||||
y += 24.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&self,
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
caret: &mut crate::ui::Caret,
|
||||
focused_at: Instant,
|
||||
) {
|
||||
let w = batch.width;
|
||||
let rows = self.rows();
|
||||
let title = crate::lang::get(self.page.title());
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(atlas, &title, centered_x(w, tw), 20.0, u32::MAX, true);
|
||||
let mut y = 53.0;
|
||||
let mut field = 0;
|
||||
let mut boxes = self.boxes.borrow_mut();
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let (text, color) = match row {
|
||||
Row::Field { label, max, .. } => {
|
||||
let Some(value) = self.texts.get(field) else {
|
||||
break;
|
||||
};
|
||||
super::Screen::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
&crate::lang::get(label),
|
||||
value,
|
||||
("page.field", r),
|
||||
*max,
|
||||
(y, y + 11.0),
|
||||
self.focus == field,
|
||||
);
|
||||
boxes[field] = [half(w) - 100.0, y + 11.0, 200.0, 20.0];
|
||||
field += 1;
|
||||
y += 41.0;
|
||||
continue;
|
||||
}
|
||||
Row::Space(px) => {
|
||||
y += f32::from(*px);
|
||||
continue;
|
||||
}
|
||||
Row::Heading(key) => (crate::lang::get(key), u32::MAX),
|
||||
Row::Note(text) => (text.clone(), rgba(160, 160, 160, 255)),
|
||||
Row::Text { text, quiet, .. } => {
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
(text.clone(), if *quiet { grey } else { u32::MAX })
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
for line in super::wrap(&text, WIDTH, atlas) {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, centered_x(w, lw), y, color, true);
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn narration(&self) -> crate::widgets::ScreenInfo {
|
||||
crate::widgets::ScreenInfo::new(self.page.id(), crate::lang::get(self.page.title()))
|
||||
}
|
||||
|
||||
/// A press on button `id`, or on a field (`id` None).
|
||||
pub fn click(&mut self, id: Option<u8>, x: f32, y: f32) -> Outcome {
|
||||
match id {
|
||||
Some(id) if id == self.back_id() => Outcome::Back,
|
||||
Some(id) if id >= FIRST => {
|
||||
let rows = self.rows();
|
||||
let buttons = Self::buttons_of(&rows, self.page.back());
|
||||
match buttons.get(usize::from(id - FIRST)) {
|
||||
Some(Button {
|
||||
row: Some(r),
|
||||
active: true,
|
||||
..
|
||||
}) => self.act(*r, Act::Press),
|
||||
_ => Outcome::None,
|
||||
}
|
||||
}
|
||||
Some(_) => Outcome::None,
|
||||
None => {
|
||||
let hit = self
|
||||
.boxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.position(|&[bx, by, bw, bh]| crate::ui::hovered((x, y), bx, by, bw, bh));
|
||||
if let Some(field) = hit {
|
||||
self.focus = field;
|
||||
}
|
||||
Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter: the focused field submits.
|
||||
pub fn confirm(&self) -> Outcome {
|
||||
let rows = self.rows();
|
||||
match field_rows(&rows).nth(self.focus) {
|
||||
Some(r) => self.act(r, Act::Submit),
|
||||
None => Outcome::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page acts; a server it asked to join is joined.
|
||||
fn act(&self, row: usize, act: Act) -> Outcome {
|
||||
self.page.act(row, act);
|
||||
match pages::engine::take_connect() {
|
||||
Some(address) => Outcome::Connect {
|
||||
address,
|
||||
lan: false,
|
||||
},
|
||||
None => Outcome::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focused_box(&self) -> Option<[f32; 4]> {
|
||||
self.boxes.borrow().get(self.focus).copied()
|
||||
}
|
||||
|
||||
/// The focused field, its id and maximum length.
|
||||
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
|
||||
let mut rows = Vec::new();
|
||||
self.page.rows(&mut rows);
|
||||
let (r, max) = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(r, row)| match row {
|
||||
Row::Field { max, .. } => Some((r, *max)),
|
||||
_ => None,
|
||||
})
|
||||
.nth(self.focus)?;
|
||||
let text = self.texts.get_mut(self.focus)?;
|
||||
Some((("page.field", r), text, max))
|
||||
}
|
||||
|
||||
/// Tab through the fields, then the enabled `buttons`, then round to the first field.
|
||||
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
|
||||
let fields = self.texts.len();
|
||||
let stops = fields + buttons.len();
|
||||
if stops == 0 {
|
||||
return;
|
||||
}
|
||||
let at = match *button {
|
||||
Some(id) => buttons.iter().position(|&b| b == id).map(|i| fields + i),
|
||||
None => Some(self.focus.min(fields.saturating_sub(1))),
|
||||
}
|
||||
.unwrap_or(0);
|
||||
let next = if reverse {
|
||||
(at + stops - 1) % stops
|
||||
} else {
|
||||
(at + 1) % stops
|
||||
};
|
||||
if next < fields {
|
||||
self.focus = next;
|
||||
*button = None;
|
||||
} else {
|
||||
self.focus = fields;
|
||||
*button = Some(buttons[next - fields]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows that are fields, in order.
|
||||
fn field_rows(rows: &[Row]) -> impl Iterator<Item = usize> + '_ {
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| matches!(row, Row::Field { .. }))
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A join form: a code and a relay, Join while the code has 4 characters or more.
|
||||
struct Join {
|
||||
heard: Mutex<Vec<(usize, Act)>>,
|
||||
code: Mutex<String>,
|
||||
}
|
||||
|
||||
impl Page for Join {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.join"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.multiplayer.join_friend"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Title
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
let code = self.code.lock().unwrap().clone();
|
||||
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(8));
|
||||
rows.push(Row::field(
|
||||
"horizon.multiplayer.relay",
|
||||
"relay.example:25599",
|
||||
));
|
||||
rows.push(Row::button("horizon.multiplayer.join").active(code.len() >= 4));
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if let (0, Act::Edit(text)) = (row, &act) {
|
||||
text.clone_into(&mut self.code.lock().unwrap());
|
||||
}
|
||||
if matches!((row, &act), (2, Act::Press) | (0, Act::Submit)) {
|
||||
pages::connect(format!("friend:{}", self.code.lock().unwrap()));
|
||||
}
|
||||
self.heard.lock().unwrap().push((row, act));
|
||||
}
|
||||
fn back(&self) -> &'static str {
|
||||
"gui.cancel"
|
||||
}
|
||||
}
|
||||
|
||||
fn join() -> &'static Join {
|
||||
Box::leak(Box::new(Join {
|
||||
heard: Mutex::new(Vec::new()),
|
||||
code: Mutex::new(String::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_lays_out_as_join_a_friend() {
|
||||
let form = Form::open(join());
|
||||
let buttons = form.buttons(427.0, 240.0);
|
||||
// Join at a quarter of the height + 114, Cancel 24 below, both 200 wide and centred.
|
||||
assert_eq!(
|
||||
buttons
|
||||
.iter()
|
||||
.map(|(x, y, w, _, on, id)| (*x, *y, *w, *on, *id))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(113.0, 174.0, 200.0, false, FIRST),
|
||||
(113.0, 198.0, 200.0, true, 57)
|
||||
]
|
||||
);
|
||||
assert_eq!(form.texts, ["", "relay.example:25599"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_reaches_the_page_before_a_press_and_enter_submits() {
|
||||
let page = join();
|
||||
let mut form = Form::open(page);
|
||||
let (id, text, max) = form.focused_text().unwrap();
|
||||
assert_eq!((id, max), (("page.field", 0), 8));
|
||||
text.push_str("AB");
|
||||
// Join is greyed: the page heard "AB" before the buttons were laid out.
|
||||
assert!(!form.buttons(427.0, 240.0)[0].4);
|
||||
form.focused_text().unwrap().1.push_str("CD");
|
||||
assert!(form.buttons(427.0, 240.0)[0].4);
|
||||
match form.click(Some(FIRST), 0.0, 0.0) {
|
||||
Outcome::Connect { address, lan } => {
|
||||
assert_eq!((address.as_str(), lan), ("friend:ABCD", false))
|
||||
}
|
||||
_ => panic!("Join did not connect"),
|
||||
}
|
||||
match form.confirm() {
|
||||
Outcome::Connect { address, .. } => assert_eq!(address, "friend:ABCD"),
|
||||
_ => panic!("Enter did not submit"),
|
||||
}
|
||||
let heard = page.heard.lock().unwrap();
|
||||
assert_eq!(
|
||||
*heard,
|
||||
[
|
||||
(0, Act::Edit("AB".into())),
|
||||
(0, Act::Edit("ABCD".into())),
|
||||
(2, Act::Press),
|
||||
(0, Act::Submit)
|
||||
]
|
||||
);
|
||||
assert!(matches!(form.click(Some(57), 0.0, 0.0), Outcome::Back));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_goes_through_the_fields_then_the_buttons() {
|
||||
let mut form = Form::open(join());
|
||||
let mut button = None;
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (1, None));
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (2, Some(57)));
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (0, None));
|
||||
form.tab_dir(true, &[57], &mut button);
|
||||
assert_eq!(button, Some(57));
|
||||
assert!(form.focused_text().is_none());
|
||||
}
|
||||
}
|
||||
@@ -149,20 +149,8 @@ pub struct Session {
|
||||
far: Arc<crate::lod_net::Inbox>,
|
||||
/// The recording running, if any.
|
||||
recorder: RecorderSlot,
|
||||
/// This connection's number, new for each one ([`Session::serial`]).
|
||||
serial: u64,
|
||||
}
|
||||
/// The next [`Session::serial`].
|
||||
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
fn next_serial() -> u64 {
|
||||
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
impl Session {
|
||||
/// A number of its own for each connection, never 0: entity ids are per connection, so what
|
||||
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
/// The negotiated wire release, once the connection has been established.
|
||||
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
|
||||
self.writer.lock().unwrap().as_ref().map_or(
|
||||
@@ -268,7 +256,6 @@ impl Session {
|
||||
debug: false,
|
||||
far: Arc::new(crate::lod_net::Inbox::default()),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
@@ -346,7 +333,6 @@ impl Session {
|
||||
debug,
|
||||
far,
|
||||
recorder,
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
/// Everything received since the last poll.
|
||||
@@ -523,22 +509,6 @@ pub fn is_invite(code: &str, full: bool) -> bool {
|
||||
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
|
||||
}
|
||||
|
||||
/// Whether an invite code can be joined: a resolver is registered (horizon-official registers
|
||||
/// horizon-friends'). The title screen's Friends icon, which opens Join a Friend, is inactive
|
||||
/// without one, as the pause menu's is without a page to share from.
|
||||
pub fn joins_friends() -> bool {
|
||||
#[cfg(test)]
|
||||
if JOINS_FRIENDS_IN_TEST.get() {
|
||||
return true;
|
||||
}
|
||||
horizon_hooks::engine::tunnel().is_some()
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's stand-in for a registered resolver: the registry is the process's and sealed.
|
||||
pub(crate) static JOINS_FRIENDS_IN_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// An address as players and logs see it: a friend's invite code stays
|
||||
/// out of it (its secret is in there).
|
||||
pub fn shown_address(address: &str) -> String {
|
||||
@@ -1859,7 +1829,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
if explicit_disconnect {
|
||||
@@ -1963,7 +1932,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let (thread_writer, thread_configuring, thread_recorder) = (
|
||||
session.writer.clone(),
|
||||
@@ -2247,7 +2215,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let recorder = TestRecorder::default();
|
||||
session.start_recording(recorder.clone(), |_| {});
|
||||
|
||||
+15
-31
@@ -1,29 +1,12 @@
|
||||
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
|
||||
//! and the withdrawn partner server, taken back out once.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
|
||||
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
|
||||
//! registers, and reads servers.dat through `horizon_platform::servers`. horizon-client's plain
|
||||
//! executable adds no server. Its addresses are in `horizon-addresses`.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{Hooks, Started};
|
||||
use horizon_platform::servers::{self, ServerEntry};
|
||||
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
|
||||
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
|
||||
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
|
||||
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
|
||||
//! leave the engine crates for a Horizon crate.
|
||||
use horizon_protocol::servers::{self, ServerEntry};
|
||||
use std::path::Path;
|
||||
|
||||
/// The server list's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: OfficialServers = OfficialServers;
|
||||
|
||||
pub struct OfficialServers;
|
||||
|
||||
impl Hooks for OfficialServers {
|
||||
/// Someone is playing (not a server, a benchmark or an automated run): their list, once.
|
||||
fn started(&self, game: &Started<'_>) {
|
||||
update(game.game_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// At the start of a player's game: the official server into the list in `game_dir` and the
|
||||
/// At startup of a player's game: the official server into the list in `game_dir` and the
|
||||
/// withdrawn partner server out of it, a failure only logged.
|
||||
pub fn update(game_dir: &Path) {
|
||||
if let Err(e) = add_official_server(game_dir) {
|
||||
@@ -34,8 +17,12 @@ pub fn update(game_dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's official survival server: a name and an address like any entry.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", horizon_addresses::OFFICIAL_SERVER);
|
||||
/// Horizon's official survival server: a name and an address like any entry. The address is
|
||||
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = (
|
||||
"Horizon Official",
|
||||
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
|
||||
);
|
||||
/// Beside servers.dat once the official server was offered, so a player who
|
||||
/// deletes it never gets it back: the address offered, so a new one moves the
|
||||
/// list once. The launcher writes the same file.
|
||||
@@ -68,7 +55,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
let file = game_dir.join("servers.dat");
|
||||
let _guard = servers::lock();
|
||||
let mut list = servers::load(&file);
|
||||
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
|
||||
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
|
||||
if move_official(&mut list, address, previous) {
|
||||
servers::save(&file, &list)?;
|
||||
}
|
||||
@@ -84,10 +71,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
|
||||
/// Afri-Network, which the game added once below the official server (#9299) until the
|
||||
/// partnership ended: the name and address the game gave its entry.
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = (
|
||||
"Afri-Network (South Africa)",
|
||||
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
|
||||
);
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
|
||||
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
//! game rules) is kept from the loaded files and written back unchanged.
|
||||
//! Worlds saved by older Horizon builds (`region/`, `DIM-1/`, `Player` in
|
||||
//! `level.dat`) are migrated on open.
|
||||
mod respawn;
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
beehive::BeehiveExt,
|
||||
@@ -3947,8 +3945,8 @@ pub struct Level {
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point in any dimension):
|
||||
/// dimension, block, yaw, pitch and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
/// dimension, block, yaw and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
pub rotation: [f32; 2],
|
||||
pub health: f32,
|
||||
@@ -4294,8 +4292,20 @@ impl Level {
|
||||
if let Some(d) = text(p.get("Dimension")) {
|
||||
self.dimension = d;
|
||||
}
|
||||
if let Some(respawn) = respawn::read(p) {
|
||||
self.respawn_elsewhere = Some(respawn);
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
let mut inventory = Vec::new();
|
||||
let mut inventory_components = HashMap::new();
|
||||
@@ -4526,8 +4536,17 @@ impl Level {
|
||||
));
|
||||
}
|
||||
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
|
||||
if let Some(saved) = &self.respawn_elsewhere {
|
||||
entries.push(("respawn", respawn::write(saved)));
|
||||
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
entries.push(("respawn", Tag::compound(respawn)));
|
||||
} else if let Some(s) = self.spawn {
|
||||
let block = [
|
||||
s[0].floor() as i32,
|
||||
@@ -7679,7 +7698,7 @@ mod tests {
|
||||
assert_eq!(back.spawn, Some([8.0, 64.0, 8.0]));
|
||||
assert_eq!(
|
||||
back.respawn_elsewhere,
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, 0.0, true))
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
|
||||
);
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
@@ -7950,7 +7969,7 @@ mod tests {
|
||||
// A personal point coinciding with the world spawn must still keep
|
||||
// its forced flag and yaw, rather than turning into a world fallback.
|
||||
for pos in [[5, 64, 5], [0, 80, 0]] {
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, 0.0, true);
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
|
||||
let level = Level {
|
||||
respawn_elsewhere: Some(point.clone()),
|
||||
..Level::default()
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Personal respawn record leaves from Level, using its existing typed NBT rules.
|
||||
use super::{Tag, number, string, text};
|
||||
|
||||
pub(super) fn read(p: &Tag) -> Option<(String, [i32; 3], f32, f32, bool)> {
|
||||
let mut value = None;
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
let pitch = match respawn.get("pitch") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
value = Some((dimension, [pos[0], pos[1], pos[2]], yaw, pitch, forced));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn write(
|
||||
(dimension, block, yaw, pitch, forced): &(String, [i32; 3], f32, f32, bool),
|
||||
) -> Tag {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(pitch.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
Tag::compound(respawn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Level, read_dat, write_dat};
|
||||
use super::*;
|
||||
|
||||
/// Added model control: old records without pitch retain the existing positive-zero default.
|
||||
#[test]
|
||||
fn personal_respawn_missing_pitch_loads_positive_zero() {
|
||||
let tag = Tag::compound(vec![(
|
||||
"respawn",
|
||||
Tag::compound(vec![
|
||||
("dimension", string("minecraft:overworld")),
|
||||
("pos", Tag::IntArray(vec![12, 100, 12])),
|
||||
("yaw", Tag::Float(45f32.to_bits())),
|
||||
("forced", Tag::Byte(1)),
|
||||
]),
|
||||
)]);
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
let (_, pos, yaw, pitch, forced) = level.respawn_elsewhere.unwrap();
|
||||
assert_eq!(pos, [12, 100, 12]);
|
||||
assert_eq!(yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(forced);
|
||||
}
|
||||
|
||||
/// Added model/I/O control, separate from the connected runtime proof.
|
||||
#[test]
|
||||
fn personal_respawn_nonzero_pitch_survives_actual_player_file_write_and_read() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-personal-pitch-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("player.dat");
|
||||
let original = Level {
|
||||
respawn_elsewhere: Some((
|
||||
"minecraft:overworld".into(),
|
||||
[12, 100, 12],
|
||||
45.0,
|
||||
10.0,
|
||||
true,
|
||||
)),
|
||||
..Level::default()
|
||||
};
|
||||
write_dat(&file, original.player_tag()).unwrap();
|
||||
let tag = read_dat(&file).expect("actual written NBT player file");
|
||||
assert_eq!(
|
||||
tag.get("respawn").unwrap().get("pitch"),
|
||||
Some(&Tag::Float(10f32.to_bits()))
|
||||
);
|
||||
let mut loaded = Level::default();
|
||||
loaded.read_player(&tag);
|
||||
assert_eq!(loaded.respawn_elsewhere, original.respawn_elsewhere);
|
||||
println!("actual saved personal respawn {:?}", tag.get("respawn"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1940,8 +1940,8 @@ impl Title {
|
||||
)
|
||||
.does(crate::widgets::Action::Exit),
|
||||
];
|
||||
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
|
||||
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
|
||||
// CommonButtons' icon buttons; Horizon's Join a Friend (an
|
||||
// invite code) sits behind vanilla's Friends button.
|
||||
for (i, (key, plain, id, icon)) in [
|
||||
("gui.friends.open", "Friends", 5, "friends/friends"),
|
||||
("options.language", "Language...", 6, "icon/language"),
|
||||
@@ -1960,7 +1960,7 @@ impl Title {
|
||||
y + 72.0,
|
||||
20.0,
|
||||
&lang::get_or(key, plain),
|
||||
id != 5 || crate::multiplayer::has_title_entry(),
|
||||
true,
|
||||
id,
|
||||
);
|
||||
button.icon = Some((icon, 15.0));
|
||||
@@ -3658,7 +3658,12 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
.map(|b| b.id);
|
||||
if hit.is_some() {
|
||||
self.focused_button = hit;
|
||||
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
|
||||
} else if matches!(
|
||||
self.mode,
|
||||
Mode::Multiplayer(
|
||||
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
|
||||
)
|
||||
) {
|
||||
// A press on a text field takes the focus from the button.
|
||||
self.focused_button = None;
|
||||
}
|
||||
@@ -3777,7 +3782,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
crate::multiplayer::Outcome::None => Action::None,
|
||||
},
|
||||
(Mode::Main, Some(5)) => {
|
||||
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
|
||||
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
|
||||
Action::None
|
||||
}
|
||||
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
|
||||
@@ -5301,8 +5306,6 @@ mod classic_tests {
|
||||
|
||||
#[test]
|
||||
fn classic_main_menu_icons_route_language_and_accessibility() {
|
||||
// Join a Friend needs a resolver (title/tests.rs tests the icon without one).
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
let mut title = Title::new(
|
||||
PathBuf::new(),
|
||||
Path::new("/nonexistent/horizon-test-resources"),
|
||||
|
||||
@@ -100,61 +100,13 @@ impl Drop for Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a Friend needs a resolver; these tests stand one in (`net::JOINS_FRIENDS_IN_TEST`).
|
||||
fn with_resolver() -> Fixture {
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
Fixture::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_resolver_the_friends_icon_is_inactive_and_opens_nothing() {
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
|
||||
let mut f = Fixture::new();
|
||||
let icons: Vec<_> = f
|
||||
.title
|
||||
.buttons(480.0, 262.0)
|
||||
.into_iter()
|
||||
.filter(|b| b.icon.is_some())
|
||||
.map(|b| (b.label.clone(), b.enabled, b.x))
|
||||
.collect();
|
||||
// Same place and look as with one, only greyed; Language and Accessibility stay active.
|
||||
let friends = icons.iter().find(|(label, ..)| label == "Friends").unwrap();
|
||||
assert!(!friends.1);
|
||||
assert!(
|
||||
icons
|
||||
.iter()
|
||||
.filter(|(label, ..)| label != "Friends")
|
||||
.all(|(_, on, _)| *on)
|
||||
);
|
||||
assert_eq!(f.click("Friends"), Action::None);
|
||||
assert!(matches!(f.title.mode, Mode::Main));
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
let with: Vec<_> = f
|
||||
.title
|
||||
.buttons(480.0, 262.0)
|
||||
.into_iter()
|
||||
.filter(|b| b.icon.is_some())
|
||||
.map(|b| (b.label.clone(), b.x, b.y))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
with.iter()
|
||||
.map(|(l, x, _)| (l.clone(), *x))
|
||||
.collect::<Vec<_>>(),
|
||||
icons
|
||||
.iter()
|
||||
.map(|(l, _, x)| (l.clone(), *x))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
|
||||
}
|
||||
|
||||
mod multiplayer_parent_navigation {
|
||||
use super::*;
|
||||
use crate::multiplayer::{Outcome, Screen};
|
||||
|
||||
#[test]
|
||||
fn friend_cancel_returns_to_title() {
|
||||
let mut f = with_resolver();
|
||||
let mut f = Fixture::new();
|
||||
f.click("Friends");
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
@@ -166,7 +118,7 @@ mod multiplayer_parent_navigation {
|
||||
|
||||
#[test]
|
||||
fn friend_escape_returns_to_title() {
|
||||
let mut f = with_resolver();
|
||||
let mut f = Fixture::new();
|
||||
f.click("Friends");
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
|
||||
@@ -70,8 +70,7 @@ struct Shared {
|
||||
/// The game's handle. Dropping it stops the thread.
|
||||
pub struct Cosmetics {
|
||||
shared: Arc<Shared>,
|
||||
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
|
||||
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
|
||||
worker: Option<Box<dyn FnMut() + Send>>,
|
||||
}
|
||||
|
||||
impl Cosmetics {
|
||||
@@ -79,12 +78,7 @@ impl Cosmetics {
|
||||
/// `cache` when given.
|
||||
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
|
||||
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
|
||||
let mut work = c
|
||||
.worker
|
||||
.get_mut()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
.expect("worker");
|
||||
let mut work = c.worker.take().expect("worker");
|
||||
let shared = c.shared.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Cosmetics".into())
|
||||
@@ -131,7 +125,7 @@ impl Cosmetics {
|
||||
});
|
||||
Self {
|
||||
shared,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
|
||||
}
|
||||
|
||||
fn run(c: &mut Cosmetics) {
|
||||
(c.worker.get_mut().unwrap().as_mut().unwrap())();
|
||||
(c.worker.as_mut().unwrap())();
|
||||
}
|
||||
|
||||
fn ids(worn: Option<Worn>) -> Vec<String> {
|
||||
|
||||
@@ -19,13 +19,11 @@
|
||||
|
||||
pub mod hud;
|
||||
pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// What a crate above the engine hears from the game. Every method has an empty default:
|
||||
@@ -44,39 +42,6 @@ pub trait Hooks: Sync {
|
||||
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
|
||||
fn shutdown(&self) {}
|
||||
|
||||
/// Once a frame, before drawing: `sprites` takes the pictures the crate's
|
||||
/// [`players::Sprites`] lacked. Only a window's game calls it (each splitscreen player in
|
||||
/// turn, [`seat`]), never the integrated server's thread or a dedicated server; so are the
|
||||
/// player methods below (not [`Hooks::tunnel`]).
|
||||
fn frame(&self, _frame: &players::Frame, _sprites: &mut dyn players::SpriteStore) {}
|
||||
|
||||
/// Once a client tick while a world is shown: the player models in view.
|
||||
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
|
||||
|
||||
/// What goes by a player model's name over its head, each frame its name tag is drawn. The
|
||||
/// first hook that answers is used.
|
||||
fn name_decoration(
|
||||
&self,
|
||||
_model: &players::PlayerModel,
|
||||
_sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The mark before the name of the player `uuid` in the player list, each frame it shows.
|
||||
/// The first hook that answers is used.
|
||||
fn player_mark(&self, _uuid: u128) -> Option<players::Mark> {
|
||||
None
|
||||
}
|
||||
|
||||
/// How the camera stands in third person, each frame. The first hook that answers is used.
|
||||
fn camera(&self, _own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Draws on a player model, after its vanilla layers, each time it is drawn.
|
||||
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
@@ -90,14 +55,11 @@ pub trait Hooks: Sync {
|
||||
pub struct Started<'a> {
|
||||
/// The build: the release tag (`0.2.31`), or `dev <crate version>` for a build from source.
|
||||
pub version: &'a str,
|
||||
/// The player's game folder: servers.dat, options.txt and saves (the working directory unless
|
||||
/// `--game-dir` names another).
|
||||
pub game_dir: &'a std::path::Path,
|
||||
}
|
||||
|
||||
impl<'a> Started<'a> {
|
||||
pub fn new(version: &'a str, game_dir: &'a std::path::Path) -> Self {
|
||||
Self { version, game_dir }
|
||||
pub fn new(version: &'a str) -> Self {
|
||||
Self { version }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,27 +89,10 @@ fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SEAT: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
|
||||
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
|
||||
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
|
||||
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
|
||||
pub fn seat() -> usize {
|
||||
SEAT.get()
|
||||
}
|
||||
|
||||
/// The engine's side: where it calls the hooks. Only the engine calls these.
|
||||
pub mod engine {
|
||||
use super::*;
|
||||
|
||||
/// The player whose turn starts on this thread ([`seat`]).
|
||||
pub fn set_seat(seat: usize) {
|
||||
SEAT.set(seat);
|
||||
}
|
||||
|
||||
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
|
||||
/// registration ends.
|
||||
pub fn seal() {
|
||||
@@ -185,50 +130,6 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hook is registered: without one the engine builds no view to hand them.
|
||||
pub fn any() -> bool {
|
||||
!all().is_empty()
|
||||
}
|
||||
|
||||
/// [`Hooks::frame`].
|
||||
pub fn frame(frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
for hooks in all() {
|
||||
hooks.frame(frame, sprites);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::name_decoration`].
|
||||
pub fn name_decoration(
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
all().iter().find_map(|h| h.name_decoration(model, sprites))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_mark`].
|
||||
pub fn player_mark(uuid: u128) -> Option<players::Mark> {
|
||||
all().iter().find_map(|h| h.player_mark(uuid))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_layers`].
|
||||
pub fn player_layers(model: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
for hooks in all() {
|
||||
hooks.player_layers(model, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
}
|
||||
|
||||
/// [`Hooks::tick`].
|
||||
pub fn tick(models: &mut dyn players::PlayerModels) {
|
||||
for hooks in all() {
|
||||
hooks.tick(models);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
@@ -238,7 +139,6 @@ pub mod engine {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use players::PlayerModels as _;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Records what it heard. One process-wide list, as the game has: this test is the only
|
||||
@@ -255,149 +155,6 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}", frame.connection));
|
||||
if !sprites.has(1) {
|
||||
sprites.put(1, vec![0; 64 * 64 * 4]);
|
||||
}
|
||||
}
|
||||
fn name_decoration(
|
||||
&self,
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
let badge = sprites.get(1).map(|uv| players::Badge::new(uv, 0xFF0000));
|
||||
(model.kind == players::Kind::Player)
|
||||
.then(|| players::NameDecoration::new(Some(MARK), badge, Some(2.0)))
|
||||
}
|
||||
fn player_mark(&self, uuid: u128) -> Option<players::Mark> {
|
||||
(uuid == 9).then_some(MARK)
|
||||
}
|
||||
fn camera(&self, own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
Some(players::CameraOverride::new(own.age / 10.0))
|
||||
}
|
||||
/// A hat on the head, its brim turned by the model's age.
|
||||
fn player_layers(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let (Some(head), Some(uv)) = (draw.bone(players::Bone::Head), draw.sprite(1)) else {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
models.for_each(&mut |m| ids.push(m.id));
|
||||
for id in ids {
|
||||
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Still;
|
||||
impl players::BodyPose for Still {
|
||||
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Two models, poses by id.
|
||||
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
|
||||
impl players::PlayerModels for Two {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
|
||||
for id in [3, 7] {
|
||||
f(players::PlayerModel::new(
|
||||
id,
|
||||
players::Kind::Player,
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
self.0.get(&id)
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
|
||||
match pose {
|
||||
Some(p) => self.0.insert(id, p),
|
||||
None => self.0.remove(&id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const MARK: players::Mark = &[(0, 0, 8, 8, 0xFF00_00FF)];
|
||||
|
||||
/// One sprite slot, as the game's atlas has many.
|
||||
#[derive(Default)]
|
||||
struct Atlas(Option<(u64, usize)>);
|
||||
impl players::SpriteStore for Atlas {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0.is_some_and(|(i, _)| i == id)
|
||||
}
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
self.0 = Some((id, rgba.len()));
|
||||
}
|
||||
}
|
||||
impl players::Sprites for Atlas {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
players::SpriteStore::has(self, id).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
}
|
||||
|
||||
static HAT: std::sync::LazyLock<std::sync::Arc<players::Mesh>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
let brim = players::Mesh::new("brim".into(), [0.0; 9], Vec::new(), Vec::new());
|
||||
std::sync::Arc::new(players::Mesh::new(
|
||||
"hat".into(),
|
||||
[0.0; 9],
|
||||
Vec::new(),
|
||||
vec![brim],
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is; logs each draw.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
}
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
|
||||
}
|
||||
fn body_pose(&self) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
None
|
||||
}
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
(id == 1).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
impl players::PartPoses for Rest {
|
||||
fn pose(&mut self, _: &str) -> &mut [f32; 9] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements nothing: the defaults do nothing.
|
||||
@@ -410,52 +167,15 @@ mod tests {
|
||||
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
|
||||
register(&RECORDER).unwrap();
|
||||
register(&Silent).unwrap();
|
||||
engine::started(&Started::new("0.2.31", std::path::Path::new("game")));
|
||||
engine::started(&Started::new("0.2.31"));
|
||||
// Changes only: the same state every frame is said once.
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
|
||||
// Before the frame put the sprite in: the decoration without its badge.
|
||||
let before = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert!(before.badge.is_none() && before.top == Some(2.0));
|
||||
engine::frame(&players::Frame::new(4, false), &mut atlas);
|
||||
assert_eq!(atlas.0, Some((1, 64 * 64 * 4)));
|
||||
let after = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert_eq!(
|
||||
after.badge.map(|b| (b.uv, b.line)),
|
||||
Some(([0.0, 0.0, 0.5, 0.5], 0xFF0000))
|
||||
);
|
||||
assert!(engine::name_decoration(&model(players::Kind::Mannequin), &atlas).is_none());
|
||||
assert_eq!(engine::player_mark(9), Some(MARK));
|
||||
assert_eq!(engine::player_mark(8), None);
|
||||
let camera = engine::camera(&players::CameraView::new(5.0));
|
||||
assert_eq!(camera.map(|c| c.distance), Some(0.5));
|
||||
let mut canvas = Canvas::default();
|
||||
engine::player_layers(&model(players::Kind::Player), &mut canvas);
|
||||
assert_eq!(
|
||||
canvas.0,
|
||||
[(
|
||||
"hat".to_owned(),
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4],
|
||||
[0.0, 0.0, 0.5, 0.5],
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
let mut models = Two(Default::default());
|
||||
engine::tick(&mut models);
|
||||
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
|
||||
assert_eq!(
|
||||
*RECORDER.0.lock().unwrap(),
|
||||
[
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"frame 4"
|
||||
]
|
||||
["started 0.2.31", "published true", "published false"]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@ pub enum Place {
|
||||
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
|
||||
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
|
||||
Extras,
|
||||
/// The title screen's Friends icon slot (vanilla's `CommonButtons.friends`, beside Language
|
||||
/// and Accessibility): with a page registered here the icon opens the first one as a form of
|
||||
/// its own, as Join a Friend is drawn ([`Row::Field`]). It shows heading, note, text, space,
|
||||
/// field and button rows; the others belong in the options shell.
|
||||
Title,
|
||||
}
|
||||
|
||||
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
|
||||
@@ -45,11 +40,6 @@ pub trait Page: Sync {
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
None
|
||||
}
|
||||
/// The label of the button the engine adds to leave a page drawn as its own screen: a
|
||||
/// translation key, `gui.done` by default (`gui.cancel` for a form that does something).
|
||||
fn back(&self) -> &'static str {
|
||||
"gui.done"
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of a page. Labels are translation keys.
|
||||
@@ -71,118 +61,11 @@ pub enum Row {
|
||||
value: f32,
|
||||
text: Label,
|
||||
},
|
||||
/// A button that does something ([`Row::button`]). An inactive one is drawn greyed and
|
||||
/// never pressed; two half-width ones in a row sit side by side; a footer one sits at the
|
||||
/// bottom beside Done on a page drawn as its own screen, and with the others elsewhere.
|
||||
/// Horizon Settings' Extras shows only active ones.
|
||||
#[non_exhaustive]
|
||||
Button {
|
||||
label: &'static str,
|
||||
active: bool,
|
||||
half: bool,
|
||||
footer: bool,
|
||||
},
|
||||
/// Text shown as it is, wrapped to the page's width ([`Row::text`]): white, or grey when
|
||||
/// `quiet` (secondary); `groups` breaks it only between its `-`-separated groups (a code).
|
||||
#[non_exhaustive]
|
||||
Text {
|
||||
text: String,
|
||||
quiet: bool,
|
||||
groups: bool,
|
||||
},
|
||||
/// Empty space, in GUI pixels, on a page drawn as its own screen; nothing elsewhere.
|
||||
Space(u8),
|
||||
/// A button that does something.
|
||||
Button { label: &'static str },
|
||||
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
|
||||
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
|
||||
Key { label: &'static str, key: String },
|
||||
/// A text field ([`Row::field`]) on a page drawn as its own screen, as vanilla's `EditBox`:
|
||||
/// its label above it, focus by click or Tab, typing, the caret keys, selection, copy and
|
||||
/// paste, at most `max` characters. `text` is its value when the page opens; the engine then
|
||||
/// keeps it, and sends each change ([`Act::Edit`]) and Enter ([`Act::Submit`]).
|
||||
#[non_exhaustive]
|
||||
Field {
|
||||
label: &'static str,
|
||||
text: String,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// An active, full-width button in the page's body.
|
||||
pub fn button(label: &'static str) -> Self {
|
||||
Self::Button {
|
||||
label,
|
||||
active: true,
|
||||
half: false,
|
||||
footer: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// White text in the page's body, wrapped at spaces.
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text {
|
||||
text: text.into(),
|
||||
quiet: false,
|
||||
groups: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field starting with `text`, at most 32 characters (`EditBox`'s default).
|
||||
pub fn field(label: &'static str, text: impl Into<String>) -> Self {
|
||||
Self::Field {
|
||||
label,
|
||||
text: text.into(),
|
||||
max: 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field taking at most `chars` characters.
|
||||
pub fn max(mut self, chars: usize) -> Self {
|
||||
if let Self::Field { max, .. } = &mut self {
|
||||
*max = chars;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button greyed out (`false`): drawn, never pressed.
|
||||
pub fn active(mut self, on: bool) -> Self {
|
||||
if let Self::Button { active, .. } = &mut self {
|
||||
*active = on;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button half as wide: two in a row sit side by side.
|
||||
pub fn half(mut self) -> Self {
|
||||
if let Self::Button { half, .. } = &mut self {
|
||||
*half = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button in the footer, beside Done, on a page drawn as its own screen.
|
||||
pub fn footer(mut self) -> Self {
|
||||
if let Self::Button { footer, .. } = &mut self {
|
||||
*footer = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text in grey: secondary.
|
||||
pub fn quiet(mut self) -> Self {
|
||||
if let Self::Text { quiet, .. } = &mut self {
|
||||
*quiet = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Text broken only between its `-`-separated groups, never inside one (a code).
|
||||
pub fn groups(mut self) -> Self {
|
||||
if let Self::Text { groups, .. } = &mut self {
|
||||
*groups = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A value shown on a row: a translation key, or text shown as it is (a number, a name).
|
||||
@@ -204,19 +87,6 @@ pub enum Act {
|
||||
Slide(f32),
|
||||
/// A key row got its new key: its key name (`key.keyboard.z`).
|
||||
Bind(String),
|
||||
/// A field's text changed: all of it.
|
||||
Edit(String),
|
||||
/// Enter in a field.
|
||||
Submit,
|
||||
}
|
||||
|
||||
/// The server a page asked to join ([`connect`]).
|
||||
static CONNECT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// From a [`Place::Title`] page's [`Page::act`]: join the server at `address` as Direct
|
||||
/// Connection does (the engine's addresses, `friend:<code>` among them), once the act returns.
|
||||
pub fn connect(address: impl Into<String>) {
|
||||
*CONNECT.lock().unwrap_or_else(|e| e.into_inner()) = Some(address.into());
|
||||
}
|
||||
|
||||
static PAGES: Registry<dyn Page> = Registry::new();
|
||||
@@ -235,9 +105,4 @@ pub mod engine {
|
||||
pub fn all() -> &'static [&'static dyn Page] {
|
||||
PAGES.all()
|
||||
}
|
||||
|
||||
/// The server a page asked to join since the last call ([`connect`]).
|
||||
pub fn take_connect() -> Option<String> {
|
||||
CONNECT.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
|
||||
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`],
|
||||
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation, what goes by their
|
||||
//! names, and the crate's [`Sprites`]. Plain types only: no engine type crosses this line.
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 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 crate lays over a player model's body after its vanilla animation (Horizon's emotes,
|
||||
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
|
||||
/// 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 model's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); 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;
|
||||
}
|
||||
|
||||
/// What a player model is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Kind {
|
||||
/// The player of this window, seen from outside (third person, the inventory picture).
|
||||
Own,
|
||||
/// Another player.
|
||||
Player,
|
||||
/// A mannequin: a player model with no player.
|
||||
Mannequin,
|
||||
}
|
||||
|
||||
/// One player model, as [`PlayerModels::for_each`] shows it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct PlayerModel {
|
||||
/// Its entity id on this connection ([`PlayerModels::connection`]).
|
||||
pub id: u32,
|
||||
pub kind: Kind,
|
||||
/// The player's UUID as the server gave it (the window's own: its profile); None for
|
||||
/// mannequins.
|
||||
pub uuid: Option<u128>,
|
||||
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
|
||||
pub age: u32,
|
||||
/// It moved sideways since the last tick (more than 0.01 blocks).
|
||||
pub moved: bool,
|
||||
/// What it wears that covers a crate's drawing.
|
||||
pub gear: Gear,
|
||||
/// It is drawn invisible (the effect, or a trader's disguise).
|
||||
pub invisible: bool,
|
||||
}
|
||||
|
||||
impl PlayerModel {
|
||||
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
uuid,
|
||||
age,
|
||||
moved,
|
||||
gear: Gear::default(),
|
||||
invisible: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// With what it wears and whether it is invisible.
|
||||
pub fn looks(self, gear: Gear, invisible: bool) -> Self {
|
||||
Self {
|
||||
gear,
|
||||
invisible,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a player model wears where a crate may draw.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct Gear {
|
||||
/// A helmet or a head item.
|
||||
pub head: bool,
|
||||
/// A chestplate (an elytra is on the back).
|
||||
pub chest: bool,
|
||||
/// A cape or an elytra.
|
||||
pub back: bool,
|
||||
}
|
||||
|
||||
impl Gear {
|
||||
pub fn new(head: bool, chest: bool, back: bool) -> Self {
|
||||
Self { head, chest, back }
|
||||
}
|
||||
}
|
||||
|
||||
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
|
||||
pub trait PlayerModels {
|
||||
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
|
||||
/// when this changes.
|
||||
fn connection(&self) -> u64;
|
||||
/// Each player model in view (other players and mannequins; the window's own model first).
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
|
||||
/// The pose laid over model `id`, if any.
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
|
||||
}
|
||||
|
||||
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Frame {
|
||||
/// As [`PlayerModels::connection`]; 0 with no world.
|
||||
pub connection: u64,
|
||||
/// The world is the window's own single-player one: its server names the player by an
|
||||
/// offline profile.
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(connection: u64, solo: bool) -> Self {
|
||||
Self { connection, solo }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangles drawn before a name: x, y, width, height in text pixels from the name line's top
|
||||
/// left corner (a pixel left of its background), and `0xAARRGGBB`.
|
||||
pub type Mark = &'static [(u8, u8, u8, u8, u32)];
|
||||
|
||||
/// What a crate draws by a player's name over their head ([`crate::Hooks::name_decoration`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct NameDecoration {
|
||||
/// Before the name.
|
||||
pub mark: Option<Mark>,
|
||||
/// After the name, and a line under it.
|
||||
pub badge: Option<Badge>,
|
||||
/// How high the crate's drawing on the body gets above the neck, in model pixels: the tag
|
||||
/// goes up over it.
|
||||
pub top: Option<f32>,
|
||||
}
|
||||
|
||||
impl NameDecoration {
|
||||
pub fn new(mark: Option<Mark>, badge: Option<Badge>, top: Option<f32>) -> Self {
|
||||
Self { mark, badge, top }
|
||||
}
|
||||
}
|
||||
|
||||
/// A badge after a name: 8 text pixels square, a pixel right of the background.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Badge {
|
||||
/// Its picture: a part of a [`Sprites`] sprite, in atlas coordinates (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
/// The line under the name, `0xRRGGBB`.
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
pub fn new(uv: [f32; 4], line: u32) -> Self {
|
||||
Self { uv, line }
|
||||
}
|
||||
}
|
||||
|
||||
/// A crate's pictures in the game's atlas, by ids of its own: 64×64 RGBA each, in the slots skins
|
||||
/// use (one no frame drew for a while may go). Read while drawing.
|
||||
pub trait Sprites {
|
||||
/// Where sprite `id` is (u0, v0, u1, v1), and it is kept this frame. None until
|
||||
/// [`SpriteStore::put`] put it there.
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]>;
|
||||
}
|
||||
|
||||
/// Where a crate puts its [`Sprites`], in [`crate::Hooks::frame`].
|
||||
pub trait SpriteStore {
|
||||
/// Sprite `id` is in the atlas.
|
||||
fn has(&self, id: u64) -> bool;
|
||||
/// Puts sprite `id` in the atlas (64×64 RGBA), if a slot is free.
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>);
|
||||
}
|
||||
|
||||
/// The window's own model, as [`crate::Hooks::camera`] sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraView {
|
||||
/// Its age in ticks, with the frame's part of a tick.
|
||||
pub age: f32,
|
||||
}
|
||||
|
||||
impl CameraView {
|
||||
pub fn new(age: f32) -> Self {
|
||||
Self { age }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a crate moves the camera in third person.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraOverride {
|
||||
/// How far it stands back, as a share of the usual distance.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl CameraOverride {
|
||||
pub fn new(distance: f32) -> Self {
|
||||
Self { distance }
|
||||
}
|
||||
}
|
||||
|
||||
/// A model a crate draws on players, as the game's own models are: a tree of parts, 16 units a
|
||||
/// block, y down. Build it once and keep the `Arc`: the engine prepares it for the GPU once, by
|
||||
/// the `Arc`'s address, while the crate holds it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Mesh {
|
||||
/// What [`PartPoses::pose`] names it by.
|
||||
pub name: String,
|
||||
/// At rest: x, y, z, x/y/z rotation (radians), x/y/z scale.
|
||||
pub pose: [f32; 9],
|
||||
pub quads: Vec<Quad>,
|
||||
pub children: Vec<Mesh>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
pub fn new(name: String, pose: [f32; 9], quads: Vec<Quad>, children: Vec<Mesh>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
pose,
|
||||
quads,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One face of a [`Mesh`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Quad {
|
||||
pub normal: [f32; 3],
|
||||
/// Its corners: x, y, z, then u, v across the sprite (0 to 1).
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
}
|
||||
|
||||
impl Quad {
|
||||
pub fn new(normal: [f32; 3], vertices: [[f32; 5]; 4]) -> Self {
|
||||
Self { normal, vertices }
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of the player's body a crate draws on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Bone {
|
||||
Head,
|
||||
Body,
|
||||
RightArm,
|
||||
LeftArm,
|
||||
RightLeg,
|
||||
LeftLeg,
|
||||
}
|
||||
|
||||
impl Bone {
|
||||
/// Its name among [`BODY_PARTS`].
|
||||
pub fn part(self) -> &'static str {
|
||||
BODY_PARTS[self as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which picture of a player is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera.
|
||||
World,
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
pub trait PartPoses {
|
||||
/// Part `name`'s pose, to change (see [`Mesh::pose`]); a name the mesh lacks gets a pose that
|
||||
/// is not used.
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9];
|
||||
}
|
||||
|
||||
/// Where a crate draws on a player model ([`crate::Hooks::player_layers`]). Matrices are
|
||||
/// column-major, relative to the camera, in blocks.
|
||||
pub trait PlayerDraw {
|
||||
fn pass(&self) -> Pass;
|
||||
/// The model's age in ticks, with the frame's part of a tick.
|
||||
fn ticks(&self) -> f32;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
|
||||
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// As [`Sprites::get`].
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
|
||||
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
|
||||
/// model is; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Horizon's official server in the multiplayer list, and the withdrawn partner server taken out of it (docs/layers.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-addresses = { path = "../horizon-addresses" }
|
||||
horizon-platform = { path = "../horizon-platform", features = ["protocol"] }
|
||||
log = "0.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -15,7 +15,6 @@ horizon-game = { path = "../horizon-game" }
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-official-servers = { path = "../horizon-official-servers" }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -11,13 +11,11 @@
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// The official server in the player's server list.
|
||||
&horizon_official_servers::HOOKS,
|
||||
];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
|
||||
@@ -13,7 +13,7 @@ regex = { version = "1", default-features = false, features = ["std", "unicode-p
|
||||
|
||||
[dev-dependencies]
|
||||
# `cpu_seconds` for cem_expr_bench.
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -101,7 +101,7 @@ fn main() {
|
||||
let mut slots = vec![0.0f32; NAMES.len()];
|
||||
let frames = 2_000_000u32;
|
||||
let pid = std::process::id();
|
||||
let cpu = horizon_bench::cpu_seconds(pid);
|
||||
let cpu = horizon_system::cpu_seconds(pid);
|
||||
let start = Instant::now();
|
||||
for frame in 0..frames {
|
||||
// An entity walking in and out of water.
|
||||
@@ -125,7 +125,7 @@ fn main() {
|
||||
evals / elapsed / 1e6,
|
||||
elapsed * 1e6 / f64::from(frames),
|
||||
);
|
||||
match cpu.zip(horizon_bench::cpu_seconds(pid)) {
|
||||
match cpu.zip(horizon_system::cpu_seconds(pid)) {
|
||||
Some((before, after)) => println!(
|
||||
" cpu time {:.1} ns per expression, {:.2} us per entity frame",
|
||||
(after - before) * 1e9 / evals,
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "The platform's public API: what the Horizon layer and mods may us
|
||||
# Each part of the engine a Horizon crate may reach, so a crate builds only the engine it uses.
|
||||
# level.dat, servers.dat and region files: NBT, compression, region read and write.
|
||||
world = ["dep:horizon-world"]
|
||||
# A server's address after its SRV record, the offline-mode UUID of a name, and servers.dat.
|
||||
# A server's address after its SRV record, and the offline-mode UUID of a name.
|
||||
protocol = ["world", "dep:horizon-protocol"]
|
||||
# Preparing the game's files from the player's own Minecraft install.
|
||||
prepare = ["dep:horizon-prepare"]
|
||||
|
||||
@@ -46,15 +46,7 @@ pub use horizon_server_files as server_files;
|
||||
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
|
||||
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
|
||||
/// the crate's sprites, the camera, and what a crate draws on them.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
|
||||
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
|
||||
SpriteStore, Sprites,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
@@ -65,19 +57,10 @@ pub mod settings {
|
||||
|
||||
/// Settings pages a crate adds to the options screens (docs/layers.md, "Platform registries").
|
||||
pub mod pages {
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
|
||||
}
|
||||
|
||||
/// Drawing on the HUD (docs/layers.md, "Platform registries").
|
||||
pub mod hud {
|
||||
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
|
||||
}
|
||||
|
||||
/// The multiplayer server list as vanilla stores it (`servers.dat`): read, write, add, remove,
|
||||
/// and the lock held while it is read and written back.
|
||||
#[cfg(feature = "protocol")]
|
||||
pub mod servers {
|
||||
pub use horizon_protocol::servers::{
|
||||
ServerEntry, load, lock, parse_address, prepend, remove_where, save,
|
||||
};
|
||||
}
|
||||
@@ -50,111 +50,12 @@ pub enum BiomeColor {
|
||||
Colormap(String),
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog (`fluid_modifiers`): `fog_colormap` as a flat colour (RGB), and the
|
||||
/// `fog_radius` and `fog_fade` multipliers (1 is vanilla).
|
||||
#[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, read with the colours.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl FluidFogs {
|
||||
/// Every `assets/<namespace>/polytone/fluid_modifiers/**.json` under a pack overlay root that
|
||||
/// sets a fog field and targets `minecraft:water` or `minecraft:lava` (still or flowing). A
|
||||
/// colormap id or an expression isn't read (a warning names it).
|
||||
pub fn read(root: &Path) -> Self {
|
||||
let mut fogs = Self::default();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let mut stack = vec![namespace.path().join("polytone/fluid_modifiers")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
fogs.add(root, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fogs
|
||||
}
|
||||
|
||||
fn add(&mut self, root: &Path, path: &Path) {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<Json>(&text) else {
|
||||
return;
|
||||
};
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let number = |key: &str| match json.get(key) {
|
||||
None => Some(1.0),
|
||||
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
|
||||
Some(Json::String(s)) => s.trim().parse().ok().or_else(|| {
|
||||
log::warn!("{relative}: {key} expressions aren't read yet");
|
||||
None
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let color = match json.get("fog_colormap") {
|
||||
None => None,
|
||||
Some(value) => match parse_color(value) {
|
||||
Some(c) => Some(c & 0xFF_FFFF),
|
||||
None => {
|
||||
log::warn!("{relative}: only flat fog colours are read");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let fog = FluidFog {
|
||||
color,
|
||||
radius: number("fog_radius").unwrap_or(1.0),
|
||||
fade: number("fog_fade").unwrap_or(1.0),
|
||||
};
|
||||
if fog
|
||||
== (FluidFog {
|
||||
color: None,
|
||||
radius: 1.0,
|
||||
fade: 1.0,
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let implicit = crate::implicit_id(&relative, "fluid_modifiers");
|
||||
let Ok(targets) = crate::Targets::parse(json.get("targets"), implicit.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let aims = |id: &str| targets.matches(id, |_| false);
|
||||
if aims("minecraft:water") || aims("minecraft:flowing_water") {
|
||||
self.water = Some(fog);
|
||||
}
|
||||
if aims("minecraft:lava") || aims("minecraft:flowing_lava") {
|
||||
self.lava = Some(fog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pack's custom colours, by dotted key.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PackColors {
|
||||
entries: BTreeMap<String, Json>,
|
||||
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
|
||||
pub env: Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
@@ -182,7 +83,6 @@ impl PackColors {
|
||||
}
|
||||
}
|
||||
colors.env = colors.environment();
|
||||
colors.fluid_fog = FluidFogs::read(root);
|
||||
colors
|
||||
}
|
||||
|
||||
@@ -476,31 +376,6 @@ mod tests {
|
||||
assert_eq!(c.biome_color("dry_foliage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_modifiers_give_water_and_lava_their_fog() {
|
||||
let root = std::env::temp_dir().join(format!("fluid-fog-{}", std::process::id()));
|
||||
let dir = root.join("assets/minecraft/polytone/fluid_modifiers");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("water.json"),
|
||||
r#"{"targets": ["minecraft:water", "minecraft:flowing_water"], "fog_colormap": "ff0000", "fog_radius": 0.3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lava.json"), r#"{"fog_fade": "0"}"#).unwrap();
|
||||
std::fs::write(dir.join("honey.json"), r#"{"colormap": "foliage_color"}"#).unwrap();
|
||||
let fogs = FluidFogs::read(&root);
|
||||
let fog = |color, radius, fade| {
|
||||
Some(FluidFog {
|
||||
color,
|
||||
radius,
|
||||
fade,
|
||||
})
|
||||
};
|
||||
assert_eq!(fogs.water, fog(Some(0xff0000), 0.3, 1.0));
|
||||
assert_eq!(fogs.lava, fog(None, 1.0, 0.0));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyes_name_their_colour_kind() {
|
||||
let mut c = PackColors::default();
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
//! Horizon layer (docs/layers.md#engine).
|
||||
//!
|
||||
//! Only what explains performance: no serial number, host name, user name or path is read. The
|
||||
//! slower probes (a command to run) are read once per process.
|
||||
//! slower probes (a command to run) are read once per process. [`scrub`] reads the user name only
|
||||
//! to take it out of text that leaves the machine (a telemetry item, a bug report's bundle).
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub mod scrub;
|
||||
|
||||
/// What `program args` prints, trimmed, when it succeeds and prints something. On Windows the
|
||||
/// program opens no console window: a GUI launcher or a game started without one would flash a
|
||||
/// terminal for every probe.
|
||||
@@ -229,7 +232,8 @@ fn os_version() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu() -> Option<String> {
|
||||
/// The CPU's name (`Apple M4 Pro`, `AMD Ryzen 7 7700X 8-Core Processor`).
|
||||
pub fn cpu() -> Option<String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -281,7 +285,8 @@ fn processor_name_from_reg_query(query: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
/// Installed memory, in bytes.
|
||||
pub fn ram_bytes() -> Option<u64> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let kib = read("/proc/meminfo").and_then(|t| field(&t, "MemTotal"))?;
|
||||
kib.split_whitespace()
|
||||
@@ -740,6 +745,64 @@ impl Machine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
command_output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return command_output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = command_output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
/// The GPU's maker from its name and the vendor ID wgpu reports (0: none, as on Metal).
|
||||
pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
|
||||
match vendor_id {
|
||||
@@ -1005,4 +1068,13 @@ mod tests {
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(["", "nvme", "ssd", "hdd"].contains(&Machine::detect_in(Some(&dir)).storage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_process_is_measured() {
|
||||
if cfg!(any(target_os = "macos", target_os = "linux")) {
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -18,11 +18,13 @@ pub mod consent;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
pub mod scrub;
|
||||
pub mod server_status;
|
||||
/// The machine's probes, from the platform (`horizon-system`). Kept at this path for the
|
||||
/// launcher and the benchmark.
|
||||
pub use horizon_platform::system;
|
||||
/// Takes private text out before it leaves (`horizon_system::scrub`). Kept at this path for the
|
||||
/// launcher.
|
||||
pub use horizon_platform::system::scrub;
|
||||
#[cfg(feature = "upload")]
|
||||
pub mod upload;
|
||||
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user