mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8933e8c2d8 | ||
|
|
226a707d0d | ||
|
|
1453e0e614 | ||
|
|
9b4258fecc | ||
|
|
2d610e9482 | ||
|
|
e6eccbd3b9 | ||
|
|
537539fe58 | ||
|
|
cbbd362eed | ||
|
|
1430b9990a | ||
|
|
64a6b82dd0 | ||
|
|
a84f637dd6 | ||
|
|
f2d50d4a75 | ||
|
|
efab4d1bbf | ||
|
|
604fb90bf1 | ||
|
|
b2d72cea9e | ||
|
|
7b50bae250 | ||
|
|
3780c215a7 | ||
|
|
3c853b46eb | ||
|
|
e64af82d00 | ||
|
|
37bf47f477 | ||
|
|
03d8150bdb | ||
|
|
0b8d44458a | ||
|
|
c21f627431 | ||
|
|
62e62c21e6 | ||
|
|
a6350aa8f9 | ||
|
|
c817ed7fff | ||
|
|
b020ab26b9 | ||
|
|
35f095bd41 | ||
|
|
4e22c1ad9a | ||
|
|
e1aa435188 | ||
|
|
0341ad3d3a | ||
|
|
128575e4e5 | ||
|
|
6cc30fbed4 | ||
|
|
cb870cf80d | ||
|
|
e138304d2e | ||
|
|
5385996153 | ||
|
|
83a9845928 | ||
|
|
3563a7f986 | ||
|
|
cc4933eaed | ||
|
|
4be9343eca | ||
|
|
03fd6705a1 | ||
|
|
a5a60598e4 | ||
|
|
0fd4e22139 | ||
|
|
ec19b6320a | ||
|
|
16e29a2774 | ||
|
|
d016678e24 | ||
|
|
3d9df204e9 | ||
|
|
984b7d8635 | ||
|
|
afa60454b2 | ||
|
|
aa8592fbf9 | ||
|
|
7a4aedf231 | ||
|
|
8e45334760 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
8aed306751 | ||
|
|
da800dff55 | ||
|
|
67f61f1c24 | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
2c5baa6a60 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
385dc78afc | ||
|
|
661a253ec4 | ||
|
|
d56b769df5 | ||
|
|
7bfa76577f | ||
|
|
0022d3f42d | ||
|
|
ac1f560622 | ||
|
|
6a4172369c | ||
|
|
06130ae40d | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
01343707af | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
96aa9791e6 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
c66936f747 | ||
|
|
1d52dd41ba | ||
|
|
0a6874877d | ||
|
|
e2ad02a58a | ||
|
|
fb23266b6e | ||
|
|
d7d1e72afc | ||
|
|
2a5ec67594 | ||
|
|
97ab5f100a | ||
|
|
95a3721df1 | ||
|
|
4a296fffb2 | ||
|
|
f3e4420699 | ||
|
|
db3f211cfa | ||
|
|
dc2aaacea3 | ||
|
|
300d65c317 | ||
|
|
18c8cc5eb9 | ||
|
|
f9baaaed27 | ||
|
|
72352e0006 | ||
|
|
d4da822ac2 | ||
|
|
26f8689238 | ||
|
|
9a43f998c1 | ||
|
|
2cdde29154 | ||
|
|
7816e1adbb | ||
|
|
d94ceb2eb9 | ||
|
|
4843a2b975 | ||
|
|
0d7c032320 | ||
|
|
932fea900e | ||
|
|
a53de4943f | ||
|
|
2aee551905 | ||
|
|
599ef2acd3 | ||
|
|
9969321db4 | ||
|
|
acfa08d9e7 | ||
|
|
91d32f40bc | ||
|
|
1691d8d65b | ||
|
|
408b3dc47d | ||
|
|
d55b4b0a78 | ||
|
|
1a1e4c6f46 | ||
|
|
7008bc32ba | ||
|
|
2ccc6f7ffe | ||
|
|
a14242eb15 | ||
|
|
d747ae6c26 | ||
|
|
a9e60a6782 | ||
|
|
7a92b5064c | ||
|
|
87346bb92f | ||
|
|
d16c9137f9 | ||
|
|
0e4163f67f |
No files matched your search
Generated
+10
@@ -1650,6 +1650,15 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1793,6 +1802,7 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
|
||||
@@ -34,6 +34,7 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
|
||||
+145
-45
@@ -2,6 +2,9 @@
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
>
|
||||
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
|
||||
> the mods decide moderation matters). The council can overturn this amendment by poll.
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
@@ -50,13 +53,13 @@ the Horizon Team, for example.
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
@@ -64,6 +67,55 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
### 2.1 Three kinds of action
|
||||
|
||||
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
|
||||
|
||||
| Kind | What | How |
|
||||
| --- | --- | --- |
|
||||
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
|
||||
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
|
||||
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
|
||||
|
||||
Free actions, for example:
|
||||
|
||||
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
|
||||
- a moderator gives or removes 🤝 Support, through Kira;
|
||||
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
|
||||
unbans;
|
||||
- the Team merges and labels;
|
||||
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
|
||||
- a lead pins, renames or creates threads in their group's channels;
|
||||
- Kira's automatic actions.
|
||||
|
||||
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
|
||||
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
|
||||
within 24 hours of a result.
|
||||
|
||||
### 2.2 Who may do what
|
||||
|
||||
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
|
||||
council matter.
|
||||
|
||||
| Post | Decides | Discord | Official server | GitHub |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
|
||||
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
|
||||
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
|
||||
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
|
||||
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
|
||||
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
|
||||
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
|
||||
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
|
||||
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
|
||||
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
|
||||
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
|
||||
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
|
||||
|
||||
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
|
||||
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
|
||||
posts as a list of clicks for a named person.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
@@ -84,13 +136,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
|
||||
group's elections and decisions (section 5.3).
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
@@ -151,7 +203,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
@@ -165,20 +217,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
|
||||
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
|
||||
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
|
||||
discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
@@ -220,7 +272,7 @@ false age declaration (section 9).
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
@@ -254,25 +306,63 @@ A nomination is refused below these floors.
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
### 5.3 Elections
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
|
||||
for a recall.
|
||||
|
||||
| Post | Who votes | Passes with | Who can start it |
|
||||
| --- | --- | --- | --- |
|
||||
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
|
||||
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
|
||||
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
|
||||
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
|
||||
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
|
||||
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
|
||||
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
|
||||
|
||||
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
|
||||
and has at least one merged pull request or review with the group's label in the last 60 days.
|
||||
|
||||
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
|
||||
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
|
||||
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
|
||||
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
|
||||
the voters judge the rest.
|
||||
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
|
||||
runs. Kira shows how many voted, never who. It closes early once the result can't change.
|
||||
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
|
||||
rest as clicks for a named person. Only the result is published, never the ballots.
|
||||
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
|
||||
majority, without the people involved) decides; if it passes, Kira undoes the result.
|
||||
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
|
||||
|
||||
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
|
||||
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
|
||||
|
||||
#### Hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Fewer than 3 can vote | The council decides by poll |
|
||||
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
|
||||
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
|
||||
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
|
||||
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
|
||||
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
|
||||
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
|
||||
| The council is already voting on it | No election starts for the same person and post |
|
||||
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
|
||||
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
|
||||
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
|
||||
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
|
||||
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
|
||||
|
||||
#### Reminders, not nominations
|
||||
|
||||
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
|
||||
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
|
||||
lists privately the people who meet a floor. These are suggestions: only people start elections.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
@@ -299,8 +389,8 @@ holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
|
||||
floor and is nominated within its first 12 hours, both go on the same ballot.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
@@ -339,8 +429,9 @@ the design system:
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- giving and removing the group's helper role, through Kira (a free action);
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
@@ -356,21 +447,28 @@ the design system:
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
|
||||
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
|
||||
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
|
||||
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
|
||||
decision, any council member can call a council poll, and a simple majority overturns it.
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
|
||||
involved don't vote, and the council's veto applies. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
|
||||
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
|
||||
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
|
||||
any other, and the mods can reverse it by vote.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
@@ -458,7 +556,9 @@ and the founder carries it out.
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
|
||||
and body votes are posted there too; for elections, only the result.
|
||||
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
|
||||
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,6 +253,17 @@ impl Activity {
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
|
||||
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
|
||||
/// Held, opens the radial menu (`radial.rs`).
|
||||
pub const RADIAL: &str = "pad.radial";
|
||||
|
||||
mod radial_file;
|
||||
pub use radial_file::{RadialMode, RadialStick, opener_refused};
|
||||
|
||||
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
|
||||
/// are; 2: the radial menu's opener resolved against the player's own buttons.
|
||||
const BIND_LAYOUT: u32 = 2;
|
||||
/// The layout from which the file, not the default, says where the opener is.
|
||||
const OPENER_LAYOUT: u32 = 2;
|
||||
|
||||
/// Bindable actions and their default buttons: vanilla key mappings (the
|
||||
/// game acts as their default key) and Horizon's pad-only actions. Start is
|
||||
@@ -271,6 +282,7 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.togglePerspective", Some(Button::Select)),
|
||||
("key.pickItem", Some(Button::DPadLeft)),
|
||||
("key.chat", Some(Button::DPadUp)),
|
||||
(RADIAL, Some(Button::DPadRight)),
|
||||
("key.advancements", None),
|
||||
("key.playerlist", None),
|
||||
("key.command", None),
|
||||
@@ -303,11 +315,14 @@ pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
Radial,
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Fullscreen,
|
||||
/// F3's debug screen, shown or hidden as F3 alone does.
|
||||
DebugOverlay,
|
||||
/// Does nothing by itself: what reads it asks whether the button is held
|
||||
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
|
||||
Held,
|
||||
@@ -320,10 +335,12 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
RADIAL => Some(Route::Radial),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
"key.fullscreen" => Some(Route::Fullscreen),
|
||||
"key.debug.overlay" => Some(Route::DebugOverlay),
|
||||
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
@@ -334,12 +351,16 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's opener, as the binds list and the gameplay guide name it.
|
||||
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
|
||||
HOTBAR_NEXT => "Hotbar: Next Slot",
|
||||
RADIAL => RADIAL_CAPTION,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -654,6 +675,21 @@ pub struct PadSettings {
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
/// The radial menu's pages.
|
||||
pub radial: crate::radial::Pages,
|
||||
/// Whether the radial menu's opener is held or pressed once.
|
||||
pub radial_mode: RadialMode,
|
||||
/// The stick that selects in it.
|
||||
pub radial_stick: RadialStick,
|
||||
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
|
||||
/// button it doesn't know), kept as they were so a save here loses nothing.
|
||||
extra: Vec<(String, String)>,
|
||||
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
|
||||
/// kept so that one doesn't resolve its file again as older than it is.
|
||||
bind_layout: u32,
|
||||
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
|
||||
/// With row and back gives each stick's own opener again.
|
||||
openers: radial_file::StickOpeners,
|
||||
}
|
||||
impl Default for PadSettings {
|
||||
fn default() -> Self {
|
||||
@@ -680,6 +716,12 @@ impl Default for PadSettings {
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
radial: Default::default(),
|
||||
radial_mode: RadialMode::Hold,
|
||||
radial_stick: RadialStick::Right,
|
||||
extra: Vec::new(),
|
||||
bind_layout: BIND_LAYOUT,
|
||||
openers: radial_file::StickOpeners::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +748,7 @@ impl PadSettings {
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut s = Self::default();
|
||||
let mut saved_opener = None;
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -754,22 +797,85 @@ impl PadSettings {
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
"radialMode" => s.radial_mode = RadialMode::parse(value),
|
||||
"radialStick" => s.radial_stick = RadialStick::parse(value),
|
||||
// Read after the loop, from the whole text.
|
||||
"bindLayout" => {}
|
||||
// The radial menu's pages (`radial::Pages::parse`).
|
||||
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
|
||||
other => {
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
if let Some(button) = button {
|
||||
s.bind(action, button);
|
||||
}
|
||||
let known = other
|
||||
.strip_prefix("bind_")
|
||||
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
match (known, button) {
|
||||
// The opener once the selecting stick is known.
|
||||
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
|
||||
(Some(action), Some(button)) => s.bind(action, button),
|
||||
_ => s.extra.push((other.to_owned(), value.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were.
|
||||
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
|
||||
// An opener on a button it can't be with this stick reads as never saved.
|
||||
let refused_opener = match saved_opener {
|
||||
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
|
||||
Some(button) => {
|
||||
s.bind(RADIAL, button);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if let Some(pages) = crate::radial::Pages::parse(
|
||||
text.lines()
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim(), value.trim())),
|
||||
) {
|
||||
s.radial = pages;
|
||||
}
|
||||
let lines = || text.lines().map(str::trim);
|
||||
let saved = |action: &str| {
|
||||
let prefix = format!("bind_{action}:");
|
||||
lines().any(|line| line.starts_with(&prefix))
|
||||
};
|
||||
// The layout the file was written in; none in a file from before these defaults.
|
||||
let layout = lines()
|
||||
.find_map(|line| line.strip_prefix("bindLayout:"))
|
||||
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
|
||||
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
|
||||
// Once, for a file from before the radial menu's opener, or one with the opener on a
|
||||
// button it can no longer be: a button the player bound by hand stays theirs, and the
|
||||
// opener's default doesn't take it. From then on the file says where the opener is, and
|
||||
// a conflict made since stays shown in the binds list.
|
||||
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
|
||||
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
|
||||
let default_opener = ACTIONS
|
||||
.iter()
|
||||
.find(|(action, _)| *action == RADIAL)
|
||||
.and_then(|(_, button)| *button);
|
||||
let taken = s.bindings.iter().any(|(action, button)| {
|
||||
*action != RADIAL && button.is_some() && *button == default_opener
|
||||
});
|
||||
if taken {
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
}
|
||||
// The default too can be under the selecting thumb (D-pad Right, the left stick
|
||||
// selecting): no button opens the menu until the player picks one.
|
||||
if s.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(s.radial_stick, b))
|
||||
{
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were, and
|
||||
// nothing they saved shares a button with the radial menu's opener. They can bind one
|
||||
// in the binds list.
|
||||
if !text.is_empty() && layout.is_none() {
|
||||
s.bindings.entry(RADIAL).or_insert(None);
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -778,10 +884,7 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
if !saved(action) {
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -822,7 +925,25 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
text.push_str(&format!(
|
||||
"radialMode:{}\nradialStick:{}\n",
|
||||
self.radial_mode.name(),
|
||||
self.radial_stick.name()
|
||||
));
|
||||
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
|
||||
for (key, value) in &self.extra {
|
||||
// A button this game didn't know, for an action the player has bound here since.
|
||||
let rebound = key
|
||||
.strip_prefix("bind_")
|
||||
.is_some_and(|action| self.bindings.contains_key(action));
|
||||
if !rebound {
|
||||
text.push_str(&format!("{key}:{value}\n"));
|
||||
}
|
||||
}
|
||||
// The default pages aren't written: they can improve for a player who never changed them.
|
||||
if self.radial != crate::radial::Pages::default() {
|
||||
text.push_str(&self.radial.serialize());
|
||||
}
|
||||
text
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -835,11 +956,15 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
pub fn bind(&mut self, action: &str, button: Option<Button>) {
|
||||
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
|
||||
return;
|
||||
};
|
||||
if button.is_some_and(|b| !self.can_bind(action, b)) {
|
||||
return;
|
||||
}
|
||||
if button == *default {
|
||||
self.bindings.remove(name);
|
||||
} else {
|
||||
@@ -1440,6 +1565,14 @@ pub enum Rumble {
|
||||
radius: f32,
|
||||
},
|
||||
Totem,
|
||||
/// The radial menu's stick reached a slot that would run: a tick.
|
||||
RadialSlot,
|
||||
/// The radial menu's page turned: a shorter tick.
|
||||
RadialPage,
|
||||
/// The page turn went round an end: a second tick after the first.
|
||||
RadialWrap,
|
||||
/// A release ran nothing after something had been selected: a soft buzz.
|
||||
RadialNothing,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
@@ -1459,6 +1592,7 @@ pub struct Pulse {
|
||||
pub weak: f32,
|
||||
pub millis: u32,
|
||||
}
|
||||
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
|
||||
impl Rumble {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -1466,6 +1600,10 @@ impl Rumble {
|
||||
Rumble::BlockBroken => "block",
|
||||
Rumble::Explosion { .. } => "explosion",
|
||||
Rumble::Totem => "totem",
|
||||
Rumble::RadialSlot => "radial",
|
||||
Rumble::RadialPage => "radial_page",
|
||||
Rumble::RadialWrap => "radial_wrap",
|
||||
Rumble::RadialNothing => "radial_nothing",
|
||||
}
|
||||
}
|
||||
/// The pulse, or `None` when it is too faint to feel.
|
||||
@@ -1492,6 +1630,9 @@ impl Rumble {
|
||||
millis: 350,
|
||||
})
|
||||
}
|
||||
Rumble::RadialSlot => Some(RADIAL_TICK),
|
||||
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
|
||||
Rumble::RadialNothing => Some(RADIAL_NOTHING),
|
||||
Rumble::Totem => Some(Pulse {
|
||||
strong: 0.7,
|
||||
weak: 0.7,
|
||||
@@ -1513,6 +1654,10 @@ impl Rumble {
|
||||
};
|
||||
match self {
|
||||
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
|
||||
Rumble::RadialSlot
|
||||
| Rumble::RadialPage
|
||||
| Rumble::RadialWrap
|
||||
| Rumble::RadialNothing => vec![p],
|
||||
Rumble::Hurt(_) => {
|
||||
let tail = p.millis.saturating_sub(50) / 2;
|
||||
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
|
||||
@@ -1873,7 +2018,55 @@ bind_key.hotbar.2:north
|
||||
let odd = PadSettings::parse(
|
||||
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
|
||||
);
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
assert_eq!(
|
||||
odd.look_sensitivity,
|
||||
PadSettings::default().look_sensitivity
|
||||
);
|
||||
assert_eq!(odd.binding("key.jump"), Some(Button::South));
|
||||
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_game_doesnt_read_survive_a_save() {
|
||||
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
|
||||
let text =
|
||||
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
|
||||
let s = PadSettings::parse(text);
|
||||
assert_eq!(
|
||||
s.binding("key.jump"),
|
||||
Some(Button::South),
|
||||
"the default meanwhile"
|
||||
);
|
||||
let saved = s.serialize();
|
||||
for line in [
|
||||
"hapticStrength:0.4\n",
|
||||
"bind_horizon:dash:south\n",
|
||||
"bind_key.jump:paddle1\n",
|
||||
] {
|
||||
assert!(saved.contains(line), "{line} lost from\n{saved}");
|
||||
}
|
||||
assert_eq!(PadSettings::parse(&saved), s);
|
||||
// Bound here since, the action's own line is the one written.
|
||||
let mut s = s;
|
||||
s.bind("key.jump", Some(Button::West));
|
||||
let saved = s.serialize();
|
||||
assert!(!saved.contains("paddle1"), "{saved}");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&saved).binding("key.jump"),
|
||||
Some(Button::West)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_bind_layout_is_kept() {
|
||||
let newer = BIND_LAYOUT + 1;
|
||||
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
|
||||
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert!(
|
||||
s.serialize()
|
||||
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
|
||||
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
|
||||
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
|
||||
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
|
||||
use super::{Button, PadSettings, RADIAL};
|
||||
|
||||
/// The buttons under the right thumb, which steers the right stick.
|
||||
const RIGHT_THUMB: [Button; 4] = [
|
||||
Button::South,
|
||||
Button::West,
|
||||
Button::North,
|
||||
Button::RightStick,
|
||||
];
|
||||
/// The buttons under the left thumb, which steers the left stick.
|
||||
const LEFT_THUMB: [Button; 5] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
Button::LeftStick,
|
||||
];
|
||||
|
||||
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
|
||||
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
|
||||
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
|
||||
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
|
||||
/// with a bumper for the opener the other bumper turns the pages.
|
||||
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
|
||||
button == Button::East
|
||||
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|
||||
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
|
||||
}
|
||||
|
||||
/// How the radial menu's opener holds it open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialMode {
|
||||
/// Held: letting go runs what is selected.
|
||||
#[default]
|
||||
Hold,
|
||||
/// A press opens it; A or the opener again runs what is selected, B closes it.
|
||||
Toggle,
|
||||
}
|
||||
impl RadialMode {
|
||||
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hold => "hold",
|
||||
Self::Toggle => "toggle",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The stick that selects in the radial menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialStick {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// The last stick to lean past the select tilt.
|
||||
Either,
|
||||
}
|
||||
impl RadialStick {
|
||||
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "right",
|
||||
Self::Left => "left",
|
||||
Self::Either => "either",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|stick| stick.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The opener each stick had, for this session: what the player stepped through, not a setting,
|
||||
/// so it is neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct StickOpeners([Option<Button>; 3]);
|
||||
impl PartialEq for StickOpeners {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl StickOpeners {
|
||||
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
|
||||
let at = RadialStick::ALL
|
||||
.iter()
|
||||
.position(|s| *s == stick)
|
||||
.unwrap_or(0);
|
||||
&mut self.0[at]
|
||||
}
|
||||
}
|
||||
|
||||
impl PadSettings {
|
||||
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
|
||||
/// a thumb that selects, or on B (`opener_refused`).
|
||||
pub fn can_bind(&self, action: &str, button: Button) -> bool {
|
||||
action != RADIAL || !opener_refused(self.radial_stick, button)
|
||||
}
|
||||
/// The stick the radial menu selects with. An opener under the thumb that now selects is
|
||||
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
|
||||
/// stick had before in this session, or else its default where nothing else has it (D-pad
|
||||
/// Right, back on the right stick).
|
||||
pub fn set_radial_stick(&mut self, stick: RadialStick) {
|
||||
let was = self.radial_stick;
|
||||
*self.openers.slot(was) = self.binding(RADIAL);
|
||||
self.radial_stick = stick;
|
||||
if self
|
||||
.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(stick, b))
|
||||
{
|
||||
self.bindings.insert(RADIAL, None);
|
||||
}
|
||||
if self.binding(RADIAL).is_some() {
|
||||
return;
|
||||
}
|
||||
// The player's own, shared or not: they put it there.
|
||||
if let Some(own) = self
|
||||
.openers
|
||||
.slot(stick)
|
||||
.filter(|b| !opener_refused(stick, *b))
|
||||
{
|
||||
self.bind(RADIAL, Some(own));
|
||||
return;
|
||||
}
|
||||
let default = Self::default_of(RADIAL);
|
||||
let free =
|
||||
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
|
||||
if free {
|
||||
self.bindings.remove(RADIAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::Rumble;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
|
||||
for s in [PadSettings::default(), PadSettings::parse("")] {
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(!s.conflicts(RADIAL));
|
||||
}
|
||||
let s = PadSettings::parse(&PadSettings::default().serialize());
|
||||
assert_eq!(s, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
|
||||
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert!(!s.conflicts("key.playerlist"));
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_opener_stays_where_it_was_saved() {
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
|
||||
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind("key.playerlist", Some(Button::DPadRight));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2"));
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(read.conflicts(RADIAL));
|
||||
assert_eq!(read, s);
|
||||
// The same, hand-written with spaces around the lines.
|
||||
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_one_file_resolves_the_opener_once() {
|
||||
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
|
||||
assert_eq!(PadSettings::parse(&text), s);
|
||||
// Without a conflict the opener keeps its default.
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// A line with leading spaces is still a saved opener.
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
|
||||
use Button as B;
|
||||
let face = [B::South, B::West, B::North, B::RightStick];
|
||||
let dpad = [
|
||||
B::DPadUp,
|
||||
B::DPadDown,
|
||||
B::DPadLeft,
|
||||
B::DPadRight,
|
||||
B::LeftStick,
|
||||
];
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(stick);
|
||||
for button in Button::ALL {
|
||||
let refused = button == B::East
|
||||
|| (stick != RadialStick::Left && face.contains(&button))
|
||||
|| (stick != RadialStick::Right && dpad.contains(&button));
|
||||
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
|
||||
// Other actions still take any of them.
|
||||
assert!(s.can_bind("key.chat", button));
|
||||
}
|
||||
// A bumper is always free, and stays one through the file.
|
||||
s.bind(RADIAL, Some(B::LeftBumper));
|
||||
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
|
||||
}
|
||||
// A file with a refused opener reads as never saved: the default, unless taken.
|
||||
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
|
||||
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
|
||||
let taken = format!("{text}bind_key.playerlist:right\n");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&taken).binding(RADIAL),
|
||||
None,
|
||||
"{button:?}"
|
||||
);
|
||||
}
|
||||
// Read with the stick it was saved for, wherever the stick's line is.
|
||||
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
|
||||
assert_eq!(left.binding(RADIAL), Some(B::North));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(
|
||||
s.binding(RADIAL),
|
||||
None,
|
||||
"D-pad Right is under the left thumb"
|
||||
);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// An opener the player picked that both sticks allow stays.
|
||||
s.bind(RADIAL, Some(Button::RightBumper));
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
|
||||
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
s.bind(RADIAL, Some(Button::North));
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// And Left again has its Y back, shared with Inventory as the player left it.
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::North));
|
||||
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind(RADIAL, Some(Button::DPadLeft));
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_the_radial_menu_binds_no_opener() {
|
||||
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
|
||||
let tick = Rumble::RadialSlot.pulse().unwrap();
|
||||
assert!((40..=50).contains(&tick.millis), "{tick:?}");
|
||||
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
|
||||
let page = Rumble::RadialPage.pulse().unwrap();
|
||||
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
|
||||
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
|
||||
let nothing = Rumble::RadialNothing.pulse().unwrap();
|
||||
assert!(
|
||||
nothing.strong > 0.0 && nothing.millis > tick.millis,
|
||||
"{nothing:?}"
|
||||
);
|
||||
assert!(
|
||||
nothing.strong.max(nothing.weak) < 0.3,
|
||||
"a soft buzz: {nothing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
|
||||
let s = PadSettings::default();
|
||||
assert_eq!(
|
||||
(s.radial_mode, s.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
for mode in RadialMode::ALL {
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings {
|
||||
radial_mode: mode,
|
||||
..PadSettings::default()
|
||||
};
|
||||
s.set_radial_stick(stick);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
}
|
||||
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
|
||||
assert_eq!(
|
||||
(read.radial_mode, read.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_pages_survive_the_settings_file() {
|
||||
let mut s = PadSettings::default();
|
||||
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
|
||||
s.radial =
|
||||
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
|
||||
let read = PadSettings::parse(&s.serialize());
|
||||
assert_eq!(read.radial, s.radial);
|
||||
assert_ne!(read.radial, crate::radial::Pages::default());
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,13 @@ impl Guide {
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
// Nothing else in play names the radial menu.
|
||||
GuideDetail::Basic => Guide {
|
||||
left: Vec::new(),
|
||||
left: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -178,6 +183,8 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
// Nothing else in play says the menu is there; it opens for a spectator too.
|
||||
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -566,6 +573,7 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -736,12 +744,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -765,7 +773,8 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod radial;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -739,7 +760,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -109,16 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
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-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -130,6 +125,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -29,7 +29,6 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -85,5 +84,7 @@ horizon-extras = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -57,6 +57,8 @@ pub enum Item {
|
||||
/// Wait for a button to bind to this action.
|
||||
Bind(&'static str),
|
||||
ResetBinds,
|
||||
/// Opens the radial menu's pages (`radial_editor`).
|
||||
Radial,
|
||||
Info(Info),
|
||||
}
|
||||
|
||||
@@ -106,6 +108,15 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
Opt::PadRadialMode => match pad.radial_mode {
|
||||
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
|
||||
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
|
||||
},
|
||||
Opt::PadRadialStick => match pad.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -123,6 +134,11 @@ fn info(info: Info, text: &'static str) -> Setting {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
|
||||
const RADIAL_GROUP: &str = "Radial Menu";
|
||||
/// The row that opens the radial menu's pages.
|
||||
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
|
||||
|
||||
/// Shown on a binding's row until it is focused, and in the search.
|
||||
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
|
||||
|
||||
@@ -201,6 +217,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![
|
||||
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
|
||||
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
vec![
|
||||
@@ -231,6 +254,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
(
|
||||
Page::Bindings,
|
||||
vec![
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![Setting {
|
||||
item: Item::Radial,
|
||||
text: "Named pages of eight actions, held open with a button.",
|
||||
}],
|
||||
),
|
||||
("Actions", binds),
|
||||
(
|
||||
"Reset",
|
||||
@@ -437,6 +467,7 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
|
||||
Row::new(item, name, value).about(about)
|
||||
}
|
||||
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
|
||||
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
|
||||
Item::Info(which) => {
|
||||
let (name, value) = context.read(which);
|
||||
Row::new(item, lang::get(name), Value::Read(value)).about(about)
|
||||
@@ -496,6 +527,7 @@ impl Screen for ControllerScreen {
|
||||
s.controller.reset_bindings();
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
|
||||
Item::Bind(_) | Item::Info(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
/// This player's controller settings, bindings included, as the screen found them. The
|
||||
/// radial menu's pages stay as they are: their own screen has its own Undo.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
let radial = std::mem::take(&mut s.controller.radial);
|
||||
s.controller = before.controller.clone();
|
||||
s.controller.radial = radial;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
|
||||
/// and nor do the radial menu's pages, which this Undo keeps.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
Some(s.controller != before.controller)
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,6 +831,48 @@ mod tests {
|
||||
assert_eq!(arts(&batch, row), 0);
|
||||
}
|
||||
|
||||
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
|
||||
/// and another change's Undo keeps them as edited.
|
||||
#[test]
|
||||
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::General);
|
||||
draw(&mut ui, &s, None);
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(
|
||||
!ui.changed(),
|
||||
"Undo here keeps the pages: it has nothing to do"
|
||||
);
|
||||
s.controller.invert_y = !s.controller.invert_y;
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(ui.changed());
|
||||
let reached = (0..100).any(|_| {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
ui.focused() == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
|
||||
}
|
||||
|
||||
/// The Bindings page's radial row opens the radial menu's pages.
|
||||
#[test]
|
||||
fn the_radial_row_opens_the_radial_pages() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::Bindings);
|
||||
let at = ui.point_at(&s, SIZE, Item::Radial);
|
||||
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(
|
||||
outputs,
|
||||
[
|
||||
CLICK,
|
||||
Output::Push(crate::options_ui::Page::ControllerRadial)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
|
||||
let s = Settings::default();
|
||||
@@ -816,8 +896,8 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind(JUMP, Some(Button::North));
|
||||
let mut ui = ui(Page::Bindings);
|
||||
// Search, the three pages, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
// Search, the three pages, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -939,6 +1019,8 @@ mod tests {
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
(Opt::PadRadialMode, 2),
|
||||
(Opt::PadRadialStick, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
@@ -972,6 +1054,8 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -990,6 +1074,7 @@ mod tests {
|
||||
}
|
||||
ControllerScreen::default().side();
|
||||
lang::get(TITLE);
|
||||
lang::get(RADIAL_PAGES);
|
||||
for text in [
|
||||
"Press the button for this. %s cancels.",
|
||||
"This button also does: %s",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,8 +145,6 @@ pub struct Extras {
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
@@ -233,7 +231,6 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -338,7 +335,6 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
@@ -376,7 +372,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -418,7 +414,6 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
@@ -613,8 +608,6 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
@@ -691,7 +684,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
@@ -724,7 +716,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
@@ -768,7 +759,6 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
@@ -792,10 +782,9 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -844,9 +833,6 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -927,7 +913,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
@@ -986,7 +971,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
@@ -1104,7 +1088,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(d.discord_presence && !d.wheel_cycles_settings);
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -1143,7 +1127,6 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
|
||||
//! the hotbar at the bottom centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
@@ -34,6 +35,16 @@ pub const GAP: f32 = 2.0;
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
|
||||
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
|
||||
/// bottom.
|
||||
StatusRows,
|
||||
}
|
||||
impl Keep {
|
||||
/// It sits at the top of the view, not the bottom.
|
||||
fn at_top(self) -> bool {
|
||||
matches!(self, Self::BossBars)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
@@ -90,10 +101,20 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
|
||||
/// rows' top, or the view's height with none.
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| !keep.at_top())
|
||||
.map(|(_, [_, y, _, _])| y)
|
||||
.fold(self.height, f32::min)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
@@ -134,4 +155,15 @@ mod tests {
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
|
||||
let room = Room::new(427.0, 240.0);
|
||||
assert_eq!(room.bottom(), 240.0);
|
||||
// Hearts and armour from y 191 down.
|
||||
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
|
||||
assert_eq!(rows.bottom(), 191.0);
|
||||
assert_eq!(rows.top(), 0.0);
|
||||
assert_eq!(rows.below_top(18.0), 18.0);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,6 +131,23 @@ pub enum GuiDraw {
|
||||
|
||||
pub const WHITE: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
|
||||
/// of the HUD begins.
|
||||
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
|
||||
draws
|
||||
.iter()
|
||||
.filter_map(|d| match *d {
|
||||
GuiDraw::Sprite { y, .. }
|
||||
| GuiDraw::AtlasSprite { y, .. }
|
||||
| GuiDraw::Blit { y, .. }
|
||||
| GuiDraw::Text { y, .. }
|
||||
| GuiDraw::Item { y, .. } => Some(y),
|
||||
GuiDraw::Fill { y0, .. } => Some(y0),
|
||||
GuiDraw::Push(_) | GuiDraw::Pop => None,
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Borrowed(sprite),
|
||||
|
||||
@@ -583,6 +583,9 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
|
||||
/// span the same.
|
||||
pub const HOTBAR_HALF_WIDTH: i32 = 91;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
@@ -109,6 +110,8 @@ pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod radial_editor;
|
||||
pub mod radial_overlay;
|
||||
pub mod random_source;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
@@ -169,6 +172,7 @@ pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::radial;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
/// A lit choice and its bright edge (the controller dial's sector).
|
||||
pub lit: u32,
|
||||
pub lit_edge: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
lit: 0xffffff,
|
||||
lit_edge: 0xffffff,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -18,6 +18,8 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -322,6 +324,8 @@ impl Opt {
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::PadRadialMode => "horizon.options.radial_mode",
|
||||
Opt::PadRadialStick => "horizon.options.radial_stick",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
@@ -880,6 +884,7 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -1201,6 +1206,7 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.keyboard = all[usize::from(next(i, 3))];
|
||||
}
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1352,6 +1358,11 @@ pub enum Page {
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
|
||||
ControllerRadial,
|
||||
ControllerRadialName,
|
||||
/// The list of every action for one of its slots.
|
||||
ControllerRadialPick,
|
||||
Extras,
|
||||
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
|
||||
Pvp,
|
||||
@@ -1683,6 +1694,10 @@ pub struct OptionsUi {
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
/// The radial menu's pages, on the settings shell.
|
||||
radial: shell::Shell<crate::radial_editor::RadialScreen>,
|
||||
/// The list of actions for one of the radial menu's slots.
|
||||
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
@@ -1767,6 +1782,8 @@ impl Default for OptionsUi {
|
||||
graphics: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
radial: Default::default(),
|
||||
radial_pick: Default::default(),
|
||||
shader: Default::default(),
|
||||
pack_options: Default::default(),
|
||||
pack_configs: Default::default(),
|
||||
@@ -2112,6 +2129,7 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::ControllerRadialName => self.radial_name_typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2244,95 +2262,16 @@ impl OptionsUi {
|
||||
self.studio.refresh();
|
||||
}
|
||||
}
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
}
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
|
||||
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
|
||||
pub fn video_open(&self) -> bool {
|
||||
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// Where the graphics screen opens for `page`, if it is one of its pages.
|
||||
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
|
||||
match page {
|
||||
@@ -2369,6 +2308,9 @@ impl OptionsUi {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller | Page::PadBinds => "horizon.pages.controller",
|
||||
Page::ControllerRadial => "horizon.pages.controller_radial",
|
||||
Page::ControllerRadialName => "horizon.radial.name_title",
|
||||
Page::ControllerRadialPick => "horizon.radial.pick_title",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Pvp => "horizon.pages.pvp",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
@@ -3602,6 +3544,9 @@ impl OptionsUi {
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if self.draw_radial_name(batch, atlas, cursor, in_world) {
|
||||
return;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
@@ -3953,6 +3898,9 @@ impl OptionsUi {
|
||||
let outcome = c.click(&atlas.font, (x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
@@ -4648,6 +4596,8 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
} else if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles.type_text(text);
|
||||
} else if self.page() == Page::ControllerRadialName {
|
||||
self.type_radial_name(text);
|
||||
} else if let Some(shell) = self.shell_mut() {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
@@ -4683,6 +4633,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles {
|
||||
return self.split_profiles.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_shortcut(shortcut, clipboard);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4786,6 +4739,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_key(s, key, text);
|
||||
}
|
||||
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
|
||||
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
|
||||
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
|
||||
use super::{Event, OptionsUi, Page, tr};
|
||||
use crate::{
|
||||
gamepad,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
radial_editor::Named,
|
||||
settings::{Opt, Settings},
|
||||
settings_shell as shell,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
impl OptionsUi {
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
Page::ControllerRadial => Some(&self.radial),
|
||||
Page::ControllerRadialPick => Some(&self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
Page::ControllerRadial => Some(&mut self.radial),
|
||||
Page::ControllerRadialPick => Some(&mut self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
|
||||
/// action), for a controller's focus to land there.
|
||||
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
|
||||
self.shell()?.opened_focus()
|
||||
}
|
||||
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
|
||||
/// settings shell steps, rather than opens.
|
||||
pub fn pad_clicks_step(&mut self, on: bool) {
|
||||
if on {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clicks_step(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Every shell: the click may have changed the page.
|
||||
let shells: [&mut dyn shell::Hosted; 6] = [
|
||||
&mut self.extras,
|
||||
&mut self.graphics,
|
||||
&mut self.controller,
|
||||
&mut self.radial,
|
||||
&mut self.radial_pick,
|
||||
&mut self.shader,
|
||||
];
|
||||
for shell in shells {
|
||||
shell.clicks_step(false);
|
||||
}
|
||||
}
|
||||
/// The controller's focus landed there.
|
||||
pub fn clear_shell_opened_focus(&mut self) {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clear_opened_focus();
|
||||
}
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
pub(super) fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
pub(super) fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
pub(super) fn shell_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
outputs: Vec<shell::Output>,
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
// A slot's list, for the slot, opening on what it holds.
|
||||
if page == Page::ControllerRadialPick
|
||||
&& let Some(at) = self.radial.screen.picking.take()
|
||||
{
|
||||
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
|
||||
self.radial_pick.focus_soon(row);
|
||||
}
|
||||
}
|
||||
shell::Output::Done => {
|
||||
// Back from a slot's list, the focus is on the slot.
|
||||
let slot = (self.page() == Page::ControllerRadialPick)
|
||||
.then(|| self.radial_pick.screen.at());
|
||||
events.extend(self.back(s));
|
||||
if let Some((page, slot)) = slot {
|
||||
self.radial
|
||||
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
|
||||
if self.radial_pick.screen.take_picked() {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
// A radial page added or deleted: the one to show now.
|
||||
if let Some(page) = self.radial.screen.take_shown() {
|
||||
self.radial.show(page);
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What a press on a radial page's name does here.
|
||||
pub(super) fn name_events(
|
||||
&mut self,
|
||||
named: crate::radial_editor::Named,
|
||||
s: &mut Settings,
|
||||
) -> Vec<Event> {
|
||||
use crate::radial_editor::Named;
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
match named {
|
||||
Named::Nothing => return Vec::new(),
|
||||
Named::Done => {
|
||||
let entry = self.radial.screen.naming.take();
|
||||
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
Named::Back => events.extend(self.back(s)),
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
|
||||
/// in place of a shell: whether it was.
|
||||
pub(super) fn draw_radial_name(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) -> bool {
|
||||
self.radial.screen.glyphs = self.pad_glyphs;
|
||||
if self.page() == Page::ControllerRadialName
|
||||
&& let Some(entry) = &mut self.radial.screen.naming
|
||||
{
|
||||
entry.draw(batch, atlas, cursor, in_world);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
|
||||
pub(super) fn click_radial_name(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
atlas: &HudAtlas,
|
||||
at: (f32, f32),
|
||||
size: (f32, f32),
|
||||
ignored: bool,
|
||||
) -> Vec<Event> {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
let named = match entry.filter(|_| !ignored) {
|
||||
Some(entry) => entry.click(atlas, at, size),
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
/// Whether a radial page's name is being typed.
|
||||
pub(super) fn radial_name_typing(&self) -> bool {
|
||||
self.radial
|
||||
.screen
|
||||
.naming
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.typing())
|
||||
}
|
||||
/// Text typed into a radial page's name.
|
||||
pub(super) fn type_radial_name(&mut self, text: &str) {
|
||||
if let Some(entry) = &mut self.radial.screen.naming {
|
||||
entry.type_text(text);
|
||||
}
|
||||
}
|
||||
/// An edit shortcut in a radial page's name: whether it did something.
|
||||
pub(super) fn radial_name_shortcut(
|
||||
&mut self,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) -> bool {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
|
||||
}
|
||||
/// A key on a radial page's name entry.
|
||||
pub(super) fn radial_name_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
key: winit::keyboard::KeyCode,
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
let named = match &mut self.radial.screen.naming {
|
||||
Some(entry) => entry.key(key, text),
|
||||
// Nothing to name: Escape still leaves.
|
||||
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// The radial menu's rows of Controller Settings: their value.
|
||||
pub(super) fn radial_text(self, s: &Settings) -> Text {
|
||||
match self {
|
||||
Opt::PadRadialMode => tr(match s.controller.radial_mode {
|
||||
gamepad::RadialMode::Hold => "options.key.hold",
|
||||
gamepad::RadialMode::Toggle => "options.key.toggle",
|
||||
}),
|
||||
Opt::PadRadialStick => tr(match s.controller.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
/// The radial menu's rows of Controller Settings: a step, back or on.
|
||||
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
|
||||
let next = |n: u8, count: u8| {
|
||||
if back {
|
||||
(n + count - 1) % count
|
||||
} else {
|
||||
(n + 1) % count
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Opt::PadRadialMode => {
|
||||
let all = gamepad::RadialMode::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|m| *m == s.controller.radial_mode)
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
|
||||
}
|
||||
Opt::PadRadialStick => {
|
||||
let all = gamepad::RadialStick::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|k| *k == s.controller.radial_stick)
|
||||
.unwrap_or(0) as u8;
|
||||
// An opener under the thumb that now selects goes unbound.
|
||||
s.controller
|
||||
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,21 +124,15 @@ impl OptionsUi {
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai)
|
||||
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
|
||||
)
|
||||
.collect();
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.agent", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
];
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
|
||||
//! a weak reference tells an edited or replaced section from the one the list
|
||||
//! was built from (see `random_tick_sections`), without keeping its blocks.
|
||||
use crate::{chunk::Section, fast_hash::FxHashMap};
|
||||
use crate::{
|
||||
chunk::{Chunk, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
|
||||
@@ -18,13 +21,16 @@ struct Cached<T> {
|
||||
section: Weak<Section>,
|
||||
pois: SectionPois<T>,
|
||||
}
|
||||
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
|
||||
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
|
||||
/// index: a search through a column looks it up once.
|
||||
pub struct PoiSections<T> {
|
||||
sections: Mutex<Lists<T>>,
|
||||
}
|
||||
struct Lists<T> {
|
||||
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
|
||||
/// Size above which lists of sections no longer resident are dropped.
|
||||
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
|
||||
/// How many section lists are kept, and the count above which the lists
|
||||
/// of sections no longer resident are dropped.
|
||||
len: usize,
|
||||
prune_at: usize,
|
||||
}
|
||||
const MIN_PRUNE: usize = 4096;
|
||||
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sections: Mutex::new(Lists {
|
||||
by_section: FxHashMap::default(),
|
||||
by_column: FxHashMap::default(),
|
||||
len: 0,
|
||||
prune_at: MIN_PRUNE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, whose block states `kind` classifies. `kind`
|
||||
/// must give the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cached) = lists.by_section.get(&key)
|
||||
&& cached.section.as_ptr() == Arc::as_ptr(section)
|
||||
{
|
||||
return cached.pois.clone();
|
||||
impl<T: Copy> Lists<T> {
|
||||
/// The column's lists, for sections up to `last`, after dropping the
|
||||
/// lists of sections no longer resident if there are too many.
|
||||
fn column(
|
||||
&mut self,
|
||||
column: (i32, i32),
|
||||
last: usize,
|
||||
) -> (&mut [Option<Cached<T>>], &mut usize) {
|
||||
if self.len >= self.prune_at {
|
||||
self.prune();
|
||||
}
|
||||
let lists = self.by_column.entry(column).or_default();
|
||||
if lists.len() <= last {
|
||||
lists.resize_with(last + 1, || None);
|
||||
}
|
||||
(lists, &mut self.len)
|
||||
}
|
||||
|
||||
/// Drops the lists of sections no longer resident.
|
||||
fn prune(&mut self) {
|
||||
let mut len = 0;
|
||||
self.by_column.retain(|_, lists| {
|
||||
for c in lists.iter_mut() {
|
||||
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
|
||||
*c = None;
|
||||
}
|
||||
}
|
||||
let kept = lists.iter().filter(|c| c.is_some()).count();
|
||||
len += kept;
|
||||
kept > 0
|
||||
});
|
||||
self.len = len;
|
||||
self.prune_at = (len * 2).max(MIN_PRUNE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The POIs of `section` from `slot`, listed again (and kept there) unless
|
||||
/// `slot` was listed from this very section; `len` counts the slots filled.
|
||||
fn list<'a, T: Copy>(
|
||||
slot: &'a mut Option<Cached<T>>,
|
||||
len: &mut usize,
|
||||
section: &Arc<Section>,
|
||||
kind: &mut impl FnMut(u32) -> Option<T>,
|
||||
) -> &'a SectionPois<T> {
|
||||
let fresh = slot
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
|
||||
if !fresh {
|
||||
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
|
||||
let mut states = [0u32; 4096];
|
||||
section.unpack(&mut states);
|
||||
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
|
||||
} else {
|
||||
Arc::from([])
|
||||
};
|
||||
if lists.by_section.len() >= lists.prune_at {
|
||||
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
|
||||
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
|
||||
*len += usize::from(slot.is_none());
|
||||
*slot = Some(Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois,
|
||||
});
|
||||
}
|
||||
&slot.as_ref().expect("just listed").pois
|
||||
}
|
||||
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, the `key.1`-th section of the column
|
||||
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
|
||||
/// the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let s = usize::try_from(key.1).expect("a section index");
|
||||
let (column, len) = lists.column((key.0, key.2), s);
|
||||
list(&mut column[s], len, section, &mut kind).clone()
|
||||
}
|
||||
|
||||
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
|
||||
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
|
||||
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
|
||||
/// column.
|
||||
pub fn column(
|
||||
&self,
|
||||
chunk: &Chunk,
|
||||
min_y: i32,
|
||||
(y0, y1): (i32, i32),
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
out: &mut Vec<(i32, SectionPois<T>)>,
|
||||
) {
|
||||
out.clear();
|
||||
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
|
||||
if first > last {
|
||||
return;
|
||||
}
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
|
||||
for s in first as usize..=last as usize {
|
||||
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
|
||||
if !pois.is_empty() {
|
||||
out.push((min_y + s as i32 * 16, pois.clone()));
|
||||
}
|
||||
}
|
||||
lists.by_section.insert(
|
||||
key,
|
||||
Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois: pois.clone(),
|
||||
},
|
||||
);
|
||||
pois
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +221,48 @@ mod tests {
|
||||
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
|
||||
}
|
||||
|
||||
/// A column gives each section's list as `get` does, the sections
|
||||
/// without any left out, and keeps them until a section changes.
|
||||
#[test]
|
||||
fn a_column_gives_what_each_section_gives() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
let kind = |v: u32| (v == 5).then_some(5);
|
||||
let mut chunk = Arc::new(Chunk::empty(2, -3));
|
||||
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
|
||||
Arc::make_mut(&mut chunk).set(x, y, z, 5);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
|
||||
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
|
||||
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
|
||||
for (base, pois) in &out {
|
||||
let s = (base - MIN_Y) / 16;
|
||||
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
|
||||
assert!(Arc::ptr_eq(pois, &one));
|
||||
}
|
||||
// Kept: no rescan.
|
||||
index.column(
|
||||
&chunk,
|
||||
MIN_Y,
|
||||
(MIN_Y, MIN_Y + 63),
|
||||
|_| panic!("rescanned"),
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
// An edit lists that section again.
|
||||
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
|
||||
let mut rescans = 0;
|
||||
let counted = |v: u32| {
|
||||
rescans += 1;
|
||||
kind(v)
|
||||
};
|
||||
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
|
||||
assert!(rescans > 0);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].0, MIN_Y + 32);
|
||||
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_sections_are_dropped() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
@@ -153,7 +273,7 @@ mod tests {
|
||||
index.get((1, i, 0), &Arc::new(Section::default()), kind);
|
||||
}
|
||||
let lists = index.sections.lock().unwrap();
|
||||
assert!(lists.by_section.len() <= 2);
|
||||
assert!(lists.by_section.contains_key(&(0, 0, 0)));
|
||||
assert!(lists.len <= 2);
|
||||
assert!(lists.by_column[&(0, 0)][0].is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
|
||||
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
|
||||
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
|
||||
//! page's own name is typed on a small page of its own, [`NameEntry`].
|
||||
use crate::{
|
||||
gamepad::{self, GlyphOf, MenuJob},
|
||||
graphics_style::{self as style, BUTTON_H},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
pad_glyph::{Glyph, Glyphs},
|
||||
radial::{self, Pages, RadialAction, SLOTS, Slot},
|
||||
radial_overlay::{page_label, slot_caption},
|
||||
settings::Settings,
|
||||
settings_shell::{
|
||||
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
|
||||
},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.controller_radial";
|
||||
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
|
||||
const PAGE_GROUP: &str = "horizon.radial.group.page";
|
||||
const ALL_GROUP: &str = "horizon.radial.group.all";
|
||||
/// The opener's row binds the menu's own pad action, in place.
|
||||
const OPENER: &str = gamepad::RADIAL;
|
||||
|
||||
/// A row of a page; `usize`s are the page and the slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RadialRow {
|
||||
Slot(usize, usize),
|
||||
Rename(usize),
|
||||
NewPage,
|
||||
Delete(usize),
|
||||
Reset,
|
||||
Opener,
|
||||
}
|
||||
|
||||
/// A slot's action, that it holds none, or that its action is missing.
|
||||
fn slot_label(slot: &Slot) -> String {
|
||||
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
|
||||
}
|
||||
|
||||
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
|
||||
/// A missing action steps as Empty does.
|
||||
fn step(slot: &Slot, back: bool) -> Slot {
|
||||
let order: Vec<_> = std::iter::once(None)
|
||||
.chain(RadialAction::ALL.map(Some))
|
||||
.collect();
|
||||
let at = order
|
||||
.iter()
|
||||
.position(|entry| *entry == slot.action())
|
||||
.unwrap_or(0);
|
||||
let next = if back { at + order.len() - 1 } else { at + 1 };
|
||||
order[next % order.len()].into()
|
||||
}
|
||||
|
||||
fn page_of(page: usize, pages: usize) -> String {
|
||||
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
|
||||
}
|
||||
|
||||
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
|
||||
/// strip says what stands in the way (no button, or one another action shares); while it waits,
|
||||
/// the shell says how to bind it.
|
||||
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
|
||||
let pad = &s.controller;
|
||||
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
|
||||
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
|
||||
let warning = match pad.binding(OPENER) {
|
||||
None => Some(lang::get("horizon.radial.unbound")),
|
||||
Some(_) => pad
|
||||
.conflicts(OPENER)
|
||||
.then(|| lang::get("horizon.radial.conflict")),
|
||||
};
|
||||
let binding = Binding {
|
||||
capture: Capture::Pad(OPENER),
|
||||
shown: Shown::Pad {
|
||||
glyph,
|
||||
cancel: Glyph::menu(MenuJob::Start, glyphs),
|
||||
},
|
||||
shared: Vec::new(),
|
||||
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
|
||||
reset: Reset::None,
|
||||
};
|
||||
let name = lang::get("horizon.radial.opener");
|
||||
Row {
|
||||
chosen: warning,
|
||||
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
|
||||
.about(lang::get(opener_about(pad.radial_stick)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
|
||||
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
|
||||
match stick {
|
||||
gamepad::RadialStick::Right => "horizon.radial.opener.about",
|
||||
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
|
||||
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's rows: its slots, then what changes it.
|
||||
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
|
||||
let slots = (0..SLOTS).map(|slot| {
|
||||
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
|
||||
let cell = pages.page(page).slot(slot);
|
||||
let value = Value::Choice(slot_label(cell));
|
||||
let missing = match cell {
|
||||
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
|
||||
_ => None,
|
||||
};
|
||||
Row {
|
||||
chosen: missing,
|
||||
..Row::new(RadialRow::Slot(page, slot), name, value)
|
||||
.about(lang::get("horizon.radial.slot.about"))
|
||||
}
|
||||
});
|
||||
let full = (pages.len() >= radial::MAX_PAGES)
|
||||
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
|
||||
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
|
||||
let changes = vec![
|
||||
Row::new(
|
||||
RadialRow::Rename(page),
|
||||
lang::get("horizon.radial.rename"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.rename.about")),
|
||||
Row::new(
|
||||
RadialRow::NewPage,
|
||||
lang::get(radial::NEW_PAGE_NAME),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.new_page.about"))
|
||||
.blocked(full),
|
||||
Row::new(
|
||||
RadialRow::Delete(page),
|
||||
lang::get("horizon.radial.delete_page"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.delete_page.about"))
|
||||
.blocked(last),
|
||||
];
|
||||
[
|
||||
Group {
|
||||
name: SLOTS_GROUP,
|
||||
rows: slots.collect(),
|
||||
},
|
||||
Group {
|
||||
name: PAGE_GROUP,
|
||||
rows: changes,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The screen's own state; the pages are the player's settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RadialScreen {
|
||||
/// The buttons' names follow the controller in use: the options screen says which.
|
||||
pub(crate) glyphs: Glyphs,
|
||||
/// The page to show next: one just added, or where a deleted one was.
|
||||
show: Option<usize>,
|
||||
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
|
||||
pub(crate) naming: Option<NameEntry>,
|
||||
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
|
||||
pub(crate) picking: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl RadialScreen {
|
||||
/// The page the shell should show now, once.
|
||||
pub(crate) fn take_shown(&mut self) -> Option<usize> {
|
||||
self.show.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for RadialScreen {
|
||||
type Page = usize;
|
||||
type Row = RadialRow;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
|
||||
/// named with.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let pad = &s.controller;
|
||||
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
|
||||
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
|
||||
let pages = &s.controller.radial;
|
||||
let default = *pages == Pages::default();
|
||||
let all = Group {
|
||||
name: ALL_GROUP,
|
||||
rows: vec![
|
||||
Row::new(
|
||||
RadialRow::Reset,
|
||||
lang::get("horizon.radial.reset"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.reset.about"))
|
||||
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
|
||||
opener_row(s, self.glyphs),
|
||||
],
|
||||
};
|
||||
(0..pages.len())
|
||||
.map(|page| {
|
||||
let [slots, changes] = page_groups(pages, page);
|
||||
let all = Group {
|
||||
name: all.name,
|
||||
rows: all.rows.clone(),
|
||||
};
|
||||
Sheet::new(
|
||||
page,
|
||||
page_label(pages.page(page)),
|
||||
page_of(page, pages.len()),
|
||||
vec![slots, changes, all],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
|
||||
let pages = &mut s.controller.radial;
|
||||
match row {
|
||||
RadialRow::Slot(page, slot) => {
|
||||
let next = step(pages.page(page).slot(slot), back);
|
||||
pages.set_slot(page, slot, next);
|
||||
}
|
||||
RadialRow::Rename(page) => {
|
||||
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
|
||||
return vec![Output::Push(Page::ControllerRadialName)];
|
||||
}
|
||||
RadialRow::NewPage => self.show = pages.add(),
|
||||
RadialRow::Delete(page) => {
|
||||
pages.remove(page);
|
||||
self.show = Some(pages.clamp(page));
|
||||
}
|
||||
RadialRow::Reset => {
|
||||
*pages = Pages::default();
|
||||
self.show = Some(0);
|
||||
}
|
||||
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
|
||||
RadialRow::Opener => return Vec::new(),
|
||||
}
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
/// A press on a slot opens the list of every action for it; Left and Right still step it.
|
||||
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
|
||||
let RadialRow::Slot(page, slot) = row else {
|
||||
return None;
|
||||
};
|
||||
self.picking = Some((page, slot));
|
||||
Some(vec![Output::Push(Page::ControllerRadialPick)])
|
||||
}
|
||||
/// The wheel over a slot steps it, as a press does.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
|
||||
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
|
||||
}
|
||||
/// Undo is how a delete or a reset is taken back: they act at once.
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The pages, and the opener's button, which its row binds.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.controller.radial = before.controller.radial.clone();
|
||||
s.controller.bind(OPENER, before.controller.binding(OPENER));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
const PICK_TITLE: &str = "horizon.radial.pick_title";
|
||||
const PICK_GROUP: &str = "horizon.radial.group.actions";
|
||||
|
||||
/// A row of a slot's list: the line naming the slot, then Empty and every action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PickRow {
|
||||
Slot,
|
||||
Pick(Option<RadialAction>),
|
||||
}
|
||||
|
||||
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
|
||||
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PickScreen {
|
||||
/// The page and the slot.
|
||||
at: (usize, usize),
|
||||
picked: bool,
|
||||
}
|
||||
|
||||
impl PickScreen {
|
||||
/// For `at`; the row the focus opens on, what the slot holds.
|
||||
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
|
||||
*self = Self { at, picked: false };
|
||||
self.current(pages).unwrap_or(PickRow::Pick(None))
|
||||
}
|
||||
/// The page and the slot it is for.
|
||||
pub(crate) fn at(&self) -> (usize, usize) {
|
||||
self.at
|
||||
}
|
||||
/// A pick changed the pages since the last call, which the editor's Undo takes back.
|
||||
pub(crate) fn take_picked(&mut self) -> bool {
|
||||
std::mem::take(&mut self.picked)
|
||||
}
|
||||
/// The row of what the slot holds; none for an action this game doesn't know.
|
||||
fn current(&self, pages: &Pages) -> Option<PickRow> {
|
||||
match pages.page(self.at.0).slot(self.at.1) {
|
||||
Slot::Empty => Some(PickRow::Pick(None)),
|
||||
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
|
||||
Slot::Missing(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for PickScreen {
|
||||
type Page = ();
|
||||
type Row = PickRow;
|
||||
fn title(&self) -> &'static str {
|
||||
PICK_TITLE
|
||||
}
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let (page, slot) = self.at;
|
||||
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
|
||||
let pages = &s.controller.radial;
|
||||
let (page, slot) = self.at;
|
||||
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
|
||||
let named = lang::tr(
|
||||
"horizon.radial.pick.slot",
|
||||
&[&direction, &page_label(pages.page(page))],
|
||||
);
|
||||
let current = self.current(pages);
|
||||
let about = lang::get("horizon.radial.pick.about");
|
||||
let mut rows =
|
||||
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
|
||||
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
|
||||
let id = PickRow::Pick(action);
|
||||
let value = if current == Some(id) {
|
||||
Value::Choice(lang::get("horizon.radial.pick.current"))
|
||||
} else {
|
||||
Value::Press
|
||||
};
|
||||
let name = slot_label(&action.into());
|
||||
rows.push(Row::new(id, name, value).about(about.clone()));
|
||||
}
|
||||
vec![Sheet::new(
|
||||
(),
|
||||
named,
|
||||
about,
|
||||
vec![Group {
|
||||
name: PICK_GROUP,
|
||||
rows,
|
||||
}],
|
||||
)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
|
||||
let PickRow::Pick(action) = row else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (page, slot) = self.at;
|
||||
s.controller.radial.set_slot(page, slot, action.into());
|
||||
self.picked = true;
|
||||
vec![Output::Event(Event::Changed), Output::Done]
|
||||
}
|
||||
}
|
||||
|
||||
/// The name field and its buttons, centred in the screen but under the title, one row apart.
|
||||
const NAME_W: f32 = 200.0;
|
||||
const NAME_BLOCK_H: f32 = 100.0;
|
||||
const NAME_TOP_MIN: f32 = 30.0;
|
||||
const NAME_STEP: f32 = 25.0;
|
||||
|
||||
/// What a press on the name's page did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Named {
|
||||
Nothing,
|
||||
/// Done: the page takes the name.
|
||||
Done,
|
||||
/// Cancel, keeping the old name.
|
||||
Back,
|
||||
}
|
||||
|
||||
/// A page's name being typed.
|
||||
pub(crate) struct NameEntry {
|
||||
page: usize,
|
||||
field: ui::EditBox,
|
||||
}
|
||||
|
||||
impl NameEntry {
|
||||
fn new(page: usize, name: &str) -> Self {
|
||||
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
|
||||
field.max_length = radial::NAME_MAX;
|
||||
field.set_value(name);
|
||||
field.focused = true;
|
||||
Self { page, field }
|
||||
}
|
||||
|
||||
/// Done and Cancel, under the field.
|
||||
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
|
||||
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
|
||||
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
|
||||
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
|
||||
}
|
||||
|
||||
pub(crate) fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width.ceil(), batch.height.ceil());
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(lang::get("horizon.radial.name_title"));
|
||||
style::title(batch, atlas, &title);
|
||||
let field = &mut self.field;
|
||||
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
|
||||
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
|
||||
field.draw(batch, atlas, None);
|
||||
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
|
||||
let (label, action) = match named {
|
||||
Named::Done => ("gui.done", crate::widgets::Action::Done),
|
||||
_ => ("gui.cancel", crate::widgets::Action::Cancel),
|
||||
};
|
||||
let hover = ui::hovered(cursor, x, y, bw, bh);
|
||||
let text = Text::literal(lang::get(label));
|
||||
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// A press at `(x, y)` on a screen `w` x `h`.
|
||||
pub(crate) fn click(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
) -> Named {
|
||||
let hit = Self::buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
|
||||
if let Some((_, named)) = hit {
|
||||
return named;
|
||||
}
|
||||
let field = &mut self.field;
|
||||
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
|
||||
if field.focused {
|
||||
field.click(atlas, x, false, false);
|
||||
}
|
||||
Named::Nothing
|
||||
}
|
||||
|
||||
/// The name field takes the keys.
|
||||
pub(crate) fn typing(&self) -> bool {
|
||||
self.field.focused
|
||||
}
|
||||
|
||||
pub(crate) fn type_text(&mut self, text: &str) {
|
||||
if self.field.focused {
|
||||
self.field.insert_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
|
||||
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
match key {
|
||||
K::Escape => return Named::Back,
|
||||
K::Enter | K::NumpadEnter => return Named::Done,
|
||||
_ if self.field.focused => {
|
||||
if !self.field.key(key, false, false)
|
||||
&& let Some(t) = text
|
||||
{
|
||||
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
|
||||
self.field.insert_text(&typed);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Named::Nothing
|
||||
}
|
||||
|
||||
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
|
||||
pub(crate) fn shortcut(
|
||||
&mut self,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) -> bool {
|
||||
if self.field.focused {
|
||||
self.field.shortcut(shortcut, clipboard);
|
||||
}
|
||||
self.field.focused
|
||||
}
|
||||
|
||||
/// The name into the pages. Left as it was, a default page keeps following the language.
|
||||
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
|
||||
let changed = self.field.value != page_label(pages.page(self.page));
|
||||
if changed {
|
||||
pages.rename(self.page, &self.field.value);
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
/// The name field's top.
|
||||
fn name_top(h: f32) -> f32 {
|
||||
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gamepad::Button as PadButton;
|
||||
use crate::radial::PageName;
|
||||
use crate::settings_shell::{Hosted, Shell};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
|
||||
let sheets = RadialScreen::default().sheets(s);
|
||||
let groups = sheets.into_iter().nth(page).unwrap().groups;
|
||||
groups.into_iter().flat_map(|group| group.rows).collect()
|
||||
}
|
||||
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
|
||||
let page = match id {
|
||||
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
|
||||
_ => 0,
|
||||
};
|
||||
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
|
||||
}
|
||||
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
|
||||
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
|
||||
let at = shell.point_at(s, SIZE, id);
|
||||
shell.click(s, at, SIZE, false, false, false)
|
||||
}
|
||||
|
||||
/// Undo, at the foot over Done.
|
||||
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
|
||||
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
|
||||
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_steps_through_empty_and_every_action_both_ways() {
|
||||
let mut seen = vec![Slot::Empty];
|
||||
let mut slot = Slot::Empty;
|
||||
for _ in RadialAction::ALL {
|
||||
slot = step(&slot, false);
|
||||
seen.push(slot.clone());
|
||||
}
|
||||
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
|
||||
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
|
||||
assert_eq!(actions, RadialAction::ALL.to_vec());
|
||||
assert_eq!(
|
||||
step(&Slot::Empty, true).action(),
|
||||
RadialAction::ALL.last().copied()
|
||||
);
|
||||
let chat = Slot::Action(RadialAction::Chat);
|
||||
assert_eq!(step(&step(&chat, true), false), chat);
|
||||
// A missing action steps on as Empty would.
|
||||
let missing = Slot::Missing("mymod:dash".to_owned());
|
||||
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let before = s.controller.radial.clone();
|
||||
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
|
||||
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
|
||||
assert_eq!(shell.screen.picking, Some((0, 2)));
|
||||
assert_eq!(
|
||||
s.controller.radial, before,
|
||||
"the press itself changes nothing"
|
||||
);
|
||||
// Right on the focused row steps it.
|
||||
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
let stepped = step(before.page(0).slot(2), false);
|
||||
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut picker = PickScreen::default();
|
||||
// General's right slot holds Commands.
|
||||
let opens_on = picker.open_on((0, 2), &s.controller.radial);
|
||||
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
|
||||
let rows: Vec<_> = picker
|
||||
.sheets(&s)
|
||||
.into_iter()
|
||||
.flat_map(|sheet| sheet.groups)
|
||||
.flat_map(|group| group.rows)
|
||||
.collect();
|
||||
assert_eq!(rows[0].id, PickRow::Slot);
|
||||
assert_eq!(rows[0].value, Value::Note);
|
||||
// Empty, then every action, each once; only the slot's own marked.
|
||||
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
|
||||
let every: Vec<_> = std::iter::once(None)
|
||||
.chain(RadialAction::ALL.map(Some))
|
||||
.map(PickRow::Pick)
|
||||
.collect();
|
||||
assert_eq!(picks, every);
|
||||
let marked: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|row| matches!(row.value, Value::Choice(_)))
|
||||
.map(|row| row.id)
|
||||
.collect();
|
||||
assert_eq!(marked, [opens_on]);
|
||||
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
|
||||
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
|
||||
assert_eq!(
|
||||
*s.controller.radial.page(0).slot(2),
|
||||
Slot::Action(RadialAction::Chat)
|
||||
);
|
||||
assert!(picker.take_picked() && !picker.take_picked());
|
||||
// Empty empties it.
|
||||
picker.press(&mut s, PickRow::Pick(None), false);
|
||||
assert!(s.controller.radial.page(0).slot(2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_action_says_which_it_is() {
|
||||
let mut s = Settings::default();
|
||||
s.controller
|
||||
.radial
|
||||
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
|
||||
let english = lang::Language::load(&[], "en_us");
|
||||
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
|
||||
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let before = s.controller.radial.page(0).clone();
|
||||
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
|
||||
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert!(outputs.contains(&Output::Event(Event::Changed)));
|
||||
let after = s.controller.radial.page(0);
|
||||
assert_eq!(*after.slot(0), step(before.slot(0), false));
|
||||
assert_eq!(after.slots()[1..], before.slots()[1..]);
|
||||
assert!(shell.changed());
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.radial, Pages::default());
|
||||
assert!(!shell.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
|
||||
let mut s = Settings::default();
|
||||
s.controller.radial.rename(1, "Mine");
|
||||
let found = s.controller.radial.clone();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
click(&mut shell, &mut s, RadialRow::Delete(0));
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
|
||||
// One page left: it can't go, and the strip says why.
|
||||
let delete = row(&s, RadialRow::Delete(0));
|
||||
assert!(!delete.live && delete.why.is_some());
|
||||
let reset = row(&s, RadialRow::Reset);
|
||||
assert!(reset.live);
|
||||
shell.screen.take_shown();
|
||||
click(&mut shell, &mut s, RadialRow::Reset);
|
||||
assert_eq!(s.controller.radial, Pages::default());
|
||||
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
|
||||
assert_eq!(shell.screen.take_shown(), Some(0));
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.radial, found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
while row(&s, RadialRow::NewPage).live {
|
||||
screen.press(&mut s, RadialRow::NewPage, false);
|
||||
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
|
||||
}
|
||||
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
|
||||
assert!(row(&s, RadialRow::NewPage).why.is_some());
|
||||
// Every page is a sheet, named as the overlay names it.
|
||||
let sheets = screen.sheets(&s);
|
||||
assert_eq!(sheets.len(), radial::MAX_PAGES);
|
||||
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
screen.press(&mut s, RadialRow::Delete(1), false);
|
||||
assert_eq!(screen.take_shown(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
|
||||
let general = Pages::default().page(0).name.clone();
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
|
||||
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
|
||||
let entry = screen.naming.as_mut().unwrap();
|
||||
// Done with the name as it was: a default page keeps following the language.
|
||||
assert_eq!(entry.key(K::Enter, None), Named::Done);
|
||||
assert!(!entry.apply(&mut s.controller.radial));
|
||||
assert_eq!(s.controller.radial.page(0).name, general);
|
||||
entry.type_text("!");
|
||||
assert_eq!(entry.key(K::Escape, None), Named::Back);
|
||||
entry.field.set_value("Mine");
|
||||
entry.type_text("!");
|
||||
assert!(entry.apply(&mut s.controller.radial));
|
||||
assert_eq!(
|
||||
s.controller.radial.page(0).name,
|
||||
PageName::Custom("Mine!".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_back_a_name_given_on_the_name_page() {
|
||||
let mut s = Settings::default();
|
||||
s.controller.radial.rename(1, "Mine");
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
shell.show(1);
|
||||
click(&mut shell, &mut s, RadialRow::Rename(1));
|
||||
let entry = shell.screen.naming.as_mut().unwrap();
|
||||
entry.field.set_value("Building");
|
||||
// As the options screen does on Done: the name page changed what Undo puts back.
|
||||
let entry = shell.screen.naming.take().unwrap();
|
||||
assert!(entry.apply(&mut s.controller.radial));
|
||||
shell.changed_elsewhere();
|
||||
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
|
||||
assert!(shell.changed());
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
|
||||
assert!(!shell.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
|
||||
let mut s = Settings::default();
|
||||
// Selecting with the left stick, the top face button (North) can open it.
|
||||
s.controller.radial_stick = gamepad::RadialStick::Left;
|
||||
s.controller.bind(OPENER, Some(gamepad::Button::North));
|
||||
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
|
||||
let label = |s: &Settings| {
|
||||
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
|
||||
match opener_row(s, glyphs).value {
|
||||
Value::Binding(Binding {
|
||||
shown: Shown::Pad { glyph, .. },
|
||||
..
|
||||
}) => glyph.expect("bound").label().to_owned(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
};
|
||||
// By places, the top button: printed X on a Nintendo pad.
|
||||
s.controller.nintendo_letters = false;
|
||||
assert_eq!(label(&s), "X");
|
||||
// By letters, the part is played by the button printed Y (on the left).
|
||||
s.controller.nintendo_letters = true;
|
||||
assert_eq!(label(&s), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
|
||||
let mut s = Settings::default();
|
||||
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
|
||||
let shown = |s: &Settings| match opener_row(s, glyphs).value {
|
||||
Value::Binding(Binding {
|
||||
capture,
|
||||
shown: Shown::Pad { glyph, .. },
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(capture, Capture::Pad(OPENER));
|
||||
glyph
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let bound = s.controller.binding(OPENER).unwrap();
|
||||
// The button on its glyph.
|
||||
let glyph = shown(&s).expect("the opener's glyph");
|
||||
assert_eq!(glyph.part(), bound);
|
||||
assert_eq!(glyph.label(), "→");
|
||||
assert_eq!(opener_row(&s, glyphs).chosen, None);
|
||||
s.controller.bind("key.chat", Some(bound));
|
||||
let shared = opener_row(&s, glyphs).chosen;
|
||||
assert!(shared.is_some(), "a shared button");
|
||||
s.controller.bind(OPENER, None);
|
||||
let unbound = opener_row(&s, glyphs).chosen;
|
||||
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
|
||||
// A press waits for a button on the row, and Undo takes a new one back.
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
s.controller.bind(OPENER, Some(bound));
|
||||
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
|
||||
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
|
||||
assert!(shell.changed());
|
||||
s.controller.bind(OPENER, Some(PadButton::North));
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.binding(OPENER), Some(bound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_string_has_a_horizon_key() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let english = lang::Language::load(&[], "en_us");
|
||||
let ((), missed) = lang::scoped(english, || {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind("key.chat", Some(PadButton::DPadRight));
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
};
|
||||
// Every page's rows are built, with what the strip says of each.
|
||||
draw(&mut shell, &s);
|
||||
s.controller.bind(OPENER, None);
|
||||
s.controller.radial.remove(1);
|
||||
while s.controller.radial.add().is_some() {}
|
||||
draw(&mut shell, &s);
|
||||
s.controller.radial = Pages::default();
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut shell, &s);
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
let waiting = Some(Capture::Pad(OPENER));
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
|
||||
let mut entry = NameEntry::new(0, "Mine");
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
|
||||
let mut picker = Shell::<PickScreen>::default();
|
||||
picker.screen.open_on((0, 1), &s.controller.radial);
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
});
|
||||
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
|
||||
let missed: Vec<_> = missed
|
||||
.iter()
|
||||
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
|
||||
.collect();
|
||||
assert!(missed.is_empty(), "no text for {missed:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -869,6 +869,10 @@ pub enum Opt {
|
||||
PadNintendoButtons,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
|
||||
PadRadialMode,
|
||||
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
|
||||
PadRadialStick,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,815 @@
|
||||
use super::*;
|
||||
use crate::options_ui::Event;
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
const SHORT: (f32, f32) = (480.0, 240.0);
|
||||
const CHANGED: Output = Output::Event(Event::Changed);
|
||||
|
||||
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
|
||||
/// its own, and a row a wheel steps.
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
slider: f32,
|
||||
choice: usize,
|
||||
pressed: Vec<(u32, bool)>,
|
||||
/// How many times its rows were built.
|
||||
built: std::cell::Cell<usize>,
|
||||
}
|
||||
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
|
||||
const SLIDER: u32 = 0;
|
||||
const CHOICE: u32 = 1;
|
||||
const FIRST_FILLER: u32 = 10;
|
||||
const FILLERS: u32 = 40;
|
||||
const GREY: u32 = 100;
|
||||
const WHY: &str = "Not here.";
|
||||
impl Screen for Probe {
|
||||
type Page = u8;
|
||||
type Row = u32;
|
||||
fn title(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
/// Its rows read the field of view, besides its own state.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
stamp_of(&s.fov)
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
self.built.set(self.built.get() + 1);
|
||||
let (value, chosen) = CHOICES[self.choice];
|
||||
let mut rows = vec![
|
||||
Row::new(
|
||||
SLIDER,
|
||||
"Level".to_owned(),
|
||||
Value::Slider {
|
||||
position: self.slider,
|
||||
steps: Some(10),
|
||||
text: s.fov.to_string(),
|
||||
},
|
||||
),
|
||||
Row {
|
||||
chosen: Some(chosen.to_owned()),
|
||||
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
|
||||
.about("Picks a colour.".to_owned())
|
||||
},
|
||||
Row::new(GREY, "Greyed".to_owned(), Value::Press)
|
||||
.about("Does a thing.".to_owned())
|
||||
.blocked(Some(WHY.to_owned())),
|
||||
];
|
||||
rows.extend((0..FILLERS).map(|i| {
|
||||
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
|
||||
.about("Does nothing.".to_owned())
|
||||
}));
|
||||
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
|
||||
vec![
|
||||
Sheet::new(
|
||||
0,
|
||||
"One".to_owned(),
|
||||
"The first.".to_owned(),
|
||||
vec![Group { name: "G", rows }],
|
||||
),
|
||||
Sheet::new(
|
||||
1,
|
||||
"Two".to_owned(),
|
||||
"The second.".to_owned(),
|
||||
vec![Group {
|
||||
name: "H",
|
||||
rows: elsewhere,
|
||||
}],
|
||||
),
|
||||
]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
|
||||
self.pressed.push((row, back));
|
||||
match row {
|
||||
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
|
||||
FIRST_FILLER => s.fov += 1.0,
|
||||
_ => {}
|
||||
}
|
||||
vec![CHANGED]
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.fov = before.fov;
|
||||
Vec::new()
|
||||
}
|
||||
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
|
||||
self.slider = position;
|
||||
}
|
||||
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
|
||||
(row == CHOICE).then(|| {
|
||||
self.choice = (self.choice + 1) % CHOICES.len();
|
||||
vec![CHANGED]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
|
||||
shell.point_at(&Settings::default(), size, row)
|
||||
}
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
|
||||
/// away from any row, at the size it was drawn at.
|
||||
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
|
||||
let size = (batch.width, batch.height);
|
||||
shell.scroll(s, size, -1.0);
|
||||
shell.key(s, K::ArrowDown, None, false, size);
|
||||
shell.wheel(s, size, (0.0, 0.0), 1.0);
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let built = |shell: &Shell<Probe>| shell.screen.built.get();
|
||||
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
|
||||
let view = shell.view(s, size);
|
||||
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
|
||||
labels.any(|label| label.ends_with(end))
|
||||
};
|
||||
// Each of the four built them before: now the first frame builds them once, the next none.
|
||||
let size = frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
// A press changes the screen's own state: its row reads the new value.
|
||||
let at = middle_of(&mut shell, CHOICE, size);
|
||||
shell.click(&mut s, at, size, false, false, false);
|
||||
assert!(reads(&mut shell, &s, size, "Blue"));
|
||||
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
|
||||
s.fov = 90.0;
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert!(reads(&mut shell, &s, size, "90"));
|
||||
let before = built(&shell);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), before);
|
||||
// Searching, and another size, build them again.
|
||||
shell.type_text("filler");
|
||||
shell.view(&s, size);
|
||||
assert_eq!(built(&shell), before + 1);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 2);
|
||||
// So does another language, and coming back from it.
|
||||
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
|
||||
assert_eq!(built(&shell), before + 3);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let cols = Columns::of(SHORT.0, SHORT.1);
|
||||
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
|
||||
let cut = FIRST_FILLER + 20;
|
||||
let view = shell.view(&s, SHORT);
|
||||
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
|
||||
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
|
||||
let rect = cols.row(y - shell.scroll.amount);
|
||||
assert!(
|
||||
rect[1] + rect[3] / 2.0 >= cols.bottom,
|
||||
"the row's middle is hidden"
|
||||
);
|
||||
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
|
||||
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
|
||||
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
|
||||
// All in view, it stays put.
|
||||
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
|
||||
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle, SHORT, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.pressed, [(cut, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
|
||||
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
|
||||
// Nothing changed: there is no Undo to press.
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(s.fov, 71.0);
|
||||
// Another option changed meanwhile isn't the screen's to put back.
|
||||
s.chat_opacity = 0.25;
|
||||
assert_eq!(
|
||||
shell.click(&mut s, on_undo, SIZE, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
let reached = (0..100).any(|_| {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
shell.focus == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
assert_eq!(
|
||||
shell.key(&mut s, K::Enter, None, false, SIZE),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(s.fov, 70.0);
|
||||
assert_eq!(shell.focus, Some(Target::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_strip_says_why_a_greyed_row_cant_be_used() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
let size = (batch.width, batch.height);
|
||||
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
|
||||
shell.draw(&s, batch, &atlas, at, false, None);
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
let dock = Columns::of(size.0, size.1).dock;
|
||||
let labelled = crate::widgets::last_labelled();
|
||||
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
|
||||
found.map(|w| w.label.clone())
|
||||
};
|
||||
let grey = middle_of(&mut shell, GREY, size);
|
||||
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
|
||||
let choice = middle_of(&mut shell, CHOICE, size);
|
||||
assert_eq!(
|
||||
strip(&mut batch, &mut shell, choice).as_deref(),
|
||||
Some(CHOICES[0].1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_the_window_keeps_the_scroll_within_the_list() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
shell.scroll(&s, SIZE, -1000.0);
|
||||
assert!(shell.scroll.amount > 0.0);
|
||||
let tall = (SIZE.0, 4000.0);
|
||||
let view = shell.view(&s, tall);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
|
||||
assert!(first >= view.cols.top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let rate = SCROLL_RATE;
|
||||
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
|
||||
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
|
||||
assert_eq!(shell.scroll.amount, rate);
|
||||
shell.scroll(&s, SIZE, -2.0);
|
||||
assert_eq!(shell.scroll.amount, 3.0 * rate);
|
||||
shell.scroll(&s, SIZE, 100.0);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
|
||||
shell.wheel(&mut s, SIZE, over_choice, -1.0);
|
||||
assert_eq!(shell.screen.choice, 0);
|
||||
assert_eq!(shell.scroll.amount, SCROLL_RATE);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
shell.scroll.amount = 0.0;
|
||||
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
|
||||
assert_eq!(outputs, vec![CHANGED]);
|
||||
assert_eq!(shell.screen.choice, 1);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
// A row the screen doesn't step scrolls, the greyed one too.
|
||||
let over_grey = middle_of(&mut shell, GREY, SIZE);
|
||||
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
|
||||
assert_eq!(shell.scroll.amount, SCROLL_RATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let about = |shell: &mut Shell<Probe>, s: &Settings| {
|
||||
let view = shell.view(s, SIZE);
|
||||
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
|
||||
row.described().to_owned()
|
||||
};
|
||||
assert_eq!(about(&mut shell, &s), "Paints it red.");
|
||||
let at = middle_of(&mut shell, CHOICE, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(about(&mut shell, &s), "Paints it blue.");
|
||||
// The search reads the row's own description, on every page.
|
||||
shell.type_text("colour");
|
||||
let view = shell.view(&s, SIZE);
|
||||
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
|
||||
assert!(found.contains(&CHOICE) && !found.contains(&99));
|
||||
shell.search.set_value("elsewhere");
|
||||
shell.focus = None;
|
||||
let view = shell.view(&s, SIZE);
|
||||
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, SLIDER, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 0.0);
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
let mid = (track + width / 2.0, at.1);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, mid, SIZE, false, false, false),
|
||||
vec![DRAGGED]
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 0.5);
|
||||
let end = (track + width, at.1);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, end, SIZE, true, false, false),
|
||||
vec![DRAGGED]
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 1.0);
|
||||
assert!(shell.release().is_some());
|
||||
assert!(shell.release().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
|
||||
// Tab: the search, the two pages, then the first row.
|
||||
for _ in 0..4 {
|
||||
key(&mut shell, K::Tab);
|
||||
}
|
||||
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
|
||||
// A slider Tab reaches changes with the arrows, in its steps.
|
||||
key(&mut shell, K::ArrowRight);
|
||||
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
|
||||
// The arrows go on past the greyed row and rows out of view, which scroll in.
|
||||
key(&mut shell, K::ArrowDown);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
key(&mut shell, K::ArrowDown);
|
||||
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
|
||||
for _ in 0..30 {
|
||||
key(&mut shell, K::ArrowDown);
|
||||
}
|
||||
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
|
||||
assert!(shell.scroll.amount > 0.0);
|
||||
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
|
||||
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
|
||||
shell.set_focus(Some(Target::Row(CHOICE)));
|
||||
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
|
||||
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
|
||||
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
assert!(shell.changed);
|
||||
// A row that isn't a choice: Left goes to the page list, as before.
|
||||
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
|
||||
assert!(key(&mut shell, K::ArrowLeft).is_empty());
|
||||
assert!(matches!(shell.focus, Some(Target::Page(_))));
|
||||
assert_eq!(shell.screen.pressed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(u64::MAX);
|
||||
let view = shell.view(&s, (batch.width, batch.height));
|
||||
let rect_of = |id| {
|
||||
let mut placed = shell.placed(&view);
|
||||
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
|
||||
};
|
||||
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
/// A controller presses with a click, then moves its own focus: only keys show this one.
|
||||
#[test]
|
||||
fn leaving_the_search_field_gives_the_keys_back() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let view = shell.view(&s, SIZE);
|
||||
let [x, y, w, h] = view.cols.search();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(shell.typing());
|
||||
Hosted::blur_search(&mut shell);
|
||||
assert!(!shell.typing());
|
||||
// Another focus isn't dropped.
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
Hosted::blur_search(&mut shell);
|
||||
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
|
||||
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_moves_nothing_shows_no_focus() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
|
||||
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_description_says_so() {
|
||||
assert_eq!(cut_off("off, it never "), "off, it never…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
|
||||
// The next key goes on from the row clicked, and shows where it went.
|
||||
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
|
||||
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
|
||||
let (wide, base) = (
|
||||
Columns::of(480.0, 270.0).with_note_lines(4),
|
||||
Columns::of(480.0, 270.0),
|
||||
);
|
||||
assert_eq!(wide.note_lines, 4);
|
||||
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
|
||||
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
|
||||
assert_eq!(wide.button(0), base.button(0));
|
||||
// A 720p window at scale 4 has room for one more line, not two.
|
||||
let short = Columns::of(320.0, 180.0).with_note_lines(4);
|
||||
assert_eq!(short.note_lines, 3);
|
||||
assert!(short.bottom - short.top >= MIN_LIST_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_presses_a_row_backwards() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, true);
|
||||
// The right button is not a press.
|
||||
shell.click(&mut s, at, SIZE, false, true, false);
|
||||
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, GREY, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(shell.screen.pressed.is_empty());
|
||||
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
|
||||
let at = (x + w / 2.0, y + h / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![CLICK, Output::Done]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_picked_shows_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
|
||||
let at = (x + w / 2.0, y + h / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![CLICK]
|
||||
);
|
||||
let view = shell.view(&s, SIZE);
|
||||
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
|
||||
}
|
||||
|
||||
/// A screen with more pages than a short view's page list has room for: a page of its own,
|
||||
/// then a section of `PAGES` under a line, one more page out of the list that the section's
|
||||
/// first page links to, and a change that waits for Apply.
|
||||
#[derive(Default)]
|
||||
struct Pages {
|
||||
pending: bool,
|
||||
applied: usize,
|
||||
}
|
||||
const PAGES: u8 = 12;
|
||||
const HOME: u8 = 0;
|
||||
const DEEP: u8 = 100;
|
||||
const LINK: u32 = 1000;
|
||||
const WAITS: u32 = 1001;
|
||||
const SECTION: &str = "Section";
|
||||
impl Screen for Pages {
|
||||
type Page = u8;
|
||||
type Row = u32;
|
||||
fn title(&self) -> &'static str {
|
||||
"Pages"
|
||||
}
|
||||
fn stamp(&self, _: &Settings) -> u64 {
|
||||
stamp_of(&self.pending)
|
||||
}
|
||||
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
let page = |id: u8, name: String, rows| {
|
||||
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
|
||||
};
|
||||
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
|
||||
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
|
||||
for i in 1..=PAGES {
|
||||
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
|
||||
if i == 1 {
|
||||
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
|
||||
}
|
||||
let mut sheet = page(i, format!("Page {i}"), rows);
|
||||
sheet.heading = (i == 1).then(|| SECTION.to_owned());
|
||||
sheets.push(sheet);
|
||||
}
|
||||
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
|
||||
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
|
||||
deeper.listed = false;
|
||||
sheets.push(deeper);
|
||||
sheets
|
||||
}
|
||||
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
|
||||
self.pending |= row == WAITS;
|
||||
vec![CHANGED]
|
||||
}
|
||||
fn link(&self, row: u32) -> Option<u8> {
|
||||
(row == LINK).then_some(DEEP)
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn pending(&self, _: &Settings) -> bool {
|
||||
self.pending
|
||||
}
|
||||
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
|
||||
self.pending = false;
|
||||
self.applied += 1;
|
||||
vec![CHANGED]
|
||||
}
|
||||
}
|
||||
|
||||
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
|
||||
/// the page list has room for six places).
|
||||
const TALL: [u32; 2] = [854, 960];
|
||||
const SHORT_WINDOW: [u32; 2] = [854, 480];
|
||||
|
||||
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
|
||||
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new(window);
|
||||
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
(batch.width, batch.height)
|
||||
}
|
||||
|
||||
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
|
||||
(x + w / 2.0, y + h / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Tall enough: each page under the last, the deeper page left out as it is in no list.
|
||||
drawn(&mut shell, &s, TALL);
|
||||
let tall = shell.page_places();
|
||||
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
|
||||
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
|
||||
assert!(tall.iter().all(|p| p.2 == Part::Tab));
|
||||
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
|
||||
assert_eq!(shell.strip_ends(), None);
|
||||
// Short: the first page, then the section in one row, its first page between nothing and
|
||||
// its next. Every place is inside the column, the row's on one line under the section's.
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let short = shell.page_places();
|
||||
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
|
||||
assert_eq!(
|
||||
parts,
|
||||
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
|
||||
);
|
||||
assert_eq!(shell.strip_ends(), Some((false, true)));
|
||||
let cols = Columns::of(size.0, size.1);
|
||||
for (_, [x, _, w, _], _) in &short {
|
||||
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
|
||||
}
|
||||
assert_eq!(short[1].1[1], short[2].1[1]);
|
||||
assert_eq!(short[1].1[1], cols.page(2)[1]);
|
||||
// The line naming the section stays over it.
|
||||
let labelled = crate::widgets::last_labelled();
|
||||
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
|
||||
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
shell.show(1);
|
||||
let mut seen = vec![1];
|
||||
let size = loop {
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let places = shell.page_places();
|
||||
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
|
||||
break size;
|
||||
};
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle(rect), size, false, false, false),
|
||||
[CLICK]
|
||||
);
|
||||
assert_eq!(shell.open_page(), Some(next));
|
||||
seen.push(next);
|
||||
};
|
||||
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
|
||||
assert_eq!(shell.strip_ends(), Some((true, false)));
|
||||
// At the last page, the right of the row picks nothing: its arrow is dimmed.
|
||||
let cols = Columns::of(size.0, size.1);
|
||||
let [x, y, w, h] = cols.page(2);
|
||||
assert!(
|
||||
shell
|
||||
.click(
|
||||
&mut s,
|
||||
(x + w - 2.0, y + h / 2.0),
|
||||
size,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(shell.open_page(), Some(PAGES));
|
||||
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
|
||||
// it to the middle of the row.
|
||||
let before = shell
|
||||
.page_places()
|
||||
.iter()
|
||||
.find(|p| p.0 == PAGES - 1)
|
||||
.unwrap()
|
||||
.2;
|
||||
assert_eq!(before, Part::Prev);
|
||||
shell.set_focus(Some(Target::Page(PAGES - 1)));
|
||||
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let now = shell
|
||||
.page_places()
|
||||
.iter()
|
||||
.find(|p| p.0 == PAGES - 1)
|
||||
.unwrap()
|
||||
.2;
|
||||
assert_eq!(now, Part::Shown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
|
||||
shell.set_search("row 1");
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
|
||||
assert_eq!(pages(&shell), [HOME, 1, 10]);
|
||||
assert_eq!(shell.strip_ends(), Some((false, true)));
|
||||
// Its arrow goes to the next match, which has the first one before it and the last after.
|
||||
let next = shell.page_places()[2].1;
|
||||
shell.click(&mut s, middle(next), size, false, false, false);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
|
||||
assert_eq!(shell.strip_ends(), Some((true, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
shell.show(5);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
|
||||
.into_iter()
|
||||
.filter_map(|t| match t.kind {
|
||||
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The first page, the page before, the open one (lit) and the one after.
|
||||
assert_eq!(tabs.len(), 4, "{tabs:?}");
|
||||
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
|
||||
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
|
||||
assert!(open.1 && before < open.0 && open.0 < after);
|
||||
// The arrows are vanilla's Previous Page and Next Page to the narrator.
|
||||
let labels = crate::widgets::last_labelled();
|
||||
let named = |key| {
|
||||
labels
|
||||
.iter()
|
||||
.any(|w| w.label == lang::get(key) && w.enabled)
|
||||
};
|
||||
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Found by the search from another page: back is the page the link is on.
|
||||
shell.show(HOME);
|
||||
shell.set_search("deeper");
|
||||
let at = shell.point_at(&s, SIZE, LINK);
|
||||
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
|
||||
// The page opens alone, the search cleared; the list lights the page the link was on.
|
||||
assert_eq!(shell.open_page(), Some(DEEP));
|
||||
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
|
||||
drawn(&mut shell, &s, TALL);
|
||||
assert_eq!(shell.lit_page(), Some(1));
|
||||
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
|
||||
assert_eq!(shell.open_page(), Some(1));
|
||||
assert_eq!(
|
||||
shell.key(&mut s, K::Escape, None, false, SIZE),
|
||||
[Output::Done]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
let done = Columns::of(SIZE.0, SIZE.1).button(0);
|
||||
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
|
||||
let at = shell.point_at(&s, SIZE, WAITS);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
let foot = shell.foot_at(&s, SIZE);
|
||||
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
|
||||
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
|
||||
// Side by side over Done, as wide as it together.
|
||||
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
|
||||
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
|
||||
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
|
||||
[CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.applied, 1);
|
||||
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
|
||||
assert_eq!(targets, [Target::Undo, Target::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let choice = middle_of(&mut shell, CHOICE, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, choice, SIZE, false, true, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(shell.screen.pressed.is_empty());
|
||||
}
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
style::row_rule()
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -119,14 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
|
||||
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
"horizon.options.gameplay_guide.basic": "Basic",
|
||||
"horizon.options.gameplay_guide.full": "Full",
|
||||
"horizon.options.gameplay_guide.off": "Off",
|
||||
"horizon.options.radial_mode": "Radial Menu",
|
||||
"horizon.options.radial_mode.about": "Hold the opener for the radial menu, or press it once to open it and again to close it.",
|
||||
"horizon.options.radial_mode.hold.about": "Hold the opener and let go to run what the stick points at.",
|
||||
"horizon.options.radial_mode.toggle.about": "Press the opener to open it; A or the opener again runs what the stick points at, B closes it.",
|
||||
"horizon.options.radial_stick": "Radial Menu Selects With",
|
||||
"horizon.options.radial_stick.about": "The stick that points at the radial menu's slots.",
|
||||
"horizon.options.radial_stick.right": "Right Stick",
|
||||
"horizon.options.radial_stick.left": "Left Stick",
|
||||
"horizon.options.radial_stick.either": "Either",
|
||||
"horizon.options.radial_stick.right.about": "The right stick points at the slots; the left still walks.",
|
||||
"horizon.options.radial_stick.left.about": "The left stick points and you stand still. D-pad Right is under that thumb: pick an opener on the Radial Menu page.",
|
||||
"horizon.options.radial_stick.either.about": "The last stick pushed points; neither walks nor turns the view. Only a bumper, a trigger or View can open the menu.",
|
||||
"horizon.controller.guide.use": "Use",
|
||||
"horizon.controller.guide.open": "Open",
|
||||
"horizon.controller.guide.ride": "Ride",
|
||||
@@ -129,6 +141,82 @@
|
||||
"horizon.pages.shader_options": "Shader Options",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.controller_radial": "Radial Menu",
|
||||
"horizon.radial.page.general": "General",
|
||||
"horizon.radial.page.camera": "Camera",
|
||||
"horizon.radial.release_stick": "Let go of the stick",
|
||||
"horizon.radial.action.key_inventory": "Inventory",
|
||||
"horizon.radial.action.key_chat": "Chat",
|
||||
"horizon.radial.action.key_command": "Commands",
|
||||
"horizon.radial.action.key_advancements": "Advancements",
|
||||
"horizon.radial.action.key_togglePerspective": "Perspective",
|
||||
"horizon.radial.action.key_swapOffhand": "Swap Hands",
|
||||
"horizon.radial.action.key_drop": "Drop",
|
||||
"horizon.radial.action.key_pickItem": "Pick Block",
|
||||
"horizon.radial.action.key_screenshot": "Screenshot",
|
||||
"horizon.radial.action.key_toggleGui": "Hide HUD",
|
||||
"horizon.radial.action.key_smoothCamera": "Cinematic Camera",
|
||||
"horizon.radial.action.key_socialInteractions": "Social",
|
||||
"horizon.radial.action.key_emote": "Emote",
|
||||
"horizon.radial.action.pad_hotbarPrevious": "Previous Slot",
|
||||
"horizon.radial.action.pad_hotbarNext": "Next Slot",
|
||||
"horizon.radial.action.key_fullscreen": "Fullscreen",
|
||||
"horizon.radial.action.key_quickActions": "Quick Actions",
|
||||
"horizon.radial.action.key_debug_overlay": "Debug Screen",
|
||||
"horizon.radial.missing": "Missing",
|
||||
"horizon.radial.why.missing": "Unknown action",
|
||||
"horizon.radial.why.cosmetics": "Needs cosmetics on",
|
||||
"horizon.radial.why.quick_actions": "This server has no Quick Actions",
|
||||
"horizon.radial.why.social": "Only on a server",
|
||||
"horizon.radial.next": "→ %s",
|
||||
"horizon.radial.state.first_person": "First Person",
|
||||
"horizon.radial.state.third_person_back": "Third Person",
|
||||
"horizon.radial.state.third_person_front": "Front View",
|
||||
"horizon.radial.state.shown": "Shown",
|
||||
"horizon.radial.state.hidden": "Hidden",
|
||||
"horizon.radial.state.on": "On",
|
||||
"horizon.radial.state.off": "Off",
|
||||
"horizon.radial.pick_title": "Choose an Action",
|
||||
"horizon.radial.group.actions": "Actions",
|
||||
"horizon.radial.pick.slot": "%s slot of %s",
|
||||
"horizon.radial.pick.about": "Puts this action in the slot and goes back. The slot's own is marked.",
|
||||
"horizon.radial.pick.current": "Current",
|
||||
"horizon.radial.missing.about": "This game doesn't know the action %s: the slot keeps it and runs nothing. Press or Right to put another here.",
|
||||
"horizon.radial.page.new": "New Page",
|
||||
"horizon.pages.open.controller_radial": "Radial Menu...",
|
||||
"horizon.radial.group.slots": "Slots",
|
||||
"horizon.radial.group.page": "Page",
|
||||
"horizon.radial.group.all": "All Pages",
|
||||
"horizon.radial.empty": "Empty",
|
||||
"horizon.radial.page_of": "Page %s of %s. Each slot is a direction of the selecting stick while the opener is held.",
|
||||
"horizon.radial.slot.0": "Up",
|
||||
"horizon.radial.slot.1": "Up-Right",
|
||||
"horizon.radial.slot.2": "Right",
|
||||
"horizon.radial.slot.3": "Down-Right",
|
||||
"horizon.radial.slot.4": "Down",
|
||||
"horizon.radial.slot.5": "Down-Left",
|
||||
"horizon.radial.slot.6": "Left",
|
||||
"horizon.radial.slot.7": "Up-Left",
|
||||
"horizon.radial.slot.about": "The action this direction runs. Press it for the list of actions, Right for the next one, Left or Shift-click for the one before.",
|
||||
"horizon.radial.rename": "Rename Page",
|
||||
"horizon.radial.rename.about": "Give this page a name of your own.",
|
||||
"horizon.radial.name_title": "Name This Page",
|
||||
"horizon.radial.new_page.about": "Adds an empty page after the last one, and shows it.",
|
||||
"horizon.radial.new_page.full": "The menu holds at most %s pages.",
|
||||
"horizon.radial.delete_page": "Delete Page",
|
||||
"horizon.radial.delete_page.about": "Removes this page and its slots at once. Undo brings it back.",
|
||||
"horizon.radial.delete_page.last": "The menu keeps at least one page.",
|
||||
"horizon.radial.reset": "Reset All Pages",
|
||||
"horizon.radial.reset.about": "Puts back the General and Camera pages and removes the rest, at once. Undo brings them back.",
|
||||
"horizon.radial.reset.default": "The pages are the defaults already.",
|
||||
"horizon.radial.opener": "Opener",
|
||||
"horizon.radial.opener.about": "The button held to open the menu. Press, then press the controller button to use: a bumper or the D-pad, not A, B, X, Y or the right stick, which steer and cancel.",
|
||||
"horizon.radial.opener.about.left": "The button held to open the menu. Press, then press the controller button to use: a bumper, A, X or Y, not the D-pad or the left stick, which steer, nor B, which cancels.",
|
||||
"horizon.radial.opener.about.either": "The button held to open the menu. Press, then press the controller button to use: a bumper, a trigger or View; both thumbs steer, and B cancels.",
|
||||
"horizon.radial.unbound": "No button opens the menu (one under the thumb that selects is let go): press, then press a controller button.",
|
||||
"horizon.radial.conflict": "Shared with another action: in play the menu takes that button. Press to pick another.",
|
||||
"Radial Menu": "Radial Menu",
|
||||
"Named pages of eight actions, held open with a button.": "Named pages of eight actions, held open with a button.",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
@@ -1020,6 +1108,7 @@
|
||||
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
|
||||
"This button also does: %s": "This button also does: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
|
||||
"horizon.pad.action.radial": "Radial Menu",
|
||||
"horizon.pad.notice.connected.press": "Press to use it",
|
||||
"horizon.recipe_transfer.missing": "Missing Items",
|
||||
"horizon.recipe_transfer.no_room": "Inventory full",
|
||||
|
||||
@@ -17,8 +17,8 @@ pub use horizon_client_render::{
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
|
||||
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
@@ -58,8 +58,6 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
|
||||
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
write_guarded_piglin_anger(m, map);
|
||||
crate::mobs::piglin::write_guarded_anger(m, map);
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
@@ -2232,6 +2232,9 @@ pub fn mob_from_nbt_with_random(
|
||||
) {
|
||||
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
|
||||
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Piglin {
|
||||
crate::mobs::piglin::read_guarded_anger(&mut m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Hoglin {
|
||||
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
|
||||
}
|
||||
@@ -5969,42 +5972,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
|
||||
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
|
||||
fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
|
||||
let Some(actor) = m.extra.piglin.guarded_angry_at else {
|
||||
return;
|
||||
};
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
|
||||
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
|
||||
memories.insert(
|
||||
"minecraft:angry_at".into(),
|
||||
Tag::compound([
|
||||
("value", Tag::IntArray(uuid.to_vec())),
|
||||
("ttl", Tag::Long(i64::from(m.extra.anger))),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
memories.remove(&"minecraft:angry_at".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
|
||||
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
|
||||
let brain = map
|
||||
@@ -9375,6 +9342,59 @@ mod piglin_live_anger_memory_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Piglin.readAdditionalSaveData` reads the brain's `ANGRY_AT` memory
|
||||
/// back: the actor's UUID and the ticks left (`ExpirableValue`'s TTL).
|
||||
/// Horizon wrote the memory but never read it, so a piglin that a player
|
||||
/// had angered came back calm after its chunk reloaded.
|
||||
#[test]
|
||||
fn a_saved_guarded_anger_loads_its_uuid_and_ticks() {
|
||||
// A whole saved piglin: its id, a position and the brain's memory.
|
||||
let saved = |memory: Tag| {
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:piglin".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::compound([("minecraft:angry_at", memory)]))]),
|
||||
),
|
||||
])
|
||||
};
|
||||
let template = mob();
|
||||
let types = [(template.kind.id.clone(), template.kind.clone())].into();
|
||||
let uuid = Tag::IntArray(vec![90, 91, 92, 93]);
|
||||
let ticking = saved(Tag::compound([
|
||||
("value", uuid.clone()),
|
||||
("ttl", Tag::Long(99)),
|
||||
]));
|
||||
let loaded = mob_from_nbt(&ticking, &types, 2, None).unwrap();
|
||||
assert_eq!(loaded.extra.anger, 99);
|
||||
assert_eq!(
|
||||
loaded.extra.piglin.guarded_angry_at,
|
||||
Some(Some([90, 91, 92, 93]))
|
||||
);
|
||||
// `ExpirableValue` leaves the TTL out when it never expires, and a
|
||||
// long past the counter's range holds as long as it can: the counter
|
||||
// keeps the longest it can while the goal ticks it down.
|
||||
for memory in [
|
||||
Tag::compound([("value", uuid.clone())]),
|
||||
Tag::compound([("value", uuid.clone()), ("ttl", Tag::Long(1 << 40))]),
|
||||
] {
|
||||
let loaded = mob_from_nbt(&saved(memory), &types, 3, None).unwrap();
|
||||
assert_eq!(loaded.extra.anger, i32::MAX);
|
||||
assert_eq!(
|
||||
loaded.extra.piglin.guarded_angry_at,
|
||||
Some(Some([90, 91, 92, 93]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_owned_memory_cannot_resurrect_stale_raw_anger() {
|
||||
let mut m = mob();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
|
||||
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -128,8 +129,6 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -1499,7 +1498,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal;
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -5833,7 +5832,7 @@ impl Game<'_> {
|
||||
self.draw_debug_overlay(&mut batch, size);
|
||||
self.frame_graph
|
||||
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
|
||||
self.draw_pad_cursor(&mut batch);
|
||||
self.draw_pad_over_hud(&mut batch);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
@@ -5964,6 +5963,7 @@ impl Game<'_> {
|
||||
if self.camera_type.first_person()
|
||||
&& (!spectator || self.spectator_crosshair())
|
||||
&& !self.debug_crosshair()
|
||||
&& !self.overlay_holds_view()
|
||||
{
|
||||
let attack = (indicator == 1).then_some(strength);
|
||||
let full = attack.is_some_and(|a| {
|
||||
@@ -6093,6 +6093,7 @@ impl Game<'_> {
|
||||
Some(draws) => draws,
|
||||
None => self.health_hud.layout(&inputs, wi, hi),
|
||||
};
|
||||
self.keep_status_rows(&draws, wi, hi);
|
||||
// The rows draw in one pass here, so a mod's `player_health`
|
||||
// layer stands for the armor, food and air rows too.
|
||||
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
|
||||
@@ -6192,7 +6193,9 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat {
|
||||
self.draw_chat(&mut batch);
|
||||
} else if show_hud {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
if !self.overlay_holds_view() {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
}
|
||||
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
|
||||
g.draw_tab_list(batch);
|
||||
});
|
||||
@@ -6263,11 +6266,7 @@ impl Game<'_> {
|
||||
// after this point and so over what a mod drew.
|
||||
self.fire_screen_render_post(&mut batch);
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_tutorial_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.draw_music_toast(&mut batch, !show_hud);
|
||||
self.draw_every_toast(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
@@ -6717,8 +6716,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
@@ -9425,6 +9424,7 @@ impl Game<'_> {
|
||||
self.camera_type.first_person(),
|
||||
self.ui.hide_gui,
|
||||
self.shows_panorama(),
|
||||
self.overlay_holds_view(),
|
||||
) {
|
||||
let scale = HudBatch::new(size).scale;
|
||||
crosshair.debug_crosshair(at, view_pitch, view_yaw, scale);
|
||||
@@ -9980,10 +9980,8 @@ impl Game<'_> {
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sneak"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sprint"),
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
@@ -11253,8 +11251,7 @@ impl Game<'_> {
|
||||
self.heal_hold_latches();
|
||||
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
|
||||
horizon_hooks::engine::published(self.is_published());
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
self.report_activity();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -12529,15 +12526,8 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
#[cfg(feature = "discord")]
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options);
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
|
||||
//! layer's Discord presence shows it).
|
||||
use super::*;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_hooks::{Activity, Client, Dimension, Play};
|
||||
|
||||
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
|
||||
/// `Hooks::client_started`.
|
||||
pub(super) fn client_started(options: &Options) {
|
||||
if options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_hooks::engine::activity(Activity::Menus);
|
||||
horizon_hooks::engine::client_started(&Client::new(automated));
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the hooks.
|
||||
pub(super) fn report_activity(&self) {
|
||||
// One status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
horizon_hooks::engine::activity(self.activity());
|
||||
}
|
||||
fn activity(&self) -> Activity {
|
||||
if self.title.is_some() {
|
||||
return Activity::Menus;
|
||||
}
|
||||
let max = horizon_client::lan::MAX_PLAYERS;
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Activity::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: match self.mode {
|
||||
GameMode::Survival => horizon_hooks::GameMode::Survival,
|
||||
GameMode::Creative => horizon_hooks::GameMode::Creative,
|
||||
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
|
||||
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1278,10 +1278,21 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(&id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
|
||||
let a = &self.armor_stands[i];
|
||||
if (invulnerable && !creative) || a.invisible || a.marker {
|
||||
if (invulnerable && !creative)
|
||||
|| a.invisible
|
||||
|| a.marker
|
||||
|| self.stand_immune_to_damage(i, damage_type, causing)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
let (explosion, ignites, burns) = (
|
||||
tagged("minecraft:is_explosion"),
|
||||
tagged("minecraft:ignites_armor_stands"),
|
||||
@@ -1292,21 +1303,26 @@ impl Game<'_> {
|
||||
tagged("minecraft:always_kills_armor_stands"),
|
||||
);
|
||||
if explosion {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
return false;
|
||||
}
|
||||
if ignites {
|
||||
if self.armor_stands[i].fire_ticks > 0 {
|
||||
self.cause_stand_damage(i, 0.15);
|
||||
self.cause_stand_damage(i, 0.15, by);
|
||||
} else {
|
||||
// igniteForSeconds(5.0f): at least 100 ticks.
|
||||
// igniteForSeconds(5.0f): at least 100 ticks, and
|
||||
// Entity.igniteForTicks calls clearFreeze.
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.fire_ticks = a.fire_ticks.max(100);
|
||||
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
|
||||
{
|
||||
map.remove(&"TicksFrozen".into());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if burns && self.armor_stands[i].health > 0.5 {
|
||||
self.cause_stand_damage(i, 4.0);
|
||||
self.cause_stand_damage(i, 4.0, by);
|
||||
return false;
|
||||
}
|
||||
if !breaks && !always_kills {
|
||||
@@ -1321,22 +1337,85 @@ impl Game<'_> {
|
||||
if !may_build {
|
||||
return false;
|
||||
}
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
self.break_armor_stand_by_hit(i, creative, always_kills, by)
|
||||
}
|
||||
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
|
||||
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
|
||||
/// effect whose requirements hold for `damage_type` and `causing`.
|
||||
fn stand_immune_to_damage(
|
||||
&mut self,
|
||||
i: usize,
|
||||
damage_type: &str,
|
||||
causing: Option<super::commands::Target>,
|
||||
) -> bool {
|
||||
use super::commands::Target;
|
||||
let mut checks = Vec::new();
|
||||
let equipment = &self.armor_stands[i].equipment;
|
||||
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
|
||||
let Some(stack) = stack else { continue };
|
||||
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
|
||||
let hand = matches!(slot, "mainhand" | "offhand");
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let immunity = &def.effects["minecraft:damage_immunity"];
|
||||
for entry in immunity.as_array().into_iter().flatten() {
|
||||
checks.push((entry.get("requirements").cloned(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
if checks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let attacker = match causing {
|
||||
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
|
||||
Some(Target::Mob(m_id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == m_id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
let canonical = if damage_type.contains(':') {
|
||||
damage_type.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{damage_type}")
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
damage_type: Some(&canonical),
|
||||
is_direct: attacker.is_some(),
|
||||
this: Some("minecraft:armor_stand"),
|
||||
attacker: attacker.as_deref(),
|
||||
direct_attacker: attacker.as_deref(),
|
||||
..Default::default()
|
||||
};
|
||||
let ench = &self.res.stations().enchantments;
|
||||
let rng = &mut self.world.random;
|
||||
for (req, level) in checks {
|
||||
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
|
||||
/// at 0.5 or less.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
|
||||
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
|
||||
let health = self.armor_stands[i].health - amount;
|
||||
if health <= 0.5 {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
} else {
|
||||
let at = self.armor_stands[i].position;
|
||||
self.armor_stands[i].health = health;
|
||||
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
|
||||
}
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
|
||||
@@ -1608,6 +1608,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
@@ -1721,6 +1731,7 @@ mod lectern_signal_tests {
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
@@ -1738,6 +1749,55 @@ mod lectern_signal_tests {
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
/// the state's `HAS_BOOK` is true.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lectern_without_the_has_book_state_feeds_no_comparator() {
|
||||
let mut game = server_game();
|
||||
let key = (8, 64, 8);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
key,
|
||||
BlockEntity::Lectern(Lectern {
|
||||
book: Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let signal = |game: &mut super::Game<'_>, state| {
|
||||
game.with_level(|_, level, _| {
|
||||
level.block_entity_analog_signal(
|
||||
BlockPos::new(key.0, key.1, key.2),
|
||||
state,
|
||||
horizon_core::geometry::Direction::North,
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
signal(&mut game, state),
|
||||
0,
|
||||
"the block state says the lectern holds no book"
|
||||
);
|
||||
let registry = game.pack.assets.registry;
|
||||
let mut with_book = registry.state(state).unwrap().clone();
|
||||
with_book
|
||||
.properties
|
||||
.insert("has_book".into(), "true".into());
|
||||
let with_book = registry.id(&with_book).unwrap();
|
||||
game.world.set(key.0, key.1, key.2, with_book).unwrap();
|
||||
assert_eq!(
|
||||
signal(&mut game, with_book),
|
||||
15,
|
||||
"with HAS_BOOK the book's page signal"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
|
||||
@@ -633,6 +633,26 @@ impl Game<'_> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
// The real event, so focus loss reaches all that a window's does (the radial menu
|
||||
// cancels, held keys go).
|
||||
"pad" if words.get(1) == Some(&"focus") => {
|
||||
let focused = match arg(2)? {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("pad focus true|false".into()),
|
||||
};
|
||||
let id = self
|
||||
.window
|
||||
.as_ref()
|
||||
.map_or_else(winit::window::WindowId::dummy, |w| w.id());
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
winit::event::WindowEvent::Focused(focused),
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
ok()
|
||||
}
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
@@ -645,6 +665,8 @@ impl Game<'_> {
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.input_keyboard_used();
|
||||
// As a window's key press: the radial menu closes, and the key goes on.
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
@@ -829,6 +851,10 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// As a window's click: the radial menu closes, and the click goes on.
|
||||
if arg(2)? != "up" {
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
@@ -1103,6 +1129,7 @@ impl Game<'_> {
|
||||
"keybinds" => Page::KeyBinds,
|
||||
"controller" => Page::Controller,
|
||||
"controller-binds" => Page::PadBinds,
|
||||
"controller-radial" => Page::ControllerRadial,
|
||||
"mouse" => Page::Mouse,
|
||||
"chat" => Page::Chat,
|
||||
"accessibility" => Page::Accessibility,
|
||||
|
||||
@@ -235,8 +235,14 @@ fn glfw_button_indices_are_glfw_indices() {
|
||||
/// F3 left on when leaving a world kept the axes on the title screen.
|
||||
#[test]
|
||||
fn the_debug_crosshair_is_gone_once_the_world_is() {
|
||||
assert!(debug_crosshair_drawn(true, true, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
assert!(debug_crosshair_drawn(true, true, false, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true, false));
|
||||
}
|
||||
|
||||
/// F3's 3D crosshair drew beside the radial menu's stick, which the 2D one gives way to.
|
||||
#[test]
|
||||
fn the_debug_crosshair_gives_way_to_the_radial_menu() {
|
||||
assert!(!debug_crosshair_drawn(true, true, false, false, true));
|
||||
}
|
||||
|
||||
/// Opening a world in front of a block left its outline over Loading terrain's panorama (#12872).
|
||||
|
||||
@@ -13,18 +13,12 @@ mod fx;
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part, Polygon};
|
||||
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,
|
||||
Badge, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Look, Mark, Mesh, NameDecoration,
|
||||
Pass, PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -32,46 +26,10 @@ 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;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
item: Arc<Item>,
|
||||
/// A model per bone it rides (a shirt's sleeves are on the arms).
|
||||
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
|
||||
/// Its texture's sprite (a 64×64 skin slot of the atlas).
|
||||
location: String,
|
||||
/// The frame it was last marked drawn in (its slot is kept).
|
||||
marked: Cell<u32>,
|
||||
}
|
||||
|
||||
impl Baked {
|
||||
fn new(item: &Arc<Item>, frame: u32) -> Self {
|
||||
Self {
|
||||
item: item.clone(),
|
||||
models: item
|
||||
.roots()
|
||||
.map(|(bone, root)| (bone, Arc::new(part(root))))
|
||||
.collect(),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
uploads: RefCell<Vec<Arc<Item>>>,
|
||||
frame: Cell<u32>,
|
||||
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
/// Companions, trails and auras.
|
||||
fx: RefCell<Fx>,
|
||||
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
|
||||
emotes: emotes::Emotes,
|
||||
/// The emote wheel (`cosmetics/emote_wheel.rs`).
|
||||
@@ -130,49 +88,6 @@ fn load_owned_emotes(
|
||||
)
|
||||
}
|
||||
|
||||
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
|
||||
const OWN: u32 = u32::MAX;
|
||||
/// Full light, for what glows.
|
||||
const FULL_LIGHT: u32 = 240 | 240 << 8;
|
||||
|
||||
/// What companions and trails keep between frames, per wearer (marked with the frame they were
|
||||
/// last drawn in; the others are dropped), and the frame's trails and auras, drawn after the
|
||||
/// players (`cosmetics_fx_geometry`).
|
||||
#[derive(Default)]
|
||||
struct Fx {
|
||||
companions: FxHashMap<u32, (Follow, u32)>,
|
||||
trails: FxHashMap<u32, (Emitter, u32)>,
|
||||
budget: Budget,
|
||||
clock: Option<(Instant, Instant)>,
|
||||
/// Ticks since the first frame, and the last frame's length in seconds.
|
||||
now: f64,
|
||||
dt: f32,
|
||||
/// Trails and auras: see-through.
|
||||
clear: Vec<Vertex>,
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
/// A new frame: the clock moves on and what no frame drew since the last one goes.
|
||||
fn frame(&mut self, frame: u32) {
|
||||
let now = Instant::now();
|
||||
let (start, last) = self.clock.get_or_insert((now, now));
|
||||
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
|
||||
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
|
||||
*last = now;
|
||||
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
|
||||
self.companions.retain(|_, (_, mark)| seen(*mark));
|
||||
let budget = &mut self.budget;
|
||||
self.trails.retain(|_, (e, mark)| {
|
||||
let keep = seen(*mark);
|
||||
if !keep {
|
||||
e.clear(budget);
|
||||
}
|
||||
keep
|
||||
});
|
||||
self.clear.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the signed-in account owns a cosmetic: the answer kept from the last run first
|
||||
/// (`prefs::owned`), then the service's, asked once a run and retried after a failure like Show my
|
||||
/// cosmetics. Not known yet, or no session: not owning.
|
||||
@@ -334,15 +249,11 @@ impl Cosmetics {
|
||||
/// choice, or by owning none). Hidden, the service is never asked about them.
|
||||
fn worn_of(
|
||||
client: &horizon_cosmetics::Cosmetics,
|
||||
extras: &horizon_client::extras::Extras,
|
||||
said: &Said,
|
||||
own: bool,
|
||||
uuid: impl FnOnce() -> Option<u128>,
|
||||
) -> Option<horizon_cosmetics::Worn> {
|
||||
let shown = if own {
|
||||
extras.cosmetics_mine
|
||||
} else {
|
||||
extras.effective_others() != Others::Hidden
|
||||
};
|
||||
let shown = if own { said.mine } else { !said.others_hidden };
|
||||
if !shown {
|
||||
return None;
|
||||
}
|
||||
@@ -369,24 +280,6 @@ fn own_uuid(
|
||||
/// How often a looping emote is said again (the service keeps one a minute).
|
||||
const LOOP_AGAIN: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The game's model of an item's part.
|
||||
fn part(p: &horizon_cosmetics::Part) -> Part {
|
||||
Part {
|
||||
name: p.name.clone(),
|
||||
pose: p.pose,
|
||||
polygons: p
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| Polygon {
|
||||
normal: f.normal,
|
||||
vertices: f.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: p.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
|
||||
fn slot_image(item: &Item) -> Vec<u8> {
|
||||
let t = &item.texture;
|
||||
@@ -463,12 +356,45 @@ struct Seat {
|
||||
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>)>,
|
||||
/// The items the hooks show, by the item's address.
|
||||
items: FxHashMap<usize, Kept>,
|
||||
last_sprite: u64,
|
||||
/// Sprites drawing missed, put in the atlas next frame.
|
||||
wanted: Vec<u64>,
|
||||
/// Companions, trails and auras (`cosmetics/fx.rs`).
|
||||
fx: fx::Fx,
|
||||
}
|
||||
|
||||
/// An item the hooks show: its sprite's id (never reused) and a mesh per bone it rides, held
|
||||
/// with the item so its address isn't reused while it is here.
|
||||
struct Kept {
|
||||
sprite: u64,
|
||||
item: Arc<Item>,
|
||||
meshes: Vec<(Bone, Arc<Mesh>)>,
|
||||
}
|
||||
|
||||
/// The hooks' model of an item's part.
|
||||
fn mesh(p: &horizon_cosmetics::Part) -> Mesh {
|
||||
let quads = p.faces.iter().map(|f| Quad::new(f.normal, f.vertices));
|
||||
Mesh::new(
|
||||
p.name.clone(),
|
||||
p.pose,
|
||||
quads.collect(),
|
||||
p.children.iter().map(mesh).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The hooks' name of a bone.
|
||||
fn bone(b: horizon_cosmetics::Bone) -> Bone {
|
||||
use horizon_cosmetics::Bone as B;
|
||||
match b {
|
||||
B::Head => Bone::Head,
|
||||
B::Body => Bone::Body,
|
||||
B::LeftArm => Bone::LeftArm,
|
||||
B::RightArm => Bone::RightArm,
|
||||
B::LeftLeg => Bone::LeftLeg,
|
||||
B::RightLeg => Bone::RightLeg,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
@@ -483,6 +409,8 @@ struct Said {
|
||||
others_hidden: bool,
|
||||
/// Horizon's mark by Horizon players' names.
|
||||
marks: bool,
|
||||
/// Other players' trails and auras are hidden.
|
||||
hide_fx: 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).
|
||||
@@ -493,6 +421,19 @@ struct Said {
|
||||
first: Option<Arc<Item>>,
|
||||
}
|
||||
|
||||
impl Said {
|
||||
/// What the game's settings say.
|
||||
fn settings(&mut self, extras: &horizon_client::extras::Extras) {
|
||||
self.mine = extras.cosmetics_mine;
|
||||
self.listed = extras.horizon_listed;
|
||||
self.others_shown = extras.effective_others() == Others::Shown;
|
||||
self.others_hidden = extras.effective_others() == Others::Hidden;
|
||||
self.marks = extras.horizon_marks;
|
||||
self.hide_fx = extras.cosmetics_hide_fx;
|
||||
self.solo = extras.cosmetics_solo;
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`Seat`] per splitscreen player.
|
||||
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
|
||||
|
||||
@@ -515,6 +456,7 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
service(|s| {
|
||||
s.solo = frame.solo;
|
||||
s.frame();
|
||||
s.fx.frame();
|
||||
s.put_sprites(sprites);
|
||||
});
|
||||
}
|
||||
@@ -539,6 +481,15 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
}
|
||||
|
||||
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
|
||||
fn player_layers(&self, model: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
service(|s| s.player_layers(model, draw));
|
||||
}
|
||||
|
||||
/// The player's own companion in first person.
|
||||
fn first_person(&self, own: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
service(|s| s.first_person(own, draw));
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -664,14 +615,7 @@ impl Seat {
|
||||
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 uuid = self.uuid_of(m)?;
|
||||
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 };
|
||||
@@ -687,42 +631,152 @@ impl Seat {
|
||||
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;
|
||||
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
|
||||
fn uuid_of(&self, m: &PlayerModel) -> Option<u128> {
|
||||
match m.kind {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, self.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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `item` as the hooks show it, kept from its first sight.
|
||||
fn kept(&mut self, item: &Arc<Item>) -> &Kept {
|
||||
let next = self.last_sprite + 1;
|
||||
let (id, _) = *self
|
||||
.sprites
|
||||
let kept = self
|
||||
.items
|
||||
.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 {
|
||||
.or_insert_with(|| Kept {
|
||||
sprite: next,
|
||||
item: item.clone(),
|
||||
meshes: item
|
||||
.roots()
|
||||
.map(|(b, root)| (bone(b), Arc::new(mesh(root))))
|
||||
.collect(),
|
||||
});
|
||||
self.last_sprite = self.last_sprite.max(kept.sprite);
|
||||
kept
|
||||
}
|
||||
|
||||
/// Where `item`'s texture is in the atlas (`get` reads the crate's sprites): the top left
|
||||
/// corner of its slot. None until it is there (it is put there next frame).
|
||||
fn uv(&mut self, item: &Arc<Item>, get: impl Fn(u64) -> Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
let id = self.kept(item).sprite;
|
||||
let Some([u0, v0, u1, v1]) = 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]),
|
||||
))
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
item.texture.height as f32 / 64.0,
|
||||
);
|
||||
Some([u0, v0, u0 + (u1 - u0) * w, v0 + (v1 - v0) * h])
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
|
||||
/// The name tag style in `worn`: its badge and its line's colour. None until the badge is in
|
||||
/// the atlas.
|
||||
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 uv = self.uv(item, |id| sprites.get(id))?;
|
||||
Some(Badge::new(uv, u32::from_be_bytes([0, r, g, b])))
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets the 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));
|
||||
let kept = self.items.values().find(|k| k.sprite == id);
|
||||
if let Some(kept) = kept.filter(|_| !store.has(id)) {
|
||||
store.put(id, slot_image(&kept.item));
|
||||
}
|
||||
}
|
||||
self.sprites
|
||||
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
|
||||
self.items.retain(|_, k| Arc::strong_count(&k.item) > 1);
|
||||
}
|
||||
|
||||
/// The cosmetics `m` wears, on its bones and around it in the world (companions, trails,
|
||||
/// auras: `cosmetics/fx.rs`), and an emote's props.
|
||||
fn player_layers(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
if m.invisible {
|
||||
return;
|
||||
}
|
||||
let said = &self.said;
|
||||
let own = m.kind == Kind::Own;
|
||||
// "Name tags only": other players' styles beside their names, nothing on their bodies.
|
||||
let bodies = own || said.others_shown;
|
||||
let ticks = draw.ticks();
|
||||
// An emote's props: its time and weight while it plays.
|
||||
let emote = emotes::pose_playing(draw.body_pose()).and_then(|p| {
|
||||
let (t, w) = p.at(ticks)?;
|
||||
(p.item.vertices > 0).then(|| (p.item.clone(), t, w))
|
||||
});
|
||||
let client = self.client.as_ref().filter(|_| bodies);
|
||||
let worn = client.and_then(|c| worn_of(c, &self.said, own, || self.uuid_of(m)));
|
||||
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
|
||||
// matrix: the head, the body and the two arms at most.
|
||||
let mut bones: [Option<(Bone, [[f32; 4]; 4])>; 4] = [None; 4];
|
||||
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
|
||||
let emote_items = emote.iter().map(|(i, t, w)| (i, Some((*t, *w))));
|
||||
for (item, emoting) in worn_items.chain(emote_items) {
|
||||
// On no bone, around the model in the world.
|
||||
if let (Slot::Companion | Slot::Trail | Slot::Aura, None) = (item.slot, emoting) {
|
||||
if draw.pass() == Pass::World {
|
||||
self.fx(m, item, draw);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Covered by gear, or drawn by no bone (a name tag style).
|
||||
if emoting.is_none() && covered(m.gear, item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
|
||||
continue;
|
||||
};
|
||||
for i in 0..self.kept(item).meshes.len() {
|
||||
let (bone_of, mesh) = self.kept(item).meshes[i].clone();
|
||||
let known = bones.iter().flatten().find(|(b, _)| *b == bone_of);
|
||||
let matrix = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) = draw.bone(bone_of) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((bone_of, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
draw.draw(&mesh, matrix, Look::new(uv), &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose);
|
||||
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
|
||||
e.pose_props(t, |n| item.rest(n), |n, pose| *p.pose(n) = pose);
|
||||
// Props grow in and shrink out with the emote's blend.
|
||||
let root = p.pose(&mesh.name);
|
||||
for s in &mut root[6..9] {
|
||||
*s *= w;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's own companion while its body is out of view.
|
||||
fn first_person(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
let Some(client) = self.client.as_ref().filter(|_| !m.invisible) else {
|
||||
return;
|
||||
};
|
||||
let Some(worn) = worn_of(client, &self.said, true, || self.uuid_of(m)) else {
|
||||
return;
|
||||
};
|
||||
if let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) {
|
||||
self.fx(m, item, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
@@ -824,12 +878,7 @@ impl Game<'_> {
|
||||
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.settings(extras);
|
||||
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));
|
||||
@@ -840,36 +889,16 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
/// Once a frame, after the hooks: what the game reads of them (the settings' owner flag,
|
||||
/// whether items come from a folder).
|
||||
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;
|
||||
}
|
||||
s.owner.owns
|
||||
});
|
||||
let (owns, local) = service(|s| (s.owner.owns, s.local));
|
||||
// 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());
|
||||
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);
|
||||
if !self.gui.atlas.sprites.contains_key(&location) {
|
||||
self.store_skin(location, slot_image(&item));
|
||||
}
|
||||
}
|
||||
// Items no player wears any more.
|
||||
self.cosmetics
|
||||
.baked
|
||||
.borrow_mut()
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
self.cosmetics.local = local;
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
@@ -895,210 +924,6 @@ impl Game<'_> {
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
worn_of(client, &self.settings.extras, own, || {
|
||||
self.cosmetics_uuid(m, own)
|
||||
})
|
||||
}
|
||||
|
||||
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
|
||||
fn cosmetics_uuid(&self, m: &Mob, own: bool) -> Option<u128> {
|
||||
if own {
|
||||
own_uuid(
|
||||
self.remote.as_ref().map(|r| r.profile()),
|
||||
self.integrated.is_some(),
|
||||
self.settings.extras.cosmetics_solo,
|
||||
|| horizon_client::auth::current().map(|s| s.uuid),
|
||||
)
|
||||
} else if &*m.kind.id == "minecraft:player" {
|
||||
self.remote_player_uuid(m.id)
|
||||
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
|
||||
Some(u128::from(m.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
|
||||
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
|
||||
/// go to `out`. `camera`: where the world view's camera is; None in a picture of the player
|
||||
/// (the inventory), which shows no companion, trail or aura.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn cosmetics_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
own: bool,
|
||||
base: glam::Mat4,
|
||||
camera: Option<glam::DVec3>,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
use horizon_client::mobs::{anim, render};
|
||||
let worn = self.worn(m, own);
|
||||
// An emote's props: its time and weight while it plays.
|
||||
let ticks = m.age as f32 + alpha;
|
||||
let emote = emotes::playing(m).and_then(|p| {
|
||||
let (t, w) = p.at(ticks)?;
|
||||
(p.item.vertices > 0).then_some((&p.item, t, w))
|
||||
});
|
||||
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
|
||||
return None;
|
||||
}
|
||||
// The avatar's body, posed as it is drawn.
|
||||
let (key, _) = render::body(m);
|
||||
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
|
||||
let mut scratch = self.cosmetics.poses.borrow_mut();
|
||||
let (body_poses, poses) = &mut *scratch;
|
||||
body_poses.reset(body);
|
||||
let data = self.res.mob_data();
|
||||
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
|
||||
let mut bounds = None;
|
||||
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
|
||||
// matrix: the head, the body and the two arms at most.
|
||||
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
|
||||
// "Name tags only": other players' styles beside their names, nothing on their bodies.
|
||||
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
|
||||
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
|
||||
let emote_items = emote.map(|(i, t, w)| (i, Some((t, w))));
|
||||
for (item, emoting) in worn_items.chain(emote_items) {
|
||||
if emoting.is_none() && !bodies {
|
||||
continue;
|
||||
}
|
||||
if let (Some(camera), Slot::Companion | Slot::Trail | Slot::Aura) = (camera, item.slot)
|
||||
{
|
||||
// The player's own in first person: only its companion, drawn after the drain
|
||||
// of the first-person model (`cosmetics_fx_geometry`).
|
||||
if !(own && !self.own_body_in_view()) {
|
||||
let at = fx::Wearer {
|
||||
m,
|
||||
own,
|
||||
key: if own { OWN } else { m.id },
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
};
|
||||
bounds = self.cosmetics_fx(&at, item, gpu, bounds, poses, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
continue;
|
||||
};
|
||||
for (bone_of, model) in &models {
|
||||
let known = bones.iter().flatten().find(|(b, _)| b == bone_of);
|
||||
let bone = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) =
|
||||
render::part_matrix(body, base, body_poses, bone_of.part(), 0.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((*bone_of, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
poses.reset(model);
|
||||
item.animate(ticks, |name, pose| *poses.get(name) = pose);
|
||||
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
|
||||
e.pose_props(t, |n| item.rest(n), |n, pose| *poses.get(n) = pose);
|
||||
// Props grow in and shrink out with the emote's blend.
|
||||
let root = poses.get(&model.name);
|
||||
for s in &mut root[6..9] {
|
||||
*s *= w;
|
||||
}
|
||||
}
|
||||
bounds = self.cosmetics_model(model, bone, poses, uv, light, gpu, bounds, out);
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
/// An item's models and its texture's corners in the atlas, once its texture is there (it is
|
||||
/// asked for meanwhile).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn cosmetics_baked(
|
||||
&self,
|
||||
item: &Arc<Item>,
|
||||
) -> Option<(Vec<(horizon_cosmetics::Bone, Arc<Part>)>, [f32; 4])> {
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let b = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
return None;
|
||||
};
|
||||
if b.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&b.location);
|
||||
}
|
||||
// The texture fills the top-left of its 64×64 slot.
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
item.texture.height as f32 / 64.0,
|
||||
);
|
||||
let uv = [
|
||||
rect.u0,
|
||||
rect.v0,
|
||||
rect.u0 + (rect.u1 - rect.u0) * w,
|
||||
rect.v0 + (rect.v1 - rect.v0) * h,
|
||||
];
|
||||
Some((b.models.clone(), uv))
|
||||
}
|
||||
|
||||
/// One model at `matrix`, posed by `poses`: on the GPU's skin path when `gpu` (the bounds
|
||||
/// grow to hold it), else its vertices go to `out`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cosmetics_model(
|
||||
&self,
|
||||
model: &Arc<Part>,
|
||||
matrix: glam::Mat4,
|
||||
poses: &horizon_client::mobs::anim::Poses,
|
||||
uv: [f32; 4],
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
if gpu {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(model, matrix, poses, paint) {
|
||||
return skin.bounds;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1165,6 +990,13 @@ mod tests {
|
||||
|
||||
const OTHER: u128 = 0x1234;
|
||||
|
||||
/// What `extras` tell the hooks.
|
||||
fn said_of(extras: &Extras) -> Said {
|
||||
let mut said = Said::default();
|
||||
said.settings(extras);
|
||||
said
|
||||
}
|
||||
|
||||
/// What another player wears as `extras` shows it, drawn frame after frame for up to
|
||||
/// `patience` (the service answers on its own thread), and what was asked of the service.
|
||||
fn other_player(
|
||||
@@ -1182,7 +1014,7 @@ mod tests {
|
||||
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, extras, false, || Some(OTHER));
|
||||
let worn = worn_of(&client, &said_of(extras), false, || Some(OTHER));
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1243,7 +1075,7 @@ mod tests {
|
||||
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, &extras, true, uuid);
|
||||
let worn = worn_of(&client, &said_of(&extras), true, uuid);
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1398,4 +1230,173 @@ mod tests {
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
}
|
||||
|
||||
/// A player model as `player_layers` draws on it: one bone, the sprites it is given; logs
|
||||
/// each draw (mesh, matrix, uv, its root's scale once posed).
|
||||
#[derive(Default)]
|
||||
struct Canvas {
|
||||
pose: Option<Arc<dyn BodyPose>>,
|
||||
sprite: Option<[f32; 4]>,
|
||||
drawn: Vec<(String, [[f32; 4]; 4], [f32; 4], [f32; 3])>,
|
||||
/// The model's matrix (`LEFT_ARM` if None) and how it moves.
|
||||
base: Option<glam::Mat4>,
|
||||
motion: horizon_hooks::players::Motion,
|
||||
/// Each draw's look: see-through alpha and light.
|
||||
looks: Vec<(Option<f32>, horizon_hooks::players::Light)>,
|
||||
}
|
||||
|
||||
const LEFT_ARM: [[f32; 4]; 4] = [[2.0, 0.0, 0.0, 0.0]; 4];
|
||||
|
||||
impl PlayerDraw for Canvas {
|
||||
fn pass(&self) -> horizon_hooks::players::Pass {
|
||||
horizon_hooks::players::Pass::World
|
||||
}
|
||||
fn ticks(&self) -> f32 {
|
||||
102.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.map_or(LEFT_ARM, |b| b.to_cols_array_2d())
|
||||
}
|
||||
fn motion(&self) -> horizon_hooks::players::Motion {
|
||||
self.motion
|
||||
}
|
||||
fn view(&self) -> horizon_hooks::players::View {
|
||||
Default::default()
|
||||
}
|
||||
fn particles(
|
||||
&mut self,
|
||||
_: [f32; 4],
|
||||
_: &[horizon_hooks::players::Particle],
|
||||
_: horizon_hooks::players::Light,
|
||||
) {
|
||||
}
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == Bone::LeftArm).then_some(LEFT_ARM)
|
||||
}
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.pose.as_ref()
|
||||
}
|
||||
fn sprite(&self, _: u64) -> Option<[f32; 4]> {
|
||||
self.sprite
|
||||
}
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn horizon_hooks::players::PartPoses),
|
||||
) {
|
||||
struct Rest(std::collections::HashMap<String, [f32; 9]>);
|
||||
impl horizon_hooks::players::PartPoses for Rest {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.entry(name.to_owned()).or_insert([0.0; 9])
|
||||
}
|
||||
}
|
||||
let mut rest = Rest([(mesh.name.clone(), mesh.pose)].into());
|
||||
pose(&mut rest);
|
||||
let root = rest.0[&mesh.name];
|
||||
self.looks.push((look.see_through, look.light));
|
||||
self.drawn.push((
|
||||
mesh.name.clone(),
|
||||
matrix,
|
||||
look.uv,
|
||||
[root[6], root[7], root[8]],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emotes_prop_goes_on_its_bone_once_its_texture_is_in_scaled_by_the_blend() {
|
||||
let pop = CROWN
|
||||
.replace(
|
||||
r#""slot": "head", "bone": "head""#,
|
||||
r#""slot": "emote", "bone": "left_arm""#,
|
||||
)
|
||||
.replace(
|
||||
r#""uv": [0, 0]}]}]}"#,
|
||||
r#""uv": [0, 0]}]}],
|
||||
"emote": {"length": 40, "blend": 4, "tracks": [
|
||||
{"part": "right_arm", "keys": [{"t": 0}, {"t": 40}]}]}}"#,
|
||||
);
|
||||
let item = Arc::new(Item::parse(pop.as_bytes()).unwrap());
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: item.clone(),
|
||||
start: 100.0,
|
||||
stop: None,
|
||||
};
|
||||
let (_, weight) = playing.at(102.0).unwrap();
|
||||
assert!(weight > 0.0 && weight < 1.0, "{weight}");
|
||||
let player = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
|
||||
let mut view = View(vec![(player, None)]);
|
||||
emotes::set_model_playing(&mut view, 9, Some(playing));
|
||||
let mut canvas = Canvas {
|
||||
pose: view.0[0].1.clone(),
|
||||
..Canvas::default()
|
||||
};
|
||||
// Others' cosmetics hidden, no service: the player's own emote still shows.
|
||||
let mut seat = Seat {
|
||||
said: Said {
|
||||
others_hidden: true,
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
};
|
||||
// Its texture not in the atlas yet: nothing drawn, the sprite asked for.
|
||||
seat.player_layers(&player, &mut canvas);
|
||||
assert!(canvas.drawn.is_empty());
|
||||
assert_eq!(seat.wanted.len(), 1);
|
||||
canvas.sprite = Some([0.0, 0.0, 0.5, 0.5]);
|
||||
seat.player_layers(&player, &mut canvas);
|
||||
// A 16×16 texture: the slot's top left quarter of it.
|
||||
let quarter = 0.5 * 16.0 / 64.0;
|
||||
assert_eq!(canvas.drawn.len(), 1);
|
||||
let (_, matrix, uv, scale) = &canvas.drawn[0];
|
||||
assert_eq!((matrix, uv), (&LEFT_ARM, &[0.0, 0.0, quarter, quarter]));
|
||||
for s in scale {
|
||||
assert!((s - weight).abs() < 1e-6, "{scale:?} by {weight}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aura_goes_see_through_at_the_feet_unless_swimming_far_or_hidden() {
|
||||
use horizon_hooks::players::{Light, Motion};
|
||||
let ring = CROWN
|
||||
.replace(r#""slot": "head", "bone": "head""#, r#""slot": "aura""#)
|
||||
// At the feet: an aura reaches at most 6 pixels above them.
|
||||
.replace(r#""attach": {"offset": [0, -18, 0]},"#, "")
|
||||
.replace(
|
||||
r#""uv": [0, 0]}]}]}"#,
|
||||
r#""uv": [0, 0]}]}], "aura": {"at": "feet", "alpha": 0.5}}"#,
|
||||
);
|
||||
let ring = Arc::new(Item::parse(ring.as_bytes()).unwrap());
|
||||
let at = glam::Vec3::new(3.0, 0.0, 4.0);
|
||||
let mut canvas = Canvas {
|
||||
sprite: Some([0.0, 0.0, 0.5, 0.5]),
|
||||
base: Some(glam::Mat4::from_translation(at)),
|
||||
..Canvas::default()
|
||||
};
|
||||
let other = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
|
||||
let own = PlayerModel::new(1, Kind::Own, None, 102, false);
|
||||
let mut seat = Seat::default();
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
// Half see-through, lit as the wearer, 24 pixels under the neck.
|
||||
assert_eq!(canvas.looks, [(Some(0.5), Light::Model)]);
|
||||
let feet = glam::Mat4::from_cols_array_2d(&canvas.drawn[0].1).w_axis;
|
||||
assert_eq!(feet.truncate(), at + glam::Vec3::new(0.0, 1.5, 0.0));
|
||||
// Swimming, it would tip over with the body: none.
|
||||
canvas.motion = Motion::default().poses(false, false, false, true);
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
canvas.motion = Motion::default();
|
||||
// Past 48 blocks: none.
|
||||
canvas.base = Some(glam::Mat4::from_translation(glam::Vec3::X * 49.0));
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 1);
|
||||
// Others' trails and auras hidden: theirs go, the player's own stays.
|
||||
canvas.base = Some(glam::Mat4::from_translation(at));
|
||||
seat.said.hide_fx = true;
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 1);
|
||||
seat.fx(&own, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,6 @@ pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<
|
||||
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(
|
||||
|
||||
@@ -1,47 +1,78 @@
|
||||
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
|
||||
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
|
||||
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
|
||||
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
|
||||
//! to a server.
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
|
||||
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
|
||||
//! or is sent to a server.
|
||||
|
||||
use super::{FULL_LIGHT, OWN};
|
||||
use crate::game::Game;
|
||||
use super::Seat;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_cosmetics::follow::{self, Follow};
|
||||
use horizon_cosmetics::trail::Emitter;
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
|
||||
const FAR: f32 = 48.0;
|
||||
|
||||
/// Who wears it, as drawn this frame.
|
||||
pub(super) struct Wearer<'a> {
|
||||
pub m: &'a Mob,
|
||||
/// The player's own model.
|
||||
pub own: bool,
|
||||
/// `OWN`, or the entity id.
|
||||
pub key: u32,
|
||||
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
|
||||
pub base: Mat4,
|
||||
pub camera: DVec3,
|
||||
pub alpha: f32,
|
||||
pub light: u32,
|
||||
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
|
||||
const OWN: u32 = u32::MAX;
|
||||
|
||||
/// What companions and trails keep between frames, per wearer (marked with the frame they were
|
||||
/// last drawn in; the others are dropped).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fx {
|
||||
companions: FxHashMap<u32, (Follow, u32)>,
|
||||
trails: FxHashMap<u32, (Emitter, u32)>,
|
||||
budget: Budget,
|
||||
clock: Option<(Instant, Instant)>,
|
||||
/// Ticks since the first frame, and the last frame's length in seconds.
|
||||
now: f64,
|
||||
dt: f32,
|
||||
frame: u32,
|
||||
/// Kept so a frame of trails allocates none.
|
||||
particles: Vec<Particle>,
|
||||
}
|
||||
|
||||
/// `m`'s head yaw this frame (degrees).
|
||||
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
m.previous_head_yaw + turn * alpha
|
||||
}
|
||||
impl Fx {
|
||||
/// A new frame: the clock moves on and what no frame drew since the last one goes.
|
||||
pub(super) fn frame(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(1);
|
||||
let frame = self.frame;
|
||||
let now = Instant::now();
|
||||
let (start, last) = self.clock.get_or_insert((now, now));
|
||||
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
|
||||
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
|
||||
*last = now;
|
||||
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
|
||||
self.companions.retain(|_, (_, mark)| seen(*mark));
|
||||
let budget = &mut self.budget;
|
||||
self.trails.retain(|_, (e, mark)| {
|
||||
let keep = seen(*mark);
|
||||
if !keep {
|
||||
e.clear(budget);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `m` lies in a bed.
|
||||
fn asleep(m: &Mob) -> bool {
|
||||
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let (frame, dt) = (self.frame, self.dt);
|
||||
let (f, mark) = self
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
|
||||
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
|
||||
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
|
||||
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
|
||||
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn cosmetics_fx(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
item: &Arc<Item>,
|
||||
gpu: bool,
|
||||
bounds: Option<(Vec3, Vec3)>,
|
||||
poses: &mut Poses,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(Vec3, Vec3)> {
|
||||
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
|
||||
return bounds;
|
||||
/// Full light for what glows, else `otherwise`.
|
||||
fn glowing(glow: bool, otherwise: Light) -> Light {
|
||||
if glow { Light::Full } else { otherwise }
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
|
||||
/// trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
|
||||
let own = m.kind == Kind::Own;
|
||||
if !own && item.slot.is_effect() && self.said.hide_fx {
|
||||
return;
|
||||
}
|
||||
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
|
||||
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
|
||||
// budget).
|
||||
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return bounds;
|
||||
let base = Mat4::from_cols_array_2d(&draw.base());
|
||||
if base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return;
|
||||
}
|
||||
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
|
||||
return;
|
||||
};
|
||||
let key = if own { OWN } else { m.id };
|
||||
match item.slot {
|
||||
Slot::Companion => {
|
||||
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
|
||||
return bounds;
|
||||
};
|
||||
self.pose_companion(item, at.m, at.alpha, poses, &model);
|
||||
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
|
||||
let light = if glow { FULL_LIGHT } else { at.light };
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
|
||||
}
|
||||
Slot::Trail => {
|
||||
self.trail(at, item);
|
||||
bounds
|
||||
}
|
||||
Slot::Aura => {
|
||||
self.aura(at, item, poses);
|
||||
bounds
|
||||
}
|
||||
_ => bounds,
|
||||
Slot::Companion => self.companion(key, item, uv, base, draw),
|
||||
Slot::Trail => self.trail(key, item, uv, draw),
|
||||
Slot::Aura => self.aura(item, uv, base, draw),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
|
||||
/// `first_person`: the camera's forward and left, for the player's own in first person: the
|
||||
/// companion keeps out of the middle of the view.
|
||||
fn companion_at(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
|
||||
/// out of the middle of the view, lit where it floats.
|
||||
fn companion(
|
||||
&mut self,
|
||||
key: u32,
|
||||
item: &Arc<Item>,
|
||||
first_person: Option<(Vec3, Vec3)>,
|
||||
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
|
||||
let c = item.companion.as_ref()?;
|
||||
let (models, uv) = self.cosmetics_baked(item)?;
|
||||
let model = models.first()?.1.clone();
|
||||
let scale = at.base.x_axis.truncate().length();
|
||||
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = at
|
||||
.base
|
||||
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let yaw = head_yaw(at.m, at.alpha);
|
||||
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
|
||||
uv: [f32; 4],
|
||||
base: Mat4,
|
||||
draw: &mut dyn PlayerDraw,
|
||||
) {
|
||||
let Some(c) = item.companion.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let first_person = draw.pass() == Pass::FirstPerson;
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let camera = DVec3::from(view.camera);
|
||||
let scale = base.x_axis.truncate().length();
|
||||
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let follow = self
|
||||
.fx
|
||||
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
|
||||
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
|
||||
let mut rel = (follow.pos - at.camera).as_vec3();
|
||||
if let Some((forward, left)) = first_person {
|
||||
rel = follow::out_of_view(rel, forward, left);
|
||||
let mut rel = (follow.pos - camera).as_vec3();
|
||||
if first_person {
|
||||
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
|
||||
}
|
||||
rel = follow::clear_of(rel, head, clearance);
|
||||
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
|
||||
}
|
||||
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let dt = fx.dt;
|
||||
let (f, mark) = fx
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
|
||||
/// The companion's animations, and its `look` part turned with the wearer's pitch.
|
||||
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
|
||||
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
|
||||
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
}
|
||||
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
|
||||
let near = if first_person {
|
||||
Light::AtPlace
|
||||
} else {
|
||||
Light::Model
|
||||
};
|
||||
let look = Look::new(uv).lit(glowing(c.glow, near));
|
||||
let ticks = draw.ticks();
|
||||
// Its animations, and its `look` part turned with the wearer's pitch.
|
||||
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
draw.draw(&mesh, matrix, look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose);
|
||||
if let Some(part) = c.look.as_deref() {
|
||||
p.pose(part)[3] += pitch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
|
||||
/// asleep), facing the camera.
|
||||
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
|
||||
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
|
||||
let Some(t) = &item.trail else {
|
||||
return;
|
||||
};
|
||||
let Some((_, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let m = at.m;
|
||||
let a = f64::from(at.alpha);
|
||||
let feet = DVec3::new(
|
||||
m.previous.x + (m.position.x - m.previous.x) * a,
|
||||
m.previous.y + (m.position.y - m.previous.y) * a,
|
||||
m.previous.z + (m.position.z - m.previous.z) * a,
|
||||
);
|
||||
let moves = !m.extra.crouching && !asleep(m);
|
||||
let q = self.camera_orientation();
|
||||
let (right, up) = (q * Vec3::X, q * Vec3::Y);
|
||||
let light = if t.glow { FULL_LIGHT } else { at.light };
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let super::Fx {
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let moves = !motion.crouching && !motion.asleep;
|
||||
let Fx {
|
||||
trails,
|
||||
budget,
|
||||
now,
|
||||
clear,
|
||||
frame,
|
||||
particles,
|
||||
..
|
||||
} = &mut *fx;
|
||||
} = &mut self.fx;
|
||||
let (e, mark) = trails
|
||||
.entry(at.key)
|
||||
.entry(key)
|
||||
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
|
||||
if *mark != frame {
|
||||
e.step(t, feet, moves, *now, budget);
|
||||
*mark = frame;
|
||||
}
|
||||
for quad in e.quads(t, *now, at.camera, right, up) {
|
||||
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
|
||||
clear.push(Vertex {
|
||||
pos: corner.to_array(),
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
if *mark != *frame {
|
||||
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
|
||||
*mark = *frame;
|
||||
}
|
||||
let (camera, right, up) = (
|
||||
DVec3::from(view.camera),
|
||||
Vec3::from(view.right),
|
||||
Vec3::from(view.up),
|
||||
);
|
||||
particles.clear();
|
||||
particles.extend(
|
||||
e.quads(t, *now, camera, right, up)
|
||||
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
|
||||
);
|
||||
draw.particles(uv, particles, glowing(t.glow, Light::Model));
|
||||
}
|
||||
|
||||
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
|
||||
/// tip over with the body.
|
||||
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
|
||||
let (m, Some(a)) = (at.m, &item.aura) else {
|
||||
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
|
||||
let Some(a) = &item.aura else {
|
||||
return;
|
||||
};
|
||||
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
|
||||
let motion = draw.motion();
|
||||
if motion.flying || motion.asleep || motion.swimming {
|
||||
return;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let Some((_, model)) = models.first() else {
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
// The model's origin is the middle of the feet: 24 pixels under the neck.
|
||||
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + at.alpha, |name, pose| {
|
||||
*poses.get(name) = pose
|
||||
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
let look = Look::new(uv)
|
||||
.lit(glowing(a.glow, Light::Model))
|
||||
.see_through(a.alpha);
|
||||
let ticks = draw.ticks();
|
||||
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose)
|
||||
});
|
||||
let light = if a.glow { FULL_LIGHT } else { at.light };
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let start = fx.clear.len();
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
&mut fx.clear,
|
||||
);
|
||||
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for v in &mut fx.clear[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one (and a card's two
|
||||
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
}
|
||||
|
||||
/// After every player: the player's own companion in first person (out of the middle of the
|
||||
/// view, after the first-person model went to the shadow pass only), then the frame's trails
|
||||
/// and auras in one see-through span.
|
||||
pub(in crate::game) fn cosmetics_fx_geometry(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !super::enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.own_companion_first_person(m, camera, alpha as f32, out);
|
||||
}
|
||||
let fx = self.cosmetics.fx.borrow();
|
||||
if !fx.clear.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&fx.clear);
|
||||
}
|
||||
}
|
||||
|
||||
fn own_companion_first_person(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: DVec3,
|
||||
alpha: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
|
||||
return;
|
||||
}
|
||||
let Some(worn) = self.worn(m, true) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
|
||||
return;
|
||||
};
|
||||
let at = Wearer {
|
||||
m,
|
||||
own: true,
|
||||
key: OWN,
|
||||
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light: FULL_LIGHT,
|
||||
};
|
||||
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
|
||||
let forward = Vec3::new(
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
|
||||
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
|
||||
return;
|
||||
};
|
||||
// Lit where it floats.
|
||||
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
|
||||
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
if item.companion.as_ref().is_some_and(|c| c.glow) {
|
||||
light = FULL_LIGHT;
|
||||
}
|
||||
let mut poses = self.cosmetics.poses.borrow_mut();
|
||||
let poses = &mut poses.1;
|
||||
self.pose_companion(item, m, alpha, poses, &model);
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let shown = self.hands_to_render();
|
||||
let shown = self.hands_shown();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
|
||||
@@ -7,8 +7,9 @@ pub(super) fn debug_crosshair_drawn(
|
||||
first_person: bool,
|
||||
hide_gui: bool,
|
||||
panorama: bool,
|
||||
overlay_holds_view: bool,
|
||||
) -> bool {
|
||||
enabled && first_person && !hide_gui && !panorama
|
||||
enabled && first_person && !hide_gui && !panorama && !overlay_holds_view
|
||||
}
|
||||
|
||||
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
//! The HUD's `Room` this frame (`docs/ui-kit.md`): what vanilla's HUD shows that Horizon's own
|
||||
//! overlays keep clear of. `hud` empties it each frame and `hud_layers` fills it in before the
|
||||
//! world's HUD draws, into `ui.room`; the controller notice and splitscreen's join strip read it.
|
||||
//! world's HUD draws, into `ui.room`; the controller notice, splitscreen's join strip and the
|
||||
//! radial menu read it. With it, the frame's `motion::Clock` (`hud_clock`), and whether an
|
||||
//! overlay holds the view (`overlay_holds_view`), which what vanilla draws there gives way to.
|
||||
use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
use horizon_client::hud_kit::motion::Clock;
|
||||
use horizon_client::hud_kit::room::{Keep, Room};
|
||||
use horizon_client::{boss_overlay, hud_layout, hud_overlays};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The HUD's clock this frame (`hud_kit::motion`): still, so overlays show their motion
|
||||
/// settled, while the game is paused, frame-stepped (`HORIZON_STILL`, a screenshot
|
||||
/// sequence) or a repro recording or replay runs.
|
||||
pub(super) fn hud_clock(&self) -> Clock {
|
||||
let still = self.pause
|
||||
|| self.clock.still()
|
||||
|| self.screenshot_sequence.is_some()
|
||||
|| self.repro.recording_active()
|
||||
|| self.repro.replaying();
|
||||
Clock::new(std::time::Instant::now(), still)
|
||||
}
|
||||
|
||||
/// What the HUD shows on a `w` x `h` GUI px view this frame, as keep-outs: the boss bars
|
||||
/// while the HUD shows (`show_hud`: not F1, not Replay Studio), as wide as the bar or its
|
||||
/// widest name.
|
||||
@@ -36,4 +52,50 @@ impl Game<'_> {
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// The status rows above the hotbar in a `w` x `h` GUI px view, from what they draw this
|
||||
/// frame (`draws`): a keep-out from their highest row's top down, as wide as the hotbar. Called
|
||||
/// as they are laid out, after `hud_room`, so the overlays drawn over the HUD see them.
|
||||
pub(super) fn keep_status_rows(&mut self, draws: &[hud_layout::GuiDraw], w: i32, h: i32) {
|
||||
if let Some(top) = hud_layout::draws_top(draws).filter(|top| *top < h) {
|
||||
let half = hud_overlays::HOTBAR_HALF_WIDTH;
|
||||
self.ui.room.add(
|
||||
Keep::StatusRows,
|
||||
[
|
||||
(w / 2 - half) as f32,
|
||||
top as f32,
|
||||
(2 * half) as f32,
|
||||
(h - top) as f32,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An overlay holds the middle of the view (the radial menu, once it shows: a quick tap of
|
||||
/// its opener draws nothing, so nothing blinks). The 2D and F3 crosshairs, the chat's lines,
|
||||
/// the toasts and the first-person hands give way to it.
|
||||
pub(super) fn overlay_holds_view(&self) -> bool {
|
||||
self.pad_radial_shown()
|
||||
}
|
||||
|
||||
/// ToastManager.extractRenderState: F1 hides the toasts (`hidden`), and so does an overlay
|
||||
/// holding the view, as the radial menu's page tabs run across the top where the toasts are.
|
||||
/// They go on timing out meanwhile, as under F1.
|
||||
pub(super) fn draw_every_toast(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let hidden = hidden || self.overlay_holds_view();
|
||||
self.draw_toasts(batch, hidden);
|
||||
self.draw_tutorial_toasts(batch, hidden);
|
||||
self.draw_pack_toasts(batch, hidden);
|
||||
self.draw_music_toast(batch, hidden);
|
||||
}
|
||||
|
||||
/// The hands drawn in first person this frame: none while an overlay holds the view, as the
|
||||
/// radial menu's tiles sit where they are.
|
||||
pub(super) fn hands_shown(&self) -> [bool; 2] {
|
||||
if self.overlay_holds_view() {
|
||||
[false; 2]
|
||||
} else {
|
||||
self.hands_to_render()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,11 @@ impl Game<'_> {
|
||||
pub(super) fn has_singleplayer_server(&self) -> bool {
|
||||
self.remote.is_none() || self.integrated.is_some()
|
||||
}
|
||||
/// `Minecraft.isMultiplayerServer`: on another's server, or a world of one's own open to the
|
||||
/// network.
|
||||
pub(super) fn is_multiplayer_server(&self) -> bool {
|
||||
!self.has_singleplayer_server() || self.is_published()
|
||||
}
|
||||
/// `IntegratedServer.isPublished`: the world is open to the network.
|
||||
pub(super) fn is_published(&self) -> bool {
|
||||
self.lan.is_some()
|
||||
|
||||
@@ -21,6 +21,43 @@ use horizon_core::geometry::{Aabb, Vec3};
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
/// The game's painter for the HUD kit's icons (`hud_kit::icon`): items by their models, the
|
||||
/// player's face from `face`, their skin's texture.
|
||||
pub(super) struct IconPainter<'g, 'a> {
|
||||
pub(super) game: &'g Game<'a>,
|
||||
pub(super) face: Option<&'g str>,
|
||||
}
|
||||
|
||||
impl horizon_client::hud_kit::icon::IconPainter for IconPainter<'_, '_> {
|
||||
fn item(&self, batch: &mut HudBatch, id: &str, [x, y, size]: [f32; 3]) -> bool {
|
||||
let start = batch.vertices.len();
|
||||
self.game
|
||||
.draw_item_model(batch, &ItemStack::new(id, 1), x, y);
|
||||
let scale = size / horizon_client::hud_kit::icon::ITEM_SIZE;
|
||||
batch.scale_since(start, x, y, scale, scale);
|
||||
batch.vertices.len() > start
|
||||
}
|
||||
fn face(&self, batch: &mut HudBatch, [x, y, size]: [f32; 3]) -> bool {
|
||||
let Some(skin) = self.face else {
|
||||
return false;
|
||||
};
|
||||
let mut draws = Vec::new();
|
||||
// `PlayerFaceExtractor`: the face, then its hat layer.
|
||||
horizon_client::tab_list::face(
|
||||
&mut draws,
|
||||
skin,
|
||||
x as i32,
|
||||
y as i32,
|
||||
size as i32,
|
||||
true,
|
||||
false,
|
||||
u32::MAX,
|
||||
);
|
||||
horizon_client::hud_layout::paint(batch, &self.game.gui.atlas, &draws);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
|
||||
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
@@ -98,9 +96,8 @@ impl Loading {
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -130,22 +127,25 @@ impl Loading {
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
// In the fonts given to `menu_style`; with none, the window stays as it is.
|
||||
let boot_paint_stop = hwnd(&window)
|
||||
.zip(horizon_client::menu_style::get().fonts)
|
||||
.map(|(hwnd, fonts)| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
fonts.semibold,
|
||||
fonts.mono,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
|
||||
@@ -930,6 +930,7 @@ impl Game<'_> {
|
||||
let m = &self.mobs[i];
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
self.keep_status_rows(&draws, w, h);
|
||||
self.gui_layer("minecraft:vehicle_health", batch, |g, batch| {
|
||||
horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
|
||||
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
)
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -17,6 +17,7 @@ use horizon_client::{
|
||||
keymapping::SNEAK,
|
||||
pad_glyph::{Glyph, Glyphs},
|
||||
};
|
||||
use horizon_client_input::radial;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::event::MouseButton;
|
||||
@@ -26,7 +27,8 @@ pub(super) const HELP: &[&str] = &[
|
||||
"pad BUTTON down|up|tap a virtual controller button (south east west north lb rb lt rt select start ls rs up down left right)",
|
||||
"pad stick left|right X Y tilt a virtual stick (-1..1, up positive); 0 0 centres it",
|
||||
"pad trigger left|right VALUE pull a virtual trigger (0..1)",
|
||||
"pad release centre and release the virtual controller",
|
||||
"pad release centre and release the virtual controller (as an unplug: the radial menu cancels)",
|
||||
"pad focus true|false the window gains or loses the focus, as a window focus event does",
|
||||
"pad status connected controllers, the active one and its state",
|
||||
"pad connection connected [FAMILY]|disconnected as if a controller (PlayStation unless FAMILY) came or went at this game's own reader",
|
||||
"pad family xbox|playstation|nintendo|auto the pad playing is that family's (its glyphs; a Nintendo one's letters)",
|
||||
@@ -86,6 +88,31 @@ pub(super) struct Pads {
|
||||
connections: Vec<Connection>,
|
||||
/// The cursor the pad drives in menus.
|
||||
pub(super) menu: super::pad_menu::MenuCursor,
|
||||
/// The radial menu while its opener is held, and the page it stays on.
|
||||
pub(super) radial: radial::RadialMenu,
|
||||
/// What the radial menu ran, for `pad status`.
|
||||
radial_runs: RadialRuns,
|
||||
/// The moments the radial menu's overlay moves from (`radial_overlay::Look`).
|
||||
pub(super) radial_look: horizon_client_render::radial_overlay::Look,
|
||||
/// The player's skin for the overlay's Emote face, read as it opens.
|
||||
radial_face: Option<String>,
|
||||
/// The D-pad's quiet after the menu closed on a D-pad opener.
|
||||
radial_settle: radial::DpadSettle,
|
||||
/// The narrator said the page shown, and the slot selected.
|
||||
radial_page_said: bool,
|
||||
radial_said: radial::Settle<(usize, usize)>,
|
||||
/// A page turn's second tick, when the pages went round an end.
|
||||
radial_rumble_due: Option<(Instant, Rumble)>,
|
||||
/// The furthest each stick leant (left, right) between the last two polls, from the pad's
|
||||
/// own events: a flick out and back between two frames still reaches the radial menu.
|
||||
peaks: [[f32; 2]; 2],
|
||||
/// With Either selecting: the stick that last leant past its select tilt (0 left, 1 right),
|
||||
/// and whether each was past it at the last poll.
|
||||
radial_either: Option<usize>,
|
||||
radial_over: [bool; 2],
|
||||
/// While the radial menu is open with Either: each stick has been at rest since the open, so
|
||||
/// it may take over. A thumb still on the walking stick can't commit for the other.
|
||||
radial_rested: [bool; 2],
|
||||
/// The last vibration asked for, for `pad status`.
|
||||
last_rumble: Option<(&'static str, gamepad::Pulse)>,
|
||||
/// The drop button, while it is held in play.
|
||||
@@ -111,13 +138,37 @@ struct Context {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
mod capture;
|
||||
mod guide;
|
||||
#[cfg(feature = "controllers")]
|
||||
mod hardware;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
mod radial_play;
|
||||
pub(super) use hardware::{PadId, PadRouter};
|
||||
|
||||
/// How many of the radial menu's last actions `pad status` lists.
|
||||
const RADIAL_RUNS_KEPT: usize = 16;
|
||||
|
||||
/// What the radial menu ran: how many actions in all, and the last few, last one last.
|
||||
#[derive(Default)]
|
||||
struct RadialRuns {
|
||||
count: u64,
|
||||
last: std::collections::VecDeque<&'static str>,
|
||||
}
|
||||
impl RadialRuns {
|
||||
fn push(&mut self, action: &'static str) {
|
||||
self.count += 1;
|
||||
if self.last.len() == RADIAL_RUNS_KEPT {
|
||||
self.last.pop_front();
|
||||
}
|
||||
self.last.push_back(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume of a UI button's click (`AbstractButton.playDownSound`).
|
||||
const UI_CLICK_VOLUME: f32 = 0.25;
|
||||
|
||||
/// How long the death screen's confirm ignores cancelling (`ConfirmScreen`).
|
||||
const DEATH_CONFIRM_WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -185,6 +236,9 @@ mod hardware {
|
||||
pub(in crate::game) fn state(&self, _: PadId) -> PadState {
|
||||
PadState::default()
|
||||
}
|
||||
pub(in crate::game) fn peaks(&self, _: PadId) -> [[f32; 2]; 2] {
|
||||
[[0.0; 2]; 2]
|
||||
}
|
||||
pub(in crate::game) fn name(&self, _: PadId) -> String {
|
||||
String::new()
|
||||
}
|
||||
@@ -225,10 +279,18 @@ impl Pads {
|
||||
}
|
||||
/// Splitscreen: this player's pad state, from `source`. Another pad, or
|
||||
/// none: the old one's A going away isn't a release.
|
||||
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
|
||||
/// `peaks`: the furthest its sticks leant since the last feed (`Pads::peaks`).
|
||||
pub(super) fn feed_from(
|
||||
&mut self,
|
||||
source: Option<PadId>,
|
||||
state: PadState,
|
||||
peaks: [[f32; 2]; 2],
|
||||
) {
|
||||
self.peaks = peaks;
|
||||
if self.feed_source != source {
|
||||
self.feed_source = source;
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
self.feed = Some(state);
|
||||
@@ -246,19 +308,25 @@ impl Pads {
|
||||
/// A replay ended: let go of everything it held.
|
||||
pub(super) fn clear_injected(&mut self) {
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
self.injected = PadState::default();
|
||||
}
|
||||
|
||||
/// Include releases delayed until the next simulation tick.
|
||||
pub(super) fn holds_action(&self, settings: &gamepad::PadSettings, action: &str) -> bool {
|
||||
settings.binding(action).is_some_and(|button| {
|
||||
self.edges.is_held(button) || self.late_releases.contains(&button)
|
||||
/// Whether a held button's press went to `action`, including releases delayed until the next
|
||||
/// simulation tick. What the press reached, not what the button is bound to now: a press the
|
||||
/// radial menu swallowed holds nothing, while a sneak held since before the menu opened is
|
||||
/// still held through it.
|
||||
pub(super) fn holds_action(&self, action: &str) -> bool {
|
||||
Button::ALL.into_iter().any(|button| {
|
||||
self.actions[button as usize].contains(&action)
|
||||
&& (self.edges.is_held(button) || self.late_releases.contains(&button))
|
||||
})
|
||||
}
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a
|
||||
/// dimension change): its health is not compared with the old one's.
|
||||
/// The radial menu closes with it, in `Game::pad_player_replaced`.
|
||||
pub(super) fn player_replaced(&mut self) {
|
||||
self.health.forget();
|
||||
}
|
||||
@@ -291,7 +359,7 @@ impl Game<'_> {
|
||||
screen: self.key_screen(),
|
||||
focus_navigation: self.pad_navigation_mode(),
|
||||
focused: self.focused,
|
||||
osk: self.pad.menu.osk.is_some(),
|
||||
osk: self.pad_keyboard_captures(),
|
||||
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
|
||||
enabled: self.settings.controller.enabled,
|
||||
}
|
||||
@@ -311,6 +379,10 @@ impl Game<'_> {
|
||||
if !self.settings.controller.enabled {
|
||||
self.pad_output_off();
|
||||
self.pad_cancel_inputs(event_loop);
|
||||
// The radial menu closed with the pad: its overlay fades out, as for a cancel.
|
||||
self.pad
|
||||
.radial_look
|
||||
.observe(None, std::time::Instant::now());
|
||||
self.pad.state = PadState::default();
|
||||
self.pad.in_use = false;
|
||||
// The keyboard's menu focus follows its widget all the same.
|
||||
@@ -364,6 +436,7 @@ impl Game<'_> {
|
||||
}
|
||||
if self.pad.blocked_sticks[i] || !self.pad_focused() {
|
||||
*stick = [0.0; 2];
|
||||
self.pad.peaks[i] = [0.0; 2];
|
||||
}
|
||||
}
|
||||
for button in std::mem::take(&mut self.pad.taps) {
|
||||
@@ -377,6 +450,7 @@ impl Game<'_> {
|
||||
self.pad.keyboard_took_over = false;
|
||||
}
|
||||
self.pad.state = state;
|
||||
self.pad_radial_frame();
|
||||
self.pad_drop_frame();
|
||||
self.pad_output_frame();
|
||||
// Damage from any source, here or on a server; nothing counts on
|
||||
@@ -390,14 +464,24 @@ impl Game<'_> {
|
||||
self.pad_rumble(Rumble::Hurt(damage));
|
||||
}
|
||||
let ticking = self.title.is_none() && self.screen == Screen::Playing && !self.pause;
|
||||
let mut due = Vec::new();
|
||||
for button in std::mem::take(&mut self.pad.late_releases) {
|
||||
if !ticking || self.pad.pressed_at[button as usize] != Some(self.ticks) {
|
||||
self.pad_button_logged(event_loop, button, false);
|
||||
due.push(button);
|
||||
} else {
|
||||
self.pad.late_releases.push(button);
|
||||
}
|
||||
}
|
||||
for (button, down) in edges {
|
||||
// The steps, in order: a press in the same poll as the opener's release, held back or
|
||||
// not, gets there first, so B still cancels.
|
||||
for step in radial_play::poll_order(due, edges, self.pad_radial_captures()) {
|
||||
let (button, down) = match step {
|
||||
radial_play::PollStep::Late(button) => {
|
||||
self.pad_button_logged(event_loop, button, false);
|
||||
continue;
|
||||
}
|
||||
radial_play::PollStep::Edge(button, down) => (button, down),
|
||||
};
|
||||
self.pad_edge(event_loop, button, down);
|
||||
let context = self.pad_context();
|
||||
if let Some(old) = self.pad.context.filter(|old| *old != context) {
|
||||
@@ -430,10 +514,12 @@ impl Game<'_> {
|
||||
|
||||
/// End held actions and captures before another context receives input.
|
||||
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// Before the releases below: a cancelled press must not click, nor a cancelled trigger
|
||||
// change the recipe book's category as it comes up.
|
||||
// Before the releases below: a cancelled press must not click, a cancelled trigger
|
||||
// change the recipe book's category as it comes up, nor a cancelled radial menu
|
||||
// commit when its opener comes up.
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.menu.trigger = None;
|
||||
self.pad.radial.cancel();
|
||||
self.direct_craft_stop();
|
||||
let held_sticks = [
|
||||
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
|
||||
@@ -501,7 +587,19 @@ impl Game<'_> {
|
||||
if down && !self.pad_focused() {
|
||||
return Ok(());
|
||||
}
|
||||
if !down {
|
||||
self.pad_radial_button_up(button);
|
||||
}
|
||||
// While the radial menu is open its presses are its own, but for the triggers'.
|
||||
if down && self.pad_radial_captures() && self.pad_radial_button(button)? {
|
||||
return Ok(());
|
||||
}
|
||||
let playing = !self.pad_in_menu();
|
||||
// A thumb rolling off a D-pad opener: its press, and so its release, reach nothing.
|
||||
if down && playing && self.pad_radial_settling(button) {
|
||||
self.pad.actions[button as usize].clear();
|
||||
return Ok(());
|
||||
}
|
||||
if down && playing && !self.captured && self.focused {
|
||||
// Like a click into the window: take the pointer.
|
||||
self.capture(true);
|
||||
@@ -517,7 +615,16 @@ impl Game<'_> {
|
||||
// Releases always reach the mappings, so nothing stays held when a
|
||||
// screen opened in between.
|
||||
let actions = if down {
|
||||
let actions = self.settings.controller.actions_of(button);
|
||||
let mut actions = self.settings.controller.actions_of(button);
|
||||
// An opener sharing its button (marked in the binds list) has it to
|
||||
// itself wherever it can open, and gives it up elsewhere.
|
||||
if actions.contains(&gamepad::RADIAL) {
|
||||
if self.pad_radial_can_open() {
|
||||
actions = vec![gamepad::RADIAL];
|
||||
} else {
|
||||
actions.retain(|action| *action != gamepad::RADIAL);
|
||||
}
|
||||
}
|
||||
self.pad.actions[button as usize] = actions.clone();
|
||||
actions
|
||||
} else {
|
||||
@@ -537,6 +644,14 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
match route {
|
||||
Route::Radial => {
|
||||
if down {
|
||||
self.pad_radial_open();
|
||||
Ok(())
|
||||
} else {
|
||||
self.pad_radial_release()
|
||||
}
|
||||
}
|
||||
Route::HotbarStep { next } => {
|
||||
if down {
|
||||
self.hotbar_step(next);
|
||||
@@ -588,6 +703,13 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// KeyboardHandler: F3 alone, let go, shows or hides the debug screen.
|
||||
Route::DebugOverlay => {
|
||||
if down {
|
||||
self.debug = !self.debug;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Route::Held => Ok(()),
|
||||
Route::Mouse(button) => {
|
||||
let playing = self.title.is_none() && self.screen == Screen::Playing;
|
||||
@@ -618,7 +740,7 @@ impl Game<'_> {
|
||||
|
||||
/// Each poll: a held drop button, as a held Q's key repeats reach the mapping.
|
||||
fn pad_drop_frame(&mut self) {
|
||||
if self.pad_in_menu() || self.screen_over_playing() {
|
||||
if self.pad_in_menu() || self.screen_over_playing() || self.pad_radial_captures() {
|
||||
return;
|
||||
}
|
||||
let interval = Duration::from_secs_f32(self.settings.controller.repeat_interval);
|
||||
@@ -673,7 +795,11 @@ impl Game<'_> {
|
||||
/// The left stick as the move vector, while playing.
|
||||
pub(super) fn pad_move(&self) -> Option<[f32; 2]> {
|
||||
let settings = &self.settings.controller;
|
||||
if !settings.enabled || self.pad_in_menu() || !self.pad_focused() {
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(0))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
@@ -682,7 +808,12 @@ impl Game<'_> {
|
||||
/// The right stick turns the view, once per frame (`Entity.turn`).
|
||||
pub(super) fn pad_look(&mut self, seconds: f32) {
|
||||
let settings = &self.settings.controller;
|
||||
if !settings.enabled || self.pad_in_menu() || self.player.is_none() || !self.pad_focused() {
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| self.player.is_none()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A long frame (a hitch) must not spin the view.
|
||||
@@ -786,6 +917,7 @@ impl Game<'_> {
|
||||
json!({ "title": title, "body": body })
|
||||
}),
|
||||
"menu": self.pad_menu_status(),
|
||||
"radial": self.pad_radial_status(),
|
||||
"guide": self.pad_guide_status(),
|
||||
"held": held,
|
||||
"left": s.left,
|
||||
@@ -823,6 +955,7 @@ impl Game<'_> {
|
||||
}
|
||||
"release" => {
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.radial.cancel();
|
||||
self.direct_craft_stop();
|
||||
self.pad.injected = PadState::default();
|
||||
self.pad.taps.clear();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Which of the overlays over play owns the controller now, for the checks that ask about the
|
||||
//! on-screen keyboard and the radial menu. The prompts' own checks still call `prompt_open`
|
||||
//! directly (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`, `pad/guide.rs`); here a prompt only
|
||||
//! outranks the radial menu.
|
||||
//!
|
||||
//! In priority order, highest first ([`PadCapture`]): the on-screen keyboard, then a prompt over
|
||||
//! the world (a server dialog, the code of conduct, a link, command or resource pack prompt),
|
||||
//! then the radial menu. Only one owns the pad at a time; the radial menu never opens under the
|
||||
//! others and closes when one comes up (`pad_radial_can_open`, `pad_radial_frame`), so the order
|
||||
//! only settles the poll in which both are up. Screens are not overlays: `pad_in_menu` says
|
||||
//! when one has the pad.
|
||||
use super::super::Game;
|
||||
|
||||
/// An overlay that owns the controller, highest priority first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::game) enum PadCapture {
|
||||
/// The on-screen keyboard: every press types or moves on it.
|
||||
Keyboard,
|
||||
/// A prompt over the world, answered before anything behind it.
|
||||
Prompt,
|
||||
/// The radial menu, held open in play: its stick selects, its buttons page and cancel.
|
||||
Radial,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The overlay that owns the pad now, if one does.
|
||||
pub(in crate::game) fn pad_capture(&self) -> Option<PadCapture> {
|
||||
if self.pad.menu.osk.is_some() {
|
||||
Some(PadCapture::Keyboard)
|
||||
} else if self.prompt_open() {
|
||||
Some(PadCapture::Prompt)
|
||||
} else if self.pad.radial.is_open() {
|
||||
Some(PadCapture::Radial)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard owns the pad.
|
||||
pub(in crate::game) fn pad_keyboard_captures(&self) -> bool {
|
||||
self.pad_capture() == Some(PadCapture::Keyboard)
|
||||
}
|
||||
|
||||
/// The radial menu owns the pad: its stick doesn't walk or turn the view, its buttons are its
|
||||
/// own, and a bumped mouse does nothing. What is drawn gives way only once it shows
|
||||
/// (`pad_radial_shown`): a quick tap draws nothing.
|
||||
pub(in crate::game) fn pad_radial_captures(&self) -> bool {
|
||||
self.pad_capture() == Some(PadCapture::Radial)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ impl Game<'_> {
|
||||
&& settings.guide_detail != GuideDetail::Off
|
||||
&& self.pad.in_use
|
||||
&& !self.pad.keyboard_took_over
|
||||
// Hidden once the radial menu shows, not on the opener's press: a quick tap draws
|
||||
// nothing, so the guide doesn't blink.
|
||||
&& !self.pad_radial_shown()
|
||||
&& !self.pad_in_menu()
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
@@ -118,7 +121,7 @@ impl Game<'_> {
|
||||
|| self.pad.keyboard_took_over
|
||||
|| self.prompt_open()
|
||||
|| self.title.is_some()
|
||||
|| self.pad.menu.osk.is_some()
|
||||
|| self.pad_keyboard_captures()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,8 +223,14 @@ impl Pads {
|
||||
return PadState::default();
|
||||
};
|
||||
let active = self.devices.active;
|
||||
self.peaks = [[0.0; 2]; 2];
|
||||
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
|
||||
use gilrs::EventType as E;
|
||||
// The furthest each stick of the active pad leant between the events, which a read
|
||||
// after them all misses (`Pads::peaks`).
|
||||
if matches!(event, E::AxisChanged(..)) && self.devices.active == Some(id) {
|
||||
keep_peaks(&mut self.peaks, sticks_of(&gilrs.gamepad(id)));
|
||||
}
|
||||
match event {
|
||||
E::Connected => {
|
||||
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
|
||||
@@ -265,6 +271,7 @@ impl Pads {
|
||||
// Another pad, or none: its A going away isn't a release.
|
||||
if self.devices.active != active {
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
let Some(pad) = self
|
||||
@@ -284,6 +291,16 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest each stick leant: `sticks` (left, right) where they lean further than `peaks`.
|
||||
fn keep_peaks(peaks: &mut [[f32; 2]; 2], sticks: [[f32; 2]; 2]) {
|
||||
use horizon_client_input::radial::stick_length;
|
||||
for (peak, stick) in peaks.iter_mut().zip(sticks) {
|
||||
if stick_length(stick) > stick_length(*peak) {
|
||||
*peak = stick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a stick or trigger has to go for its pad to become the active one.
|
||||
const TAKEOVER: f32 = 0.5;
|
||||
|
||||
@@ -329,6 +346,13 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
|
||||
state.set(button, pad.is_pressed(named));
|
||||
}
|
||||
}
|
||||
[state.left, state.right] = sticks_of(pad);
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
}
|
||||
|
||||
/// A pad's sticks now (left, right).
|
||||
fn sticks_of(pad: &gilrs::Gamepad) -> [[f32; 2]; 2] {
|
||||
let axis = |a| pad.value(a);
|
||||
use gilrs::Axis as A;
|
||||
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
|
||||
@@ -341,14 +365,12 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
|
||||
sdl_mapped,
|
||||
);
|
||||
let (left, right) = sdl_row::sticks(pad, left, right);
|
||||
state.left = left;
|
||||
state.right = if hid_layout::right_stick_on_z(pad) {
|
||||
let right = if hid_layout::right_stick_on_z(pad) {
|
||||
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
|
||||
} else {
|
||||
right
|
||||
};
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
[left, right]
|
||||
}
|
||||
|
||||
/// A controller, as splitscreen tells them apart.
|
||||
@@ -362,6 +384,8 @@ pub(in crate::game) struct PadRouter {
|
||||
started: bool,
|
||||
/// Vibrations playing while several play, each until its end.
|
||||
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
|
||||
/// The furthest each pad's sticks leant since the last `events` (`Pads::peaks`).
|
||||
peaks: Vec<(PadId, [[f32; 2]; 2])>,
|
||||
}
|
||||
|
||||
impl PadRouter {
|
||||
@@ -445,7 +469,15 @@ impl PadRouter {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
self.peaks.clear();
|
||||
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
|
||||
if matches!(event, gilrs::EventType::AxisChanged(..)) {
|
||||
let sticks = sticks_of(&gilrs.gamepad(id));
|
||||
match self.peaks.iter_mut().find(|(pad, _)| *pad == id) {
|
||||
Some((_, peaks)) => keep_peaks(peaks, sticks),
|
||||
None => self.peaks.push((id, sticks)),
|
||||
}
|
||||
}
|
||||
match event {
|
||||
gilrs::EventType::ButtonPressed(pressed, _) => {
|
||||
let crossed = face_buttons::crossed(&gilrs.gamepad(id));
|
||||
@@ -481,6 +513,14 @@ impl PadRouter {
|
||||
.map_or([0; 16], |g| g.gamepad(id).uuid())
|
||||
}
|
||||
|
||||
/// The furthest the pad's sticks leant since the last `events`.
|
||||
pub(in crate::game) fn peaks(&self, id: PadId) -> [[f32; 2]; 2] {
|
||||
self.peaks
|
||||
.iter()
|
||||
.find(|(pad, _)| *pad == id)
|
||||
.map_or([[0.0; 2]; 2], |(_, peaks)| *peaks)
|
||||
}
|
||||
|
||||
/// The pad's state now (released when it is gone).
|
||||
pub(in crate::game) fn state(&self, id: PadId) -> PadState {
|
||||
self.gilrs
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
//! The radial menu in play (`horizon-client-input/src/radial.rs` has its rules,
|
||||
//! `radial_overlay.rs` draws it): what opens and closes it, what the pad's presses do while it is
|
||||
//! open, and what a commit runs.
|
||||
use super::{Game, Result, UI_CLICK_VOLUME};
|
||||
use horizon_client::gamepad::MenuJob;
|
||||
use horizon_client::gamepad::{self, Button, RadialMode, RadialStick, Rumble};
|
||||
use horizon_client::pad_glyph::Glyph;
|
||||
use horizon_client::text::Text;
|
||||
use horizon_client_input::radial::{self, RadialAction, Slot};
|
||||
use horizon_client_render::radial_overlay::{self, Dial, Seen, Toggles, View};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The D-pad's buttons, which settle after the menu closes on a D-pad opener.
|
||||
const DPAD: [Button; 4] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
];
|
||||
|
||||
/// One step of a poll's button changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum PollStep {
|
||||
/// A release held back from the poll before, until a simulation tick had passed.
|
||||
Late(Button),
|
||||
Edge(Button, bool),
|
||||
}
|
||||
|
||||
/// The order a poll acts on its changes in: the releases held back first, then this poll's
|
||||
/// presses and releases. While the radial menu is open its presses come before every release, the
|
||||
/// held-back ones too: B in the same poll as the opener's release (however late) still cancels.
|
||||
pub(super) fn poll_order(
|
||||
late: Vec<Button>,
|
||||
edges: Vec<(Button, bool)>,
|
||||
radial_open: bool,
|
||||
) -> Vec<PollStep> {
|
||||
let late = late.into_iter().map(PollStep::Late);
|
||||
let edges = edges.into_iter().map(|(b, down)| PollStep::Edge(b, down));
|
||||
if !radial_open {
|
||||
return late.chain(edges).collect();
|
||||
}
|
||||
let (presses, releases): (Vec<_>, Vec<_>) =
|
||||
edges.partition(|step| matches!(step, PollStep::Edge(_, true)));
|
||||
presses.into_iter().chain(late).chain(releases).collect()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the radial menu selects with this stick (0 left, 1 right): while it is open that
|
||||
/// stick neither walks nor turns the view.
|
||||
pub(in crate::game) fn pad_radial_selects(&self, stick: usize) -> bool {
|
||||
match self.settings.controller.radial_stick {
|
||||
RadialStick::Right => stick == 1,
|
||||
RadialStick::Left => stick == 0,
|
||||
RadialStick::Either => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Each stick's tilts, from its own deadzone (left, right).
|
||||
fn pad_radial_tilts(&self) -> [radial::Tilt; 2] {
|
||||
let settings = &self.settings.controller;
|
||||
[settings.move_deadzone, settings.look_deadzone].map(radial::Tilt::for_deadzone)
|
||||
}
|
||||
|
||||
/// The stick that selects now (0 left, 1 right), where it is, the furthest it leant since the
|
||||
/// last poll, and its tilts. With Either, the stick that last leant past the select tilt.
|
||||
fn pad_radial_selector(&self) -> (usize, [f32; 2], [f32; 2], radial::Tilt) {
|
||||
let index = match self.settings.controller.radial_stick {
|
||||
RadialStick::Right => 1,
|
||||
RadialStick::Left => 0,
|
||||
RadialStick::Either => self.pad.radial_either.unwrap_or(1),
|
||||
};
|
||||
let stick = [self.pad.state.left, self.pad.state.right][index];
|
||||
(
|
||||
index,
|
||||
stick,
|
||||
self.pad.peaks[index],
|
||||
self.pad_radial_tilts()[index],
|
||||
)
|
||||
}
|
||||
|
||||
/// With Either: a stick that has just leant past its select tilt (or did between two polls)
|
||||
/// takes over the selecting. While the menu is open, only one that has been at rest since
|
||||
/// the open: the walking thumb easing forward on its frozen stick isn't aiming. The stick
|
||||
/// that takes over selects at once, whatever the other was waiting for.
|
||||
fn pad_radial_follow_either(&mut self) {
|
||||
let tilts = self.pad_radial_tilts();
|
||||
let sticks = [self.pad.state.left, self.pad.state.right];
|
||||
let open = self.pad.radial.is_open();
|
||||
for i in 0..2 {
|
||||
if radial::stick_length(sticks[i]) < tilts[i].rest {
|
||||
self.pad.radial_rested[i] = true;
|
||||
}
|
||||
let reach =
|
||||
radial::stick_length(sticks[i]).max(radial::stick_length(self.pad.peaks[i]));
|
||||
let over = reach >= tilts[i].select;
|
||||
let may = !open || self.pad.radial_rested[i];
|
||||
if over && !self.pad.radial_over[i] && may {
|
||||
let before = self.pad.radial_either.replace(i).unwrap_or(1);
|
||||
if open && before != i {
|
||||
self.pad.radial.took_over();
|
||||
}
|
||||
}
|
||||
self.pad.radial_over[i] = over;
|
||||
}
|
||||
}
|
||||
|
||||
/// Screens, the on-screen keyboard and the prompts over gameplay come first. It isn't a
|
||||
/// screen: the world goes on behind it, here and on a server.
|
||||
pub(in crate::game) fn pad_radial_can_open(&self) -> bool {
|
||||
!self.pad_in_menu()
|
||||
&& !self.pad_keyboard_captures()
|
||||
&& !self.screen_over_playing()
|
||||
&& self.player.is_some()
|
||||
&& self.sleeping.is_none()
|
||||
&& !self.pause
|
||||
}
|
||||
|
||||
/// The opener went down: always on the first page.
|
||||
pub(in crate::game) fn pad_radial_open(&mut self) {
|
||||
let tilts = self.pad_radial_tilts();
|
||||
let sticks = [self.pad.state.left, self.pad.state.right];
|
||||
self.pad.radial_rested =
|
||||
std::array::from_fn(|i| radial::stick_length(sticks[i]) < tilts[i].rest);
|
||||
let (index, stick, _, tilt) = self.pad_radial_selector();
|
||||
self.pad.radial.open(index, stick, tilt, Instant::now());
|
||||
self.pad.radial_face = self.pad_radial_face();
|
||||
self.pad.radial_page_said = false;
|
||||
self.pad.radial_said.reset();
|
||||
}
|
||||
|
||||
/// The opener came up: held, it commits; in toggle mode the menu stays open, until A, the
|
||||
/// opener again or B.
|
||||
pub(in crate::game) fn pad_radial_release(&mut self) -> Result<()> {
|
||||
if !self.pad.radial.is_open() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.controller.radial_mode == RadialMode::Toggle && !self.pad.radial.latched()
|
||||
{
|
||||
self.pad.radial.latch();
|
||||
return Ok(());
|
||||
}
|
||||
self.pad_radial_commit(false)
|
||||
}
|
||||
|
||||
/// Whether `action` would do anything now. Only what is known here, cheaply and for certain:
|
||||
/// the emote wheel opens only with cosmetics on, Quick Actions only when the server gave a
|
||||
/// dialog for it, and Social only on a server (`Gui.handleKeybinds`). Everything else on the
|
||||
/// list always runs.
|
||||
fn pad_radial_runs(&self, action: RadialAction) -> bool {
|
||||
self.pad_radial_why(action).is_none()
|
||||
}
|
||||
|
||||
/// Why `action` wouldn't do anything now, as a language key for the overlay's hub; `None`
|
||||
/// when it runs.
|
||||
fn pad_radial_why(&self, action: RadialAction) -> Option<&'static str> {
|
||||
match action {
|
||||
RadialAction::Emote if !super::super::cosmetics::enabled() => Some(WHY_COSMETICS),
|
||||
RadialAction::QuickActions if !self.has_quick_actions() => Some(WHY_QUICK_ACTIONS),
|
||||
RadialAction::SocialInteractions if !self.is_multiplayer_server() => Some(WHY_SOCIAL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the page's slots would run now: the overlay dims the others. A missing action
|
||||
/// never runs.
|
||||
pub(in crate::game) fn pad_radial_available(&self) -> [bool; radial::SLOTS] {
|
||||
self.pad_radial_available_on(self.pad.radial.page())
|
||||
}
|
||||
|
||||
/// `pad_radial_available` on page `page`.
|
||||
fn pad_radial_available_on(&self, page: usize) -> [bool; radial::SLOTS] {
|
||||
let page = self.settings.controller.radial.page(page);
|
||||
page.slots().map(|slot| match slot {
|
||||
Slot::Empty => true,
|
||||
Slot::Action(action) => self.pad_radial_runs(*action),
|
||||
Slot::Missing(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A slot that holds something that would run: only those tick as the stick reaches them.
|
||||
fn pad_radial_fills(&self, slot: usize) -> bool {
|
||||
let page = self.settings.controller.radial.page(self.pad.radial.page());
|
||||
!page.slot(slot).is_empty() && self.pad_radial_available()[slot]
|
||||
}
|
||||
|
||||
/// Each poll, before its presses: the selecting stick, the D-pad's settle, a second page
|
||||
/// tick due; and while it's open, the stick, or the end of it if something took over (a bed,
|
||||
/// a death) in a way the context doesn't see.
|
||||
pub(in crate::game) fn pad_radial_frame(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.pad_radial_poll(now);
|
||||
let menu = &self.pad.radial;
|
||||
let seen = menu.shown(now).then(|| Seen {
|
||||
page: menu.page(),
|
||||
selected: menu.selected(),
|
||||
waiting: menu.waiting(),
|
||||
});
|
||||
self.pad.radial_look.observe(seen, now);
|
||||
}
|
||||
|
||||
/// `pad_radial_frame`'s work, before the overlay's look sees the result.
|
||||
fn pad_radial_poll(&mut self, now: Instant) {
|
||||
self.pad
|
||||
.radial
|
||||
.sample([self.pad.state.left, self.pad.state.right], now);
|
||||
if self.settings.controller.radial_stick == RadialStick::Either {
|
||||
self.pad_radial_follow_either();
|
||||
}
|
||||
let (_, stick, peak, tilt) = self.pad_radial_selector();
|
||||
if let Some((at, rumble)) = self.pad.radial_rumble_due
|
||||
&& now >= at
|
||||
{
|
||||
self.pad.radial_rumble_due = None;
|
||||
self.pad_rumble(rumble);
|
||||
}
|
||||
if !self.pad.radial.is_open() {
|
||||
return;
|
||||
}
|
||||
if !self.pad_radial_can_open() {
|
||||
self.pad_radial_cancel();
|
||||
return;
|
||||
}
|
||||
let was = self.pad.radial.selected();
|
||||
self.pad.radial.update_with_peak(stick, peak, tilt, now);
|
||||
// A tick as the stick reaches a slot that would run, so a flick can be felt.
|
||||
let selected = self.pad.radial.selected();
|
||||
if selected != was && selected.is_some_and(|slot| self.pad_radial_fills(slot)) {
|
||||
self.pad_rumble(Rumble::RadialSlot);
|
||||
}
|
||||
self.pad_radial_narrate(now);
|
||||
}
|
||||
|
||||
/// With the narrator on: the page's name once the overlay shows and after each page turn,
|
||||
/// and the slot's caption once the selection settles.
|
||||
fn pad_radial_narrate(&mut self, now: Instant) {
|
||||
if horizon_narrator::status() == horizon_narrator::Status::Off
|
||||
|| !self.pad.radial.shown(now)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = self.pad.radial.page();
|
||||
if !std::mem::replace(&mut self.pad.radial_page_said, true) {
|
||||
let name = horizon_client_render::radial_overlay::page_label(pages.page(page));
|
||||
self.say_system_now(&Text::literal(name));
|
||||
}
|
||||
let at = self.pad.radial.selected().map(|slot| (page, slot));
|
||||
if let Some((page, slot)) = self.pad.radial_said.poll(at, now) {
|
||||
let slot = pages.page(page).slot(slot);
|
||||
let caption = horizon_client_render::radial_overlay::slot_caption(slot)
|
||||
.unwrap_or_else(|| horizon_client::lang::get("horizon.radial.empty"));
|
||||
self.say_system_now(&Text::literal(caption));
|
||||
}
|
||||
}
|
||||
|
||||
/// A press while it's open: bumpers turn the page, B and Start cancel; in toggle mode A or
|
||||
/// the opener again commits. A trigger cancels and goes on to its own action (`false`: not
|
||||
/// used up), so a fight goes on; no other press reaches a mapping.
|
||||
pub(in crate::game) fn pad_radial_button(&mut self, button: Button) -> Result<bool> {
|
||||
let settings = &self.settings.controller;
|
||||
let opener = settings.binding(gamepad::RADIAL);
|
||||
let toggle = settings.radial_mode == RadialMode::Toggle;
|
||||
let bumpers = [Button::LeftBumper, Button::RightBumper];
|
||||
match button {
|
||||
// Toggle mode, latched open: the opener again commits.
|
||||
b if Some(b) == opener => {
|
||||
if self.pad.radial.latched() {
|
||||
self.pad_radial_commit(false)?;
|
||||
}
|
||||
}
|
||||
// The thumb slid off the stick to A: what it had settled on, not what it crossed.
|
||||
Button::South if toggle => self.pad_radial_commit(true)?,
|
||||
b if bumpers.contains(&b) => {
|
||||
// With a bumper for the opener, the other turns the pages on.
|
||||
let forward = opener.is_some_and(|o| bumpers.contains(&o)) || b == bumpers[1];
|
||||
self.pad_radial_turn(forward);
|
||||
}
|
||||
Button::East | Button::Start => self.pad_radial_cancel(),
|
||||
Button::LeftTrigger | Button::RightTrigger => {
|
||||
self.pad_radial_cancel();
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A page turn: a tick, and a second one when the pages went round an end.
|
||||
fn pad_radial_turn(&mut self, forward: bool) {
|
||||
let (_, stick, _, tilt) = self.pad_radial_selector();
|
||||
let pages = self.settings.controller.radial.len();
|
||||
let Some(wrapped) = self.pad.radial.turn_page(forward, stick, tilt, pages) else {
|
||||
return;
|
||||
};
|
||||
self.pad.radial_look.turned(forward);
|
||||
self.pad_rumble(Rumble::RadialPage);
|
||||
if wrapped {
|
||||
self.pad.radial_rumble_due =
|
||||
Some((Instant::now() + radial::WRAP_TICK_GAP, Rumble::RadialWrap));
|
||||
}
|
||||
self.pad.radial_page_said = false;
|
||||
self.pad.radial_said.reset();
|
||||
}
|
||||
|
||||
/// The selected action, pressed and let go, once. Every entry acts on its press, and nothing
|
||||
/// stays held behind it. One that can't run now does nothing at all, and a release that runs
|
||||
/// nothing after something was selected gives a soft buzz.
|
||||
/// `by_a`: toggle mode's A, pressed by the thumb that was selecting (`RadialMenu::commit_settled`).
|
||||
fn pad_radial_commit(&mut self, by_a: bool) -> Result<()> {
|
||||
let page = self.pad.radial.page();
|
||||
let aimed = self.pad.radial.aimed();
|
||||
let selecting = self.pad_radial_selector().0;
|
||||
let now = Instant::now();
|
||||
let slot = if by_a {
|
||||
self.pad.radial.commit_settled(now)
|
||||
} else {
|
||||
self.pad.radial.commit(now)
|
||||
};
|
||||
self.pad_radial_closed(selecting);
|
||||
// Social, dimmed in single player, still goes through its key there: vanilla's action
|
||||
// bar says it isn't available (`Gui.handleKeybinds`).
|
||||
let action = slot
|
||||
.and_then(|slot| {
|
||||
self.settings
|
||||
.controller
|
||||
.radial
|
||||
.page(page)
|
||||
.slot(slot)
|
||||
.action()
|
||||
})
|
||||
.filter(|action| {
|
||||
self.pad_radial_runs(*action) || *action == RadialAction::SocialInteractions
|
||||
});
|
||||
let Some(action) = action else {
|
||||
if aimed {
|
||||
self.pad_rumble(Rumble::RadialNothing);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
self.pad.radial_runs.push(action.id());
|
||||
if let Some(slot) = slot {
|
||||
self.pad.radial_look.ran(slot);
|
||||
}
|
||||
let shoots = matches!(action, RadialAction::Screenshot | RadialAction::ToggleGui);
|
||||
let action = action.action();
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, UI_CLICK_VOLUME, 1.0);
|
||||
// The release runs whatever the press did, so no key stays held.
|
||||
let pressed = self.pad_action(action, true);
|
||||
let released = self.pad_action(action, false);
|
||||
// What opened a screen, takes a screenshot or hides the HUD gets the view to itself at
|
||||
// once: no flash or fade over it.
|
||||
if shoots || self.pad_in_menu() || self.screen_over_playing() {
|
||||
self.pad.radial_look.cut();
|
||||
}
|
||||
pressed.and(released)
|
||||
}
|
||||
|
||||
/// B, Start, a trigger, a key or a click, a takeover the context doesn't see: the menu closes
|
||||
/// and nothing runs. (The window's focus is in the context: losing it cancels in
|
||||
/// `pad_cancel_inputs`.)
|
||||
pub(in crate::game) fn pad_radial_cancel(&mut self) {
|
||||
if self.pad.radial.is_open() {
|
||||
let selecting = self.pad_radial_selector().0;
|
||||
self.pad.radial.cancel();
|
||||
self.pad_radial_closed(selecting);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a commit or a cancel: the stick that was selecting (`selecting`, 0 left, 1 right),
|
||||
/// and with Either both, moves the player or the view only once it has been let go; a held drop starts its wait
|
||||
/// again rather than throwing the rest of the stack the moment the menu is gone; and on a
|
||||
/// D-pad opener the D-pad settles.
|
||||
fn pad_radial_closed(&mut self, selecting: usize) {
|
||||
let now = Instant::now();
|
||||
// With Either neither stick moved anything while it was open: both wait.
|
||||
let either = self.settings.controller.radial_stick == RadialStick::Either;
|
||||
for i in 0..2 {
|
||||
if i != selecting && !either {
|
||||
continue;
|
||||
}
|
||||
let deadzone = [
|
||||
self.settings.controller.move_deadzone,
|
||||
self.settings.controller.look_deadzone,
|
||||
][i];
|
||||
let stick = [self.pad.state.left, self.pad.state.right][i];
|
||||
if gamepad::radial(stick, deadzone) != [0.0; 2] {
|
||||
self.pad.blocked_sticks[i] = true;
|
||||
self.pad.peaks[i] = [0.0; 2];
|
||||
match i {
|
||||
0 => self.pad.state.left = [0.0; 2],
|
||||
_ => self.pad.state.right = [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.pad.held_drop.is_some() {
|
||||
let delay = Duration::from_secs_f32(self.settings.controller.repeat_delay);
|
||||
self.pad.held_drop = Some(gamepad::HeldDrop::new(now, delay));
|
||||
}
|
||||
if self
|
||||
.settings
|
||||
.controller
|
||||
.binding(gamepad::RADIAL)
|
||||
.is_some_and(|b| DPAD.contains(&b))
|
||||
{
|
||||
self.pad.radial_settle.closed(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// A D-pad press in play starting just after the menu closed on a D-pad opener: a thumb
|
||||
/// rolling off it. It is ignored, and so is its release; the opener's own isn't.
|
||||
pub(in crate::game) fn pad_radial_settling(&self, button: Button) -> bool {
|
||||
let opener = self.settings.controller.binding(gamepad::RADIAL);
|
||||
let held = opener.is_some_and(|opener| self.pad.state.held(opener));
|
||||
DPAD.contains(&button)
|
||||
&& self
|
||||
.pad
|
||||
.radial_settle
|
||||
.quiet(opener == Some(button), held, Instant::now())
|
||||
}
|
||||
|
||||
/// A button came up: the opener's release starts the D-pad's settle again.
|
||||
pub(in crate::game) fn pad_radial_button_up(&mut self, button: Button) {
|
||||
if self.settings.controller.binding(gamepad::RADIAL) == Some(button) {
|
||||
self.pad.radial_settle.opener_up(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// A new player entity (spawn, respawn, a server joined, a dimension change): its health is
|
||||
/// not compared with the old one's, and the radial menu closes as for a cancel, as a commit
|
||||
/// would run on the new body (a death with immediate respawn, the End's exit).
|
||||
pub(in crate::game) fn pad_player_replaced(&mut self) {
|
||||
self.pad.player_replaced();
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
|
||||
pub(in crate::game) fn pad_radial_status(&self) -> Value {
|
||||
let menu = &self.pad.radial;
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = pages.page(menu.page());
|
||||
json!({
|
||||
"open": menu.is_open(),
|
||||
"latched": menu.latched(),
|
||||
"page": menu.page(),
|
||||
"pages": pages.len(),
|
||||
"name": horizon_client_render::radial_overlay::page_label(page),
|
||||
"slots": page.slots().map(|slot| (!slot.is_empty()).then(|| slot.id())),
|
||||
"available": self.pad_radial_available(),
|
||||
"selected": menu.selected(),
|
||||
"aimed": menu.aimed(),
|
||||
"shown": menu.shown(Instant::now()),
|
||||
"drawn": self.pad_radial_drawn(self.hud_clock()).is_some(),
|
||||
// The overlay's glyphs as the player sees them: the bumpers that turn the pages and
|
||||
// the button that cancels, by their printed labels.
|
||||
"glyphs": {
|
||||
"pages": self.pad_radial_page_keys().map(|key| key.map(|glyph| glyph.label())),
|
||||
"cancel": self.menu_glyph(MenuJob::Back).label(),
|
||||
},
|
||||
"waiting": menu.waiting(),
|
||||
"selecting": self.pad_radial_selector().0,
|
||||
"mode": self.settings.controller.radial_mode.name(),
|
||||
"stick": self.settings.controller.radial_stick.name(),
|
||||
"runs": self.pad.radial_runs.last,
|
||||
"run_count": self.pad.radial_runs.count,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the pad draws over the HUD, last: the radial menu, then the pad's cursor or focus
|
||||
/// highlight and the on-screen keyboard over everything else (`draw_pad_cursor`).
|
||||
pub(in crate::game) fn draw_pad_over_hud(&self, batch: &mut horizon_client::hud::HudBatch) {
|
||||
self.draw_pad_radial(batch);
|
||||
self.draw_pad_cursor(batch);
|
||||
}
|
||||
|
||||
/// The overlay, over the HUD, in this player's view: while it shows, and while it fades out.
|
||||
fn draw_pad_radial(&self, batch: &mut horizon_client::hud::HudBatch) {
|
||||
let clock = self.hud_clock();
|
||||
let look = &self.pad.radial_look;
|
||||
let Some(seen) = self.pad_radial_drawn(clock) else {
|
||||
return;
|
||||
};
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = pages.page(seen.page);
|
||||
let why = page.slots().map(|slot| match slot {
|
||||
Slot::Action(action) => self.pad_radial_why(*action),
|
||||
_ => None,
|
||||
});
|
||||
let dial = Dial {
|
||||
pages,
|
||||
seen,
|
||||
available: self.pad_radial_available_on(seen.page),
|
||||
why,
|
||||
toggles: self.pad_radial_toggles(),
|
||||
page_keys: self.pad_radial_page_keys(),
|
||||
cancel: self.menu_glyph(MenuJob::Back),
|
||||
backing: (self.settings.text_background(radial_overlay::CHIP_BACKING) >> 24) as u8,
|
||||
room: &self.ui.room,
|
||||
};
|
||||
let painter = super::super::item_draw::IconPainter {
|
||||
game: self,
|
||||
face: self.pad.radial_face.as_deref(),
|
||||
};
|
||||
radial_overlay::draw_overlay(batch, &self.gui.atlas, &painter, &dial, look, clock);
|
||||
}
|
||||
|
||||
/// What the overlay draws at `clock`: while it shows, and while it fades out, but never a
|
||||
/// fade-out over a screen that came up meanwhile.
|
||||
fn pad_radial_drawn(&self, clock: horizon_client::hud_kit::motion::Clock) -> Option<Seen> {
|
||||
let look = &self.pad.radial_look;
|
||||
if !look.open() && (self.pad_in_menu() || self.screen_over_playing()) {
|
||||
return None;
|
||||
}
|
||||
look.showing(clock)
|
||||
}
|
||||
|
||||
/// The overlay is showing (not only fading out): it holds the view (`overlay_holds_view`).
|
||||
pub(in crate::game) fn pad_radial_shown(&self) -> bool {
|
||||
self.pad.radial_look.open()
|
||||
}
|
||||
|
||||
/// The toggles the overlay shows the state of.
|
||||
fn pad_radial_toggles(&self) -> Toggles {
|
||||
use super::super::perspective::CameraType;
|
||||
Toggles {
|
||||
view: match self.camera_type {
|
||||
CameraType::FirstPerson => View::FirstPerson,
|
||||
CameraType::ThirdPersonBack => View::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront => View::ThirdPersonFront,
|
||||
},
|
||||
hud_hidden: self.ui.hide_gui,
|
||||
cinematic: self.smooth_camera,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bumpers that turn the pages back and on, in this player's glyphs. With a bumper as the
|
||||
/// opener, only the other, which turns them on.
|
||||
fn pad_radial_page_keys(&self) -> [Option<Glyph>; 2] {
|
||||
let opener = self.settings.controller.binding(gamepad::RADIAL);
|
||||
let [back, on] = [MenuJob::PagePrev, MenuJob::PageNext];
|
||||
match opener {
|
||||
Some(Button::LeftBumper) => [None, Some(self.menu_glyph(on))],
|
||||
Some(Button::RightBumper) => [None, Some(self.menu_glyph(back))],
|
||||
_ => [Some(self.menu_glyph(back)), Some(self.menu_glyph(on))],
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's own skin, for the Emote slot's face: their entry in the player list, which
|
||||
/// single player has too (the window is its server thread's client). Before the entry or its
|
||||
/// texture has come, the default skin their id gives, as the player list draws it.
|
||||
fn pad_radial_face(&mut self) -> Option<String> {
|
||||
let me = self.remote.as_ref().map(|r| r.profile());
|
||||
let skin = match (me, self.remote_tab_players()) {
|
||||
(Some(me), Some((players, _, _))) => players
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == me)
|
||||
.map(|p| (me, p.skin)),
|
||||
_ => None,
|
||||
};
|
||||
let me = me?;
|
||||
Some(match skin {
|
||||
Some((me, skin)) => self.face_skin(me, &skin),
|
||||
None => horizon_client::tab_list::default_skin(me).to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The hub's reasons a slot can't run (language keys).
|
||||
const WHY_COSMETICS: &str = "horizon.radial.why.cosmetics";
|
||||
const WHY_QUICK_ACTIONS: &str = "horizon.radial.why.quick_actions";
|
||||
const WHY_SOCIAL: &str = "horizon.radial.why.social";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_press_in_the_poll_of_a_held_back_opener_release_comes_first_while_open() {
|
||||
let late = vec![Button::DPadRight];
|
||||
let edges = vec![(Button::East, true), (Button::South, false)];
|
||||
assert_eq!(
|
||||
poll_order(late.clone(), edges.clone(), true),
|
||||
[
|
||||
PollStep::Edge(Button::East, true),
|
||||
PollStep::Late(Button::DPadRight),
|
||||
PollStep::Edge(Button::South, false),
|
||||
]
|
||||
);
|
||||
// Closed, the held-back releases go first, then the changes as they came.
|
||||
assert_eq!(
|
||||
poll_order(late, edges, false),
|
||||
[
|
||||
PollStep::Late(Button::DPadRight),
|
||||
PollStep::Edge(Button::East, true),
|
||||
PollStep::Edge(Button::South, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl Game<'_> {
|
||||
pub(super) fn pad_action_glyphs_on(&self) -> bool {
|
||||
self.pad.in_use
|
||||
&& !self.pad.menu.keyboard
|
||||
&& self.pad.menu.osk.is_none()
|
||||
&& !self.pad_keyboard_captures()
|
||||
&& self.pad.menu.pos.is_some()
|
||||
&& self.pad_in_menu()
|
||||
}
|
||||
|
||||
@@ -495,10 +495,10 @@ impl Game<'_> {
|
||||
if self.prompt_open() {
|
||||
return self.pad_prompt_first(targets).or(targets.first().copied());
|
||||
}
|
||||
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
|
||||
// Back on a screen the pad left: where it was, as vanilla keeps a
|
||||
// screen's focused widget (Options, after its Done). The same widget
|
||||
// first: the rows under it can have moved.
|
||||
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
|
||||
if let Some(i) = self
|
||||
.pad
|
||||
.menu
|
||||
@@ -795,13 +795,16 @@ impl Game<'_> {
|
||||
}
|
||||
// A prompt over the screen answers first; no press reaches both.
|
||||
let prompt = self.prompt_open();
|
||||
// Bindings waiting for a button: this one (Start cancels).
|
||||
// Bindings, or the radial menu's opener row, waiting for a button: this one (Start
|
||||
// cancels).
|
||||
if !prompt
|
||||
&& self.screen == Screen::Options
|
||||
&& self.ui.options.controller_open()
|
||||
&& let Some(action) = self.ui.options.selected_pad_action.take()
|
||||
{
|
||||
if button != Button::Start {
|
||||
// Start cancels, and so does a button the action can't take (the radial menu's
|
||||
// opener under the selecting thumb or on B, `gamepad::opener_refused`): the binding
|
||||
// stays.
|
||||
if button != Button::Start && self.settings.controller.can_bind(action, button) {
|
||||
self.settings.controller.bind(action, Some(button));
|
||||
}
|
||||
return self.handle_option_events(vec![Event::Changed]);
|
||||
@@ -1145,6 +1148,8 @@ impl Game<'_> {
|
||||
}
|
||||
let focus = self.pad_reveal(focus);
|
||||
self.pad.menu.focus = Some(focus);
|
||||
// A step, not a press: a radial slot's press opens its list.
|
||||
self.ui.options.pad_clicks_step(true);
|
||||
if button == Button::DPadLeft {
|
||||
self.pad_with_modifier(KeyCode::ShiftLeft, |game| {
|
||||
game.pad_click_at(event_loop, focus.center(), false)
|
||||
@@ -1152,6 +1157,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.pad_click_at(event_loop, focus.center(), false);
|
||||
}
|
||||
self.ui.options.pad_clicks_step(false);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1848,7 +1854,16 @@ impl Game<'_> {
|
||||
// Two frames on, as after a step that scrolled: the first may predate the scroll.
|
||||
&& !reveal_pending(self.pad.menu.revealed, widgets::generation())
|
||||
{
|
||||
if let Some(focus) = self.pad.menu.focus {
|
||||
// A settings page that says where its focus opens (a radial slot's list on the slot's
|
||||
// action, the slot again after it), once its row is drawn.
|
||||
let opened = (self.screen == Screen::Options)
|
||||
.then(|| self.ui.options.shell_opened_focus())
|
||||
.flatten()
|
||||
.and_then(|rect| gamepad::refind(rect, &rects, 2.0));
|
||||
if let Some(i) = opened {
|
||||
self.ui.options.clear_shell_opened_focus();
|
||||
self.pad.menu.focus = Some(targets[i]);
|
||||
} else if let Some(focus) = self.pad.menu.focus {
|
||||
let kind = std::mem::discriminant(&focus.kind);
|
||||
let named = self.pad_named_focus(focus, &rects);
|
||||
let followed = stick_scrolled.and_then(|s| {
|
||||
@@ -2019,7 +2034,7 @@ impl Game<'_> {
|
||||
/// button being bound, the credits, the on-screen keyboard.
|
||||
fn key_focus_blocked(&self) -> bool {
|
||||
let playing_screen = |screen| self.title.is_none() && self.screen == screen;
|
||||
self.pad.menu.osk.is_some()
|
||||
self.pad_keyboard_captures()
|
||||
|| playing_screen(Screen::Chat)
|
||||
|| self.sign_edit.is_some()
|
||||
|| matches!(self.book, Some(super::books::BookScreen::Edit { .. }))
|
||||
@@ -2232,8 +2247,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let takeover = match event {
|
||||
// Not while the pad holds the radial menu open: as for raw mouse motion in play
|
||||
// (`device_event`), a cursor that moved is a bumped mouse.
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.pad.menu.real_pointer != Some(*position)
|
||||
self.pad.menu.real_pointer != Some(*position) && !self.pad_radial_captures()
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
|
||||
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
|
||||
|
||||
@@ -801,19 +801,8 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(
|
||||
m,
|
||||
true,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
}
|
||||
let world = horizon_hooks::players::Pass::World;
|
||||
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
@@ -927,10 +916,8 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
let picture = horizon_hooks::players::Pass::Picture;
|
||||
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
|
||||
@@ -738,14 +738,11 @@ impl Game<'_> {
|
||||
});
|
||||
self.set_pushed_on_ground(target, limited, resolved);
|
||||
|
||||
// Entity.move updates ground before its tiny-collision return. This
|
||||
// native slice covers only unoccupied server NoAI horizontal moves.
|
||||
if let Target::Entity(EntityHandle::Mob(i)) = target {
|
||||
// shortcut: unoccupied server mobs only, expand when controller/passenger authority is bound.
|
||||
let mob = if let Target::Entity(EntityHandle::Mob(i)) = target {
|
||||
let m = &self.mobs[i];
|
||||
let eligible = !self.options.execution_role.is_client()
|
||||
(!self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& limited.y == 0.0
|
||||
&& m.no_ai
|
||||
&& !m.extra.passenger
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.rider.is_none()
|
||||
@@ -757,16 +754,22 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
|
||||
});
|
||||
if eligible {
|
||||
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
}))
|
||||
.then_some(i)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Entity.move updates ground before its tiny-collision return.
|
||||
if let Some(i) = mob
|
||||
&& self.mobs[i].no_ai
|
||||
{
|
||||
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
let tnt_after_move =
|
||||
tnt.is_some() && !self.options.execution_role.is_client() && self.remote.is_none();
|
||||
if mob.is_none() && !tnt_after_move {
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
}
|
||||
// `Entity.move(MoverType.PISTON, …)` restitution, which runs whether or
|
||||
// not the step is applied: so a primed TNT in a chunk that does not tick
|
||||
// entities still loses its velocity into walls and ceilings and
|
||||
// bounces on slime (#12022).
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
let moved = resolved.length_squared() > 1.0e-7
|
||||
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
|
||||
if moved {
|
||||
@@ -775,6 +778,12 @@ impl Game<'_> {
|
||||
// A move the collisions cut to nothing still ends in the check.
|
||||
self.release_after_block_move(target);
|
||||
}
|
||||
// Restitution reads the new landing block even when the push was blocked.
|
||||
if let Some(i) = mob {
|
||||
self.mobs[i].restitute_movement(&self.world, &self.motion.blocks, limited, resolved);
|
||||
} else if tnt_after_move {
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
}
|
||||
if let Some((i, from, ..)) = tnt {
|
||||
self.finish_tnt_piston_move(i, from, resolved);
|
||||
}
|
||||
@@ -1053,6 +1062,7 @@ mod tests {
|
||||
mod order_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::{geometry::Direction, position::BlockPos};
|
||||
use horizon_world::level::pistons::MovingPiston;
|
||||
use std::sync::Arc;
|
||||
@@ -1151,8 +1161,9 @@ mod order_tests {
|
||||
}
|
||||
// The near stone's box reaches x 5.5 into a TNT from x 5.01: 0.49 plus
|
||||
// PUSH_OFFSET; the far stone's old box stops it at x 6.
|
||||
// Vanilla's TNT width is 0.98F, not the exact decimal 0.98.
|
||||
assert!(
|
||||
(moved[0] - 0.5).abs() < 1e-9,
|
||||
(moved[0] - 0.500000009536743).abs() < 1e-9,
|
||||
"far stone gone first: {}",
|
||||
moved[0]
|
||||
);
|
||||
@@ -1251,7 +1262,7 @@ mod order_tests {
|
||||
}
|
||||
|
||||
/// A primed TNT pushed down by a piston onto a slime block bounces up,
|
||||
/// matching vanilla's slime restitution (~+0.64) even in a lazy chunk (#12022).
|
||||
/// matching vanilla's restitution even in a lazy chunk (#12022).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_pushed_tnt_bounces_on_slime() {
|
||||
@@ -1312,32 +1323,36 @@ mod order_tests {
|
||||
death_ticks: 0,
|
||||
id: 1,
|
||||
};
|
||||
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
|
||||
game.sync_moving_pistons();
|
||||
game.tnts.clear();
|
||||
assert!(game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0.0d,-0.4d,0.0d]}"));
|
||||
assert!(
|
||||
(game.tnts[0].velocity.y - (-0.4)).abs() < 1.0e-9,
|
||||
"TNT starts with vy -0.4, got {:?}",
|
||||
game.tnts[0].velocity
|
||||
);
|
||||
// Piston advances from progress 0 to 0.5 and pushes the TNT down.
|
||||
let pushes: Vec<_> = game
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
|
||||
.collect();
|
||||
for (_, piston) in &mut game.block_ticks.moving_pistons {
|
||||
piston.progress += 0.5;
|
||||
// Pinned 26.3 PistonMovingBlockEntity.moveCollidedEntities, without entity ticks.
|
||||
for (floor, y, vy, final_y, final_vy) in [
|
||||
(slime, 200.5, -0.4, 200.0, 0.3510000138133766_f64),
|
||||
(stone, 200.5, -0.4, 200.0, 0.0),
|
||||
(slime, 200.0, -0.4, 200.0, 0.3200000047683716),
|
||||
(slime, 200.00001, -0.4, 200.00001, 0.32000064476813395),
|
||||
(slime, 200.5, 0.3, 200.0, 0.0),
|
||||
] {
|
||||
game.world.set(5, 199, 5, floor).unwrap();
|
||||
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
|
||||
game.sync_moving_pistons();
|
||||
game.tnts.clear();
|
||||
assert!(game.run_command(&format!(
|
||||
"summon tnt 5.5 {y} 5.5 {{fuse:80,Motion:[0.0d,{vy}d,0.0d]}}"
|
||||
)));
|
||||
assert_eq!(game.tnts[0].velocity.y, vy);
|
||||
let pushes: Vec<_> = game
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
|
||||
.collect();
|
||||
for (_, piston) in &mut game.block_ticks.moving_pistons {
|
||||
piston.progress += 0.5;
|
||||
}
|
||||
game.push_piston_entities(pushes);
|
||||
assert_eq!(game.tnts[0].position, Vec3::new(5.5, final_y, 5.5));
|
||||
assert_eq!(game.tnts[0].velocity.y.to_bits(), final_vy.to_bits());
|
||||
assert!(game.tnts[0].on_ground);
|
||||
}
|
||||
game.push_piston_entities(pushes);
|
||||
// The slime block at y=199 bounces the TNT up; vertical velocity must be positive.
|
||||
assert!(
|
||||
game.tnts[0].velocity.y > 0.0,
|
||||
"TNT should bounce up on slime, got vy {:?}",
|
||||
game.tnts[0].velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1602,6 +1617,54 @@ mod h255_mob_piston_ground_tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_tnt_authority_and_exhausted_budget() {
|
||||
use crate::game::execution_role::ExecutionRole;
|
||||
for role in [
|
||||
ExecutionRole::Client,
|
||||
ExecutionRole::IntegratedServer,
|
||||
ExecutionRole::DedicatedServer,
|
||||
] {
|
||||
with_role_game(role, |game| {
|
||||
assert!(game.run_command("setblock 5 199 5 slime_block"));
|
||||
assert!(
|
||||
game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0d,-0.4d,0d]}")
|
||||
);
|
||||
let mut budget = std::collections::HashMap::new();
|
||||
let push = |game: &mut Game<'_>,
|
||||
budget: &mut std::collections::HashMap<_, _>,
|
||||
movement| {
|
||||
let tnt = &game.tnts[0];
|
||||
game.move_pushed(
|
||||
Target::Entity(EntityHandle::Tnt(0)),
|
||||
tnt.bounds(),
|
||||
[tnt.position.x, tnt.position.z],
|
||||
0.0,
|
||||
tnt.on_ground,
|
||||
movement,
|
||||
Direction::Down,
|
||||
budget,
|
||||
);
|
||||
};
|
||||
push(game, &mut budget, Vec3::ZERO);
|
||||
assert_eq!(game.tnts[0].position.y, 200.5);
|
||||
assert_eq!(game.tnts[0].velocity.y, -0.4);
|
||||
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
|
||||
let expected = if role.is_client() {
|
||||
0.0_f64
|
||||
} else {
|
||||
0.3510000138133766
|
||||
};
|
||||
assert_eq!(game.tnts[0].velocity.y.to_bits(), expected.to_bits());
|
||||
game.tnts[0].position.y = 200.5;
|
||||
game.tnts[0].velocity.y = -0.4;
|
||||
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
|
||||
assert_eq!(game.tnts[0].position.y, 200.5);
|
||||
assert_eq!(game.tnts[0].velocity.y, -0.4);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_no_request_and_exhausted_budget_keep_state() {
|
||||
with_game(|game| {
|
||||
let i = added_mob(game, 20, "horse", true, 8.5);
|
||||
@@ -1613,6 +1676,16 @@ mod h255_mob_piston_ground_tests {
|
||||
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
|
||||
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
|
||||
assert!(game.run_command("setblock 9 201 4 slime_block"));
|
||||
game.mobs[i].position.y = 202.25;
|
||||
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
game.mobs[i].sync_position = false;
|
||||
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
|
||||
assert_eq!(
|
||||
game.mobs[i].velocity.y, 0.0,
|
||||
"occupied restitution stays outside this slice"
|
||||
);
|
||||
assert!(!game.mobs[i].sync_position);
|
||||
assert!(game.run_command("ride @s dismount"));
|
||||
assert!(game.riding.is_none());
|
||||
assert!(game.mobs[i].rider.is_none());
|
||||
@@ -1655,23 +1728,46 @@ mod h255_mob_piston_ground_tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
|
||||
fn h255_added_client_role_ai_and_vertical_ground_state() {
|
||||
use crate::game::execution_role::ExecutionRole as Role;
|
||||
for (role, no_ai, movement) in [
|
||||
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
|
||||
for (role, no_ai, movement, on_ground) in [
|
||||
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0), true),
|
||||
(
|
||||
Role::IntegratedServer,
|
||||
false,
|
||||
Vec3::new(0.25, 0.0, 0.0),
|
||||
true,
|
||||
),
|
||||
(
|
||||
Role::IntegratedServer,
|
||||
true,
|
||||
Vec3::new(0.0, 0.25, 0.0),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
with_role_game(role, |game| {
|
||||
let i = added_mob(game, 22, "husk", no_ai, 8.5);
|
||||
let before = game.mobs[i].position;
|
||||
added_push(game, i, movement);
|
||||
assert_eq!(game.mobs[i].position, before.add_vector(movement));
|
||||
assert!(
|
||||
game.mobs[i].on_ground,
|
||||
"excluded native path remains unchanged; not Java parity"
|
||||
);
|
||||
assert_eq!(game.mobs[i].on_ground, on_ground);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_partial_push_reads_slime_after_the_position_changes() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("setblock 8 201 4 slime_block"));
|
||||
let i = added_mob(game, 23, "iron_golem", true, 8.5);
|
||||
game.mobs[i].position.y = 202.25;
|
||||
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
game.mobs[i].sync_position = false;
|
||||
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
|
||||
assert!(game.mobs[i].velocity.y > 0.9);
|
||||
assert!(game.mobs[i].sync_position);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,7 @@ impl Game<'_> {
|
||||
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
|
||||
Source::Keyboard => {
|
||||
!(slot == SNEAK && self.settings.controller.toggle_sneak)
|
||||
&& self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
|
||||
&& self.pad.holds_action(["key.sneak", "key.sprint"][slot])
|
||||
}
|
||||
Source::Pad => self.physical_keys.iter().any(|physical| {
|
||||
self.settings
|
||||
@@ -459,7 +457,7 @@ impl Game<'_> {
|
||||
|
||||
/// Whether a device holds the key mapping named `mapping` (`key.use`).
|
||||
pub(super) fn input_holds(&self, mapping: &str) -> bool {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
self.pad.holds_action(mapping)
|
||||
}
|
||||
|
||||
/// Turns the view by what the devices give this frame (`Entity.turn`).
|
||||
@@ -481,7 +479,7 @@ impl Game<'_> {
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
|
||||
pub(super) fn input_player_replaced(&mut self) {
|
||||
self.pad.player_replaced();
|
||||
self.pad_player_replaced();
|
||||
}
|
||||
|
||||
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
|
||||
|
||||
@@ -146,7 +146,16 @@ impl Game<'_> {
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
// key.socialInteractions (Gui.handleKeybinds): only on a server; in single player
|
||||
// the action bar says so, and the narrator.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing && !self.is_multiplayer_server() => {
|
||||
let message = horizon_client::text::Text::translate(
|
||||
"multiplayer.socialInteractions.not_available",
|
||||
&[],
|
||||
);
|
||||
self.titles.set_overlay(message.clone(), false);
|
||||
self.say_system_now(&message);
|
||||
}
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
|
||||
@@ -11,8 +11,8 @@ 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,
|
||||
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
|
||||
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -182,6 +182,14 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Full light, packed as the vertices carry it.
|
||||
const FULL: u32 = 240 | 240 << 8;
|
||||
|
||||
thread_local! {
|
||||
/// What crates draw see-through this frame, added after every player.
|
||||
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
@@ -199,7 +207,7 @@ impl PartPoses for PosesOf<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// A player model drawn, 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> {
|
||||
@@ -208,6 +216,7 @@ struct DrawPlayer<'a, 'g> {
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
@@ -223,6 +232,19 @@ impl DrawPlayer<'_, '_> {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
|
||||
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
|
||||
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
|
||||
match light {
|
||||
Light::Full => FULL,
|
||||
Light::AtPlace => {
|
||||
let p = (self.camera + at.as_dvec3()).floor();
|
||||
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
|
||||
}
|
||||
_ => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
@@ -234,7 +256,52 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.to_cols_array_2d()
|
||||
}
|
||||
|
||||
fn motion(&self) -> Motion {
|
||||
let (m, a) = (self.m, self.alpha);
|
||||
let t = f64::from(a);
|
||||
let (p, n) = (m.previous, m.position);
|
||||
let feet = [
|
||||
p.x + (n.x - p.x) * t,
|
||||
p.y + (n.y - p.y) * t,
|
||||
p.z + (n.z - p.z) * t,
|
||||
];
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
let yaw = m.previous_head_yaw + turn * a;
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
|
||||
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
|
||||
let swimming = m.extra.swim.amount(a) > 0.0;
|
||||
Motion::new(feet, yaw, pitch).poses(
|
||||
m.extra.crouching,
|
||||
asleep,
|
||||
m.extra.fall_flying,
|
||||
swimming,
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self) -> View {
|
||||
let q = self.game.camera_orientation();
|
||||
let (right, up) = (
|
||||
(q * glam::Vec3::X).to_array(),
|
||||
(q * glam::Vec3::Y).to_array(),
|
||||
);
|
||||
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let left = [yaw.cos(), 0.0, yaw.sin()];
|
||||
View::new(self.camera.to_array(), right, up).looking(forward, left)
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.pass == Pass::FirstPerson {
|
||||
return None;
|
||||
}
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
@@ -260,13 +327,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
let poses = &self.poses;
|
||||
if let Some(alpha) = look.see_through {
|
||||
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
|
||||
for v in &mut out[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one: `entity.wgsl`
|
||||
// drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
@@ -276,30 +360,43 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: self.light,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
if skin.draw(&model, matrix, 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,
|
||||
);
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
|
||||
}
|
||||
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
|
||||
let light = self.packed(light, glam::Vec3::ZERO);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
for q in particles {
|
||||
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
|
||||
out.push(Vertex {
|
||||
pos: *corner,
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
|
||||
/// [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
@@ -315,33 +412,102 @@ impl Game<'_> {
|
||||
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 kind = kind(m).filter(|_| engine::any())?;
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
draw.bounds
|
||||
}
|
||||
|
||||
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
|
||||
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
|
||||
/// `camera`: the world view's camera, or the picture's.
|
||||
pub(super) fn own_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
pass: Pass,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass,
|
||||
base: render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu: false,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
}
|
||||
|
||||
/// After every player (`game.rs`): what crates draw around the window's own model in first
|
||||
/// person, then what was drawn see-through, in one span.
|
||||
pub(super) fn players_after_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if engine::any()
|
||||
&& !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let own = own.looks(own.gear, own.invisible || self.own_invisible());
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
pass: Pass::FirstPerson,
|
||||
base: render::base_transform(m, alpha as f32, camera),
|
||||
camera,
|
||||
alpha: alpha as f32,
|
||||
light: FULL,
|
||||
gpu: false,
|
||||
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),
|
||||
engine::first_person(&own, &mut draw);
|
||||
}
|
||||
let _ = SEE_THROUGH.try_with(|v| {
|
||||
let mut v = v.borrow_mut();
|
||||
if !v.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&v);
|
||||
v.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
@@ -358,6 +524,7 @@ impl Game<'_> {
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -11463,8 +11463,7 @@ impl Remote {
|
||||
pub(super) fn listed_player_count(&self) -> usize {
|
||||
self.listed.len()
|
||||
}
|
||||
/// Players in the server's player list, this one included (for Discord).
|
||||
#[cfg(feature = "discord")]
|
||||
/// Players in the server's player list, this one included (for the activity hooks).
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
}
|
||||
|
||||
@@ -306,8 +306,6 @@ pub(super) fn run_loading(
|
||||
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
|
||||
}
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut split) => {
|
||||
write_report(&split.host)?;
|
||||
|
||||
@@ -917,10 +917,8 @@ impl Game<'_> {
|
||||
self.drag_button = None;
|
||||
self.modifiers = Default::default();
|
||||
// A press this player's pad holds goes on, as for its keys.
|
||||
self.left = self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, "key.attack");
|
||||
self.right = self.pad.holds_action(&self.settings.controller, "key.use");
|
||||
self.left = self.pad.holds_action("key.attack");
|
||||
self.right = self.pad.holds_action("key.use");
|
||||
self.inventory_mouse_release(false);
|
||||
self.inventory_mouse_release(true);
|
||||
self.inventory_pick_release();
|
||||
|
||||
@@ -130,6 +130,12 @@ impl Split<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest a player's pad leant its sticks since the last frame: nothing for a pad just
|
||||
/// joined whose buttons are still being let go.
|
||||
fn pad_peaks(&self, pad: Option<PadId>) -> [[f32; 2]; 2] {
|
||||
pad.filter(|id| !self.fresh.contains(id))
|
||||
.map_or([[0.0; 2]; 2], |id| self.pads.peaks(id))
|
||||
}
|
||||
/// The pad's state for its player: nothing while the press that gave it is still held.
|
||||
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
|
||||
let Some(id) = pad else {
|
||||
@@ -282,14 +288,16 @@ impl Split<'_> {
|
||||
self.pads.report_status(&mut self.host.pad, owner);
|
||||
}
|
||||
let state = self.pad_state(owner);
|
||||
self.host.pad.feed_from(owner, state);
|
||||
let peaks = self.pad_peaks(owner);
|
||||
self.host.pad.feed_from(owner, state, peaks);
|
||||
for i in 0..self.guests.len() {
|
||||
let pad = self.guests[i].pad;
|
||||
if self.guests[i].game.control.is_some() {
|
||||
self.pads.report_status(&mut self.guests[i].game.pad, pad);
|
||||
}
|
||||
let state = self.pad_state(pad);
|
||||
self.guests[i].game.pad.feed_from(pad, state);
|
||||
let peaks = self.pad_peaks(pad);
|
||||
self.guests[i].game.pad.feed_from(pad, state, peaks);
|
||||
}
|
||||
joins
|
||||
}
|
||||
|
||||
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
|
||||
continue;
|
||||
}
|
||||
// Opened without the focus: whoever types in the game's window keeps the keyboard.
|
||||
let attributes =
|
||||
horizon_client::window::with_horizon_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
let attributes = horizon_client::window::with_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
|
||||
// Space of its own that hides the other windows as it slides in.
|
||||
let placement = self.placement(*key).cloned();
|
||||
|
||||
@@ -113,6 +113,117 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
|
||||
assert!(health.ends_with(": 16.0f"), "{health}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
|
||||
let mut game = game();
|
||||
summon(&mut game, ",TicksFrozen:140");
|
||||
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(frozen.ends_with(": 140"), "{frozen}");
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(!ok, "{frozen_after}");
|
||||
|
||||
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
|
||||
let mut game = game();
|
||||
summon(
|
||||
&mut game,
|
||||
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 0s"), "{fire}");
|
||||
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
|
||||
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
|
||||
let mut helmeted = self::game();
|
||||
summon(
|
||||
&mut helmeted,
|
||||
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
}
|
||||
|
||||
fn capture() {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn cause_damage_and_explosion_post_attributed_game_events() {
|
||||
use horizon_client::mobs::Who;
|
||||
let mut game = game();
|
||||
summon(&mut game, ",Fire:200s");
|
||||
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
|
||||
let pig_id = game.mobs[0].id;
|
||||
let at = [2.5, 100.0, 8.5];
|
||||
|
||||
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Stand broken by explosion posts entity_die attributed to the pig.
|
||||
summon(&mut game, "");
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
}
|
||||
|
||||
// Controls added after the baseline: the guards before the acting sources,
|
||||
// and a stand broken once however often it is hurt.
|
||||
|
||||
|
||||
@@ -111,6 +111,18 @@ impl Game<'_> {
|
||||
{
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
// A key or a mouse button pressed while the pad holds the radial menu open closes it,
|
||||
// and goes on to do what it does. Moving the mouse does neither (`device_event`).
|
||||
let pressed = match &event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
event.state == ElementState::Pressed && !event.repeat
|
||||
}
|
||||
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
|
||||
_ => false,
|
||||
};
|
||||
if pressed && !self.pad.menu.synthetic {
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
self.physical_buttons.insert(*button);
|
||||
@@ -273,9 +285,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -352,6 +363,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
// While the pad holds the radial menu open the mouse does nothing, as the right stick
|
||||
// doesn't turn the view: it neither cancels the menu nor takes control back, nor
|
||||
// turns the view. A thumb is on the opener, so the motion is a bumped desk.
|
||||
if self.pad_radial_captures() {
|
||||
return;
|
||||
}
|
||||
// Moving the mouse hands control back from the pad.
|
||||
if x != 0.0 || y != 0.0 {
|
||||
self.pad_yield_input(event_loop);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
|
||||
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
|
||||
//! (`docs/design-system.md`).
|
||||
//! Its own module so it can leave the engine crate as a pure file move, for the official
|
||||
//! executable to call.
|
||||
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
|
||||
use horizon_theme as theme;
|
||||
|
||||
/// Give the engine Horizon's look; call it once, before the first window opens.
|
||||
pub(crate) fn give() {
|
||||
horizon_client::window::set_icon_png(theme::ICON_PNG);
|
||||
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
|
||||
horizon_client::menu_style::install(MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: blocks.page,
|
||||
ink: blocks.ink,
|
||||
ink_soft: blocks.ink_soft,
|
||||
muted: blocks.muted,
|
||||
outline: blocks.outline,
|
||||
accent: blocks.accent,
|
||||
face: blocks.face,
|
||||
// The face's hover, lighter than `face`: the settings screen's own shade.
|
||||
face_hover: 0x6a50e6,
|
||||
success: blocks.success,
|
||||
warn: blocks.warn,
|
||||
danger: blocks.danger,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: dark.page,
|
||||
backdrop_muted: theme::BACKDROP_MUTED,
|
||||
ink: dark.ink,
|
||||
ink_soft: dark.ink_soft,
|
||||
rule: dark.rule,
|
||||
accent: dark.accent,
|
||||
on_accent: dark.on_accent,
|
||||
lit: theme::night::NIGHT.accent,
|
||||
lit_edge: theme::night::NIGHT.accent_hi,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: theme::CAPTION.px,
|
||||
label: theme::LABEL.px,
|
||||
ui: theme::UI.px,
|
||||
body: theme::BODY.px,
|
||||
title: theme::TITLE.px,
|
||||
display: theme::DISPLAY.px,
|
||||
micro: theme::MICRO.px,
|
||||
radius: theme::RADIUS,
|
||||
radius_plate: theme::RADIUS_PLATE,
|
||||
control_large: theme::CONTROL_LARGE,
|
||||
},
|
||||
fonts: Some(Fonts {
|
||||
regular: theme::fonts::GEIST_REGULAR,
|
||||
medium: theme::fonts::GEIST_MEDIUM,
|
||||
semibold: theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: theme::fonts::GEIST_MONO,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
|
||||
pub use horizon_client_lib::*;
|
||||
pub mod entry;
|
||||
pub mod game;
|
||||
mod horizon_look;
|
||||
pub mod mod_api;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -849,7 +849,6 @@ impl Mob {
|
||||
self.horizontal_collision = x_hit || z_hit;
|
||||
// A server's mobs are authoritative (`isLocalInstanceAuthoritative`):
|
||||
// the vertical flags update even without vertical movement.
|
||||
let moved_vertically = wanted.y.abs() > 0.0;
|
||||
let vertical = wanted.y != moved.y;
|
||||
self.vertical_collision_below = vertical && wanted.y < 0.0;
|
||||
self.on_ground = self.vertical_collision_below;
|
||||
@@ -857,42 +856,51 @@ impl Mob {
|
||||
// checkFallDamage reads the landing block (getOnPosLegacy).
|
||||
self.supporting = self.supporting_block(w, moved);
|
||||
self.species_check_fall_damage(w, moved, events);
|
||||
// Entity.restituteMovementAfterCollisions: the colliding components
|
||||
// bounce back by the entity's bounciness or the block's (a slime
|
||||
// block, a bed), whichever is larger. A mob never sneaks, so nothing
|
||||
// suppresses its bounce there.
|
||||
if moved_vertically && vertical || self.horizontal_collision {
|
||||
// Entity.getOnPos(0.2F): the block whose bounce this move sees.
|
||||
self.restitute_movement(w.world, w.movement, wanted, moved);
|
||||
self.movement_emission(w, moved, events);
|
||||
// Entity.move's last step: soul sand, honey and the like slow the
|
||||
// horizontal speed of whatever the mob stands in or on.
|
||||
let factor = self.block_speed_factor(w);
|
||||
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
/// `Entity.restituteMovementAfterCollisions`, shared by travel and piston pushes.
|
||||
pub fn restitute_movement(
|
||||
&mut self,
|
||||
world: &crate::world::World,
|
||||
movement: &horizon_world::movement_properties::BoundMovementProperties,
|
||||
wanted: Vec3,
|
||||
moved: Vec3,
|
||||
) {
|
||||
let differs = |a: f64, b: f64| (b - a).abs() >= f64::from(1.0e-5_f32);
|
||||
let x_hit = differs(wanted.x, moved.x);
|
||||
let z_hit = differs(wanted.z, moved.z);
|
||||
let vertical = wanted.y != moved.y;
|
||||
if wanted.y.abs() > 0.0 && vertical || x_hit || z_hit {
|
||||
let on = (
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - f64::from(0.2_f32)).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let input = RestitutionInput {
|
||||
block: w.world.block(on.0, on.1, on.2),
|
||||
block: world.block(on.0, on.1, on.2),
|
||||
collided_x: x_hit,
|
||||
collided_z: z_hit,
|
||||
vertical_collision: vertical,
|
||||
collision_below: self.vertical_collision_below,
|
||||
collision_below: vertical && wanted.y < 0.0,
|
||||
living: true,
|
||||
resolved_movement: moved,
|
||||
fall_distance: self.fall_distance,
|
||||
};
|
||||
// The port's phases cannot fail for a mob: a block outside the
|
||||
// registry reads as one with no bounce, never as an error.
|
||||
// Unknown block states have no bounce; the provider cannot fail.
|
||||
let _ = restitute_movement(
|
||||
&mut MobRestitution {
|
||||
mob: self,
|
||||
world: w,
|
||||
world,
|
||||
movement,
|
||||
},
|
||||
input,
|
||||
);
|
||||
}
|
||||
self.movement_emission(w, moved, events);
|
||||
// Entity.move's last step: soul sand, honey and the like slow the
|
||||
// horizontal speed of whatever the mob stands in or on.
|
||||
let factor = self.block_speed_factor(w);
|
||||
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
/// `Entity.getBlockSpeedFactor` at the mob's feet.
|
||||
fn block_speed_factor(&self, w: &MobWorld) -> f32 {
|
||||
@@ -2103,20 +2111,21 @@ pub fn client_water_splash(mob: &mut Mob, out: &mut Vec<MobEvent>) {
|
||||
/// mob that never sneaks. The phases themselves are
|
||||
/// `movement_restitution::restitute_movement`, the same port the player and
|
||||
/// dropped items use.
|
||||
struct MobRestitution<'a, 'w> {
|
||||
struct MobRestitution<'a> {
|
||||
mob: &'a mut Mob,
|
||||
world: &'a MobWorld<'w>,
|
||||
world: &'a crate::world::World,
|
||||
movement: &'a horizon_world::movement_properties::BoundMovementProperties,
|
||||
}
|
||||
impl<'a, 'w> MobRestitution<'a, 'w> {
|
||||
impl<'a> MobRestitution<'a> {
|
||||
/// The block's movement data: its coefficients and its bounce tag.
|
||||
fn movement(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
) -> Result<horizon_world::movement_properties::MovementBlock, ()> {
|
||||
self.world.movement.get(state).map_err(|_| ())
|
||||
self.movement.get(state).map_err(|_| ())
|
||||
}
|
||||
}
|
||||
impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
impl RestitutionWorld for MobRestitution<'_> {
|
||||
// The block data cannot fail here: a state the registry does not know
|
||||
// reads as a block with no bounce, never as an error.
|
||||
type Error = ();
|
||||
@@ -2162,7 +2171,7 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
position.z.floor() as i32,
|
||||
));
|
||||
};
|
||||
let state = self.world.world.block(support[0], support[1], support[2]);
|
||||
let state = self.world.block(support[0], support[1], support[2]);
|
||||
let keeps_column = self
|
||||
.movement(state)
|
||||
.is_ok_and(|b| b.wall || b.coefficients.fence_gate);
|
||||
|
||||
@@ -2205,6 +2205,67 @@ pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
p.xrot("left_hind_leg", pos.mcos() * 1.2 * sp);
|
||||
}
|
||||
|
||||
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
|
||||
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
|
||||
pub fn write_guarded_anger(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let Some(actor) = m.extra.piglin.guarded_angry_at else {
|
||||
return;
|
||||
};
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
|
||||
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
|
||||
memories.insert(
|
||||
"minecraft:angry_at".into(),
|
||||
Tag::compound([
|
||||
("value", Tag::IntArray(uuid.to_vec())),
|
||||
("ttl", Tag::Long(i64::from(m.extra.anger))),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
memories.remove(&"minecraft:angry_at".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Piglin.readAdditionalSaveData` through `Brain.Packed`: the guarded-block
|
||||
/// `ANGRY_AT` entry is an entity UUID with the ticks left as its TTL. Vanilla
|
||||
/// reads the memory with the brain, and an expired one no longer angers the
|
||||
/// piglin; the counter is what Horizon's goals ask.
|
||||
pub fn read_guarded_anger(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let memory = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:angry_at"));
|
||||
let Some(memory) = memory else { return };
|
||||
let Some(uuid) = crate::save::uuid_from_tag(memory.get("value")) else {
|
||||
return;
|
||||
};
|
||||
// The TTL is a long: one past the counter's range holds as long as it can.
|
||||
let ttl = match memory.get("ttl") {
|
||||
Some(Tag::Long(v)) => (*v).clamp(0, i64::from(i32::MAX)) as i32,
|
||||
Some(t) => crate::nbt_path::int_value(t).unwrap_or(i32::MAX),
|
||||
None => i32::MAX,
|
||||
};
|
||||
m.extra.anger = ttl.max(0);
|
||||
m.extra.piglin.guarded_angry_at = Some(Some(uuid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod brute_sound_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -928,12 +928,12 @@ fn chunk_pois(
|
||||
return;
|
||||
};
|
||||
let kind = |state: u32| poi_of_state(w, horizon_world::registry::BlockStateId(state));
|
||||
let min = w.world.bottom_y();
|
||||
for s in (y0 - min) >> 4..=(y1 - min) >> 4 {
|
||||
let section = &chunk.sections[s as usize];
|
||||
for &(i, poi) in w.world.pois.get((qx, s, qz), section, kind).iter() {
|
||||
let (min, mut lists) = (w.world.bottom_y(), Vec::new());
|
||||
w.world.pois.column(chunk, min, (y0, y1), kind, &mut lists);
|
||||
for (base, pois) in &lists {
|
||||
for &(i, poi) in pois.iter() {
|
||||
let i = i32::from(i);
|
||||
let y = min + s * 16 + (i >> 8);
|
||||
let y = base + (i >> 8);
|
||||
if (y0..=y1).contains(&y) {
|
||||
visit(poi, [qx * 16 + (i & 15), y, qz * 16 + (i >> 4 & 15)]);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@ impl NoResources {
|
||||
let x = ((ui.w - width) / 2.0).round();
|
||||
let lines: Vec<Vec<String>> = PARAGRAPHS
|
||||
.iter()
|
||||
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
|
||||
.map(|p| wrap(ui, Face::Regular, body(), p, width))
|
||||
.collect();
|
||||
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
|
||||
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
|
||||
let y = ((ui.h - height) / 2.0).max(24.0).round();
|
||||
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
|
||||
let mut buttons = Vec::new();
|
||||
@@ -59,7 +59,7 @@ impl NoResources {
|
||||
("Download the launcher", Action::Download),
|
||||
("Quit", Action::Quit),
|
||||
] {
|
||||
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
|
||||
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
|
||||
buttons.push(Button {
|
||||
label,
|
||||
action,
|
||||
@@ -80,21 +80,29 @@ impl NoResources {
|
||||
|
||||
pub fn draw(&self, ui: &mut Ui) {
|
||||
let layout = self.layout(ui);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
|
||||
let mut y = layout.y;
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
TITLE_SIZE,
|
||||
title_size(),
|
||||
TITLE_LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::TEXT,
|
||||
color::text(),
|
||||
TITLE,
|
||||
);
|
||||
y += TITLE_LINE + 16.0;
|
||||
for paragraph in &layout.lines {
|
||||
for line in paragraph {
|
||||
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
body(),
|
||||
LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::text(),
|
||||
line,
|
||||
);
|
||||
y += LINE;
|
||||
}
|
||||
y += PARAGRAPH_GAP;
|
||||
@@ -102,20 +110,20 @@ impl NoResources {
|
||||
for button in &layout.buttons {
|
||||
let hovered = button.contains(ui.cursor);
|
||||
let (plate, ink) = match (button.action, hovered) {
|
||||
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
|
||||
(Action::Download, false) => (color::accent(), color::on_accent()),
|
||||
(Action::Download, true) => {
|
||||
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
|
||||
(color::with_alpha(color::accent(), 0.85), color::on_accent())
|
||||
}
|
||||
(Action::Quit, false) => (color::RAISED, color::TEXT),
|
||||
(Action::Quit, true) => (color::HOVER, color::TEXT),
|
||||
(Action::Quit, false) => (color::raised(), color::text()),
|
||||
(Action::Quit, true) => (color::hover(), color::text()),
|
||||
};
|
||||
let h = CONTROL;
|
||||
let h = control();
|
||||
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
|
||||
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
|
||||
let lw = ui.measure(Face::Medium, button_text(), button.label);
|
||||
let lx = button.x + ((button.w - lw) / 2.0).round();
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
BUTTON_TEXT,
|
||||
button_text(),
|
||||
h,
|
||||
lx,
|
||||
button.y,
|
||||
@@ -123,11 +131,11 @@ impl NoResources {
|
||||
button.label,
|
||||
);
|
||||
}
|
||||
let small_y = layout.buttons[0].y + CONTROL + 20.0;
|
||||
let small_y = layout.buttons[0].y + control() + 20.0;
|
||||
for (i, line) in wrap(
|
||||
ui,
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
&self.looked_in,
|
||||
layout.width,
|
||||
)
|
||||
@@ -137,11 +145,11 @@ impl NoResources {
|
||||
let line_y = small_y + i as f32 * SMALL_LINE;
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
SMALL_LINE,
|
||||
layout.x,
|
||||
line_y,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
line,
|
||||
);
|
||||
}
|
||||
@@ -157,14 +165,22 @@ impl NoResources {
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
|
||||
fn title_size() -> f32 {
|
||||
ui_theme::sizes().title
|
||||
}
|
||||
const TITLE_LINE: f32 = 40.0;
|
||||
const BODY: f32 = horizon_theme::BODY.px;
|
||||
fn body() -> f32 {
|
||||
ui_theme::sizes().body
|
||||
}
|
||||
const LINE: f32 = 24.0;
|
||||
const PARAGRAPH_GAP: f32 = 10.0;
|
||||
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
|
||||
fn button_text() -> f32 {
|
||||
ui_theme::sizes().ui
|
||||
}
|
||||
const SMALL_LINE: f32 = 18.0;
|
||||
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
|
||||
fn control() -> f32 {
|
||||
ui_theme::sizes().control_large
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
@@ -184,7 +200,7 @@ struct Button {
|
||||
|
||||
impl Button {
|
||||
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +248,7 @@ mod tests {
|
||||
for size in [[1280, 720], [800, 600], [2560, 1440]] {
|
||||
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let layout = screen.layout(ui);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
|
||||
(
|
||||
center(&layout.buttons[0]),
|
||||
center(&layout.buttons[1]),
|
||||
@@ -255,7 +271,7 @@ mod tests {
|
||||
let layout = screen.layout(ui);
|
||||
let last = layout.buttons.last().unwrap();
|
||||
assert!(
|
||||
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
|
||||
last.x + last.w <= ui.w && last.y + control() <= ui.h,
|
||||
"{size:?}"
|
||||
);
|
||||
});
|
||||
@@ -266,10 +282,10 @@ mod tests {
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
}
|
||||
|
||||
@@ -293,16 +293,16 @@ impl Keyboard {
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::with_alpha(color::BG, 0.94),
|
||||
ui_theme::radius_plate(),
|
||||
color::with_alpha(color::bg(), 0.94),
|
||||
);
|
||||
ui.border(
|
||||
x0 - 12.0,
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::BORDER,
|
||||
ui_theme::radius_plate(),
|
||||
color::border(),
|
||||
);
|
||||
for (r, row) in self.rows().iter().enumerate() {
|
||||
let y = y0 + r as f32 * (KEY_H + GAP);
|
||||
@@ -314,13 +314,13 @@ impl Keyboard {
|
||||
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|
||||
|| matches!(key, Key::Layer) && self.symbols;
|
||||
let (plate, ink) = if focused {
|
||||
(color::ACCENT, color::ON_ACCENT)
|
||||
(color::accent(), color::on_accent())
|
||||
} else if lit {
|
||||
(color::HOVER, color::TEXT)
|
||||
(color::hover(), color::text())
|
||||
} else {
|
||||
(color::RAISED, color::TEXT)
|
||||
(color::raised(), color::text())
|
||||
};
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
|
||||
let label = match key {
|
||||
Key::Char(ch) => self.letter(*ch),
|
||||
Key::Shift if self.caps => "CAPS".into(),
|
||||
@@ -331,10 +331,10 @@ impl Keyboard {
|
||||
Key::Backspace => tr("horizon.osk.delete", "Delete"),
|
||||
Key::Done => tr("gui.done", "Done"),
|
||||
};
|
||||
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
|
||||
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
ui_theme::BODY,
|
||||
ui_theme::body(),
|
||||
KEY_H,
|
||||
x + ((w - lw) / 2.0).round(),
|
||||
y,
|
||||
@@ -359,10 +359,10 @@ impl Keyboard {
|
||||
.collect();
|
||||
ui.text_centered(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
x0 + width / 2.0,
|
||||
y0 + 5.0 * (KEY_H + GAP) + 2.0,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
&line.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ impl TimelineUi {
|
||||
let tx = start_x + sec * px_per_sec;
|
||||
if tx <= start_x + w {
|
||||
// Major tick line
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
|
||||
// Label every 2 or 5 seconds depending on width
|
||||
let is_labeled = if total_secs > 60.0 {
|
||||
(sec as u32).is_multiple_of(10)
|
||||
@@ -424,7 +424,7 @@ impl TimelineUi {
|
||||
"horizon.studio.timeline.seconds",
|
||||
&[Text::literal(seconds)],
|
||||
);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
|
||||
}
|
||||
}
|
||||
sec += sec_step;
|
||||
@@ -459,7 +459,7 @@ impl TimelineUi {
|
||||
// Base lane well
|
||||
let lane_rect = [start_x, y, track_w, bar_h];
|
||||
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
|
||||
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
|
||||
style::ring(batch, atlas, lane_rect, style::row_rule());
|
||||
|
||||
let total_dur = self.duration_ms.max(1) as f32;
|
||||
|
||||
@@ -514,7 +514,7 @@ impl TimelineUi {
|
||||
&scene_title,
|
||||
seg_x + 5.0,
|
||||
y + 5.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ impl TimelineUi {
|
||||
let px = start_x + norm * track_w;
|
||||
|
||||
// Scrubber line from ruler through bottom of tracks
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
|
||||
|
||||
// Inverted triangle marker at ruler top
|
||||
let tri_color = style::WHITE;
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TopBarUi {
|
||||
);
|
||||
}
|
||||
let color = if open {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
@@ -220,7 +220,7 @@ impl TopBarUi {
|
||||
if pause_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -339,7 +339,11 @@ impl TopBarUi {
|
||||
&Text::translate(key, &[]),
|
||||
x + 8.0,
|
||||
y + 6.0,
|
||||
if hovered { style::WHITE } else { style::MUTED },
|
||||
if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
if !shortcut.is_empty() {
|
||||
@@ -350,7 +354,7 @@ impl TopBarUi {
|
||||
&shortcut,
|
||||
x + menu_width - shortcut_width - 8.0,
|
||||
y + 6.0,
|
||||
style::SOFT,
|
||||
style::soft(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
|
||||
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
|
||||
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! The design system's widgets drawn in the game: palette, type scale, radii,
|
||||
//! control sizes and fonts from `menu_style`, given at startup (in the official
|
||||
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! here draws Horizon's own overlays that vanilla has no screen for: the
|
||||
//! on-screen keyboard a controller types with (`osk`), plus the
|
||||
//! `horizon.txt` preferences.
|
||||
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
|
||||
let _ = std::fs::write(dir.join("horizon.txt"), out);
|
||||
}
|
||||
|
||||
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
|
||||
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
|
||||
/// translucent plates the overlays draw over the game.
|
||||
pub(crate) mod color {
|
||||
use horizon_theme::DARK as P;
|
||||
pub const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
use horizon_client_render::menu_style::{self, OverlayColours};
|
||||
pub use menu_style::hud_colour as hex;
|
||||
fn p() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
/// The page, under every veil.
|
||||
pub const BG: u32 = hex(P.page, 255);
|
||||
pub fn bg() -> u32 {
|
||||
hex(p().page, 255)
|
||||
}
|
||||
/// Hover plates, secondary buttons, fields, the segmented track: the
|
||||
/// dark palette's soft plate, as ink over the veil so the plates read the
|
||||
/// same over any backdrop (over the page colour they are `plate_soft`).
|
||||
pub const RAISED: u32 = hex(P.ink, 20);
|
||||
pub fn raised() -> u32 {
|
||||
hex(p().ink, 20)
|
||||
}
|
||||
/// Pressed plates and a switch that is off (`plate`, the same way).
|
||||
pub const HOVER: u32 = hex(P.ink, 36);
|
||||
pub fn hover() -> u32 {
|
||||
hex(p().ink, 36)
|
||||
}
|
||||
/// One-pixel rules between rows, slider tracks.
|
||||
pub const BORDER: u32 = hex(P.rule, 255);
|
||||
pub fn border() -> u32 {
|
||||
hex(p().rule, 255)
|
||||
}
|
||||
/// Text and selection (`ink`).
|
||||
pub const TEXT: u32 = hex(P.ink, 255);
|
||||
pub fn text() -> u32 {
|
||||
hex(p().ink, 255)
|
||||
}
|
||||
/// Values and secondary text (`ink_soft`).
|
||||
pub const MUTED: u32 = hex(P.ink_soft, 255);
|
||||
pub const ACCENT: u32 = hex(P.accent, 255);
|
||||
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(p().ink_soft, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(p().accent, 255)
|
||||
}
|
||||
pub fn on_accent() -> u32 {
|
||||
hex(p().on_accent, 255)
|
||||
}
|
||||
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
|
||||
let base = (c >> 24) as f32;
|
||||
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
|
||||
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
|
||||
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
|
||||
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
|
||||
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
|
||||
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
|
||||
/// Fields; plates (hover rows, selection); pills (buttons, segments).
|
||||
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
|
||||
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
/// The type scale and shapes (`menu_style`), in dp.
|
||||
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
|
||||
&horizon_client_render::menu_style::get().sizes
|
||||
}
|
||||
pub(crate) fn small() -> f32 {
|
||||
sizes().caption
|
||||
}
|
||||
pub(crate) fn body() -> f32 {
|
||||
sizes().label
|
||||
}
|
||||
/// The boot screen's wordmark (`display`) and console lines (`micro`).
|
||||
fn display() -> f32 {
|
||||
sizes().display
|
||||
}
|
||||
fn micro() -> f32 {
|
||||
sizes().micro
|
||||
}
|
||||
/// Fields; plates (hover rows, selection).
|
||||
pub(crate) fn radius() -> f32 {
|
||||
sizes().radius
|
||||
}
|
||||
pub(crate) fn radius_plate() -> f32 {
|
||||
sizes().radius_plate
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Face {
|
||||
@@ -87,20 +119,34 @@ pub(crate) enum Face {
|
||||
/// Counts, timestamps and the boot screen's console lines.
|
||||
Mono,
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
|
||||
#[cfg(test)]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
|
||||
Some(horizon_client_render::menu_style::Fonts {
|
||||
regular: horizon_theme::fonts::GEIST_REGULAR,
|
||||
medium: horizon_theme::fonts::GEIST_MEDIUM,
|
||||
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: horizon_theme::fonts::GEIST_MONO,
|
||||
});
|
||||
struct Fonts {
|
||||
faces: [FontRef<'static>; 4],
|
||||
}
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
|
||||
Self {
|
||||
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
|
||||
fn load() -> Option<Self> {
|
||||
let given = horizon_client_render::menu_style::get()
|
||||
.fonts
|
||||
.or(TEST_FONTS)?;
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
|
||||
Some(Self {
|
||||
faces: [
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
f(given.regular),
|
||||
f(given.medium),
|
||||
f(given.semibold),
|
||||
f(given.mono),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
fn face(&self, f: Face) -> &FontRef<'static> {
|
||||
&self.faces[f as usize]
|
||||
@@ -142,7 +188,8 @@ struct Cached {
|
||||
}
|
||||
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
|
||||
struct Cache {
|
||||
fonts: Fonts,
|
||||
/// None: no fonts were given, and text draws nothing.
|
||||
fonts: Option<Fonts>,
|
||||
entries: HashMap<Key, Cached>,
|
||||
shelf: (u32, u32, u32),
|
||||
pending: Vec<crate::font::Patch>,
|
||||
@@ -230,9 +277,16 @@ impl Cache {
|
||||
if let Some(c) = self.entries.get(&key) {
|
||||
return *c;
|
||||
}
|
||||
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
|
||||
let Some(fonts) = &self.fonts else {
|
||||
return Cached {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
};
|
||||
};
|
||||
let scale = fonts.scale(face, f32::from(size) / 4.0);
|
||||
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
|
||||
let made = match self.fonts.face(face).outline_glyph(glyph) {
|
||||
let made = match fonts.face(face).outline_glyph(glyph) {
|
||||
Some(outline) => {
|
||||
let b = outline.px_bounds();
|
||||
let (w, h) = (b.width() as u32, b.height() as u32);
|
||||
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
|
||||
let atlas = self.atlas;
|
||||
let gui = self.scale;
|
||||
with_cache(atlas, |c| {
|
||||
let scale = c.fonts.scale(face, px);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
return (0.0, 0.0, 0.0, Vec::new());
|
||||
};
|
||||
let scale = fonts.scale(face, px);
|
||||
let (ascent, descent) = {
|
||||
let f = c.fonts.face(face).as_scaled(scale);
|
||||
let f = fonts.face(face).as_scaled(scale);
|
||||
(f.ascent(), f.descent())
|
||||
};
|
||||
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
|
||||
.map(|ch| {
|
||||
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
|
||||
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
|
||||
.then(|| atlas.font.glyph(ch))
|
||||
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
|
||||
.filter(|g| g.rect.is_some() || g.right <= 0.0);
|
||||
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
|
||||
continue;
|
||||
}
|
||||
let (id, advance, kern) = {
|
||||
let font = c.fonts.face(face);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
break;
|
||||
};
|
||||
let font = fonts.face(face);
|
||||
let id = font.glyph_id(ch);
|
||||
let f = font.as_scaled(scale);
|
||||
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
|
||||
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
/// The wordmark with its tagline, centred.
|
||||
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
|
||||
let small = (size * 0.25).clamp(MICRO, 18.0);
|
||||
let size = (ui.w / 16.0).clamp(display(), 96.0);
|
||||
let small = (size * 0.25).clamp(micro(), 18.0);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (ui.h - block) / 2.0;
|
||||
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
|
||||
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
return;
|
||||
}
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
|
||||
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
|
||||
let max_w = ui.w / 2.0;
|
||||
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = ui.h - pad - line;
|
||||
@@ -696,7 +756,12 @@ mod tests {
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
reset_atlas();
|
||||
let draw = |cache: &mut Cache| {
|
||||
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
|
||||
let glyph = cache
|
||||
.fonts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.face(Face::Regular)
|
||||
.glyph_id('A');
|
||||
assert!(
|
||||
cache
|
||||
.glyph(&atlas, Face::Regular, glyph, 16.0)
|
||||
@@ -782,7 +847,7 @@ mod tests {
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
MICRO,
|
||||
micro(),
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
@@ -790,7 +855,7 @@ mod tests {
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
|
||||
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
|
||||
"every wrapped line fits"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -812,7 +877,7 @@ mod tests {
|
||||
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
|
||||
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
|
||||
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
|
||||
let quads = batch
|
||||
.vertices
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
log = "0.4"
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+189
-29
@@ -1,21 +1,30 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
|
||||
//! `game/discord.rs` says what the player does.
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
|
||||
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
|
||||
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
|
||||
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
|
||||
//! executable has no presence.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use horizon_command::args::GameMode;
|
||||
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
|
||||
use horizon_platform::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_platform::settings::Store;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
@@ -40,24 +49,141 @@ const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
/// The presence's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Presence = Presence;
|
||||
|
||||
pub struct Presence;
|
||||
|
||||
impl Hooks for Presence {
|
||||
/// Starts the presence thread with the game's window (never for a dedicated server).
|
||||
fn client_started(&self, client: &hooks::Client) {
|
||||
start(client.automated);
|
||||
}
|
||||
|
||||
fn activity(&self, now: Activity) {
|
||||
set(match now {
|
||||
Activity::Menus => Status::Menus,
|
||||
Activity::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
},
|
||||
// Anything a later game says that this crate doesn't know yet.
|
||||
_ => Status::Off,
|
||||
});
|
||||
}
|
||||
|
||||
/// On quit: the activity is cleared.
|
||||
fn shutdown(&self) {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
|
||||
/// may join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
|
||||
const KEY: &str = "discordPresence";
|
||||
/// The switch's row, and what it says on hover.
|
||||
const LABEL: &str = "horizon.extras.discord_presence";
|
||||
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
|
||||
|
||||
/// The switch (on by default). One presence per computer: the first splitscreen player's
|
||||
/// switch, in their settings, on their Extras page; the other players have none.
|
||||
static ON: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Whether the player whose turn it is owns the switch: the first one.
|
||||
fn first_player() -> bool {
|
||||
hooks::seat() == 0
|
||||
}
|
||||
|
||||
/// Turns the presence on or off; off clears the activity at once.
|
||||
pub fn set_enabled(on: bool) {
|
||||
ON.store(on, Ordering::Relaxed);
|
||||
let mut shared = lock();
|
||||
let status = shown(shared.game);
|
||||
let changed = shared.wanted.update(status, unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What Discord is told for the game's `status`: nothing with the switch off.
|
||||
fn shown(status: Status) -> Status {
|
||||
if enabled() { status } else { Status::Off }
|
||||
}
|
||||
|
||||
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
|
||||
pub static SETTING: Setting = Setting;
|
||||
|
||||
pub struct Setting;
|
||||
|
||||
impl Store for Setting {
|
||||
fn file(&self) -> &'static str {
|
||||
"horizon-extras.txt"
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
key == KEY
|
||||
}
|
||||
fn load(&self, _key: &str, value: &str) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
"true" => set_enabled(true),
|
||||
"false" => set_enabled(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
if first_player() {
|
||||
line(KEY, if enabled() { "true" } else { "false" });
|
||||
}
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
enabled() || !first_player()
|
||||
}
|
||||
}
|
||||
|
||||
/// The switch's row in the Extras page's Multiplayer group, for
|
||||
/// `horizon_platform::pages::register`.
|
||||
pub static SECTION: Section = Section;
|
||||
|
||||
pub struct Section;
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-discord.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
rows.push(Row::Toggle {
|
||||
label: LABEL,
|
||||
on: enabled(),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
|
||||
set_enabled(!enabled());
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
Some(Label::Key(ABOUT))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -97,11 +223,15 @@ impl Wanted {
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// What the game said last.
|
||||
game: Status,
|
||||
/// What Discord is to show: the game's, or nothing with the switch off.
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
game: Status::Menus,
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
@@ -114,7 +244,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
/// The status [`set`] gave before, the switch included, is the first one sent.
|
||||
pub fn start(automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
@@ -122,9 +253,6 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
@@ -136,12 +264,13 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
let mut shared = lock();
|
||||
shared.game = status;
|
||||
let changed = shared.wanted.update(shown(status), unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
@@ -582,6 +711,37 @@ mod tests {
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
/// The switch as the player's settings and the Extras page see it. The only test that
|
||||
/// touches the presence's statics.
|
||||
#[test]
|
||||
fn the_switch_clears_the_activity_at_once() {
|
||||
let wanted = || lock().wanted.status;
|
||||
Setting.load(KEY, "false");
|
||||
set(Status::Menus);
|
||||
assert!(!enabled());
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
let mut rows = Vec::new();
|
||||
SECTION.rows(&mut rows);
|
||||
assert_eq!(
|
||||
rows,
|
||||
[Row::Toggle {
|
||||
label: LABEL,
|
||||
on: false
|
||||
}]
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
|
||||
assert_eq!(lines, ["discordPresence:false"]);
|
||||
assert!(!Setting.is_default());
|
||||
// On from the page: what the game said last is shown at once.
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Menus);
|
||||
assert!(Setting.is_default());
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
SECTION.act(0, Act::Press);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
@@ -77,12 +77,79 @@ pub trait Hooks: Sync {
|
||||
/// 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) {}
|
||||
|
||||
/// Draws around the window's own model while its body is out of view (first person), once a
|
||||
/// frame after the first-person model: [`players::Pass::FirstPerson`].
|
||||
fn first_person(&self, _own: &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.
|
||||
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A game window opened: once, before the first frame, for every run that has a window,
|
||||
/// automated ones included ([`Client::automated`]); never for a dedicated server.
|
||||
fn client_started(&self, _client: &Client) {}
|
||||
|
||||
/// What the player is doing, for a status shown outside the game (Discord's): called when it
|
||||
/// changes, not every frame. Splitscreen: the first player's only.
|
||||
fn activity(&self, _now: Activity) {}
|
||||
}
|
||||
|
||||
/// What [`Hooks::client_started`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Client {
|
||||
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
|
||||
pub automated: bool,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(automated: bool) -> Self {
|
||||
Self { automated }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player is doing ([`Hooks::activity`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Activity {
|
||||
/// On the title screen or another menu outside a world.
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the player is in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world.
|
||||
Multiplayer,
|
||||
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
|
||||
/// join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
Survival,
|
||||
Creative,
|
||||
Adventure,
|
||||
Spectator,
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
@@ -178,6 +245,32 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::client_started`].
|
||||
pub fn client_started(client: &Client) {
|
||||
for hooks in all() {
|
||||
hooks.client_started(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity at the last call (`None` before the first).
|
||||
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
|
||||
/// frame: with no hook registered it only loads the empty list.
|
||||
pub fn activity(now: Activity) {
|
||||
let hooks = all();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.replace(now) != Some(now) {
|
||||
drop(last);
|
||||
for h in hooks {
|
||||
h.activity(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::shutdown`].
|
||||
pub fn shutdown() {
|
||||
for hooks in all() {
|
||||
@@ -217,6 +310,13 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::first_person`].
|
||||
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
for hooks in all() {
|
||||
hooks.first_person(own, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
@@ -255,6 +355,9 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn activity(&self, now: Activity) {
|
||||
self.0.lock().unwrap().push(format!("{now:?}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
@@ -285,7 +388,14 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
|
||||
let look = players::Look::new(uv);
|
||||
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
/// A spark at the eyes, as bright as the head turns.
|
||||
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let alpha = draw.motion().head_yaw / 90.0;
|
||||
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
|
||||
draw.particles([0.0; 4], &[spark], players::Light::Full);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
@@ -362,9 +472,10 @@ mod tests {
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is; logs each draw.
|
||||
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
|
||||
/// and its particles' alphas.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
@@ -372,6 +483,15 @@ mod tests {
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4]
|
||||
}
|
||||
fn motion(&self) -> players::Motion {
|
||||
players::Motion::new([0.0; 3], 45.0, 0.0)
|
||||
}
|
||||
fn view(&self) -> players::View {
|
||||
players::View::default()
|
||||
}
|
||||
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])
|
||||
}
|
||||
@@ -385,7 +505,7 @@ mod tests {
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: players::Look,
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
@@ -396,7 +516,10 @@ mod tests {
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
|
||||
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
|
||||
}
|
||||
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
|
||||
self.1.extend(particles.iter().map(|p| p.alpha));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +538,14 @@ mod tests {
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
let world = Activity::World {
|
||||
play: Play::Singleplayer,
|
||||
dimension: Dimension::Nether,
|
||||
mode: GameMode::Creative,
|
||||
};
|
||||
for now in [Activity::Menus, Activity::Menus, world, world] {
|
||||
engine::activity(now);
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
@@ -445,6 +576,8 @@ mod tests {
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
engine::first_person(&model(players::Kind::Own), &mut canvas);
|
||||
assert_eq!(canvas.1, [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());
|
||||
@@ -454,6 +587,8 @@ mod tests {
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"Menus",
|
||||
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
|
||||
"frame 4"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -274,8 +274,149 @@ impl Bone {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera.
|
||||
/// In the world, seen by the camera (the window's own model too, in third person).
|
||||
World,
|
||||
/// The inventory's picture of the window's own model.
|
||||
Picture,
|
||||
/// The window's own model while its body is out of view (first person): no bone, only what
|
||||
/// floats around it ([`crate::Hooks::first_person`]).
|
||||
FirstPerson,
|
||||
}
|
||||
|
||||
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Motion {
|
||||
/// Where its feet are, in the world.
|
||||
pub feet: [f64; 3],
|
||||
/// Where its head turns (degrees, the game's yaw and pitch).
|
||||
pub head_yaw: f32,
|
||||
pub head_pitch: f32,
|
||||
pub crouching: bool,
|
||||
/// Lies in a bed.
|
||||
pub asleep: bool,
|
||||
/// Flies with an elytra.
|
||||
pub flying: bool,
|
||||
/// Swims (any part of the swimming pose).
|
||||
pub swimming: bool,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
|
||||
Self {
|
||||
feet,
|
||||
head_yaw,
|
||||
head_pitch,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With its poses: crouching, asleep, flying, swimming.
|
||||
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
|
||||
Self {
|
||||
crouching,
|
||||
asleep,
|
||||
flying,
|
||||
swimming,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct View {
|
||||
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
|
||||
pub camera: [f64; 3],
|
||||
/// Its right and up, for what faces it.
|
||||
pub right: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
/// Where the window's player looks, and their level left (first person).
|
||||
pub forward: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
right,
|
||||
up,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With where the window's player looks and their level left.
|
||||
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
|
||||
Self {
|
||||
forward,
|
||||
left,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mesh or particles are lit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Light {
|
||||
/// As the player model is.
|
||||
Model,
|
||||
/// Fully lit: it glows.
|
||||
Full,
|
||||
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
|
||||
AtPlace,
|
||||
}
|
||||
|
||||
/// How [`PlayerDraw::draw`] draws a mesh.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Look {
|
||||
/// The sprite part it is textured by (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
pub light: Light,
|
||||
/// Drawn see-through at this alpha, after every player, its back faces dropped.
|
||||
pub see_through: Option<f32>,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// Opaque, lit as the model.
|
||||
pub fn new(uv: [f32; 4]) -> Self {
|
||||
Self {
|
||||
uv,
|
||||
light: Light::Model,
|
||||
see_through: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lit(self, light: Light) -> Self {
|
||||
Self { light, ..self }
|
||||
}
|
||||
|
||||
pub fn see_through(self, alpha: f32) -> Self {
|
||||
Self {
|
||||
see_through: Some(alpha),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quad that faces the camera ([`PlayerDraw::particles`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Particle {
|
||||
/// Its corners, relative to the camera.
|
||||
pub corners: [[f32; 3]; 4],
|
||||
/// Each corner's place across the sprite part (0 to 1).
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
|
||||
Self { corners, uv, alpha }
|
||||
}
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
@@ -291,19 +432,25 @@ 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.
|
||||
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
|
||||
fn base(&self) -> [[f32; 4]; 4];
|
||||
fn motion(&self) -> Motion;
|
||||
fn view(&self) -> View;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
|
||||
/// no body is drawn: [`Pass::FirstPerson`]).
|
||||
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.
|
||||
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-official-servers = { path = "../horizon-official-servers" }
|
||||
horizon-discord = { path = "../horizon-discord" }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -4,27 +4,36 @@
|
||||
//! (`game-build.yml`); horizon-client keeps its own plain executable.
|
||||
//!
|
||||
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
|
||||
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
|
||||
//! crate.
|
||||
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
|
||||
//! [`register`] adds them. Nothing here depends on an engine crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use horizon_platform::{pages, settings};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
const PARTS: [&dyn Hooks; 4] = [
|
||||
// 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,
|
||||
// "Playing Horizon" on the player's Discord profile.
|
||||
&horizon_discord::HOOKS,
|
||||
];
|
||||
/// Their settings, in the player's settings files.
|
||||
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
|
||||
/// Their rows and pages in the options screens.
|
||||
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
/// game, and so does the launcher's generated executable for native mods.
|
||||
pub fn register() {
|
||||
for part in PARTS {
|
||||
if let Err(e) = hooks::register(part) {
|
||||
let late = PARTS.into_iter().map(hooks::register);
|
||||
let late = late.chain(STORES.into_iter().map(settings::register));
|
||||
for result in late.chain(PAGES.into_iter().map(pages::register)) {
|
||||
if let Err(e) = result {
|
||||
// The executable registered after starting the game: a bug, not a player's problem.
|
||||
log::warn!("Horizon: an in-game part did not register: {e}");
|
||||
}
|
||||
|
||||
@@ -50,11 +50,14 @@ pub mod hooks {
|
||||
/// [`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,
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
|
||||
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
|
||||
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
|
||||
};
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
|
||||
tunnel,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
+190
-10
@@ -64,11 +64,13 @@ once per loop iteration next to winit's events.
|
||||
| Change camera | View | Create / Share | `key.togglePerspective` |
|
||||
| Pick block | D-pad left | D-pad left | `key.pickItem` |
|
||||
| Chat | D-pad up | D-pad up | `key.chat` |
|
||||
| Radial menu (hold) | D-pad right | D-pad right | Horizon `pad.radial` |
|
||||
| Pause / back | Menu | Options | Escape (fixed) |
|
||||
|
||||
Advancements and the player list have no button of their own, and are
|
||||
bindable. A settings file from before these defaults keeps its buttons where
|
||||
they were.
|
||||
Advancements has no button of its own: it is on the radial menu's first page,
|
||||
and bindable, as the player list is. A settings file from before these defaults
|
||||
keeps its buttons where they were and leaves the menu unbound (Controller Settings > Bindings
|
||||
offers it).
|
||||
|
||||
Also bindable, with no default button: fullscreen, player social, quick actions, the save and load
|
||||
toolbar activators and hotbar slots 1 to 9. They act in play only: in a menu or a prompt the button
|
||||
@@ -90,6 +92,179 @@ awkward, which is why Bedrock does the same) and acts like vanilla's toggle
|
||||
crouch: a screen stands you up while it is open and closing it crouches you
|
||||
again. The controls options can make it hold-to-sneak.
|
||||
|
||||
## Radial menu
|
||||
|
||||
Hold the opener and the right stick points at one of eight slots, clockwise
|
||||
from the top; letting the opener go runs the selected action, once. It isn't a
|
||||
screen: the world goes on behind it, and neither the selecting stick nor the mouse
|
||||
turns the view while it is open (the stick not until it has been let go). With
|
||||
the default opener, D-pad Right, the left thumb is on the D-pad, so the player
|
||||
stands still while picking; with the opener on a bumper, a trigger or View the
|
||||
stick that doesn't select still walks or turns the view.
|
||||
Every hold opens on the first page, so a flick aimed blind always lands on the
|
||||
same slot.
|
||||
|
||||
Two settings in Controller Settings > General, under Radial Menu, change how it
|
||||
is held and aimed, saved as `radialMode` and `radialStick` in
|
||||
`horizon-controller.txt`:
|
||||
|
||||
- Radial Menu: Hold (the default) or Toggle (`radialMode:hold|toggle`). In
|
||||
Toggle a press opens it, shown at once, and it stays open with the opener up:
|
||||
A or a second press of the opener runs what is selected, B cancels, and with
|
||||
nothing selected nothing runs.
|
||||
- Radial Menu Selects With: Right Stick (the default), Left Stick or Either
|
||||
(`radialStick:right|left|either`). The selecting stick neither walks nor
|
||||
turns the view while the menu is open; with Either, the last stick to lean
|
||||
past the select tilt selects, and neither moves anything. While the menu is
|
||||
open a stick takes over only once it has been at rest since the open, so a
|
||||
thumb left on the walking stick can't commit for the other. Its tilts follow
|
||||
that stick's own deadzone.
|
||||
|
||||
- The overlay shows once the opener has been held 120 ms (`DRAW_DELAY`); the
|
||||
stick counts from the press, so a quick flick-and-release works without it,
|
||||
and a tap that selected nothing shows nothing and does nothing. A flick
|
||||
between two frames still counts: the pad's own events give the furthest the
|
||||
stick leant since the last poll.
|
||||
- The slots are eight tiles (vanilla's game mode switcher slots, each with its
|
||||
action's icon from the player's own resources) on a ring at true 45 degree
|
||||
steps, where the stick selects them; the selected sector lights up behind
|
||||
its tile. The hub in the middle names the selection; for Perspective, Hide
|
||||
HUD and Cinematic Camera it gives the state and what a press turns it to,
|
||||
and for a slot that can't run, why (`Needs cosmetics on`). An empty slot is
|
||||
a pip, one that can't run is dimmed and slashed, and a missing action (an id
|
||||
this game doesn't know) says Missing. Along the top, the pages are tabs
|
||||
between the bumpers' glyphs, the current one white; on a narrow view they
|
||||
collapse to the name between arrows, with the bumpers naming the pages they
|
||||
turn to. With a bumper for the opener only the other shows, and with one page
|
||||
only its name. B's Cancel sits at the lower right when the view has room. The
|
||||
header stays below vanilla's boss bars and the ring above the status rows
|
||||
over the hotbar (armour, health with absorption, food, a mount's hearts);
|
||||
while the menu shows, the chat lines, the toasts, the crosshair, the guide
|
||||
and the first-person hand give way (none of them blink on a quick tap,
|
||||
which draws nothing). Sizes follow the
|
||||
view's height (`radial_overlay::Layout`), so 1280x720, 854x480 and a quarter
|
||||
of a large window look the same, and smaller views drop the state chips and
|
||||
the Cancel hint first. Its motion is quiet and never selects: a quick fade in,
|
||||
the lit sector easing to a new slot, the tiles sliding in on a page turn, the
|
||||
chosen tile flashing on a commit (`hud_kit::motion`, settled while the game
|
||||
is paused or recording).
|
||||
- Bumpers change the page, around the ends, and clear the selection; with a
|
||||
bumper for the opener, the other one turns the pages on. A stick held through
|
||||
the page turn selects nothing until it moves. One that had leant past rest
|
||||
for 90 ms before the opener went down (`LEAN_BEFORE_OPEN`, a stick turning the
|
||||
camera) selects nothing until it has been at rest, page turns included; one
|
||||
that moved only just before is a flick aimed at the menu, and selects.
|
||||
Meanwhile the sector stays dark and the hub says "Let go of the stick".
|
||||
- A selected slot stays selected a little past its wedge and under the tilt, so
|
||||
a light hold or a stick on a boundary doesn't flicker. The tilts are tuned at
|
||||
the default look deadzone and keep their share of the travel past a raised
|
||||
one (`radial::Tilt`): a selection always lets go above the deadzone, so a
|
||||
drifting stick never keeps one, and no slot ever needs more than a 0.93 lean,
|
||||
which a worn stick still reaches.
|
||||
- With vibration on: reaching a slot that would run gives a tick (45 ms on the
|
||||
light motor), a page turn a shorter one, twice when the pages go round an
|
||||
end, and a release that runs nothing after something was selected a soft
|
||||
buzz. A commit that runs an action plays the button click.
|
||||
- With the narrator on, it says the page's name as the overlay shows and after
|
||||
each page turn, and a slot's caption once the selection has stayed 150 ms
|
||||
(`NARRATE_SETTLE`), so a sweep across slots says only where it stops.
|
||||
- A stick let go keeps its selection for 250 ms (`NEUTRAL_RETENTION`), so it can
|
||||
spring back before the opener comes up; then the selection clears. Latched
|
||||
open in Toggle the selection stays with the stick let go, so the thumb can
|
||||
move to A: only another slot, a page turn or a cancel changes it. A slot the
|
||||
thumb crosses on its way to A, selected less than 90 ms before A goes down
|
||||
(`A_SETTLE`), doesn't count: A runs the one it had settled on.
|
||||
- A release with nothing selected, or on an empty slot, does nothing. An empty
|
||||
slot is a faint outline with no caption; only the selected action takes the
|
||||
accent.
|
||||
- An action that can't run now is drawn dimmed. It can be selected, but its
|
||||
release does nothing: no action, no click. The game decides which: Emote
|
||||
opens the emote wheel only with cosmetics on, Quick Actions only when the
|
||||
server gave a dialog for it, and Social only on a server. Social is the one
|
||||
exception to doing nothing: in single player its release goes through its key,
|
||||
which shows vanilla's "not available" message on the action bar, as
|
||||
`Gui.handleKeybinds` does (pressing its key does the same). A Missing action
|
||||
never runs. The others always run.
|
||||
- B, Start, a disconnect, losing the window's focus (with Play in Background on
|
||||
too), the player being replaced (a death with immediate respawn, the End's
|
||||
exit) or anything that opens over gameplay cancels, also during those 250 ms.
|
||||
RT or LT pressed while it is open cancels it and goes on to attack or use in
|
||||
the same poll; so does any key or mouse button, which isn't swallowed. No
|
||||
other press made while it is open reaches a mapping, and a press in the same
|
||||
poll as the opener's release, held back to the next tick or not, comes first,
|
||||
so B still cancels. Buttons held since before it opened keep their action and
|
||||
release it as usual, so a held sneak stays held. Moving the mouse neither
|
||||
cancels it nor takes control back from the pad, nor turns the view.
|
||||
- After it closes on a D-pad opener, a press of another D-pad button that starts
|
||||
within 100 ms reaches nothing, and nor does its release (`DPAD_SETTLE`): a
|
||||
thumb rolling off the opener doesn't drop or open the chat. The opener itself
|
||||
opens the menu again at once. A drop held through it starts
|
||||
its wait again as it closes, rather than throwing the rest of the stack. F3's
|
||||
3D crosshair gives way to it, as the 2D one does.
|
||||
- Menus, the on-screen keyboard and prompts come first: there the opener's
|
||||
button does what the screen says (or its other action, if it shares one).
|
||||
- The opener is never under a thumb that selects, as holding it and steering
|
||||
are two jobs for one thumb, nor on B, which cancels (`gamepad::opener_refused`):
|
||||
selecting with the right stick, not A, X, Y or R3; with the left, not the D-pad
|
||||
or L3 (A, X and Y are free); with either, neither set, leaving the bumpers,
|
||||
the triggers and View. In the binds list a refused button cancels the capture
|
||||
as Start does, and a file with one reads as if it had no opener saved (D-pad
|
||||
Right, unless another action is there or the stick refuses it). Changing the
|
||||
stick so that the opener is under the selecting thumb unbinds it, and the
|
||||
opener's row says no button opens the menu and which ones can; back on the
|
||||
right stick an unbound opener takes D-pad Right again if it is free. A bumper
|
||||
can be the opener; the other bumper then turns the pages on, round the end.
|
||||
|
||||
The default pages hold only actions with no button of their own that always
|
||||
run, so the menu adds to the pad instead of repeating it, on the straight
|
||||
directions: General has Advancements (up), Commands (right) and Social (down);
|
||||
Camera has Screenshot (up), Hide HUD (right) and Cinematic Camera (down). The
|
||||
rest start empty. Slots hold actions from a fixed list
|
||||
(`radial::RadialAction`), those that do everything on their press (Fullscreen,
|
||||
Quick Actions and the F3 debug screen besides those of the default pages), each shown
|
||||
by a short caption (`horizon.radial.action.*`). Its rules are in
|
||||
`horizon-client-input/src/radial.rs`, the overlay in
|
||||
`horizon-client-render/src/radial_overlay.rs`.
|
||||
|
||||
Pages live in `horizon-controller.txt`, written once they differ from the
|
||||
defaults, so each splitscreen profile has its own, after `radialLayout:1` (the
|
||||
version of the default page list they were written against). A page the player
|
||||
hasn't changed is written as its preset, `radial_<page>_preset:general` or
|
||||
`camera`, and reads back as that default page as it is now: changing one slot
|
||||
of General leaves Camera following the defaults. Setting a slot or a name to
|
||||
what it already is changes nothing, and a page edited back to its default (or
|
||||
read from its slots as that) is its preset again; an edited page drops a preset
|
||||
id this game doesn't know, which would otherwise replace it. A page of the player's own is
|
||||
`radial_<page>_slots` (eight ids, by commas) and its name as
|
||||
`radial_<page>_name` (a name Horizon gave it, by language key) or
|
||||
`radial_<page>_label` (the player's own text, kept as typed);
|
||||
`radial_<page>_kind:hotbar` is read and kept for a hotbar page, which this
|
||||
version shows with empty slots. Slot ids are vanilla key mappings by name
|
||||
(`key.chat`) and Horizon's own in its namespace (`horizon:emote`,
|
||||
`horizon:hotbar_next`, `horizon:hotbar_previous`); the ids they had before
|
||||
(`key.emote`, `pad.hotbarNext`, `pad.hotbarPrevious`) read through an alias
|
||||
table. An id this game doesn't know (a newer Horizon's, a mod's) stays in its
|
||||
slot, drawn as Missing and dimmed, and never runs, so a slot can't run a
|
||||
command. Fields and kinds it doesn't read stay too. A file can hold up to 16
|
||||
pages (`PARSE_MAX`), and the lines of any past them are kept; the menu and the
|
||||
editor show the first six, and a page deleted there brings the next forward.
|
||||
Removing an action empties its slot and moves no other.
|
||||
|
||||
Controller Settings > Bindings > Radial Menu edits them, on the settings shell
|
||||
(`horizon-client-render/src/radial_editor.rs`): the page list is the pages, by
|
||||
name, and each page has its eight slots by direction (A or a click opens the list
|
||||
of every action and Empty for that slot, which names the slot, marks what it holds
|
||||
and opens on it; Right steps a slot to the next action, Empty between the last and
|
||||
the first, and Left or Shift-click back, as on every choice of the settings shell),
|
||||
then Rename Page, New Page
|
||||
and Delete Page, then Reset All Pages and the opener's button, drawn with its
|
||||
glyph. A press on the opener's row waits there for a controller button, as a key
|
||||
row does: A, B, X, Y and the right stick's click are refused, Start cancels, and
|
||||
the row's description says when no button opens the menu or another action
|
||||
shares it. Delete and Reset act at once: Undo, there once something changed, puts
|
||||
the pages and the opener back as the screen found them. Rename opens a page with
|
||||
a name field (A on it opens the on-screen keyboard), Done and Cancel.
|
||||
|
||||
A Nintendo pad's face buttons can go by their places, as the table and SDL's
|
||||
layout have them (B, at the bottom, jumps and confirms), or by their letters,
|
||||
as a Switch's own menus and keyboard do: A (right) jumps and confirms, B goes
|
||||
@@ -469,9 +644,9 @@ The picture's texture is 32 pixels tall, drawn 16 GUI pixels tall with a clear e
|
||||
glyph shows 12 tall, as a text glyph does: a square picture 12 wide, a bumper or trigger 20. A declared
|
||||
button's label leaves room for it.
|
||||
|
||||
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide and splitscreen's
|
||||
strips. What runs inside a line of text stays text: the recipe tooltip, the on-screen keyboard's hint line and the buttons on
|
||||
the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
|
||||
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide, the radial
|
||||
menu's hints and splitscreen's strips. What runs inside a line of text stays text: the recipe tooltip,
|
||||
the on-screen keyboard's hint line and the buttons on the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
|
||||
|
||||
The inventory crosshair is `crosshair_inv` in the same folder, drawn at 16 GUI pixels on the point it
|
||||
clicks (`pad_cursor.rs`).
|
||||
@@ -491,7 +666,8 @@ Extras and Graphics, with a search field and General, Bindings and Device pages.
|
||||
describes the pointed or focused row, and what a choice's value does. Left and Right step a choice both
|
||||
ways, as its `<` `>` show. The search gives the keys back as soon as the focus leaves it.
|
||||
|
||||
Bindings is a list like the key binds screen. Each action's row shows its button's art in the player's
|
||||
Bindings starts with the Radial Menu row, which opens the radial menu's pages (described above), then
|
||||
Actions, a list like the key binds screen. Each action's row shows its button's art in the player's
|
||||
glyphs. Press the row and then a button; Start, a click or a key cancels, and the keyboard's Escape
|
||||
unbinds. Meanwhile the row reads "> A <" and the strip names the button that cancels. A button that does
|
||||
more than one action is marked "[ ]" in yellow, as vanilla marks conflicts, and the strip lists what else
|
||||
@@ -525,11 +701,13 @@ bind_key.jump:south
|
||||
craftOnServers:false
|
||||
gameplayGuide:full
|
||||
bind_pad.hotbarNext:rb
|
||||
bindLayout:1
|
||||
bindLayout:2
|
||||
```
|
||||
|
||||
Only bindings that differ from the defaults are written. A file without `bindLayout` comes from before the
|
||||
current defaults and keeps its buttons where they were.
|
||||
current defaults and keeps its buttons where they were. A file below `bindLayout:2` comes from before the radial
|
||||
menu's opener: on its first load the opener stays off D-pad Right if the player had put another action there.
|
||||
That happens once; from layout 2 on, a conflict the player makes in the binds list stays as they made it.
|
||||
|
||||
Deadzones are radial (the stick's length, so diagonals aren't clipped) and the
|
||||
remaining range is rescaled to 0..1. Look uses a squared curve for precise
|
||||
@@ -552,7 +730,9 @@ button, has no row. The mouse or keyboard taking over hides them, as do a menu,
|
||||
Gameplay Guide option. They read what a click would do from the client's own answers
|
||||
(`remote_block_reacts`, `remote_mob_takes`), and a use only a fluid ray or the server can tell (an
|
||||
empty bucket) shows nothing. The rows step around what the HUD drew (effect icons, boss bars, the
|
||||
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop.
|
||||
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop. The left
|
||||
column's last row names the radial menu's opener, as nothing else in play says it's there (for a
|
||||
spectator too, who can open it).
|
||||
|
||||
An inventory or creative screen the pad drives has rows of its own, from its bottom corners, under
|
||||
the same option, and what's under the crosshair decides them: a slot, by whether it holds anything
|
||||
|
||||
+3
-3
@@ -23,14 +23,14 @@ What changes in that build:
|
||||
|---|---|---|
|
||||
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
|
||||
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
Credits & Attribution then says where the official build is (`horizon.plain.official`).
|
||||
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
|
||||
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
|
||||
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
|
||||
gate guards.
|
||||
|
||||
+2
-2
@@ -156,8 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
|
||||
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
|
||||
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
|
||||
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
|
||||
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
|
||||
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
|
||||
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
- **Rules:**
|
||||
- It uses the platform's public API only, never the engine.
|
||||
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
|
||||
|
||||
+11
-6
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
|
||||
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
|
||||
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
|
||||
("In the Nether") and the time since they entered the world. It never shows a
|
||||
world's name or a server's address. Horizon Settings has the switch
|
||||
(**Discord Rich Presence**, on by default; `discordPresence` in
|
||||
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
world's name or a server's address. The switch is on the Extras page, in its
|
||||
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
|
||||
in `horizon-extras.txt`; in splitscreen only the first player has it), and
|
||||
`HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
|
||||
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
|
||||
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
|
||||
own: the frame only hands it a status when it changes. It retries every 15 s
|
||||
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
|
||||
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
|
||||
The game hands it a status only when it changes, through the platform's
|
||||
`activity` hook (`game/activity.rs` says what the player does), and keeps the
|
||||
switch itself: its settings store and its Extras row (the platform's settings
|
||||
and pages registries). Only the official executable (`horizon-official`)
|
||||
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
|
||||
while Discord isn't running and stays within Discord's 5 updates per 20 s.
|
||||
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
|
||||
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
|
||||
|
||||
+2
-2
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
|
||||
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
|
||||
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
|
||||
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
|
||||
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
|
||||
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
|
||||
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
|
||||
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
|
||||
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
|
||||
|
||||
+72
-3
@@ -14,7 +14,7 @@ The kit grows a piece at a time, each with something on screen that needs it:
|
||||
|
||||
| Piece | What it gives |
|
||||
|---|---|
|
||||
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars |
|
||||
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars and status rows |
|
||||
| `hud_kit::plate` | `Plate`: the box behind a notice's text, or a guide row's backing |
|
||||
| `hud_kit::shapes` | triangles, fans, arcs, discs and radial gradients for `HudBatch`; `alpha_since`, `translate_since` |
|
||||
| `hud_kit::motion` | `Clock` and `Tween`: quiet motion as pure functions of the frame's time |
|
||||
@@ -32,11 +32,12 @@ All sizes are GUI px from the view's top left, and an area is `[x, y, w, h]`.
|
||||
### Room
|
||||
|
||||
A `Room` is a view's size plus the keep-outs its HUD shows this frame. Each keep-out is a `Keep`
|
||||
and an area. Today there is one kind:
|
||||
and an area. There are two kinds:
|
||||
|
||||
| `Keep` | Where it comes from |
|
||||
|---|---|
|
||||
| `BossBars` | `hud_overlays::boss_bars_bottom(h, n)`: vanilla's `BossHealthOverlay` layout, y from 12 in steps of 19 until a third of the view's height; as wide as the 182 px bar or its widest name |
|
||||
| `StatusRows` | the survival rows above the hotbar (health with its absorption rows, armour, food or the mount's hearts, air), from the highest row's top to the view's bottom, as `hud_layout::draws_top` reports them; `room.bottom()` is the lowest y an overlay may reach |
|
||||
|
||||
`Game::hud_room` (`crates/horizon-client/src/game/hud_room.rs`) gathers what is showing.
|
||||
Nothing shows while F1 or Replay Studio hides the HUD, or off a world. `hud` empties `ui.room` at
|
||||
@@ -64,6 +65,23 @@ The other HUD boxes (the minimap, the PvP & HUD widgets, mods' corner panels) do
|
||||
yet. Chat, the hotbar, effect icons and subtitles aren't keep-outs yet either. #10125 asks for
|
||||
these push-aside points for mods.
|
||||
|
||||
### Overlays that hold the view
|
||||
|
||||
Some overlays take the middle of the view for themselves, as the radial menu does once it shows.
|
||||
What vanilla draws there gives way to them through one query, `Game::overlay_holds_view()`
|
||||
(`crates/horizon-client/src/game/hud_room.rs`), not a check per overlay in each piece:
|
||||
|
||||
| Gives way | Where |
|
||||
|---|---|
|
||||
| the 2D crosshair and F3's crosshair | `hud_layers`, `debug_crosshair_drawn` (`game.rs`) |
|
||||
| the chat's lines (an open chat still draws) | `hud_layers` (`game.rs`) |
|
||||
| every toast: advancements, recipes, tutorials, packs, music | `Game::draw_every_toast` (`hud_room.rs`); they go on timing out, as under F1 |
|
||||
| the first-person hands | `Game::hands_shown` (`hud_room.rs`), which `hand_geometry` reads (`game/frame_geometry.rs`) |
|
||||
|
||||
A new overlay that holds the view adds its own condition to `overlay_holds_view`. Ask from the moment
|
||||
it shows, not from when it owns the pad ([Pad capture](#pad-capture)): a quick tap of the radial
|
||||
menu's opener owns the pad for a frame and draws nothing, so nothing should blink.
|
||||
|
||||
### Plates
|
||||
|
||||
`hud_kit::plate::draw(batch, atlas, plate, area)` fills an area with a plate's look, then draws
|
||||
@@ -207,6 +225,30 @@ keeps the list, the arrows walk every page, search skips pages without matches,
|
||||
targets), and `tools/pad_menu_e2e.py --only shader_screens` (RB through every screen of a
|
||||
twelve-screen pack in a real game).
|
||||
|
||||
## Pad capture
|
||||
|
||||
Several overlays over play take the controller for themselves. `Game::pad_capture`
|
||||
(`crates/horizon-client/src/game/pad/capture.rs`) is the one place that says which owns it now,
|
||||
in one priority order:
|
||||
|
||||
| `PadCapture` | Owns the pad while |
|
||||
|---|---|
|
||||
| `Keyboard` | the on-screen keyboard is up |
|
||||
| `Prompt` | a prompt is over the world (a server dialog, the code of conduct, a link, command or resource pack prompt) |
|
||||
| `Radial` | the radial menu is held open |
|
||||
|
||||
Code that routes the pad's presses and sticks (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`,
|
||||
`window_handler.rs`), and the controller guide, which gives way to the keyboard, ask
|
||||
`pad_radial_captures()` or `pad_keyboard_captures()` rather than the overlays themselves. A new
|
||||
overlay that takes the pad adds its variant here, in its place in the order, and a check that asks
|
||||
about it gets a `pad_<overlay>_captures()` beside these. What vanilla draws under an overlay gives
|
||||
way through `overlay_holds_view` instead ([Overlays that hold the view](#overlays-that-hold-the-view)).
|
||||
Screens are not overlays: `pad_in_menu` says when one has the pad.
|
||||
|
||||
One change came with it: a prompt that arrives while the radial menu is open owns the pad from that
|
||||
poll, so a B in the same poll answers the prompt instead of cancelling the menu (which the prompt
|
||||
closes anyway). Before, the menu took that B.
|
||||
|
||||
## Glyphs
|
||||
|
||||
Code asks for a glyph by what the button does, never by a button. The chain runs from the job to the art:
|
||||
@@ -382,7 +424,9 @@ pages read from a shader pack, rows that link to pages out of the page list, and
|
||||
included. **Apply**, for changes that wait: `pending` shows it beside Undo, and `apply` does them.
|
||||
6. **Host it**: a `Shell` of it on `OptionsUi`, named in `OptionsUi::shell` and `shell_mut`, and opened
|
||||
at its page with `Shell::show`. Every input goes through those two, so a screen can't take the wheel
|
||||
and miss the stick.
|
||||
and miss the stick. They live in `options_ui/shell_pages.rs` with the rest of the hosting glue
|
||||
(`shell_events`, `pad_clicks_step`, `shell_opened_focus`), so hosting a screen touches that file,
|
||||
not `options_ui.rs`.
|
||||
7. **Delete** the screen's own drawing, scrolling, focus, search and click code.
|
||||
|
||||
What the old screen's tests checked moves to tests on the `Shell` of the new one: press a row with
|
||||
@@ -402,6 +446,11 @@ or what else its button does, and draws the row's reset.
|
||||
- `shared`: what else the same button does, which the strip lists and the row marks "[ ]" in yellow.
|
||||
- `reset`: `Reset::None` (no button), `Reset::Default` (greyed) or `Reset::Changed` (live) for a reset
|
||||
button at the row's end; pressed (a click, or Right then Enter or A), it calls `Screen::reset`.
|
||||
- The strip says the binding's note (unbound, or what else its button does) unless the row has a
|
||||
`chosen` of its own: that outranks the note except while the host waits, so a binding can say why it
|
||||
has no button or shares one (the radial menu's opener says what that means for opening the menu).
|
||||
- A `Capture::Pad` row waits on any screen of the shell, not only Controller Settings: the game binds
|
||||
the next button for whatever `selected_pad_action` names.
|
||||
|
||||
A press gives `Output::Bind(capture)`; the host's `shell_events` starts the wait, and passes what it waits
|
||||
on back to `Hosted::draw`.
|
||||
@@ -425,6 +474,26 @@ let binding = Binding {
|
||||
Row::new(Item::Bind(action), name, Value::Binding(binding))
|
||||
```
|
||||
|
||||
### Lists, steps and where the focus opens
|
||||
|
||||
A screen whose choice has too many values to step through can open a list of them instead, as the
|
||||
radial menu's editor does for a slot's action:
|
||||
|
||||
- `Screen::choose(s, row)`: a press on a `Choice` row (A, Enter or a click; not Left, Right or a
|
||||
Shift press). `None`, the default, steps it as Right does; outputs (a `Push` of the list's page) open
|
||||
the list instead. Left and Right still step it.
|
||||
- `Hosted::clicks_step(on)`: the host marks the clicks that are a controller's D-pad Left or Right
|
||||
(`OptionsUi::pad_clicks_step`, from `game/pad_menu.rs`), so on such a choice they step rather than
|
||||
open the list.
|
||||
- `Shell::focus_soon(row)`: put the focus on a row at the next draw, scrolled into view, as the
|
||||
keyboard would. The host reads where it went (`Hosted::opened_focus`, `OptionsUi::shell_opened_focus`)
|
||||
so a controller's focus lands there too: the list opens on the slot's current action, and back on
|
||||
the slot after a pick.
|
||||
- `Shell::changed_elsewhere()`: a page pushed over the screen (the list) changed what its Undo puts
|
||||
back.
|
||||
- A screen whose pages are the player's (`Sheet`s that come and go) can delete the open one: the shell
|
||||
then shows the first page.
|
||||
|
||||
### The page list
|
||||
|
||||
A screen of two pages or more has a page list down the left, a page under the last. A `Sheet` with
|
||||
|
||||
@@ -493,9 +493,12 @@ mod input_trace {
|
||||
pub enum InputOrigin { User }
|
||||
pub enum TraceKind { Motion { x: f64, y: f64 } }
|
||||
}
|
||||
struct Radial(bool);
|
||||
impl Radial { fn is_open(&self) -> bool { self.0 } }
|
||||
struct Pad { radial: Radial }
|
||||
struct Game {
|
||||
pending_save: Option<()>, captured: bool, focused: bool, keyboard_mouse: bool, split_window: bool,
|
||||
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, scripted: bool,
|
||||
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, pad: Pad, scripted: bool,
|
||||
}
|
||||
impl Game {
|
||||
fn scripted_run(&self) -> bool { self.scripted }
|
||||
@@ -503,26 +506,30 @@ impl Game {
|
||||
fn note_input(&mut self, _: input_trace::InputOrigin, _: input_trace::TraceKind) {}
|
||||
// Handing input back from the controller does not change the turn either.
|
||||
fn pad_yield_input(&mut self, _: &ActiveEventLoop) {}
|
||||
// `game/pad/capture.rs`: the radial menu owns the pad while it is open.
|
||||
fn pad_radial_captures(&self) -> bool { self.pad.radial.is_open() }
|
||||
METHOD
|
||||
}
|
||||
fn main() {
|
||||
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving,
|
||||
// a scripted run).
|
||||
for (offscreen, split, owner, active, playing, saving, scripted, turns) in [
|
||||
(false, false, true, true, true, false, false, true),
|
||||
(true, true, true, true, true, false, false, true),
|
||||
(true, true, false, true, true, false, false, false),
|
||||
(true, false, true, true, true, false, false, false),
|
||||
(true, true, true, false, true, false, false, false),
|
||||
(true, true, true, true, false, false, false, false),
|
||||
(true, true, true, true, true, true, false, false),
|
||||
(false, false, true, true, true, false, true, false),
|
||||
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving, the pad's
|
||||
// radial menu open, a scripted run).
|
||||
for (offscreen, split, owner, active, playing, saving, radial, scripted, turns) in [
|
||||
(false, false, true, true, true, false, false, false, true),
|
||||
(true, true, true, true, true, false, false, false, true),
|
||||
(true, true, false, true, true, false, false, false, false),
|
||||
(true, false, true, true, true, false, false, false, false),
|
||||
(true, true, true, false, true, false, false, false, false),
|
||||
(true, true, true, true, false, false, false, false, false),
|
||||
(true, true, true, true, true, true, false, false, false),
|
||||
(false, false, true, true, true, false, true, false, false),
|
||||
(false, false, true, true, true, false, false, true, false),
|
||||
] {
|
||||
let mut game = Game {
|
||||
pending_save: saving.then_some(()), captured: true, focused: active,
|
||||
keyboard_mouse: owner, split_window: split, options: Options { offscreen },
|
||||
screen: if playing { Screen::Playing } else { Screen::Paused },
|
||||
mouse: MouseLook::default(), last_input: Instant::now(), scripted,
|
||||
mouse: MouseLook::default(), last_input: Instant::now(), pad: Pad { radial: Radial(radial) },
|
||||
scripted,
|
||||
};
|
||||
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (0.0, 0.0) });
|
||||
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (20.0, -10.0) });
|
||||
@@ -532,7 +539,7 @@ fn main() {
|
||||
rotation.turn(x, y);
|
||||
let want = if turns { (3.0, -1.5) } else { (0.0, 0.0) };
|
||||
assert!((rotation.y_rot - want.0).abs() < 0.00001 && (rotation.x_rot - want.1).abs() < 0.00001,
|
||||
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, radial={radial}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user