mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cea8c2ee6 | ||
|
|
0694db7dcb | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b |
No files matched your search
@@ -13,3 +13,4 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -1,34 +0,0 @@
|
||||
name: runner watch
|
||||
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
|
||||
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
|
||||
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
|
||||
# gh workflow run "runner watch" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '11,41 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with the issue
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: runner-watch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
watch:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Jobs waiting for a self-hosted runner
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -198,5 +198,3 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
Generated
+2
-21
@@ -1461,6 +1461,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1650,15 +1651,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1802,31 +1794,20 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -34,8 +34,6 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
-583
@@ -1,583 +0,0 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
>
|
||||
> 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
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; 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
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
### 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
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. 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. A moderator gives it, and takes it back, through Kira.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours; 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 |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
|
||||
| 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) |
|
||||
| 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 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
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 Elections
|
||||
|
||||
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
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by 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).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- 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 starting elections in the group.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira 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 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 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
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes. The 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
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,8 +135,6 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -70,8 +70,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
|
||||
one ACK per completed batch; use explore for a controlled pacing test
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
@@ -173,8 +171,6 @@ struct Options {
|
||||
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
|
||||
crits: bool,
|
||||
flood: usize,
|
||||
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
|
||||
ack_rate: Option<f32>,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
@@ -213,7 +209,6 @@ fn options() -> Result<Options, String> {
|
||||
no_swing: false,
|
||||
crits: false,
|
||||
flood: 100,
|
||||
ack_rate: None,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
@@ -271,13 +266,6 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
|
||||
"--ack-mode" => {
|
||||
o.ack_rate = match value.as_str() {
|
||||
"vanilla" => None,
|
||||
"64" => Some(64.0),
|
||||
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
|
||||
};
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
@@ -962,7 +950,6 @@ fn read(
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
ack_rate: Option<f32>,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
@@ -1048,8 +1035,7 @@ fn read(
|
||||
batch.finished(r.varint().unwrap_or(0));
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: ack_rate
|
||||
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
|
||||
chunks_per_tick: batch.desired_chunks_per_tick(),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
@@ -1153,8 +1139,7 @@ fn run_bot(
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
let ack_rate = o.ack_rate;
|
||||
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1534,7 +1519,6 @@ fn main() {
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
|
||||
"pvp": o.pvp,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
@@ -1600,83 +1584,6 @@ mod tests {
|
||||
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
|
||||
};
|
||||
|
||||
/// Exercise the production reader over framed connections. Pings are barriers
|
||||
/// so extra ACKs are observable without sleeps or timing guesses.
|
||||
fn check_chunk_acks(ack_rate: Option<f32>) {
|
||||
use horizon_protocol::connection::Pipe;
|
||||
let (client, server) = Pipe::pair();
|
||||
let (reader, writer) = Connection::local(client).split();
|
||||
let mut server = Connection::local(server);
|
||||
server
|
||||
.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let bot = Arc::new(Bot::default());
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
let handle = thread::spawn(move || {
|
||||
read(
|
||||
reader,
|
||||
Arc::new(Mutex::new(writer)),
|
||||
bot,
|
||||
false,
|
||||
ack_rate,
|
||||
None,
|
||||
)
|
||||
});
|
||||
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"batch start must not send an ACK"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
|
||||
w.varint(size)
|
||||
}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
|
||||
let rate = Reader::new(&body).f32().unwrap();
|
||||
if let Some(fixed) = ack_rate {
|
||||
assert_eq!(rate, fixed);
|
||||
} else if size == 0 {
|
||||
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
|
||||
} else {
|
||||
assert!(rate.is_finite() && rate > 0.0);
|
||||
}
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"exactly one ACK per finished batch"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
}
|
||||
server.writer.shutdown();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_64_ack_is_sent_once_per_finished_batch() {
|
||||
check_chunk_acks(Some(64.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_acks_remain_once_per_finished_batch() {
|
||||
check_chunk_acks(None);
|
||||
}
|
||||
|
||||
fn feed(entities: &mut Entities, packet: &Clientbound) {
|
||||
let bytes = packet.encode();
|
||||
let mut r = Reader::new(&bytes);
|
||||
|
||||
@@ -514,34 +514,6 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_fallback_references_stay_bounded() {
|
||||
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
|
||||
// 4 KB component, which ran a client out of memory.
|
||||
let bomb = |depth: usize, text: &str| {
|
||||
let pattern = "%1$s".repeat(300);
|
||||
let mut v = serde_json::json!({ "text": text });
|
||||
for _ in 0..depth {
|
||||
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
|
||||
}
|
||||
v
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
|
||||
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
|
||||
// One long argument referenced many times multiplies bytes, not spans.
|
||||
let long = "B".repeat(64 << 10);
|
||||
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
|
||||
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
|
||||
assert!(bytes <= 4 << 20, "{bytes} bytes");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A real fallback still formats.
|
||||
let ok = serde_json::json!({
|
||||
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
|
||||
});
|
||||
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
|
||||
}
|
||||
use crate::lang::{self, Language, ResourceSource};
|
||||
|
||||
struct Mem(std::collections::HashMap<String, Vec<u8>>);
|
||||
|
||||
@@ -529,13 +529,10 @@ impl Team {
|
||||
None => t,
|
||||
}
|
||||
}
|
||||
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
|
||||
/// `displayNameStyle`, which its constructor makes the team's name on hover
|
||||
/// (`ShowText`) and as insertion.
|
||||
/// `PlayerTeam.getFormattedDisplayName`.
|
||||
pub fn formatted_display_name(&self) -> Text {
|
||||
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
|
||||
click: None,
|
||||
insertion: Some(self.name.clone()),
|
||||
..Style::default()
|
||||
});
|
||||
self.apply_color(bracketed(inner))
|
||||
@@ -1414,21 +1411,6 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
|
||||
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
|
||||
#[test]
|
||||
fn a_teams_formatted_name_inserts_the_team_name() {
|
||||
let team = Team::new("reds");
|
||||
let c = team.formatted_display_name().component();
|
||||
assert_eq!(c["translate"], "chat.square_brackets");
|
||||
let inner = &c["with"][0];
|
||||
assert_eq!(inner["text"], "reds");
|
||||
assert_eq!(inner["insertion"], "reds");
|
||||
assert_eq!(inner["hover_event"]["action"], "show_text");
|
||||
// `{"text":"reds"}` or its short form `"reds"`: the same component.
|
||||
let value = &inner["hover_event"]["value"];
|
||||
assert!(value == "reds" || value["text"] == "reds", "{value}");
|
||||
}
|
||||
|
||||
fn board() -> Scoreboard {
|
||||
let mut sb = Scoreboard::default();
|
||||
|
||||
@@ -282,20 +282,17 @@ impl Text {
|
||||
pub fn push(&mut self, other: impl Into<Text>) {
|
||||
let other = other.into();
|
||||
let sibling = other.component();
|
||||
// The tree is appended to in place: cloning all of it on every append made a component
|
||||
// with N siblings (a server's chat or MOTD "extra") cost N^2.
|
||||
let root = match self.source.take() {
|
||||
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
|
||||
None => std::sync::Arc::new(self.component()),
|
||||
Some(c) => c,
|
||||
};
|
||||
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
|
||||
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
|
||||
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
|
||||
let mut root = if self.spans.is_empty() && self.source.is_none() {
|
||||
json!({ "text": "" })
|
||||
} else {
|
||||
root
|
||||
let c = self.component();
|
||||
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
|
||||
json!({ "text": "", "extra": [c] })
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
|
||||
if let Value::Object(o) = &mut root {
|
||||
match o.get_mut("extra") {
|
||||
Some(Value::Array(a)) => a.push(sibling),
|
||||
_ => {
|
||||
@@ -303,7 +300,7 @@ impl Text {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.source = Some(root);
|
||||
self.source = Some(root.into());
|
||||
self.spans.extend(other.spans);
|
||||
}
|
||||
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
|
||||
@@ -674,22 +671,11 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
|
||||
/// references to its arguments, so its cost stays linear in the input; copying them here makes
|
||||
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
|
||||
/// `fallback` template is any text it likes.
|
||||
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
|
||||
/// The most text one translation expands to: one long argument referenced many times multiplies
|
||||
/// bytes, not spans.
|
||||
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
|
||||
/// [`MAX_TRANSLATION_BYTES`].
|
||||
/// shown verbatim.
|
||||
pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let mut out = Text::default();
|
||||
let mut bytes = 0usize;
|
||||
let mut literal = String::new();
|
||||
let mut next = 0usize;
|
||||
let chars: Vec<char> = template.chars().collect();
|
||||
@@ -739,14 +725,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let Some(arg) = args.get(index) else {
|
||||
return Text::literal(template);
|
||||
};
|
||||
bytes = bytes
|
||||
.saturating_add(literal.len())
|
||||
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
|
||||
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|
||||
|| bytes > MAX_TRANSLATION_BYTES
|
||||
{
|
||||
return Text::literal(template);
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
out.spans.push(Span {
|
||||
text: std::mem::take(&mut literal),
|
||||
@@ -772,42 +750,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn appending_builds_the_same_tree_in_linear_time() {
|
||||
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
|
||||
// takes the siblings itself; a copy taken before an append keeps its own tree.
|
||||
let mut styled = Text::literal("a").with_style(&Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
styled.push(Text::literal("b"));
|
||||
let before = styled.clone();
|
||||
styled.push(Text::literal("c"));
|
||||
assert_eq!(
|
||||
styled.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
|
||||
);
|
||||
assert_eq!(
|
||||
before.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
|
||||
);
|
||||
let mut plain = Text::default();
|
||||
plain.push(Text::literal("x"));
|
||||
plain.push(Text::literal("y"));
|
||||
assert_eq!(
|
||||
plain.component(),
|
||||
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
|
||||
);
|
||||
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
|
||||
// whole tree.
|
||||
let started = std::time::Instant::now();
|
||||
let mut long = Text::default();
|
||||
for _ in 0..20_000 {
|
||||
long.push(Text::literal("a"));
|
||||
}
|
||||
assert_eq!(long.spans.len(), 20_000);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
}
|
||||
#[test]
|
||||
fn click_events_serialize_and_inherit_as_one_style_field() {
|
||||
for (event, json) in [
|
||||
(
|
||||
|
||||
@@ -9,13 +9,11 @@ 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,7 +98,6 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,17 +253,6 @@ 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
|
||||
@@ -282,7 +271,6 @@ 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),
|
||||
@@ -315,14 +303,11 @@ 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,
|
||||
@@ -335,12 +320,10 @@ 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);
|
||||
@@ -351,16 +334,12 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -675,21 +654,6 @@ 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 {
|
||||
@@ -716,12 +680,6 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,7 +706,6 @@ 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;
|
||||
@@ -797,85 +754,22 @@ 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 => {
|
||||
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())),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
// 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:")) {
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -884,7 +778,10 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !saved(action) {
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -925,25 +822,7 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
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.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -956,15 +835,11 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
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 {
|
||||
@@ -1565,14 +1440,6 @@ 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 {
|
||||
@@ -1592,7 +1459,6 @@ 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 {
|
||||
@@ -1600,10 +1466,6 @@ 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.
|
||||
@@ -1630,9 +1492,6 @@ 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,
|
||||
@@ -1654,10 +1513,6 @@ 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)]
|
||||
@@ -2018,55 +1873,7 @@ 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.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"))
|
||||
);
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
//! 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,13 +141,8 @@ 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: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -183,8 +178,6 @@ 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()
|
||||
@@ -573,7 +566,6 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -744,12 +736,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -773,8 +765,7 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ 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,30 +638,10 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -674,15 +654,14 @@ fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
/// 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 {
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -760,8 +739,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
assert!(icon().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,6 +70,7 @@ 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" }
|
||||
@@ -108,12 +109,17 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
# 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 (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# 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.
|
||||
@@ -125,8 +131,6 @@ 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,6 +29,7 @@ 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"
|
||||
@@ -84,7 +85,5 @@ 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"] }
|
||||
@@ -386,7 +386,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,7 +207,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -252,7 +251,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,7 +167,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -337,7 +336,6 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -6,12 +6,7 @@ use horizon_world::{
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
|
||||
|
||||
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -187,23 +182,6 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
/// Every block registry [`Assets::load`] has handed out, one per distinct
|
||||
/// content. Callers keep `&'static` references, so a registry is never freed;
|
||||
/// loading equal data again (tests load the same root once per fixture)
|
||||
/// returns the one already leaked instead of leaking another (about 11 MB for
|
||||
/// vanilla).
|
||||
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
|
||||
|
||||
/// `registry` leaked, or the equal one leaked before it.
|
||||
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
|
||||
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
|
||||
return same;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
leaked.push(registry);
|
||||
registry
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
|
||||
@@ -278,7 +256,7 @@ impl Assets {
|
||||
// Chunks hold every state of this registry: content mods can take it
|
||||
// past vanilla's 16-bit global palette.
|
||||
crate::chunk::set_block_states(registry.len());
|
||||
let registry = intern_registry(registry);
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
@@ -342,16 +320,6 @@ impl RenderAssets {
|
||||
}
|
||||
None => crate::model_library::ParsedModels::default(),
|
||||
};
|
||||
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
|
||||
// layer of its own, decided before the models insert their textures.
|
||||
let ctm_rules = if draws {
|
||||
horizon_optifine::ctm::load(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
|
||||
&ctm_rules,
|
||||
));
|
||||
let mut models = if draws {
|
||||
let _step = crate::spans::startup::step("core.models_bake");
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
@@ -391,7 +359,6 @@ impl RenderAssets {
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
ctm_rules,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
@@ -529,29 +496,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_registries_are_leaked_once() {
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
let registry = |extra: &str| {
|
||||
let facts = format!(
|
||||
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["{extra}",false,false,"","fixture:{extra}"]
|
||||
]}}"#
|
||||
);
|
||||
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
|
||||
};
|
||||
let first = super::intern_registry(registry("interned_once"));
|
||||
let again = super::intern_registry(registry("interned_once"));
|
||||
let other = super::intern_registry(registry("interned_other"));
|
||||
assert!(std::ptr::eq(first, again), "equal content leaked twice");
|
||||
assert!(!std::ptr::eq(first, other), "different content shared");
|
||||
assert_eq!(
|
||||
other.default_state("fixture:interned_other"),
|
||||
Some(horizon_world::registry::BlockStateId(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
|
||||
@@ -1974,11 +1974,7 @@ impl ChatScreen {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
// Round instead of truncating: touchpad/PixelDelta produces fractional
|
||||
// amounts and truncation drops movement in one direction (#14338).
|
||||
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
|
||||
.round()
|
||||
.clamp(0.0, max as f64) as usize;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input. The
|
||||
@@ -2090,11 +2086,6 @@ impl ChatScreen {
|
||||
) {
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
// SuggestionsList.extractRenderState: the whole list asks for the
|
||||
// pointing hand while the pointer is inside its rectangle.
|
||||
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
|
||||
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
|
||||
}
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
let moved = l.last_mouse != mouse;
|
||||
|
||||
@@ -1066,32 +1066,3 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
fn the_suggestion_list_asks_for_the_pointing_hand() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/");
|
||||
let rect = screen
|
||||
.suggestions
|
||||
.as_ref()
|
||||
.expect("the root commands are a suggestion list")
|
||||
.rect;
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
|
||||
assert_eq!(
|
||||
crate::widgets::take_cursor(),
|
||||
crate::widgets::Cursor::PointingHand
|
||||
);
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
|
||||
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
|
||||
}
|
||||
@@ -124,25 +124,15 @@ fn sprite_name(resource: &str) -> String {
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
|
||||
/// [`Sprites::keep_apart`]: before the models insert theirs.
|
||||
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
|
||||
(rules.iter())
|
||||
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
|
||||
.map(|t| sprite_name(t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
|
||||
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
|
||||
/// by tile.
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
mut rules: Vec<Rule>,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules with a custom name load no tiles: section meshes see no block entity.
|
||||
rules.retain(|r| r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
@@ -1280,41 +1270,6 @@ mod tests {
|
||||
assert!(!ctm.stacked_pane_hides(3, 3, post));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_name_the_sprites_they_match() {
|
||||
// `matchTiles` among the textures and beside the file (a chain onto another rule's
|
||||
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
|
||||
// under.
|
||||
let dir = "assets/minecraft/optifine/ctm/pack/crops";
|
||||
let rule = |file: &str, text: &str| {
|
||||
Rule::parse(
|
||||
&format!("{dir}/{file}"),
|
||||
&horizon_optifine::properties::Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let rules = [
|
||||
rule(
|
||||
"1.properties",
|
||||
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
|
||||
),
|
||||
rule(
|
||||
"2.properties",
|
||||
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
|
||||
connectTiles=textures/block/stone.png\ntiles=0-16",
|
||||
),
|
||||
];
|
||||
assert_eq!(
|
||||
ConnectedTextures::matched_sprites(&rules),
|
||||
[
|
||||
format!("{dir}/19.png"),
|
||||
"minecraft:block/extra/placeholder".to_owned(),
|
||||
"minecraft:block/stone".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -57,8 +57,6 @@ 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),
|
||||
}
|
||||
|
||||
@@ -108,15 +106,6 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -134,11 +123,6 @@ 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.";
|
||||
|
||||
@@ -217,13 +201,6 @@ 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![
|
||||
@@ -254,13 +231,6 @@ 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",
|
||||
@@ -467,7 +437,6 @@ 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)
|
||||
@@ -527,7 +496,6 @@ 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(),
|
||||
}
|
||||
}
|
||||
@@ -565,21 +533,15 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 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.
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
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,
|
||||
/// and nor do the radial menu's pages, which this Undo keeps.
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
Some(s.controller != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,48 +793,6 @@ 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();
|
||||
@@ -896,8 +816,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, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
// Search, the three pages, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -1019,8 +939,6 @@ 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();
|
||||
@@ -1054,8 +972,6 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -1074,7 +990,6 @@ 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",
|
||||
|
||||
@@ -12,8 +12,6 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
mod noise;
|
||||
|
||||
pub struct Environment {
|
||||
system: System,
|
||||
/// What the packs' expressions read; the frame sets it before sampling.
|
||||
@@ -561,9 +559,13 @@ impl<'a> BlockHost<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
|
||||
noise::builtin()
|
||||
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
|
||||
std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| {
|
||||
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
|
||||
})
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
@@ -613,7 +615,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
(V::Object(2), "noise") => {
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(polytone_noise().get(x, y))
|
||||
num(f64::from(polytone_noise().get2(x, y)))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
|
||||
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
|
||||
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
|
||||
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
|
||||
//! `{1}` or `{0, 1}` matches this construction too.
|
||||
|
||||
use horizon_core::noise::SimplexNoise;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
|
||||
pub(crate) struct PerlinSimplex {
|
||||
levels: Vec<Option<SimplexNoise>>,
|
||||
input_factor: f64,
|
||||
value_factor: f64,
|
||||
}
|
||||
|
||||
impl PerlinSimplex {
|
||||
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
|
||||
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
|
||||
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
|
||||
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let lowest = octaves.iter().copied().min().unwrap_or(0);
|
||||
let highest = octaves.iter().copied().max().unwrap_or(0);
|
||||
let count = (highest - lowest + 1) as usize;
|
||||
let first = SimplexNoise::new(&mut random);
|
||||
let top = highest;
|
||||
let wanted = |index: i32| octaves.contains(&(top - index));
|
||||
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
|
||||
for index in top + 1..count as i32 {
|
||||
if index >= 0 && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut random));
|
||||
} else {
|
||||
random.consume_count(262);
|
||||
}
|
||||
}
|
||||
if highest > 0 {
|
||||
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
|
||||
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
|
||||
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
|
||||
let mut upper = LegacyRandom::new(value as i64);
|
||||
for index in (0..top).rev() {
|
||||
if (index as usize) < count && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
|
||||
} else {
|
||||
upper.consume_count(262);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (0..count as i32).contains(&top) && octaves.contains(&0) {
|
||||
levels[top as usize] = Some(first);
|
||||
}
|
||||
Self {
|
||||
levels,
|
||||
input_factor: 2f64.powi(highest),
|
||||
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
|
||||
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
|
||||
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
|
||||
for level in &self.levels {
|
||||
if let Some(noise) = level {
|
||||
sum += noise.get2_f64(x * input, y * input) * value;
|
||||
}
|
||||
input /= 2.0;
|
||||
value *= 2.0;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
|
||||
pub(crate) fn builtin() -> &'static PerlinSimplex {
|
||||
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
|
||||
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
|
||||
#[test]
|
||||
fn the_builtin_noise_reads_what_the_mod_draws() {
|
||||
for (x, z, mod_value) in [
|
||||
(0, 10, -0.28373),
|
||||
(3, 10, -0.14683),
|
||||
(10, 0, 0.31833),
|
||||
(24, 14, -0.55941),
|
||||
(40, 25, -0.48358),
|
||||
(7, 3, -0.68202),
|
||||
] {
|
||||
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
|
||||
assert!(
|
||||
(value - mod_value).abs() < 0.02,
|
||||
"({x}, {z}): {value} against {mod_value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
|
||||
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
|
||||
#[test]
|
||||
fn two_octaves_weigh_a_third_and_two_thirds() {
|
||||
let both = PerlinSimplex::new(0, &[0, 1]);
|
||||
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
|
||||
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
|
||||
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
|
||||
assert!((both.get(x, y) - mixed).abs() < 1e-12);
|
||||
}
|
||||
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
@@ -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) are kept here too.
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,6 +145,8 @@ 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,
|
||||
@@ -231,6 +233,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -335,6 +338,7 @@ 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),
|
||||
@@ -372,7 +376,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:{}\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:{}\ndiscordPresence:{}\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),
|
||||
@@ -414,6 +418,7 @@ 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,
|
||||
@@ -608,6 +613,8 @@ 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.
|
||||
@@ -684,6 +691,7 @@ 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",
|
||||
@@ -716,6 +724,7 @@ 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",
|
||||
@@ -759,6 +768,7 @@ 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",
|
||||
@@ -782,9 +792,10 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -833,6 +844,9 @@ 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."
|
||||
}
|
||||
@@ -913,6 +927,7 @@ 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,
|
||||
@@ -971,6 +986,7 @@ 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,
|
||||
@@ -1088,7 +1104,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.wheel_cycles_settings);
|
||||
assert!(d.discord_presence && !d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -1127,6 +1143,7 @@ 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));
|
||||
|
||||
@@ -112,11 +112,7 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
@@ -494,7 +490,9 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,6 @@ pub struct FogInputs<'a> {
|
||||
pub night_vision: f32,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -399,24 +397,6 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -515,7 +495,6 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -632,38 +611,6 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: crate::pack_palette::Environment,
|
||||
|
||||
@@ -60,7 +60,6 @@ mod entity_outline;
|
||||
mod fluid;
|
||||
#[cfg(test)]
|
||||
mod inter_stage_tests;
|
||||
mod section_fade;
|
||||
mod terrain_model;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
@@ -75,10 +74,6 @@ pub use entity_batch::{
|
||||
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
|
||||
mark_oit,
|
||||
};
|
||||
use section_fade::{
|
||||
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
|
||||
section_visibility,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -206,7 +201,6 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
|
||||
pub use screenshot_save::Saved as SavedScreenshot;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -242,7 +236,6 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod screenshot_save;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
@@ -370,6 +363,62 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
|
||||
let whole = camera.floor();
|
||||
whole + ((camera - whole) * 65536.0).round() / 65536.0
|
||||
}
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
struct FadeInputs<'a> {
|
||||
now: Instant,
|
||||
fade_ms: u64,
|
||||
uploaded: &'a FxHashMap<SectionKey, Instant>,
|
||||
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
/// A new value for `Renderer::block_entity_revision`.
|
||||
fn next_block_entity_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -1035,9 +1084,6 @@ pub struct ScreenshotRequest {
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
|
||||
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
|
||||
pub background: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
@@ -1125,8 +1171,6 @@ pub struct Renderer {
|
||||
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
|
||||
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
|
||||
packed_quads: bool,
|
||||
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
|
||||
packed_on: bool,
|
||||
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
|
||||
/// `HORIZON_VISBUFFER=1`).
|
||||
visbuf: Option<visbuffer::Visbuffer>,
|
||||
@@ -1534,9 +1578,6 @@ pub struct Renderer {
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
|
||||
pub screenshot_hdr: Option<std::path::PathBuf>,
|
||||
/// `background` screenshots written since the caller last took them.
|
||||
pub saved_screenshots: Vec<SavedScreenshot>,
|
||||
screenshot_saves: screenshot_save::Saves,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
@@ -4904,8 +4945,8 @@ impl Renderer {
|
||||
}
|
||||
None => (solid, solid_opaque, solid_cutout, solid_mirror),
|
||||
};
|
||||
// The solid quads in the compact layout where every pass reads it; the first world
|
||||
// frames settle it for the picked pack (`follow_packed_layout`).
|
||||
// The solid quads in the compact layout, while no shader pack reads the arena
|
||||
// (`follow_packed_layout`).
|
||||
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
|
||||
packed::pack_quads(packed_quads);
|
||||
// A native mod's world warp builds its pipelines with these layouts once it is given one.
|
||||
@@ -5036,7 +5077,6 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
let screenshot_saves = screenshot_save::Saves::new(&device);
|
||||
Ok(Self {
|
||||
display: None,
|
||||
reading: display,
|
||||
@@ -5073,7 +5113,6 @@ impl Renderer {
|
||||
world_warps,
|
||||
flat_on: flat.is_some(),
|
||||
packed_quads,
|
||||
packed_on: packed_wanted(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
gpu_culling: None,
|
||||
@@ -5309,8 +5348,6 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
saved_screenshots: Vec::new(),
|
||||
screenshot_saves,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -6710,17 +6747,13 @@ impl Renderer {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
}
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
|
||||
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
|
||||
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
|
||||
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
|
||||
/// mesh again, as `follow_translucent_layout` does.
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
|
||||
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
|
||||
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
|
||||
/// for the game to mesh again, as `follow_translucent_layout` does.
|
||||
fn follow_packed_layout(&mut self) {
|
||||
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
|
||||
let want = self.flat.is_some()
|
||||
&& (!pack || self.pack_terrain_flat)
|
||||
&& self.native.is_none()
|
||||
&& self.packed_on;
|
||||
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
|
||||
if want == self.packed_quads {
|
||||
return;
|
||||
}
|
||||
@@ -6731,10 +6764,6 @@ impl Renderer {
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: compact solid terrain {}",
|
||||
if want { "on" } else { "off" }
|
||||
);
|
||||
self.packed_quads = want;
|
||||
packed::pack_quads(want);
|
||||
let laid_out = self.sections.values().any(|s| s.packed)
|
||||
@@ -6744,17 +6773,6 @@ impl Renderer {
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
}
|
||||
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
|
||||
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
|
||||
pub fn set_packed_layout(&mut self, on: bool) -> bool {
|
||||
if on != self.packed_on {
|
||||
self.packed_on = on;
|
||||
self.follow_packed_layout();
|
||||
self.sections_dropped = true;
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
self.packed_quads
|
||||
}
|
||||
/// Whether every section and far tile was dropped since the last call, to be
|
||||
/// laid out again (`follow_translucent_layout`): the game meshes them again,
|
||||
/// as for a new renderer.
|
||||
@@ -7023,10 +7041,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7197,8 +7213,7 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |t| [t.width(), t.height()]),
|
||||
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7368,8 +7383,7 @@ impl Renderer {
|
||||
lightmap,
|
||||
gui_size,
|
||||
terrain: terrain.then(|| pack_post::Geometry {
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: table.as_ref(),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7534,8 +7548,7 @@ impl Renderer {
|
||||
);
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs,
|
||||
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
|
||||
pull: String::new(),
|
||||
pull: pack_atlas::PULL.to_owned(),
|
||||
index: "hz_indices[@]".to_owned(),
|
||||
}
|
||||
});
|
||||
@@ -7962,8 +7975,6 @@ impl Renderer {
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let saved = self.screenshot_saves.finished();
|
||||
self.saved_screenshots.extend(saved);
|
||||
if let Some(request) = self.capture.take() {
|
||||
let result = self.write_capture(&request, frame);
|
||||
self.capture_result = Some(result.map_err(|e| e.to_string()));
|
||||
@@ -8308,7 +8319,9 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -12031,18 +12044,24 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
let size = [self.config.width, self.config.height];
|
||||
let hdr = hdr_readback
|
||||
.filter(|_| self.hdr.is_some())
|
||||
.map(|b| (hdr_screenshot_path(&request.path), b));
|
||||
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
|
||||
if request.background {
|
||||
self.screenshot_saves.start(shot, buffer);
|
||||
} else {
|
||||
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
|
||||
self.screenshot_hdr = saved.hdr;
|
||||
self.screenshot_result = Some(saved.result);
|
||||
self.screenshot_hdr = None;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The frame's own waits for the submit thread (its queue writes and passes).
|
||||
@@ -12645,22 +12664,96 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
if self.hdr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
@@ -15280,8 +15373,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
|
||||
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct Hdr {
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
@@ -1001,14 +1001,12 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
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 (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -144,9 +144,6 @@ pub struct EffectiveOutput {
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -169,22 +166,7 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -291,7 +273,6 @@ pub(crate) fn resolve_output(
|
||||
],
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -548,107 +548,27 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
|
||||
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
|
||||
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
|
||||
/// give back their quad's vertices bit for bit, so packs read either layout alike.
|
||||
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
|
||||
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
|
||||
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_w = hz_av * 6u;\n \
|
||||
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
|
||||
hz_uv = hz_vertices[hz_w + 3u];\n\
|
||||
}\n\
|
||||
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
|
||||
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
|
||||
}\n\
|
||||
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
|
||||
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
|
||||
uint hz_axis = (hz_o >> 12u) & 3u;\n \
|
||||
uint hz_side = (hz_o >> 14u) & 1u;\n \
|
||||
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
|
||||
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
|
||||
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
|
||||
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
|
||||
uint hz_sel = hz_lo.z >> 24u;\n \
|
||||
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
|
||||
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
|
||||
hz_p = floatBitsToUint(vec3(hz_pos));\n \
|
||||
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
|
||||
}\n\
|
||||
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
|
||||
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
|
||||
hz_l &= 0xfffu;\n \
|
||||
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
|
||||
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
|
||||
uint hz_t = hz_lo.x;\n \
|
||||
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
|
||||
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
|
||||
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
|
||||
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
|
||||
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
|
||||
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
|
||||
}\n";
|
||||
|
||||
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
|
||||
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
|
||||
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
|
||||
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
|
||||
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
|
||||
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
|
||||
uint hz_own = hz_qv & 3u;\n \
|
||||
uint hz_av = hz_qv & ~3u;\n \
|
||||
uint hz_slot = hz_qv;\n \
|
||||
bool hz_rec = false;\n \
|
||||
uvec3 hz_lo = uvec3(0u);\n \
|
||||
uvec3 hz_hi = uvec3(0u);\n \
|
||||
if (hz_av >= 0x10000000u) {\n \
|
||||
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
|
||||
uint hz_rw = hz_slot * 6u;\n \
|
||||
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
|
||||
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
|
||||
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
|
||||
hz_av = hz_slot + hz_lo.x;\n \
|
||||
}\n \
|
||||
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
|
||||
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
|
||||
uvec2 hz_rest;\n \
|
||||
if (hz_rec) {\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
|
||||
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
|
||||
} else {\n \
|
||||
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
|
||||
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
|
||||
uint hz_ow = (hz_av + hz_own) * 6u;\n \
|
||||
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
|
||||
}\n \
|
||||
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
|
||||
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
|
||||
hz_v_pos = uintBitsToFloat(hz_vp);\n \
|
||||
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
|
||||
hz_v_layer = hz_vr.y & 65535u;\n \
|
||||
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
|
||||
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
|
||||
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
/// inputs, for vertex `hz_vertex_index`.
|
||||
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
|
||||
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
|
||||
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
|
||||
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
|
||||
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
|
||||
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
|
||||
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
|
||||
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
|
||||
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
|
||||
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
|
||||
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
|
||||
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
|
||||
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
|
||||
uint hz_w3 = hz_uv3;\n \
|
||||
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
|
||||
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
|
||||
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
|
||||
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
|
||||
@@ -700,19 +620,11 @@ impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
|
||||
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
|
||||
// solid quads in the compact layout; others read them as vertex attributes.
|
||||
let mut declarations = if flat {
|
||||
String::from(
|
||||
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
)
|
||||
};
|
||||
let mut declarations = String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
);
|
||||
let origin = if flat {
|
||||
declarations.push_str(
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
@@ -725,8 +637,7 @@ impl PackAtlas {
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
declarations.push_str(CORNER_FN);
|
||||
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
|
||||
"hz_v_origin"
|
||||
@@ -789,7 +700,6 @@ impl PackAtlas {
|
||||
);
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.insert_str(0, PULL_FLAT);
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
|
||||
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
|
||||
inputs: atlas.terrain_inputs(true),
|
||||
pull: String::new(),
|
||||
pull: super::PULL.to_owned(),
|
||||
index: "@".to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -245,135 +245,6 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
|
||||
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
|
||||
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
|
||||
#[test]
|
||||
fn compact_records_feed_a_pack_the_vertices_they_hold() {
|
||||
use crate::mesher::packed;
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
atlas.separate_ao = true;
|
||||
let tint = 0x7F_B238u32;
|
||||
let ao = [255u32, 153, 102, 204];
|
||||
let face = |x: f32, y: f32| {
|
||||
let pos = [
|
||||
[x, y, 5.0],
|
||||
[x, y, 6.0],
|
||||
[x + 1.0, y, 6.0],
|
||||
[x + 1.0, y, 5.0],
|
||||
];
|
||||
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
|
||||
(0..4).map(move |i| {
|
||||
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
|
||||
TerrainVertex {
|
||||
pos: pos[i],
|
||||
uv: uv[i],
|
||||
layer: 0x8000,
|
||||
light: 0xF0 | 0x40 << 8,
|
||||
color: r | g << 8 | b << 16 | ao[i] << 24,
|
||||
}
|
||||
})
|
||||
};
|
||||
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, y)| face(x, y))
|
||||
.collect();
|
||||
let records = packed::layout(&solid, &[tint; 4]);
|
||||
assert_eq!(
|
||||
packed::packed_quads(&records, 4),
|
||||
3,
|
||||
"whole faces pack, the slab doesn't"
|
||||
);
|
||||
let page = bytemuck::cast_slice(&records);
|
||||
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let mut failures = Vec::new();
|
||||
for expression in [
|
||||
"gl_Vertex",
|
||||
"gl_Color",
|
||||
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
|
||||
"vec4(gl_Normal, 0.0)",
|
||||
"at_tangent",
|
||||
"at_midBlock",
|
||||
"vec4(mc_midTexCoord, mc_Entity.xy)",
|
||||
] {
|
||||
for shadow in [false, true] {
|
||||
let flags = [true, shadow];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
|
||||
let compact = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
packed_range.clone(),
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
if bits(&wide) != bits(&compact) {
|
||||
failures.push(format!(
|
||||
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
|
||||
));
|
||||
}
|
||||
if expression == "gl_Color" {
|
||||
for (corner, want) in ao.iter().enumerate() {
|
||||
let got = compact[corner][3];
|
||||
if (got - *want as f32 / 255.0).abs() > 1e-6 {
|
||||
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's frames keep the translucent quads in the section arena after the solid range: three
|
||||
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
|
||||
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
|
||||
let water: Vec<_> = face(9.0, 15.0).collect();
|
||||
let at = page.len() as u32;
|
||||
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
|
||||
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
|
||||
let flags = [true, false];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
|
||||
let page = bytemuck::cast_slice(&page);
|
||||
let after = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
at..at + 4,
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
if wide != after {
|
||||
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -400,38 +271,18 @@ fn sample_probe(
|
||||
atlas: &PackAtlas,
|
||||
vertices: &[TerrainVertex],
|
||||
scale: f32,
|
||||
flags: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let page = bytemuck::cast_slice(vertices);
|
||||
let drawn = 0..vertices.len() as u32;
|
||||
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
|
||||
}
|
||||
|
||||
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sample_page(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
atlas: &PackAtlas,
|
||||
page: &[u8],
|
||||
drawn: std::ops::Range<u32>,
|
||||
scale: f32,
|
||||
[flat, shadow]: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let count = drawn.len() as u32;
|
||||
let pp = |code| horizon_shaders::Preprocessed {
|
||||
code,
|
||||
..Default::default()
|
||||
};
|
||||
let vertex = pp(format!(
|
||||
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
|
||||
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
probe = {expression};\n\
|
||||
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
drawn.start,
|
||||
2. / count as f32,
|
||||
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
2. / vertices.len() as f32,
|
||||
));
|
||||
// Integer attachments retain the float bits without requiring float-render-target
|
||||
// capabilities on the adapter that the test runner picks.
|
||||
@@ -518,8 +369,7 @@ fn sample_page(
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
|
||||
buffers: if flat { &[] } else { &vertex_layouts[..] },
|
||||
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::PointList,
|
||||
@@ -558,7 +408,7 @@ fn sample_page(
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
page,
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
@@ -594,7 +444,7 @@ fn sample_page(
|
||||
let output = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: count,
|
||||
width: vertices.len() as u32,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
@@ -607,7 +457,7 @@ fn sample_page(
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(count) * 16,
|
||||
size: vertices.len() as u64 * 16,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -631,11 +481,11 @@ fn sample_page(
|
||||
for (k, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(k as u32, group, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
if !flat {
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
pass.set_vertex_buffer(1, origin.slice(..));
|
||||
}
|
||||
pass.draw(drawn, 0..1);
|
||||
pass.draw(0..vertices.len() as u32, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
|
||||
@@ -1137,10 +1137,6 @@ fn components(name: &str) -> usize {
|
||||
| "endFlashPosition" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
"entityColor" => 4,
|
||||
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
|
||||
// are ints.
|
||||
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
|
||||
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
}
|
||||
@@ -1212,28 +1208,6 @@ mod tests {
|
||||
(&matrix, "gbufferModelView", "mat4", true),
|
||||
(&matrix, "gbufferModelView", "mat3", false),
|
||||
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
|
||||
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
|
||||
// ints.
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"float",
|
||||
false,
|
||||
),
|
||||
(
|
||||
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
|
||||
"horizonWaterPatch0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
|
||||
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
|
||||
] {
|
||||
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
|
||||
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
|
||||
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
|
||||
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
|
||||
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
|
||||
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a frame's readback holds and where its PNG goes.
|
||||
pub(super) struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// `ScreenshotRequest::size`: the world icon's square.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
|
||||
pub frame: [u32; 2],
|
||||
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
|
||||
pub bgra: bool,
|
||||
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
|
||||
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
}
|
||||
impl Shot {
|
||||
pub fn new(
|
||||
request: &ScreenshotRequest,
|
||||
frame: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
frame,
|
||||
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<[u32; 2], String>,
|
||||
pub hdr: Option<PathBuf>,
|
||||
}
|
||||
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
|
||||
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
|
||||
let (sender, receiver) = channel();
|
||||
map(buffer, &sender);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &sender);
|
||||
}
|
||||
let waited = device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string());
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
|
||||
write(shot, buffer, mapped)
|
||||
}
|
||||
/// Screenshots being written on their thread.
|
||||
pub(super) struct Saves {
|
||||
device: wgpu::Device,
|
||||
sender: Sender<Saved>,
|
||||
receiver: Receiver<Saved>,
|
||||
in_flight: usize,
|
||||
}
|
||||
impl Saves {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let (sender, receiver) = channel();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
sender,
|
||||
receiver,
|
||||
in_flight: 0,
|
||||
}
|
||||
}
|
||||
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
|
||||
/// PNG on a thread of its own once the GPU has finished the frame.
|
||||
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
|
||||
let (mapped, on_mapped) = channel();
|
||||
map(&buffer, &mapped);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &mapped);
|
||||
}
|
||||
let done = self.sender.clone();
|
||||
let path = shot.path.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Screenshot writer".into())
|
||||
.spawn(move || {
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let ready = wait_mapped(&on_mapped, count);
|
||||
let _ = done.send(write(shot, &buffer, ready));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = self.sender.send(Saved {
|
||||
path,
|
||||
result: Err(format!("no thread to write it: {e}")),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.in_flight += 1;
|
||||
}
|
||||
/// The screenshots written since the last call. Polls the device so a finished frame's
|
||||
/// mapping reaches its writer: the render thread never waits for it.
|
||||
pub fn finished(&mut self) -> Vec<Saved> {
|
||||
if self.in_flight == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
let saved: Vec<Saved> = self.receiver.try_iter().collect();
|
||||
self.in_flight -= saved.len();
|
||||
saved
|
||||
}
|
||||
}
|
||||
impl Drop for Saves {
|
||||
/// A screenshot taken right before quitting is still written whole.
|
||||
fn drop(&mut self) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while self.in_flight > 0 && std::time::Instant::now() < deadline {
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
log_saved(&saved);
|
||||
self.in_flight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_saved(saved: &Saved) {
|
||||
match &saved.result {
|
||||
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
|
||||
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
|
||||
}
|
||||
}
|
||||
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
|
||||
let mapped = mapped.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = mapped.send(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
}
|
||||
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
|
||||
/// (a lost device) gives up after a while instead of keeping its thread.
|
||||
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
|
||||
for _ in 0..count {
|
||||
mapped
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "the frame never came back from the GPU".to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
|
||||
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
|
||||
let mut hdr_written = None;
|
||||
let result = mapped.and_then(|()| {
|
||||
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
|
||||
let pixels = read_rows(hdr_buffer, shot.frame);
|
||||
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
hdr_written = Some(hdr_path.clone());
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
let pixels = read_rows(buffer, shot.frame)?;
|
||||
write_png(&shot, &pixels)
|
||||
});
|
||||
Saved {
|
||||
path: shot.path,
|
||||
result,
|
||||
hdr: hdr_written,
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
|
||||
/// row; unmaps it.
|
||||
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
|
||||
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
|
||||
let [frame_w, frame_h] = shot.frame;
|
||||
let mut pixels = Vec::with_capacity(frame.len() * 4);
|
||||
for px in frame {
|
||||
let (r, b) = if shot.bgra {
|
||||
(px[2], px[0])
|
||||
} else {
|
||||
(px[0], px[2])
|
||||
};
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match shot.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, frame_w, frame_h, size),
|
||||
),
|
||||
None => (frame_w, frame_h, pixels),
|
||||
};
|
||||
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
|
||||
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.and_then(|mut w| w.write_image_data(&pixels))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_png(
|
||||
path: &std::path::Path,
|
||||
[width, height]: [u32; 2],
|
||||
frame: &[[u8; 4]],
|
||||
) -> Result<(), String> {
|
||||
let pixels = hdr10_to_rgb16(frame);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
|
||||
.and_then(|encoder| encoder.write_header())
|
||||
.and_then(|mut writer| {
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)
|
||||
});
|
||||
written.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_background_save_writes_the_png_off_the_render_thread() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
let [w, h] = [3u32, 2];
|
||||
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
|
||||
let row = padded_row(w) as usize;
|
||||
let mut bytes = vec![0u8; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
|
||||
}
|
||||
}
|
||||
let source = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
},
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
let request = ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: true,
|
||||
};
|
||||
let mut saves = Saves::new(&device);
|
||||
saves.start(
|
||||
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
|
||||
buffer,
|
||||
);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let saved = loop {
|
||||
if let Some(saved) = saves.finished().pop() {
|
||||
break saved;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "never written");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(saved.path, path);
|
||||
assert_eq!(saved.result, Ok([w, h]));
|
||||
let decoder =
|
||||
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut out = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut out).unwrap();
|
||||
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
|
||||
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
pub(super) fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FadeInputs<'a> {
|
||||
pub(super) now: Instant,
|
||||
pub(super) fade_ms: u64,
|
||||
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
|
||||
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
@@ -30,9 +30,8 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
|
||||
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
|
||||
/// uses it, and the HDR page offers calibration.
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ pub struct Reported {
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but not where its peak comes from: the page offers calibration.
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
@@ -1356,7 +1355,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
@@ -1371,7 +1370,7 @@ mod tests {
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the reported peak, over the 1000-nit default.
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! 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};
|
||||
//! 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.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -41,91 +36,54 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use menu_style::hud_colour as hex;
|
||||
/// `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)
|
||||
}
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
/// Their 1 px edge.
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
/// The open page in the page list.
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
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)
|
||||
}
|
||||
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);
|
||||
/// The one main button.
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
/// A tick box's edge when off.
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const 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 fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
pub const 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 fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
pub const 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.
|
||||
@@ -162,21 +120,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());
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
}
|
||||
/// 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());
|
||||
fill(batch, atlas, rect, WELL);
|
||||
}
|
||||
/// 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());
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -184,11 +142,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());
|
||||
fill(batch, atlas, rect, WELL);
|
||||
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 {
|
||||
@@ -211,7 +169,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);
|
||||
}
|
||||
}
|
||||
@@ -240,17 +198,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(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(
|
||||
@@ -294,7 +252,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,
|
||||
@@ -305,22 +263,17 @@ 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());
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
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_fill() } else { muted() },
|
||||
);
|
||||
fill(batch, atlas, handle, if live { HANDLE } 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.
|
||||
@@ -337,13 +290,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);
|
||||
}
|
||||
|
||||
@@ -697,7 +697,7 @@ fn description(
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
@@ -705,7 +705,7 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
// A peak of unknown source isn't said to be set up; paper white is the player's own.
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
@@ -1110,8 +1110,7 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1142,9 +1141,6 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
@@ -1219,13 +1215,10 @@ impl Item {
|
||||
}
|
||||
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
match self {
|
||||
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
|
||||
Text::option_name_value(
|
||||
Text::literal(lang::get("Full Detail Distance")),
|
||||
Text::tr("options.chunks", &[&s.render_distance]),
|
||||
)
|
||||
}
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s, out),
|
||||
Item::Extra(e) => Text::option_name_value(
|
||||
@@ -1321,11 +1314,7 @@ impl Item {
|
||||
|
||||
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
|
||||
fn shown(item: Item, s: &Settings) -> bool {
|
||||
match item {
|
||||
Item::Horizon(H::Upscaling) => s.horizon.taa,
|
||||
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
|
||||
_ => true,
|
||||
}
|
||||
item != Item::Horizon(H::Upscaling) || s.horizon.taa
|
||||
}
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
|
||||
@@ -1437,7 +1426,7 @@ impl Screen for GraphicsScreen {
|
||||
g::gpu_culling_offer(),
|
||||
g::reduced_far_detail_suggested(),
|
||||
);
|
||||
shell::stamp_of(&(read(s), s.fullbright_held, offers))
|
||||
shell::stamp_of(&(read(s), offers))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
|
||||
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
|
||||
@@ -1997,31 +1986,6 @@ mod tests {
|
||||
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
|
||||
}
|
||||
|
||||
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
|
||||
/// kept for the next world that allows it (#3823).
|
||||
#[test]
|
||||
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
|
||||
let mut s = Settings::default();
|
||||
s.extras.fullbright = true;
|
||||
let screen = GraphicsScreen::default();
|
||||
let listed = |s: &Settings, item| {
|
||||
let sheets = screen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).any(|r| r.id == item)
|
||||
};
|
||||
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
|
||||
let lights = Item::Extra(crate::extras::Item::DynamicLights);
|
||||
assert!(listed(&s, fullbright));
|
||||
let free = screen.stamp(&s);
|
||||
s.fullbright_held = true;
|
||||
assert!(!listed(&s, fullbright));
|
||||
assert!(listed(&s, lights));
|
||||
assert_ne!(screen.stamp(&s), free);
|
||||
assert!(s.extras.fullbright);
|
||||
s.fullbright_held = false;
|
||||
assert!(listed(&s, fullbright));
|
||||
}
|
||||
|
||||
/// The strip says what a row does, what its chosen value does where that differs, and why a
|
||||
/// greyed row can't be used; the search finds a row by what it does.
|
||||
#[test]
|
||||
@@ -2142,36 +2106,10 @@ mod tests {
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
}
|
||||
|
||||
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
|
||||
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
|
||||
/// (#14880).
|
||||
#[test]
|
||||
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"options.renderDistance": "Render Distance",
|
||||
"options.chunks": "%s Chunks",
|
||||
"options.generic_value": "%s: %s"
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
let near = s.render_distance;
|
||||
let render = Item::Vanilla(Opt::RenderDistance);
|
||||
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
let off = render.label(&s, &NONE).plain();
|
||||
s.horizon.lod_distance = 256;
|
||||
(off, render.label(&s, &NONE).plain())
|
||||
});
|
||||
assert_eq!(off, format!("Render Distance: {near} Chunks"));
|
||||
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -31,49 +31,6 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
@@ -164,7 +121,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
@@ -200,7 +157,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
@@ -219,7 +176,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
@@ -348,30 +305,3 @@ fn undo_never_restores_a_held_hdr_start() {
|
||||
screen.undo(&mut s, &opened);
|
||||
assert_eq!(s.horizon, opened.horizon);
|
||||
}
|
||||
|
||||
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
|
||||
/// 1080p window at scale 4 is wider): a longer one would end in "…".
|
||||
#[test]
|
||||
fn the_calibrate_descriptions_fit_the_strip() {
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut guessed = offering(true, Some(true));
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let mut changed = Settings::default();
|
||||
changed.horizon.hdr_peak = 800;
|
||||
let item = Item::Horizon(H::HdrCalibrate);
|
||||
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
|
||||
for (s, out) in [
|
||||
(Settings::default(), NONE),
|
||||
(Settings::default(), guessed),
|
||||
(Settings::default(), offering(true, Some(true))),
|
||||
(changed, guessed),
|
||||
] {
|
||||
let text = description(item, &s, &out, "-");
|
||||
assert_ne!(text, "-");
|
||||
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
|
||||
assert!(lines.len() <= 2, "{text}");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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 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.
|
||||
//!
|
||||
//! 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
|
||||
@@ -17,7 +16,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! // 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);
|
||||
@@ -27,6 +26,7 @@
|
||||
//! 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,20 +50,28 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and an easing curve.
|
||||
/// A duration and one of the design system's easing curves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `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 }
|
||||
/// `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)
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -76,7 +84,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -90,29 +98,6 @@ 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)
|
||||
@@ -160,7 +145,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.curve);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
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);
|
||||
@@ -175,7 +160,8 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
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);
|
||||
@@ -187,7 +173,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -198,25 +184,10 @@ 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.curve);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
// 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,7 +1,6 @@
|
||||
//! 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. The keep-outs: the boss bars at the top centre, and the status rows above
|
||||
//! the hotbar at the bottom centre.
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top 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.
|
||||
@@ -35,16 +34,6 @@ 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.
|
||||
@@ -101,20 +90,10 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, y, _, h])| y + h)
|
||||
.map(|(Keep::BossBars, [_, 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.
|
||||
@@ -155,15 +134,4 @@ 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,12 +38,6 @@
|
||||
//! ```
|
||||
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];
|
||||
@@ -51,12 +45,8 @@ 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 fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::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);
|
||||
@@ -75,15 +65,9 @@ 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 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)
|
||||
}
|
||||
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);
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -160,11 +144,7 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -302,9 +282,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.
|
||||
@@ -400,16 +380,15 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 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_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,23 +131,6 @@ 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,9 +583,6 @@ 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,7 +74,6 @@ 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;
|
||||
@@ -110,8 +109,6 @@ 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;
|
||||
@@ -172,7 +169,6 @@ 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;
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -13,9 +13,8 @@
|
||||
//! - word 1: each corner's grey level before the tint;
|
||||
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
|
||||
//! corner, which edge of it its u and v take;
|
||||
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
|
||||
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
|
||||
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
|
||||
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
|
||||
//! - word 4: corners 1 and 2's light;
|
||||
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
|
||||
//! of the block, and where and which way its corners go round), and `FULL` clear.
|
||||
use super::TerrainVertex;
|
||||
@@ -28,8 +27,6 @@ pub const WHITE: u32 = 0xFF_FFFF;
|
||||
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
|
||||
/// to its first vertex.
|
||||
pub const FULL: u32 = 1 << 31;
|
||||
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
|
||||
pub const ALPHA_STEP: u32 = 51;
|
||||
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
|
||||
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
|
||||
pub const PACKED_BASE: u32 = 1 << 28;
|
||||
@@ -136,24 +133,12 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
|
||||
for (i, v) in quad.iter().enumerate() {
|
||||
grays |= gray_of(v.color, tint)? << (8 * i);
|
||||
}
|
||||
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
|
||||
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
|
||||
let mut codes = [0u32; 3];
|
||||
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
|
||||
let a = v.color >> 24;
|
||||
if v.color & 0xFF00_0000 != alpha {
|
||||
if a == 0 || a % ALPHA_STEP != 0 {
|
||||
return None;
|
||||
}
|
||||
*code = a / ALPHA_STEP;
|
||||
}
|
||||
}
|
||||
let words = [
|
||||
tint | alpha,
|
||||
grays,
|
||||
rect | select << 24,
|
||||
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
|
||||
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
|
||||
u32::from(quad[0].layer) | light[0] << 16,
|
||||
light[1] | light[2] << 12,
|
||||
light[3] | orient << 12,
|
||||
];
|
||||
let exact = (0..4).all(|c| {
|
||||
@@ -205,18 +190,12 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
|
||||
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
|
||||
let g = w1 >> (8 * i) & 255;
|
||||
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
|
||||
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
|
||||
let alpha = if code == 0 {
|
||||
w0 & 0xFF00_0000
|
||||
} else {
|
||||
(code * ALPHA_STEP) << 24
|
||||
};
|
||||
TerrainVertex {
|
||||
pos: pos.map(|c| c as f32),
|
||||
uv,
|
||||
layer: (w3 & 0xFFFF) as u16,
|
||||
light,
|
||||
color: r | gr << 8 | b << 16 | alpha,
|
||||
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,10 +247,11 @@ pub fn place_into(
|
||||
let n = quads.len();
|
||||
let packed = records.iter().filter(|r| r.is_some()).count();
|
||||
let full = n - packed;
|
||||
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
|
||||
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
|
||||
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
|
||||
let pad = n.next_multiple_of(4) - n;
|
||||
let pad = if full > 0 {
|
||||
n.next_multiple_of(4) - n
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if n + pad + 4 * full >= 4 * n {
|
||||
return;
|
||||
}
|
||||
@@ -354,22 +334,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
|
||||
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
|
||||
let mut quad = top([0, 1, 2, 3]);
|
||||
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
|
||||
v.color = v.color & 0x00FF_FFFF | a << 24;
|
||||
}
|
||||
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
|
||||
for (corner, v) in quad.iter().enumerate() {
|
||||
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
|
||||
}
|
||||
// A partial face's blended occlusion falls between the steps.
|
||||
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
|
||||
assert!(pack_quad(&quad, TINT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
|
||||
// Half a block up: a slab's top.
|
||||
@@ -411,13 +375,4 @@ mod tests {
|
||||
// Nothing smaller to gain: no layout at all.
|
||||
assert!(layout(&odd, &[TINT]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_records_end_on_a_whole_quad_of_slots() {
|
||||
// Five whole faces, every one a record: the translucent vertices after them start a quad.
|
||||
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
|
||||
let out = layout(&solid, &[TINT; 5]);
|
||||
assert_eq!(packed_quads(&out, 5), 5);
|
||||
assert_eq!(out.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -324,8 +322,6 @@ 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",
|
||||
@@ -884,7 +880,6 @@ 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(),
|
||||
}
|
||||
@@ -1206,7 +1201,6 @@ 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() {
|
||||
@@ -1358,11 +1352,6 @@ 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,
|
||||
@@ -1694,10 +1683,6 @@ 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
|
||||
@@ -1782,8 +1767,6 @@ 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(),
|
||||
@@ -2129,7 +2112,6 @@ 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(),
|
||||
@@ -2262,16 +2244,95 @@ 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 {
|
||||
@@ -2308,9 +2369,6 @@ 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",
|
||||
@@ -3544,9 +3602,6 @@ 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.
|
||||
@@ -3898,9 +3953,6 @@ 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() {
|
||||
@@ -4596,8 +4648,6 @@ 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);
|
||||
@@ -4633,9 +4683,6 @@ 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);
|
||||
}
|
||||
@@ -4739,9 +4786,6 @@ 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 {
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
//! 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,15 +124,21 @@ impl OptionsUi {
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
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)),
|
||||
)
|
||||
},
|
||||
];
|
||||
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();
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
@@ -156,38 +162,10 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -199,10 +177,7 @@ impl OptionsUi {
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -486,84 +461,4 @@ mod tests {
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,6 @@ use horizon_polytone::colors::PackColors;
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
@@ -37,10 +29,6 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,24 +59,6 @@ pub struct PackColors {
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
@@ -90,7 +72,6 @@ impl Default for PackColors {
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,57 +368,6 @@ pub struct PackScreen {
|
||||
browser_search: TextField,
|
||||
/// The browser's search box has the keyboard.
|
||||
browser_focus: bool,
|
||||
/// Which atlas slot holds which icon, kept from one frame to the next
|
||||
/// ([`PackScreen::bind_icons`]).
|
||||
icon_slots: std::cell::RefCell<IconSlots>,
|
||||
}
|
||||
|
||||
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
|
||||
/// written into a slot, so an icon is sent once, when it takes a slot, and not
|
||||
/// again every frame it is drawn.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct IconSlots {
|
||||
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
|
||||
revision: u64,
|
||||
/// Frames bound so far: an icon drawn in this one keeps its slot.
|
||||
frame: u64,
|
||||
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
|
||||
/// frame it was last drawn in.
|
||||
held: Vec<(String, usize, Arc<[u8]>, u64)>,
|
||||
}
|
||||
|
||||
impl IconSlots {
|
||||
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
|
||||
fn begin(&mut self, atlas: &HudAtlas) {
|
||||
if self.revision != atlas.revision {
|
||||
self.held.clear();
|
||||
self.revision = atlas.revision;
|
||||
}
|
||||
self.frame += 1;
|
||||
}
|
||||
|
||||
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
|
||||
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
|
||||
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
|
||||
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
|
||||
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
|
||||
let mut kept = self.held.remove(at);
|
||||
let same = Arc::ptr_eq(&kept.2, icon);
|
||||
kept.2 = icon.clone();
|
||||
kept.3 = self.frame;
|
||||
let slot = kept.1;
|
||||
self.held.push(kept);
|
||||
return Some((slot, !same));
|
||||
}
|
||||
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
|
||||
Some(free) => free,
|
||||
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
|
||||
None => return None,
|
||||
};
|
||||
self.held
|
||||
.push((texture.to_owned(), slot, icon.clone(), self.frame));
|
||||
Some((slot, true))
|
||||
}
|
||||
}
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
@@ -510,7 +459,6 @@ impl PackScreen {
|
||||
field
|
||||
},
|
||||
browser_focus: false,
|
||||
icon_slots: Default::default(),
|
||||
changed: false,
|
||||
};
|
||||
s.reposition(m);
|
||||
@@ -983,13 +931,10 @@ impl PackScreen {
|
||||
self.model.commit()
|
||||
}
|
||||
/// Points the icon blits of `draws` at the atlas's pack icon slots and
|
||||
/// writes the icons into them (vanilla registers a texture per pack). An
|
||||
/// icon keeps its slot from one frame to the next and is sent when it takes
|
||||
/// the slot, not every frame; the slot of the icon drawn longest ago is
|
||||
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
|
||||
/// writes the icons into them, one slot per icon drawn (vanilla registers a
|
||||
/// texture per pack). Icons past the last slot keep `unknown_pack`.
|
||||
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
|
||||
let mut slots = self.icon_slots.borrow_mut();
|
||||
slots.begin(atlas);
|
||||
let mut slots = 0..PACK_ICON_SLOTS;
|
||||
for draw in draws {
|
||||
let Draw::Blit { texture, .. } = draw else {
|
||||
continue;
|
||||
@@ -997,23 +942,20 @@ impl PackScreen {
|
||||
let Some(icon) = self.icons.get(texture.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some((slot, send)) = slots.slot_for(texture, icon) else {
|
||||
continue;
|
||||
let Some(slot) = slots.next() else {
|
||||
return;
|
||||
};
|
||||
let sprite = format!("horizon/pack-icon{slot}");
|
||||
let Some(rect) = atlas.sprites.get(&sprite) else {
|
||||
slots.held.pop();
|
||||
return;
|
||||
};
|
||||
if send {
|
||||
patches.push(Patch {
|
||||
x: (rect.u0 * atlas.width as f32).round() as u32,
|
||||
y: (rect.v0 * atlas.height as f32).round() as u32,
|
||||
width: PACK_ICON_SIZE,
|
||||
height: PACK_ICON_SIZE,
|
||||
rgba: icon.to_vec(),
|
||||
});
|
||||
}
|
||||
patches.push(Patch {
|
||||
x: (rect.u0 * atlas.width as f32).round() as u32,
|
||||
y: (rect.v0 * atlas.height as f32).round() as u32,
|
||||
width: PACK_ICON_SIZE,
|
||||
height: PACK_ICON_SIZE,
|
||||
rgba: icon.to_vec(),
|
||||
});
|
||||
*texture = sprite;
|
||||
}
|
||||
}
|
||||
@@ -1676,12 +1618,6 @@ impl PackScreen {
|
||||
let hover = hovered == Some(i)
|
||||
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
Self::draw_options_button(out, bx, by, hover);
|
||||
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
|
||||
// while the list shows it whole.
|
||||
let mark = crate::widgets::targets_mark();
|
||||
let size = OPTIONS_SIZE as f32;
|
||||
crate::widgets::target([bx as f32, by as f32, size, size]);
|
||||
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
|
||||
if hover {
|
||||
tooltip = Some(tr("horizon.pack.optionsInfo"));
|
||||
}
|
||||
@@ -1809,14 +1745,9 @@ mod icon_tests {
|
||||
.unwrap();
|
||||
assert_eq!(id, "file/Configurable");
|
||||
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
|
||||
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
|
||||
// Hovered, it explains itself.
|
||||
let draws = screen.draw(&m, (cx, cy), 0, 0);
|
||||
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
let size = OPTIONS_SIZE as f32;
|
||||
let button = [bx as f32, by as f32, size, size];
|
||||
let targets = crate::widgets::last_targets();
|
||||
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
|
||||
assert_eq!(
|
||||
screen.click(&m, cx as f64, cy as f64),
|
||||
Action::Options("file/Configurable".into())
|
||||
@@ -2139,177 +2070,3 @@ mod footer_label_tests {
|
||||
assert_eq!(lang::code(), before, "the global language was left alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_slot_tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
|
||||
fn packs(count: usize) -> Vec<PackInfo> {
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
(0..count)
|
||||
.map(|i| PackInfo {
|
||||
id: format!("file/pack{i:03}"),
|
||||
title: Text::literal(format!("pack{i:03}")),
|
||||
description: Text::literal(""),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: Some(vec![i as u8; side * side * 4].into()),
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
|
||||
/// (their texture keys before binding), the textures after, and the patches sent.
|
||||
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
|
||||
let m = Measure(&atlas.font);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0, 0);
|
||||
let drawn: Vec<String> = draws
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
|
||||
Some(texture.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, atlas, &mut patches);
|
||||
let bound = draws
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
|
||||
Some(texture.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(drawn, bound, patches)
|
||||
}
|
||||
|
||||
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
|
||||
/// window `height` high.
|
||||
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
|
||||
PackScreen::new(
|
||||
m,
|
||||
tr("resourcePack.title"),
|
||||
480,
|
||||
height,
|
||||
false,
|
||||
packs(count),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
|
||||
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
|
||||
#[test]
|
||||
fn an_icon_is_sent_once_and_not_every_frame() {
|
||||
let atlas = atlas();
|
||||
let screen = screen(&Measure(&atlas.font), 5, 400);
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
|
||||
for again in 0..3 {
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!(rebound, bound, "frame {again}");
|
||||
assert_eq!(resent.len(), 0, "frame {again}");
|
||||
}
|
||||
}
|
||||
|
||||
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
|
||||
/// `unknown_pack`, and the next frame does not move them or send anything.
|
||||
#[test]
|
||||
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
|
||||
let atlas = atlas();
|
||||
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(
|
||||
drawn.len(),
|
||||
PACK_ICON_SLOTS + 6,
|
||||
"the window shows every row"
|
||||
);
|
||||
assert_eq!(
|
||||
(bound.len(), patches.len()),
|
||||
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
|
||||
);
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!((rebound, resent.len()), (bound, 0));
|
||||
}
|
||||
|
||||
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
|
||||
/// once on the way down, and a frame that draws what the last one drew sends nothing.
|
||||
#[test]
|
||||
fn scrolling_sends_only_the_icons_that_come_into_view() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = screen(&m, 70, 330);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut sent = 0;
|
||||
// 150 wheel steps of 18 are more than the 2520 high list.
|
||||
for step in 0..150 {
|
||||
let (drawn, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
|
||||
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
|
||||
assert_eq!(patches.len(), new, "step {step}");
|
||||
sent += patches.len();
|
||||
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
|
||||
screen.scroll(100.0, 200.0, -1.0);
|
||||
}
|
||||
assert_eq!((seen.len(), sent), (70, 70));
|
||||
}
|
||||
|
||||
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
|
||||
/// sent again when it comes back.
|
||||
#[test]
|
||||
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
|
||||
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
|
||||
.into_iter()
|
||||
.map(|p| p.icon.unwrap())
|
||||
.collect();
|
||||
let mut slots = IconSlots::default();
|
||||
let key = |i: usize| format!("k{i}");
|
||||
slots.begin(&atlas());
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
|
||||
}
|
||||
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
|
||||
slots.frame += 1;
|
||||
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
|
||||
assert_eq!(
|
||||
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
|
||||
Some((1, true))
|
||||
);
|
||||
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
|
||||
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
|
||||
}
|
||||
|
||||
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
|
||||
/// of the icons, so all are sent again.
|
||||
#[test]
|
||||
fn changed_pixels_and_a_new_atlas_send_again() {
|
||||
let atlas = atlas();
|
||||
let mut screen = screen(&Measure(&atlas.font), 3, 400);
|
||||
let (_, bound, patches) = frame(&screen, &atlas);
|
||||
assert_eq!(patches.len(), 3);
|
||||
let key = icon_texture("file/pack001");
|
||||
screen
|
||||
.icons
|
||||
.insert(key, vec![200; patches[1].rgba.len()].into());
|
||||
let (_, rebound, resent) = frame(&screen, &atlas);
|
||||
assert_eq!(rebound, bound);
|
||||
assert_eq!(resent.len(), 1);
|
||||
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
|
||||
assert_eq!(resent[0].rgba[0], 200);
|
||||
let other = self::atlas();
|
||||
assert_ne!(other.revision, atlas.revision);
|
||||
assert_eq!(frame(&screen, &other).2.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,8 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod simple;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1327,9 +1325,6 @@ pub struct ParticleEngine {
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
|
||||
parallel_min: usize,
|
||||
parallel_threads: usize,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// Vanilla particle types a pack replaces with a custom one of the same id
|
||||
@@ -1532,8 +1527,6 @@ impl ParticleEngine {
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
parallel_min: PARALLEL_MIN,
|
||||
parallel_threads: parallel_threads(),
|
||||
polytone: None,
|
||||
polytone_overrides: HashMap::new(),
|
||||
polytone_gpu: Vec::new(),
|
||||
@@ -2128,7 +2121,23 @@ impl ParticleEngine {
|
||||
}
|
||||
/// `SingleQuadParticle.setSpriteFromAge`.
|
||||
fn sprite_from_age(&self, p: &mut Particle) {
|
||||
sprite_from_age_in(&self.set_sprites, p);
|
||||
if p.removed {
|
||||
return;
|
||||
}
|
||||
if let Some(set) = p.set {
|
||||
// A sprite set's index can outlive the reload that rebuilt them.
|
||||
let Some(sprites) = self.set_sprites.get(set as usize) else {
|
||||
return;
|
||||
};
|
||||
let lifetime = p.lifetime.max(1);
|
||||
let index = if p.frame_ticks > 0 {
|
||||
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
|
||||
} else {
|
||||
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
|
||||
.clamp(0, sprites.len() as i64 - 1)
|
||||
};
|
||||
p.sprite = Sprite::Index(sprites[index as usize]);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// `ParticleProvider.createParticle`: provider draws use the engine's
|
||||
@@ -3838,7 +3847,9 @@ impl ParticleEngine {
|
||||
self.emitters = emitters;
|
||||
let mut particles = std::mem::take(&mut self.particles);
|
||||
self.spawned_in_tick = Some(Vec::new());
|
||||
self.tick_all(&mut particles, level);
|
||||
for p in &mut particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
particles.retain(|p| !p.removed);
|
||||
self.particles = particles;
|
||||
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
|
||||
@@ -3943,15 +3954,7 @@ impl ParticleEngine {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
Behaviour::Base
|
||||
| Behaviour::Mod { .. }
|
||||
| Behaviour::Animated
|
||||
| Behaviour::Snowflake
|
||||
| Behaviour::Crit
|
||||
| Behaviour::Spark { .. } => {
|
||||
let simple = tick_simple(&self.set_sprites, p, level);
|
||||
self.finish_simple(p, simple);
|
||||
}
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Controlled { .. } => {
|
||||
if let Some(mut controller) = p.controller.take() {
|
||||
let update = controller.tick(p, level);
|
||||
@@ -3970,6 +3973,23 @@ impl ParticleEngine {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
|
||||
self.sprite_from_age(p);
|
||||
}
|
||||
Behaviour::Animated => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
}
|
||||
Behaviour::Snowflake => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
p.velocity[0] *= f64::from(0.95f32);
|
||||
p.velocity[1] *= f64::from(0.9f32);
|
||||
p.velocity[2] *= f64::from(0.95f32);
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
@@ -3980,6 +4000,11 @@ impl ParticleEngine {
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
p.base_tick(level);
|
||||
p.rgba[1] *= 0.96;
|
||||
p.rgba[2] *= 0.9;
|
||||
}
|
||||
Behaviour::Drip {
|
||||
fluid,
|
||||
stage,
|
||||
@@ -4450,6 +4475,27 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::Spark {
|
||||
fade,
|
||||
trail,
|
||||
twinkle,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age > p.lifetime / 2 {
|
||||
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
|
||||
if let Some(f) = fade {
|
||||
for i in 0..3 {
|
||||
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
|
||||
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
|
||||
child.age = child.lifetime / 2;
|
||||
self.pending.push(child);
|
||||
}
|
||||
}
|
||||
Behaviour::Spell { original_alpha } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
|
||||
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
|
||||
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(crate::environment::polytone_noise().get(x, y))
|
||||
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
|
||||
}
|
||||
(id, _) if id >= BLOCKS => {
|
||||
let at = *self
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
|
||||
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
|
||||
//! threads in a busy scene.
|
||||
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
|
||||
|
||||
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
|
||||
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
|
||||
pub(super) const PARALLEL_MIN: usize = 2048;
|
||||
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
|
||||
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
|
||||
pub(super) fn parallel_threads() -> usize {
|
||||
(horizon_threads::worker_count() / 2).clamp(1, 4)
|
||||
}
|
||||
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
|
||||
if p.removed {
|
||||
return;
|
||||
}
|
||||
if let Some(set) = p.set {
|
||||
// A sprite set's index can outlive the reload that rebuilt them.
|
||||
let Some(sprites) = set_sprites.get(set as usize) else {
|
||||
return;
|
||||
};
|
||||
let lifetime = p.lifetime.max(1);
|
||||
let index = if p.frame_ticks > 0 {
|
||||
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
|
||||
} else {
|
||||
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
|
||||
.clamp(0, sprites.len() as i64 - 1)
|
||||
};
|
||||
p.sprite = Sprite::Index(sprites[index as usize]);
|
||||
}
|
||||
}
|
||||
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
|
||||
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
|
||||
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
|
||||
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
|
||||
pub(super) fn tick_simple(
|
||||
set_sprites: &[Vec<u32>],
|
||||
p: &mut Particle,
|
||||
level: &ParticleLevel,
|
||||
) -> Simple {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
}
|
||||
Behaviour::Animated => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
}
|
||||
Behaviour::Snowflake => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
p.velocity[0] *= f64::from(0.95f32);
|
||||
p.velocity[1] *= f64::from(0.9f32);
|
||||
p.velocity[2] *= f64::from(0.95f32);
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
p.base_tick(level);
|
||||
p.rgba[1] *= 0.96;
|
||||
p.rgba[2] *= 0.9;
|
||||
}
|
||||
Behaviour::Spark { fade, trail, .. } => {
|
||||
p.base_tick(level);
|
||||
sprite_from_age_in(set_sprites, p);
|
||||
if p.age > p.lifetime / 2 {
|
||||
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
|
||||
if let Some(f) = fade {
|
||||
for i in 0..3 {
|
||||
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
|
||||
return Simple::SparkTrail;
|
||||
}
|
||||
}
|
||||
_ => return Simple::No,
|
||||
}
|
||||
Simple::Done
|
||||
}
|
||||
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
|
||||
///
|
||||
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
|
||||
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
|
||||
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
|
||||
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
|
||||
ParticleLevel {
|
||||
world: level.world,
|
||||
facts: level.facts,
|
||||
collision: level.collision,
|
||||
player: level.player,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
}
|
||||
}
|
||||
/// What `tick_simple` did with a particle.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Simple {
|
||||
/// Not one of its behaviours: the engine ticks it.
|
||||
No,
|
||||
/// Ticked completely.
|
||||
Done,
|
||||
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
|
||||
/// it takes the next seed, so the children come in the order the sparks have.
|
||||
SparkTrail,
|
||||
}
|
||||
impl ParticleEngine {
|
||||
/// What `tick_simple` leaves to the engine: a spark's trail child.
|
||||
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
|
||||
if simple != Simple::SparkTrail {
|
||||
return;
|
||||
}
|
||||
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
|
||||
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
|
||||
child.age = child.lifetime / 2;
|
||||
self.pending.push(child);
|
||||
}
|
||||
}
|
||||
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
|
||||
/// other and of the engine, so with enough of them they tick on several threads; every other
|
||||
/// one then ticks here, in the order it had, so the particles, the engine's random and what
|
||||
/// is spawned come out as when all of them ticked one after another.
|
||||
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
|
||||
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
|
||||
{
|
||||
for p in particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sets = &self.set_sprites;
|
||||
let per = particles.len().div_ceil(self.parallel_threads);
|
||||
let mut handled = vec![Simple::No; particles.len()];
|
||||
std::thread::scope(|scope| {
|
||||
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
|
||||
for (p, done) in part.iter_mut().zip(done) {
|
||||
*done = tick_simple(sets, p, level);
|
||||
}
|
||||
};
|
||||
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
|
||||
let own = parts.next();
|
||||
for (part, done) in parts {
|
||||
let view = thread_view(level);
|
||||
scope.spawn(move || run(part, done, &view));
|
||||
}
|
||||
// This thread takes the first share, instead of waiting for the others.
|
||||
if let Some((part, done)) = own {
|
||||
run(part, done, level);
|
||||
}
|
||||
});
|
||||
for (p, done) in particles.iter_mut().zip(handled) {
|
||||
match done {
|
||||
Simple::No => self.tick_particle(p, level),
|
||||
simple => self.finish_simple(p, simple),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game.
|
||||
//!
|
||||
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
|
||||
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
|
||||
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
|
||||
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
|
||||
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
|
||||
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
|
||||
//! and the engine tick apart, once the engine holds as many particles as it will.
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
|
||||
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
|
||||
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
|
||||
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
|
||||
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
|
||||
use super::*;
|
||||
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
|
||||
use std::sync::Arc;
|
||||
@@ -29,7 +25,6 @@ impl Lcg {
|
||||
}
|
||||
}
|
||||
|
||||
/// What to spawn each tick: a type, how many, around where, how far, how fast.
|
||||
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
|
||||
|
||||
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
|
||||
@@ -63,29 +58,9 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
|
||||
fn emitters() -> [Emitter; 5] {
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
[
|
||||
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
|
||||
(
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
),
|
||||
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
|
||||
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
|
||||
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
|
||||
]
|
||||
}
|
||||
|
||||
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
|
||||
/// without prepared resources.
|
||||
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -113,127 +88,69 @@ fn with_scene(f: impl FnOnce(&ParticleLevel)) {
|
||||
world.set(x, FLOOR_Y, z, floor).unwrap();
|
||||
}
|
||||
}
|
||||
f(&ParticleLevel {
|
||||
let level = ParticleLevel {
|
||||
world: &world,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
});
|
||||
}
|
||||
|
||||
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
engine.parallel_min = parallel_min;
|
||||
engine.parallel_threads = threads;
|
||||
engine
|
||||
}
|
||||
|
||||
/// Every live particle's fields, as text.
|
||||
fn state(engine: &ParticleEngine) -> Vec<String> {
|
||||
engine
|
||||
.particles
|
||||
.iter()
|
||||
.map(|p| format!("{:?}", engine.snapshot(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tick_matches_serial_tick() {
|
||||
with_scene(|level| {
|
||||
let types = emitters();
|
||||
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
|
||||
let mut serial = engine(usize::MAX, 1);
|
||||
let mut threaded = engine(0, 3);
|
||||
for e in [&mut serial, &mut threaded] {
|
||||
e.seed_randoms(7, 8, 9);
|
||||
}
|
||||
let (mut a, mut b) = (Lcg(99), Lcg(99));
|
||||
for tick in 0..90 {
|
||||
spawn_tick(&mut serial, &mut a, &types, 3);
|
||||
spawn_tick(&mut threaded, &mut b, &types, 3);
|
||||
serial.tick(level);
|
||||
threaded.tick(level);
|
||||
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
|
||||
if tick % 10 == 9 {
|
||||
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
|
||||
}
|
||||
}
|
||||
assert!(serial.len() > 1000, "{} particles", serial.len());
|
||||
assert_eq!(serial.sounds.len(), threaded.sounds.len());
|
||||
assert_eq!(serial.next_randoms(), threaded.next_randoms());
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
|
||||
fn measure(
|
||||
level: &ParticleLevel,
|
||||
name: &str,
|
||||
(types, rockets): (&[Emitter], usize),
|
||||
(parallel_min, threads): (usize, usize),
|
||||
secs: f64,
|
||||
) {
|
||||
let mut engine = engine(parallel_min, threads);
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, types, rockets);
|
||||
engine.tick(level);
|
||||
}
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
}
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
|
||||
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
|
||||
let cosy: Emitter = (
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
with_scene(|level| {
|
||||
let [flame, cosy, rod, poof, smoke] = emitters();
|
||||
let all = [flame, cosy, rod, poof, smoke];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
let one = (usize::MAX, 1);
|
||||
eprintln!("default threads: {}", parallel_threads());
|
||||
for (name, types, rockets) in [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
] {
|
||||
measure(level, name, (&types, rockets), one, secs);
|
||||
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
|
||||
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let cases: [(&str, Vec<Emitter>, usize); 7] = [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
|
||||
];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
for (name, types, rockets) in cases {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
engine.tick(&level);
|
||||
}
|
||||
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
|
||||
for threads in [2, 3, 4, 6] {
|
||||
measure(
|
||||
level,
|
||||
&format!("whole scene, {threads} threads"),
|
||||
(&all, 3),
|
||||
(PARALLEL_MIN, threads),
|
||||
secs,
|
||||
);
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(&level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
}
|
||||
});
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,7 @@
|
||||
//! 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::{Chunk, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use crate::{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`)
|
||||
@@ -21,16 +18,13 @@ struct Cached<T> {
|
||||
section: Weak<Section>,
|
||||
pois: SectionPois<T>,
|
||||
}
|
||||
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
|
||||
/// index: a search through a column looks it up once.
|
||||
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
|
||||
pub struct PoiSections<T> {
|
||||
sections: Mutex<Lists<T>>,
|
||||
}
|
||||
struct Lists<T> {
|
||||
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,
|
||||
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
|
||||
/// Size above which lists of sections no longer resident are dropped.
|
||||
prune_at: usize,
|
||||
}
|
||||
const MIN_PRUNE: usize = 4096;
|
||||
@@ -38,61 +32,27 @@ impl<T> Default for PoiSections<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sections: Mutex::new(Lists {
|
||||
by_column: FxHashMap::default(),
|
||||
len: 0,
|
||||
by_section: FxHashMap::default(),
|
||||
prune_at: MIN_PRUNE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
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();
|
||||
}
|
||||
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);
|
||||
@@ -104,56 +64,18 @@ fn list<'a, T: Copy>(
|
||||
} else {
|
||||
Arc::from([])
|
||||
};
|
||||
*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()));
|
||||
}
|
||||
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);
|
||||
}
|
||||
lists.by_section.insert(
|
||||
key,
|
||||
Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois: pois.clone(),
|
||||
},
|
||||
);
|
||||
pois
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,48 +143,6 @@ 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();
|
||||
@@ -273,7 +153,7 @@ mod tests {
|
||||
index.get((1, i, 0), &Arc::new(Section::default()), kind);
|
||||
}
|
||||
let lists = index.sections.lock().unwrap();
|
||||
assert!(lists.len <= 2);
|
||||
assert!(lists.by_column[&(0, 0)][0].is_some());
|
||||
assert!(lists.by_section.len() <= 2);
|
||||
assert!(lists.by_section.contains_key(&(0, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
//! 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
@@ -203,9 +203,6 @@ pub struct Settings {
|
||||
pub startup_api: crate::graphics::GraphicsApi,
|
||||
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
|
||||
pub video_modes: Vec<crate::window::VideoMode>,
|
||||
/// Fullbright is held off: this window plays on another's server (set by the game, not
|
||||
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
|
||||
pub fullbright_held: bool,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
pub controller: crate::gamepad::PadSettings,
|
||||
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
|
||||
@@ -296,7 +293,6 @@ impl Default for Settings {
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
fullbright_held: false,
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -341,13 +337,6 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
@@ -518,7 +507,6 @@ impl Settings {
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
fullbright_held: false,
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -874,10 +862,6 @@ 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
@@ -1,815 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -932,56 +932,7 @@ pub struct Downloader {
|
||||
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
|
||||
/// How long a failed texture download waits before it is tried again.
|
||||
retry_after: std::time::Duration,
|
||||
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
|
||||
state_order: std::collections::VecDeque<(Kind, String)>,
|
||||
/// The `resources` keys, oldest first ([`Bounds::resources`]).
|
||||
resource_order: std::collections::VecDeque<String>,
|
||||
/// Downloads started whose answer `state` has not taken yet: one thread each.
|
||||
in_flight: usize,
|
||||
/// Fetches a texture's body; the HTTP GET, but for a test.
|
||||
fetch: fn(&str) -> Option<Vec<u8>>,
|
||||
bounds: Bounds,
|
||||
}
|
||||
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
|
||||
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
|
||||
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
|
||||
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
|
||||
struct Answer {
|
||||
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
|
||||
key: Option<(Kind, String)>,
|
||||
}
|
||||
impl Answer {
|
||||
fn give(&mut self, image: Option<Vec<u8>>) {
|
||||
if let Some(key) = self.key.take() {
|
||||
let _ = self.send.send((key, image));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Answer {
|
||||
fn drop(&mut self) {
|
||||
self.give(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
|
||||
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
|
||||
/// `texture` id a `resources` entry, none of them ever dropped.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Bounds {
|
||||
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
|
||||
/// dropped texture is loaded again from the disk cache when it is asked for.
|
||||
states: usize,
|
||||
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
|
||||
resources: usize,
|
||||
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
|
||||
/// caller, which asks again every frame, starts it when one has finished.
|
||||
downloads: usize,
|
||||
}
|
||||
const BOUNDS: Bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
const MAX_SKIN_IMAGES: usize = 1024;
|
||||
@@ -1013,11 +964,6 @@ impl Downloader {
|
||||
resources: Default::default(),
|
||||
failed: Default::default(),
|
||||
retry_after: std::time::Duration::from_secs(60),
|
||||
state_order: Default::default(),
|
||||
resource_order: Default::default(),
|
||||
in_flight: 0,
|
||||
fetch: http_get,
|
||||
bounds: BOUNDS,
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
@@ -1027,7 +973,6 @@ impl Downloader {
|
||||
self.images.remove(&location);
|
||||
self.image_order.retain(|l| *l != location);
|
||||
}
|
||||
self.resource_order.clear();
|
||||
self.resource_root = root;
|
||||
}
|
||||
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
|
||||
@@ -1057,47 +1002,12 @@ impl Downloader {
|
||||
}
|
||||
None => Download::Failed,
|
||||
};
|
||||
if self.resources.insert(location.to_owned(), state).is_none() {
|
||||
self.resource_order.push_back(location.to_owned());
|
||||
while self.resources.len() > self.bounds.resources
|
||||
&& let Some(old) = self.resource_order.pop_front()
|
||||
{
|
||||
self.resources.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Forgets how `location` loaded, so `resource` reads it again.
|
||||
fn forget_resource(&mut self, location: &str) {
|
||||
if self.resources.remove(location).is_some() {
|
||||
self.resource_order.retain(|l| l != location);
|
||||
}
|
||||
}
|
||||
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
|
||||
/// downloads running are never dropped, so their answer has a key to land on.
|
||||
fn set_state(&mut self, key: (Kind, String), state: Download) {
|
||||
if self.states.insert(key.clone(), state).is_some() {
|
||||
return;
|
||||
}
|
||||
self.state_order.push_back(key);
|
||||
while self.states.len() > self.bounds.states {
|
||||
let Some(at) = self
|
||||
.state_order
|
||||
.iter()
|
||||
.position(|k| self.states.get(k) != Some(&Download::Pending))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some(old) = self.state_order.remove(at) {
|
||||
self.states.remove(&old);
|
||||
self.failed.remove(&old);
|
||||
}
|
||||
}
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time. A
|
||||
/// failure is permanent for `retry_after`, then tried again.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
self.in_flight = self.in_flight.saturating_sub(1);
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
match key.0 {
|
||||
@@ -1113,42 +1023,29 @@ impl Downloader {
|
||||
Download::Failed
|
||||
}
|
||||
};
|
||||
self.set_state(key, state);
|
||||
self.states.insert(key, state);
|
||||
}
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
return Download::Failed;
|
||||
};
|
||||
let key = (kind, hash);
|
||||
let full = self.in_flight >= self.bounds.downloads;
|
||||
if let Some(state) = self.states.get(&key).copied() {
|
||||
let due = self
|
||||
.failed
|
||||
.get(&key)
|
||||
.is_none_or(|at| at.elapsed() >= self.retry_after);
|
||||
// A failure that is due waits for a free download like a new texture does.
|
||||
if state != Download::Failed || !due || full {
|
||||
if state != Download::Failed || !due {
|
||||
return state;
|
||||
}
|
||||
self.states.remove(&key);
|
||||
self.failed.remove(&key);
|
||||
self.state_order.retain(|k| *k != key);
|
||||
}
|
||||
if full {
|
||||
// Not recorded: the next call asks again, and starts it once one has finished.
|
||||
return Download::Pending;
|
||||
}
|
||||
self.in_flight += 1;
|
||||
self.set_state(key.clone(), Download::Pending);
|
||||
self.states.insert(key.clone(), Download::Pending);
|
||||
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
|
||||
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
|
||||
let fetch = self.fetch;
|
||||
std::thread::spawn(move || {
|
||||
let mut answer = Answer {
|
||||
send,
|
||||
key: Some(key),
|
||||
};
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
|
||||
answer.give(image);
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
Download::Pending
|
||||
}
|
||||
@@ -1211,7 +1108,7 @@ impl Downloader {
|
||||
if location.starts_with("patch/") {
|
||||
let Some(image) = self.images.get(location).cloned() else {
|
||||
// Dropped from memory: read from the packs again.
|
||||
self.forget_resource(location);
|
||||
self.resources.remove(location);
|
||||
return false;
|
||||
};
|
||||
self.queue(location.to_owned(), image);
|
||||
@@ -2028,225 +1925,3 @@ mod tests {
|
||||
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_bound_tests {
|
||||
use super::{Bounds, Download, Downloader, Kind};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut d = Downloader::new(root);
|
||||
d.fetch = fetch;
|
||||
d.bounds = bounds;
|
||||
d
|
||||
}
|
||||
|
||||
fn url(i: usize) -> String {
|
||||
format!("https://textures.example/texture/skin{i:05}")
|
||||
}
|
||||
|
||||
/// Asks again until the texture's download has settled.
|
||||
fn wait(d: &mut Downloader, url: &str) -> Download {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let state = d.state(Kind::Skin, url);
|
||||
if state != Download::Pending || std::time::Instant::now() > deadline {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
static RUNNING: AtomicUsize = AtomicUsize::new(0);
|
||||
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
|
||||
fn slow_failure(_: &str) -> Option<Vec<u8>> {
|
||||
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
PEAK.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
RUNNING.fetch_sub(1, Ordering::SeqCst);
|
||||
None
|
||||
}
|
||||
|
||||
/// The same, not counted: tests run side by side, and the count is another test's.
|
||||
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
None
|
||||
}
|
||||
|
||||
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
|
||||
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
|
||||
#[test]
|
||||
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 4,
|
||||
};
|
||||
let mut d = downloader("threads", bounds, slow_failure);
|
||||
let urls: Vec<String> = (0..60).map(url).collect();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
let mut first = true;
|
||||
loop {
|
||||
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
|
||||
if first {
|
||||
// Asked all at once: four started, and the others were not even recorded.
|
||||
assert_eq!(d.in_flight, 4);
|
||||
assert_eq!(d.states.len(), 4);
|
||||
assert!(states.iter().all(|s| *s == Download::Pending));
|
||||
first = false;
|
||||
}
|
||||
if states.iter().all(|s| *s == Download::Failed) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{} still pending",
|
||||
states.len()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(
|
||||
PEAK.load(Ordering::SeqCst) <= 4,
|
||||
"{}",
|
||||
PEAK.load(Ordering::SeqCst)
|
||||
);
|
||||
assert_eq!(d.states.len(), 60);
|
||||
assert_eq!(d.in_flight, 0);
|
||||
}
|
||||
|
||||
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
|
||||
/// (and their failure time with them), and one that went is asked again as a new texture.
|
||||
#[test]
|
||||
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
|
||||
let bounds = Bounds {
|
||||
states: 8,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("states", bounds, |_| None);
|
||||
for i in 0..40 {
|
||||
let u = url(i);
|
||||
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
|
||||
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
|
||||
assert!(d.failed.len() <= 8);
|
||||
}
|
||||
let kept: Vec<usize> = (0..40)
|
||||
.filter(|i| {
|
||||
d.states
|
||||
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kept, (32..40).collect::<Vec<_>>());
|
||||
// The first one went: asked again, it starts again (and fails again).
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
}
|
||||
|
||||
/// A fetch that panics, as a decoder can on a body a server chose.
|
||||
fn panicking(_: &str) -> Option<Vec<u8>> {
|
||||
panic!("a fetch that panics, on purpose");
|
||||
}
|
||||
|
||||
/// A thread that panics before it answers still gives its place back: with two places and six
|
||||
/// textures whose fetch panics, every one settles as failed instead of the places staying
|
||||
/// taken for good.
|
||||
#[test]
|
||||
fn a_download_that_panics_gives_its_place_back() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 2,
|
||||
};
|
||||
let mut d = downloader("panics", bounds, panicking);
|
||||
for i in 0..6 {
|
||||
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
|
||||
}
|
||||
assert_eq!(d.in_flight, 0);
|
||||
assert_eq!(d.states.len(), 6);
|
||||
}
|
||||
|
||||
/// A fetch long enough for a test to ask between its start and its end.
|
||||
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
None
|
||||
}
|
||||
|
||||
/// A failure whose wait is over is retried like a new texture: not while every place is taken
|
||||
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
|
||||
#[test]
|
||||
fn a_failure_that_is_due_waits_for_a_free_place() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 4096,
|
||||
downloads: 1,
|
||||
};
|
||||
let mut d = downloader("due", bounds, slowish_failure);
|
||||
let wait_time = d.retry_after;
|
||||
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
|
||||
// Another texture takes the only place; the first one's retry becomes due but has to wait.
|
||||
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
d.retry_after = wait_time;
|
||||
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
|
||||
// The place is free: the retry starts.
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
assert_eq!(d.in_flight, 1);
|
||||
}
|
||||
|
||||
/// A texture that is running is never the one dropped, however many settle behind it.
|
||||
#[test]
|
||||
fn a_running_download_keeps_its_place_in_the_states() {
|
||||
let bounds = Bounds {
|
||||
states: 2,
|
||||
resources: 4096,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("running", bounds, sleepy_failure);
|
||||
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
|
||||
for i in 1..6 {
|
||||
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
|
||||
}
|
||||
assert_eq!(
|
||||
d.states
|
||||
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
|
||||
Some(&Download::Pending)
|
||||
);
|
||||
assert!(d.states.len() <= 3);
|
||||
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
|
||||
}
|
||||
|
||||
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
|
||||
/// longer pile up, and one that was dropped is read from the packs again.
|
||||
#[test]
|
||||
fn the_resource_textures_keep_to_their_bound() {
|
||||
let bounds = Bounds {
|
||||
states: 4096,
|
||||
resources: 8,
|
||||
downloads: 16,
|
||||
};
|
||||
let mut d = downloader("resources", bounds, |_| None);
|
||||
for i in 0..40 {
|
||||
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
|
||||
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
|
||||
}
|
||||
let kept: Vec<usize> = (0..40)
|
||||
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
|
||||
.collect();
|
||||
assert_eq!(kept, (32..40).collect::<Vec<_>>());
|
||||
// Forgetting one by hand (as `again` does) leaves no stale key behind.
|
||||
d.forget_resource("patch/n/35");
|
||||
assert_eq!(d.resource_order.len(), d.resources.len());
|
||||
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
|
||||
// New packs start the count over.
|
||||
d.set_resource_root(std::env::temp_dir());
|
||||
assert!(d.resources.is_empty() && d.resource_order.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,6 @@ pub struct Sprites {
|
||||
/// textures under their own names, and one layer serves them all (the GPU's array may
|
||||
/// hold as few as 2048).
|
||||
still: std::collections::HashMap<StillKey, u32>,
|
||||
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
|
||||
/// layer of its own ([`Sprites::keep_apart`]).
|
||||
apart: std::collections::HashSet<String>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
@@ -221,13 +218,6 @@ impl Sprites {
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
|
||||
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
|
||||
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
|
||||
/// with the same pixels). Before the textures that bear these names are inserted.
|
||||
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
|
||||
self.apart.extend(names);
|
||||
}
|
||||
/// Write what this run decoded to the cache file.
|
||||
pub fn save_disk_cache(&mut self) {
|
||||
if let Some(disk) = &mut self.disk {
|
||||
@@ -297,13 +287,8 @@ impl Sprites {
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
// Native frames already match the atlas size: share the immutable
|
||||
// buffers with animations rather than copying every frame again.
|
||||
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
|
||||
Arc::clone(frames)
|
||||
} else {
|
||||
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
|
||||
};
|
||||
let frames: Arc<Vec<Vec<u8>>> =
|
||||
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
|
||||
self.pixels.extend_from_slice(&frames[*initial]);
|
||||
resized.push(frames);
|
||||
}
|
||||
@@ -701,24 +686,23 @@ impl Sprites {
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still =
|
||||
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut frame = std::collections::hash_map::DefaultHasher::new();
|
||||
frames[0].hash(&mut frame);
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
StillKey {
|
||||
frame: frame.finish(),
|
||||
pbr: maps.finish(),
|
||||
side,
|
||||
settings: [
|
||||
settings.strategy as u32,
|
||||
settings.alpha_cutoff_bias.to_bits(),
|
||||
],
|
||||
flags: [settings.has_transparent, settings.item, opaque, translucent],
|
||||
}
|
||||
});
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut frame = std::collections::hash_map::DefaultHasher::new();
|
||||
frames[0].hash(&mut frame);
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
StillKey {
|
||||
frame: frame.finish(),
|
||||
pbr: maps.finish(),
|
||||
side,
|
||||
settings: [
|
||||
settings.strategy as u32,
|
||||
settings.alpha_cutoff_bias.to_bits(),
|
||||
],
|
||||
flags: [settings.has_transparent, settings.item, opaque, translucent],
|
||||
}
|
||||
});
|
||||
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
|
||||
self.layers.insert(name.to_owned(), same);
|
||||
return Ok(same);
|
||||
@@ -888,35 +872,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
#[test]
|
||||
fn native_frames_at_atlas_size_are_shared_without_resampling() {
|
||||
let pixels = vec![
|
||||
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
|
||||
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
|
||||
];
|
||||
let native = Arc::new(pixels.clone());
|
||||
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
|
||||
let metadata = r#"{"frametime":2}"#;
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
(SPRITE_SIZE, Arc::clone(&native), 1),
|
||||
(8, Arc::clone(&smaller), 0),
|
||||
],
|
||||
pbr: vec![[None, None], [None, None]],
|
||||
animations: vec![
|
||||
test_frames(0, pixels.clone(), metadata),
|
||||
test_frames(1, smaller.as_ref().clone(), metadata),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
|
||||
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
|
||||
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
|
||||
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
|
||||
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
|
||||
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
|
||||
}
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
|
||||
@@ -1327,40 +1282,6 @@ mod tests {
|
||||
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
|
||||
// A resource pack's transparent placeholders: a model's layer and a tile another
|
||||
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
|
||||
// rule finds its faces by layer, so the one it names can't share the others'.
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
|
||||
let directory = root.join("assets/a/textures/block");
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
let write = |name: &str| {
|
||||
let mut encoder =
|
||||
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[0; 16 * 16 * 4])
|
||||
.unwrap();
|
||||
};
|
||||
for name in ["placeholder.png", "named.png", "again.png"] {
|
||||
write(name);
|
||||
}
|
||||
let mut sprites = Sprites::default();
|
||||
sprites.keep_apart(["a:block/named".to_owned()]);
|
||||
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
|
||||
let named = sprites.insert(&root, "a:block/named").unwrap();
|
||||
let again = sprites.insert(&root, "a:block/again").unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The named one alone; the others still share.
|
||||
assert_ne!(named, placeholder);
|
||||
assert_eq!(again, placeholder);
|
||||
assert_eq!(sprites.count(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
|
||||
@@ -605,9 +605,6 @@ pub struct Streaming {
|
||||
/// Set while `update` runs, after it refreshed `cover`: nothing else can
|
||||
/// change `extra` meanwhile, so `cover` answers without comparing them.
|
||||
cover_current: bool,
|
||||
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
|
||||
/// they change; read like `cover`, while `cover_current`.
|
||||
forced_cover: Option<ForcedCover>,
|
||||
/// Mesh the sections for drawing (false on a server thread).
|
||||
pub mesh: bool,
|
||||
/// The pregeneration band (`set_band`).
|
||||
@@ -809,7 +806,6 @@ impl GenerationScan {
|
||||
forced: input.ranges.forced,
|
||||
mod_forced: input.ranges.mod_forced,
|
||||
cover: input.ranges.cover,
|
||||
forced_cover: None,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
@@ -1070,10 +1066,8 @@ pub struct WorkerContext {
|
||||
pub light: Arc<LightTables>,
|
||||
}
|
||||
mod bounded;
|
||||
mod forced_cover;
|
||||
mod relight_now;
|
||||
use bounded::nearest_first;
|
||||
use forced_cover::ForcedCover;
|
||||
impl Streaming {
|
||||
pub fn new<G: ChunkGenerator>(
|
||||
generator: Arc<G>,
|
||||
@@ -1182,7 +1176,6 @@ impl Streaming {
|
||||
extra: Vec::new(),
|
||||
cover: None,
|
||||
cover_current: false,
|
||||
forced_cover: None,
|
||||
mesh: true,
|
||||
band: None,
|
||||
band_ranked: None,
|
||||
@@ -1698,7 +1691,6 @@ impl Streaming {
|
||||
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
|
||||
columns.clear();
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
|
||||
self.cover_current = true;
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
// at once when the view moves and every eighth tick otherwise: a
|
||||
@@ -1878,7 +1870,6 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
let (protos, generating) = (&self.protos, &self.generating);
|
||||
@@ -2049,7 +2040,6 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
self.waiting.retain(|&(x, sy, z)| {
|
||||
@@ -2155,7 +2145,6 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
},
|
||||
world,
|
||||
@@ -2294,7 +2283,6 @@ impl Streaming {
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
}
|
||||
}
|
||||
@@ -2575,8 +2563,6 @@ struct Ranges<'a> {
|
||||
/// `extra` indexed by a grid or buckets: coverage queries without a
|
||||
/// scan of every area.
|
||||
cover: Option<&'a ExtraCover>,
|
||||
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
|
||||
forced_cover: Option<&'a ForcedCover>,
|
||||
band: Option<&'a Band>,
|
||||
}
|
||||
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
|
||||
@@ -2625,7 +2611,6 @@ impl Band {
|
||||
+ i64::from(x - self.x0 + 8).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// For every column near the extra areas, how far it lies outside the
|
||||
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
|
||||
/// loaded and wanted column whether one reaches it, several times a tick:
|
||||
@@ -2933,6 +2918,16 @@ impl Ranges<'_> {
|
||||
.iter()
|
||||
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
|
||||
}
|
||||
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
||||
fn is_forced(&self, x: i32, z: i32) -> bool {
|
||||
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
||||
}
|
||||
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
||||
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
||||
self.forced
|
||||
.iter()
|
||||
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
||||
}
|
||||
/// The columns meshed for drawing: those vanilla draws around the
|
||||
/// player (`in_view_distance`), and forced ones.
|
||||
fn in_view(&self, x: i32, z: i32) -> bool {
|
||||
@@ -2944,7 +2939,18 @@ impl Ranges<'_> {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
self.near_forced(x, z, margin)
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced_chunks()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
}
|
||||
}
|
||||
impl Drop for Streaming {
|
||||
@@ -3478,7 +3484,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let generate = |x, z, priority, order| Queued {
|
||||
@@ -3719,7 +3724,6 @@ mod tests {
|
||||
forced: &self.forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
},
|
||||
world: &self.world,
|
||||
@@ -4103,7 +4107,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let square = |x: i32, z: i32, n: i32| {
|
||||
@@ -4161,7 +4164,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let span = radius * 3 + 20;
|
||||
@@ -4199,7 +4201,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
@@ -4234,7 +4235,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
|
||||
@@ -4290,7 +4290,6 @@ mod tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
@@ -4431,7 +4430,6 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
|
||||
@@ -4475,7 +4473,6 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
|
||||
@@ -4542,7 +4539,6 @@ mod cover_tests {
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
// The columns streaming asks about: everything kept, and the
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
|
||||
//! distance map.
|
||||
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
|
||||
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
|
||||
/// through every forced chunk (it is asked for every column, several times a tick).
|
||||
pub(super) struct ForcedCover {
|
||||
forced: HashSet<(i32, i32)>,
|
||||
mod_forced: HashSet<(i32, i32)>,
|
||||
near: FxHashMap<(i32, i32), u8>,
|
||||
}
|
||||
impl ForcedCover {
|
||||
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
|
||||
const REACH: i32 = FORCED_TERRAIN_RING + 1;
|
||||
/// Past this many forced chunks the cover is not made: the scan answers.
|
||||
const MOST: usize = 4096;
|
||||
pub(super) fn refresh(
|
||||
cover: &mut Option<Self>,
|
||||
forced: &HashSet<(i32, i32)>,
|
||||
mod_forced: &HashSet<(i32, i32)>,
|
||||
) {
|
||||
if cover
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if forced.len() + mod_forced.len() > Self::MOST {
|
||||
*cover = None;
|
||||
return;
|
||||
}
|
||||
let mut near = FxHashMap::default();
|
||||
for &(fx, fz) in forced.iter().chain(mod_forced) {
|
||||
for dx in -Self::REACH..=Self::REACH {
|
||||
for dz in -Self::REACH..=Self::REACH {
|
||||
let d = dx.abs().max(dz.abs()) as u8;
|
||||
near.entry((fx + dx, fz + dz))
|
||||
.and_modify(|e: &mut u8| *e = (*e).min(d))
|
||||
.or_insert(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
*cover = Some(Self {
|
||||
forced: forced.clone(),
|
||||
mod_forced: mod_forced.clone(),
|
||||
near,
|
||||
});
|
||||
}
|
||||
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
|
||||
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
|
||||
(reach <= Self::REACH).then(|| {
|
||||
self.near
|
||||
.get(&(x, z))
|
||||
.is_some_and(|&d| i32::from(d) <= reach)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranges<'_> {
|
||||
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
||||
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
|
||||
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
||||
}
|
||||
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
||||
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
||||
self.forced
|
||||
.iter()
|
||||
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
||||
}
|
||||
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
|
||||
/// `margin` asks for.
|
||||
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
|
||||
return hit;
|
||||
}
|
||||
self.forced_chunks()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
|
||||
let mut next = |m: i32| {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
(*seed % (2 * m as u64 + 1)) as i32 - m
|
||||
};
|
||||
(0..n).map(|_| (next(spread), next(spread))).collect()
|
||||
}
|
||||
|
||||
/// With the forced chunks by distance, every column gets the answer the scan of every forced
|
||||
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
|
||||
/// forced chunks change too.
|
||||
#[test]
|
||||
fn the_distances_answer_as_the_scan() {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut cover = None;
|
||||
for case in 0..24 {
|
||||
let spread = [3, 12, 40][case % 3];
|
||||
let forced = chunks(&mut seed, 1 + case % 9, spread);
|
||||
// Some mod tickets on forced chunks, some apart.
|
||||
let mut mod_forced = chunks(&mut seed, case % 4, spread);
|
||||
mod_forced.extend(forced.iter().take(case % 2));
|
||||
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
|
||||
let made = cover.as_ref().expect("a few forced chunks get a map");
|
||||
let ranges = |forced_cover| Ranges {
|
||||
// The player's circle far away: only the forced chunks answer.
|
||||
center: (10_000, 10_000),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
mod_forced: &mod_forced,
|
||||
cover: None,
|
||||
forced_cover,
|
||||
band: None,
|
||||
};
|
||||
let (scan, map) = (ranges(None), ranges(Some(made)));
|
||||
let span = spread + 10;
|
||||
for z in -span..=span {
|
||||
for x in -span..=span {
|
||||
for margin in 0..=7 {
|
||||
assert_eq!(
|
||||
map.in_main_range(x, z, margin),
|
||||
scan.in_main_range(x, z, margin),
|
||||
"case {case} ({x}, {z}) margin {margin}"
|
||||
);
|
||||
}
|
||||
for d in [false, true] {
|
||||
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
|
||||
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
|
||||
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Too many forced chunks for a map: none is made, and the scan answers.
|
||||
#[test]
|
||||
fn past_the_most_no_map_is_made() {
|
||||
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
|
||||
let mut cover = None;
|
||||
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
|
||||
assert!(cover.is_some());
|
||||
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
|
||||
assert!(cover.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -524,18 +524,8 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
|
||||
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
|
||||
let g = (lo.y >> (8u * corner)) & 255u;
|
||||
let t = lo.x;
|
||||
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
|
||||
var code = 0u;
|
||||
if corner == 1u {
|
||||
code = (hi.x >> 28u) & 7u;
|
||||
} else if corner == 2u {
|
||||
code = (hi.y >> 24u) & 7u;
|
||||
} else if corner == 3u {
|
||||
code = (hi.y >> 27u) & 7u;
|
||||
}
|
||||
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
|
||||
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
|
||||
| ((g * (t & 255u) / 255u) << 16u) | alpha;
|
||||
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
|
||||
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
|
||||
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
@@ -119,11 +121,14 @@ 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 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, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
# 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,18 +11,6 @@
|
||||
"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",
|
||||
@@ -141,82 +129,6 @@
|
||||
"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",
|
||||
@@ -375,7 +287,6 @@
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
@@ -530,7 +441,7 @@
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped.": "Auto uses the peak Windows reports. Calibrate if highlights look clipped.",
|
||||
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
@@ -1108,7 +1019,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1989,13 +1989,13 @@ mod tests {
|
||||
}
|
||||
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
|
||||
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
|
||||
/// with HORIZON_TEST_RESOURCES set to the prepared resources.
|
||||
/// with HORIZON_RESOURCES set to the prepared resources.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
#[ignore = "needs the prepared resources"]
|
||||
fn index_on_vanilla_tree() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let data = root.join("data");
|
||||
let t = std::time::Instant::now();
|
||||
let tree = Tree::load(&data);
|
||||
|
||||
@@ -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, menu_style, 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, 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,6 +58,8 @@ 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;
|
||||
@@ -100,6 +102,8 @@ pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
pub mod official_servers;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
|
||||
@@ -26,9 +26,8 @@ pub struct Tab {
|
||||
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
|
||||
/// pages them.
|
||||
pub page: u32,
|
||||
/// A Polytone creative tab modifier's `name`: its text (a translation key when
|
||||
/// the flag is set) and colour, over the tab's own.
|
||||
pub name: Option<(String, Option<u32>, bool)>,
|
||||
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
|
||||
pub name: Option<(String, Option<u32>)>,
|
||||
}
|
||||
impl Tab {
|
||||
/// The tab without its stacks.
|
||||
@@ -104,28 +103,25 @@ pub struct Creative {
|
||||
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
|
||||
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
|
||||
}
|
||||
/// A text component's text, its colour (a name, or `#rrggbb`) and whether the
|
||||
/// text is a `translate` key, to be resolved when drawn.
|
||||
fn component_text(json: &Value) -> (String, Option<u32>, bool) {
|
||||
/// A text component's plain text and its colour (a name, or `#rrggbb`).
|
||||
fn component_text(json: &Value) -> (String, Option<u32>) {
|
||||
match json {
|
||||
Value::String(s) => (s.clone(), None, false),
|
||||
Value::String(s) => (s.clone(), None),
|
||||
Value::Object(o) => {
|
||||
let translate = o.get("translate").and_then(Value::as_str);
|
||||
let text = o
|
||||
.get("text")
|
||||
.or_else(|| o.get("translate"))
|
||||
.and_then(Value::as_str)
|
||||
.or(translate)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let translate = o.get("text").is_none() && translate.is_some();
|
||||
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
|
||||
.or_else(|| crate::text::named_color(c))
|
||||
});
|
||||
(text, color, translate)
|
||||
(text, color)
|
||||
}
|
||||
_ => (String::new(), None, false),
|
||||
_ => (String::new(), None),
|
||||
}
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
@@ -916,16 +912,6 @@ impl Creative {
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
(x, y)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.checkTabHovering`: the tab whose icon
|
||||
/// rectangle, 3 pixels in from its 26x32 slot (`isHovering(x + 3, y + 3,
|
||||
/// 21, 27)`), holds the cursor. The screen draws its display name.
|
||||
pub fn hovered_tab(tabs: &[Tab], origin: (f32, f32), cursor: (f32, f32)) -> Option<usize> {
|
||||
tabs.iter().position(|tab| {
|
||||
let (x, y) = Self::tab_position(tab);
|
||||
let (x, y) = (origin.0 + x + 3.0, origin.1 + y + 3.0);
|
||||
cursor.0 >= x && cursor.0 < x + 21.0 && cursor.1 >= y && cursor.1 < y + 27.0
|
||||
})
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
|
||||
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
|
||||
/// the screen origin, is strictly inside its 26x32 button.
|
||||
@@ -1803,21 +1789,4 @@ mod tests {
|
||||
assert!(c.tab_headers().is_empty());
|
||||
assert_eq!(c.search_tab(), None);
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.checkTabHovering`: the 3-pixel inset of a
|
||||
/// tab's 26x32 slot (21x27) is the hover area whose name the screen shows.
|
||||
#[test]
|
||||
fn only_a_tabs_inset_icon_is_hovered() {
|
||||
let screen = tooltip_tabs();
|
||||
let tab = &screen.tabs[0];
|
||||
let (x, y) = Creative::tab_position(tab);
|
||||
let at =
|
||||
|dx: f32, dy: f32| Creative::hovered_tab(&screen.tabs, (0.0, 0.0), (x + dx, y + dy));
|
||||
assert_eq!(at(4.0, 4.0), Some(0));
|
||||
assert_eq!(at(23.5, 4.0), Some(0));
|
||||
assert_eq!(at(2.0, 4.0), None);
|
||||
assert_eq!(at(24.0, 4.0), None);
|
||||
assert_eq!(at(4.0, 2.0), None);
|
||||
assert_eq!(at(4.0, 30.0), None);
|
||||
}
|
||||
}
|
||||
@@ -8,51 +8,28 @@ use crate::{
|
||||
text::{Style, Text},
|
||||
};
|
||||
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the last style `wanted`
|
||||
/// under the cursor, in the same visual order and advances as rendering.
|
||||
/// A drawn glyph is hit where it is drawn (`BakedSheetGlyph.GlyphInstance`),
|
||||
/// even far below the line when its font's `ascent` moves it; an empty one
|
||||
/// covers the line's 9 pixels (`EmptyArea`).
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
|
||||
/// message glyph, using the same visual order and advances as rendering.
|
||||
pub fn line_style_at(
|
||||
font: &Font,
|
||||
text: &Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cursor: (f32, f32),
|
||||
wanted: impl Fn(&Style) -> bool,
|
||||
) -> Option<Style> {
|
||||
let mut left = cursor.0 - x;
|
||||
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
|
||||
return None;
|
||||
}
|
||||
let text = text.resolve_legacy();
|
||||
let visual = crate::bidi::visual(&text);
|
||||
let mut pen = x;
|
||||
let mut found = None;
|
||||
for (c, style) in visual.chars() {
|
||||
let advance = font.advance(c, style);
|
||||
let object = c == '\u{fffc}' && style.inline_object.is_some();
|
||||
let glyph = (!object).then(|| font.glyph_styled(c, style));
|
||||
let (left, top, bottom) = match glyph {
|
||||
Some(g) if g.rect.is_some() => {
|
||||
let thick = if style.is_bold() { 0.1 } else { 0.0 };
|
||||
let shear = if style.is_italic() {
|
||||
1.0 - 0.25 * g.bottom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Dialog text is drawn with a shadow, which extends the bottom.
|
||||
(
|
||||
pen + g.left + shear - thick,
|
||||
y + g.top - thick,
|
||||
y + g.bottom + g.offset + thick,
|
||||
)
|
||||
}
|
||||
_ => (pen, y, y + 9.0),
|
||||
};
|
||||
let inside = (left..pen + advance).contains(&cursor.0) && (top..bottom).contains(&cursor.1);
|
||||
if inside && wanted(style) {
|
||||
found = Some(style.clone());
|
||||
left -= font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
return Some(style.clone());
|
||||
}
|
||||
pen += advance;
|
||||
}
|
||||
found
|
||||
None
|
||||
}
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
@@ -508,7 +485,7 @@ mod tests {
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y), |_| true);
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
|
||||
assert!(style(9.9, 20.0).is_none());
|
||||
assert_eq!(style(17.9, 20.0).unwrap().click, None);
|
||||
for x in [18.0, 21.0, 25.9] {
|
||||
@@ -526,77 +503,6 @@ mod tests {
|
||||
assert!(style(21.0, 29.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_moved_down_by_its_ascent_is_clicked_where_it_is_drawn() {
|
||||
use crate::{component, hud::Rect, text::ClickEvent};
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-dialog-ascent-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut sheet = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut sheet, 8, 8);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[255; 8 * 8 * 4])
|
||||
.unwrap();
|
||||
}
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"assets/minecraft/font/low.json",
|
||||
br#"{"providers":[{"type":"bitmap","file":"minecraft:font/box.png","height":8,"ascent":-28,"chars":["a"]}]}"#
|
||||
.as_slice(),
|
||||
),
|
||||
("assets/minecraft/textures/font/box.png", sheet.as_slice()),
|
||||
] {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
let font = Font::load(
|
||||
&root,
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(4096, 1024),
|
||||
(512, 512),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0,
|
||||
v1: 0.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let text = component::to_text(
|
||||
&serde_json::json!([
|
||||
{"text": "a", "font": "minecraft:low"},
|
||||
{"text": "a", "font": "minecraft:low", "click_event": {
|
||||
"action": "custom", "id": "test:requests"
|
||||
}},
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let click = |x, y| {
|
||||
line_style_at(&font, &text, 10.0, 20.0, (x, y), |s| s.click.is_some())
|
||||
.and_then(|s| s.click)
|
||||
};
|
||||
let requests = Some(ClickEvent::Custom {
|
||||
id: "test:requests".into(),
|
||||
payload: None,
|
||||
});
|
||||
for (x, y) in [(19.0, 55.0), (22.0, 60.0), (27.9, 63.9)] {
|
||||
assert_eq!(click(x, y), requests, "drawn 35 below the line at {x}, {y}");
|
||||
}
|
||||
assert_eq!(click(22.0, 24.0), None, "the line's own row holds nothing");
|
||||
assert_eq!(click(22.0, 54.9), None, "above the glyph");
|
||||
assert_eq!(click(22.0, 65.0), None, "below the glyph and its shadow");
|
||||
assert_eq!(click(15.0, 60.0), None, "the plain glyph left of it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
|
||||
+29
-189
@@ -1,30 +1,21 @@
|
||||
//! 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 says what the player does on each change ([`set`]), which wakes the thread,
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change 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`.
|
||||
//!
|
||||
//! 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)]
|
||||
//! 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.
|
||||
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_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
|
||||
use horizon_platform::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_platform::settings::Store;
|
||||
use horizon_command::args::GameMode;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
@@ -49,141 +40,24 @@ const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
/// 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 Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -223,15 +97,11 @@ 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,
|
||||
@@ -244,8 +114,7 @@ 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.
|
||||
/// The status [`set`] gave before, the switch included, is the first one sent.
|
||||
pub fn start(automated: bool) {
|
||||
pub fn start(enabled: bool, 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")
|
||||
@@ -253,6 +122,9 @@ pub fn start(automated: bool) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
@@ -264,13 +136,12 @@ pub fn start(automated: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
pub fn set(status: Status) {
|
||||
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) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
@@ -709,37 +580,6 @@ mod tests {
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
crate::mobs::piglin::write_guarded_anger(m, map);
|
||||
write_guarded_piglin_anger(m, map);
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
@@ -2232,9 +2232,6 @@ 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");
|
||||
}
|
||||
@@ -5972,6 +5969,42 @@ 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
|
||||
@@ -9342,59 +9375,6 @@ 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,7 +13,6 @@ 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.
|
||||
@@ -24,9 +23,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -166,8 +166,7 @@ impl FurnaceCooking for Furnace {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
// Vanilla Mth.clamp is min(max(v, 0), max): saturates at a negative total instead of panicking.
|
||||
self.cooking_timer = (self.cooking_timer - 2).max(0).min(self.cooking_total);
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -129,6 +128,8 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -372,7 +373,6 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -402,7 +402,6 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_settings;
|
||||
mod render_tick;
|
||||
mod replay;
|
||||
mod repro;
|
||||
mod resource_load;
|
||||
mod resource_loader;
|
||||
@@ -1498,7 +1497,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
return signal;
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -1524,14 +1523,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
fn block_entity_signal(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
_state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
// TestBlock.ownSignal: the block state's mode gates the entity.
|
||||
// TestBlock.ownSignal.
|
||||
if let Some(BlockEntity::TestBlock(b)) =
|
||||
self.world.block_entities.get(&(pos.x, pos.y, pos.z))
|
||||
{
|
||||
return crate::game::test_blocks::own_signal(self.registry, state, b.powered);
|
||||
return b.own_signal();
|
||||
}
|
||||
// TrappedChestBlock.getSignal: the players with it open, at most 15.
|
||||
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
|
||||
@@ -2873,9 +2872,9 @@ struct Game<'a> {
|
||||
water_plane_memo: std::cell::Cell<Option<f32>>,
|
||||
/// The camera's block and the second `water_plane` last scanned at.
|
||||
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
|
||||
/// What the boats' and minecarts' ticks know about the mobs: their box reach
|
||||
/// (`vehicle_mob_reach`) and a grid of their boxes (`mobs_meeting`).
|
||||
vehicle_mobs: boat_passengers::VehicleMobs,
|
||||
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
|
||||
/// worked out for (`vehicle_mob_reach`).
|
||||
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
|
||||
persist: persist::EntityMemory,
|
||||
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
@@ -2883,9 +2882,19 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
|
||||
pub(super) replay: replay::Replay,
|
||||
pub(super) studio: studio::Studio,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5832,7 +5841,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_over_hud(&mut batch);
|
||||
self.draw_pad_cursor(&mut batch);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
@@ -5933,7 +5942,8 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let show_hud = !self.ui.hide_gui && !self.replay.playing();
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
|
||||
// defers the subtitles over itself, and keeps them while F1 hides the
|
||||
// rest of the HUD.
|
||||
@@ -5963,7 +5973,6 @@ 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,7 +6102,6 @@ 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| {
|
||||
@@ -6193,9 +6201,7 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat {
|
||||
self.draw_chat(&mut batch);
|
||||
} else if show_hud {
|
||||
if !self.overlay_holds_view() {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
}
|
||||
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);
|
||||
});
|
||||
@@ -6266,13 +6272,33 @@ 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);
|
||||
self.draw_every_toast(&mut batch, !show_hud);
|
||||
// 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);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
self.draw_studio_bars(&mut batch);
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
}
|
||||
if show_hud {
|
||||
self.draw_pad_guide(&mut batch, guide_from);
|
||||
}
|
||||
@@ -6495,12 +6521,12 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio.flying() {
|
||||
if self.studio_freecam.active {
|
||||
return camera_rotation(
|
||||
perspective::CameraType::FirstPerson,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
|
||||
}
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
@@ -6707,7 +6733,11 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = self.own_player_alpha();
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.clock.camera_fraction()
|
||||
};
|
||||
self.player_model_geometry(camera, player_alpha, &mut out);
|
||||
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
@@ -6716,8 +6746,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_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);
|
||||
@@ -7436,6 +7466,7 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9205,7 +9236,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio.flying() {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9383,7 +9414,14 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
self.studio_camera_path(&mut g);
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
@@ -9405,7 +9443,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio.flying() {
|
||||
let view = if self.studio_freecam.active {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9424,7 +9462,6 @@ 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);
|
||||
@@ -9532,7 +9569,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -9980,8 +10017,10 @@ impl Game<'_> {
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sneak"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
@@ -10423,7 +10462,21 @@ impl Game<'_> {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.studio_mouse_dragged(b.width) {
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
@@ -10474,7 +10527,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.replay.playing() => {}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10751,7 +10804,90 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if self.studio_mouse_input(pressed, button) {
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio_freecam.active || self.studio_replay.is_some())
|
||||
&& button == MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch =
|
||||
HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio_timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
} else if self.studio_rmb_down {
|
||||
self.studio_rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = HudBatch::new(size);
|
||||
let had_open_menu = self.studio_topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio_topbar.handle_mouse_click(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
);
|
||||
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
return Ok(());
|
||||
}
|
||||
if self.studio_timeline.visible {
|
||||
let action = self.studio_timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return Ok(());
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11221,7 +11357,24 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
self.studio_fly(elapsed);
|
||||
if self.studio_freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
|
||||
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
|
||||
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
fast,
|
||||
elapsed.as_secs_f64().min(0.1),
|
||||
);
|
||||
}
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
// other screens use the same keys for their own navigation.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -11251,7 +11404,8 @@ 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());
|
||||
self.report_activity();
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -11405,18 +11559,17 @@ impl Game<'_> {
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let saved = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.saved_screenshots))
|
||||
.unwrap_or_default();
|
||||
for saved in saved {
|
||||
self.finish_screenshot(saved);
|
||||
}
|
||||
let mut consumed = false;
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.and_then(|r| r.screenshot_result.take());
|
||||
if let Some(result) = &finished
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
@@ -11429,6 +11582,7 @@ impl Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) =
|
||||
self.renderer.as_mut().and_then(|r| r.capture_result.take())
|
||||
@@ -11437,6 +11591,12 @@ impl Game<'_> {
|
||||
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
|
||||
let _ = reply.send(value.to_string());
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
|
||||
@@ -11992,13 +12152,21 @@ impl<'a> Game<'a> {
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
vehicle_mobs: Default::default(),
|
||||
vehicle_mob_reach: Default::default(),
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
replay: Default::default(),
|
||||
studio: Default::default(),
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
@@ -12497,8 +12665,10 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
@@ -12526,8 +12696,15 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options);
|
||||
// "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);
|
||||
}
|
||||
// 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"));
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//! 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1255,7 +1255,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.check_inventory_trigger();
|
||||
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
|
||||
let me = self.player_entity_if_listened("minecraft:tick");
|
||||
let me = self.player_entity_for("minecraft:tick");
|
||||
self.trigger("minecraft:tick", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1289,7 +1289,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_marks();
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity_if_listened("minecraft:location");
|
||||
let me = self.player_entity_for("minecraft:location");
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
//! An allay dances to the jukebox it heard first: `Allay.setJukeboxPlaying`
|
||||
//! keeps that jukebox's position while it dances, and only a stop from there
|
||||
//! ends the dance; another jukebox's `jukebox_play` (every 20 ticks of its
|
||||
//! song) starts it again (tools/test_allay_jukebox_source_parity.py measures
|
||||
//! the same on vanilla 26.3).
|
||||
use crate::game::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
for command in [
|
||||
"tick freeze",
|
||||
"forceload add 0 0",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
"fill 7 99 7 9 102 9 glass",
|
||||
"fill 8 100 8 8 101 8 air",
|
||||
"setblock 5 100 8 jukebox",
|
||||
"setblock 12 100 8 jukebox",
|
||||
"summon allay 8.5 100 8.5 {PersistenceRequired:1b,UUID:[I;2577,0,0,1]}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
step(&mut game, 12);
|
||||
game
|
||||
}
|
||||
|
||||
fn step(game: &mut Game<'_>, ticks: usize) {
|
||||
for _ in 0..ticks {
|
||||
assert!(game.run_command("tick step 1"));
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn dancing(game: &Game<'_>) -> bool {
|
||||
game.mobs
|
||||
.iter()
|
||||
.find(|m| m.species() == horizon_client::mobs::Species::Allay)
|
||||
.expect("the allay")
|
||||
.extra
|
||||
.flag
|
||||
}
|
||||
|
||||
fn play(game: &mut Game<'_>, x: i32, item: &str) {
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace block {x} 100 8 container.0 with {item}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn stopping_the_first_jukebox_stops_the_dance_until_the_other_plays_again() {
|
||||
let mut game = game();
|
||||
assert!(!dancing(&game));
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
assert!(dancing(&game), "jukebox A starts the dance");
|
||||
play(&mut game, 12, "minecraft:music_disc_cat");
|
||||
step(&mut game, 25);
|
||||
assert!(dancing(&game));
|
||||
// B has played for 25 ticks: its next jukebox_play is 15 ticks away.
|
||||
play(&mut game, 5, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(
|
||||
!dancing(&game),
|
||||
"the stop of A, the dance's jukebox, ends it though B still plays"
|
||||
);
|
||||
step(&mut game, 20);
|
||||
assert!(dancing(&game), "B's next jukebox_play starts it again");
|
||||
}
|
||||
|
||||
// Controls added after the baseline: the source through an in-place load, a
|
||||
// NoAI allay, and the out-of-reach check of `aiStep`.
|
||||
|
||||
const ALLAY: &str = "00000a11-0000-0000-0000-000000000001";
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_data_merge_keeps_the_dance_and_its_jukebox() {
|
||||
let mut game = game();
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
play(&mut game, 12, "minecraft:music_disc_cat");
|
||||
step(&mut game, 5);
|
||||
assert!(game.run_command(&format!("data merge entity {ALLAY} {{CustomName:\"x\"}}")));
|
||||
step(&mut game, 1);
|
||||
assert!(dancing(&game), "Entity.load keeps DATA_DANCING");
|
||||
// The kept jukebox is still A: B's stop does not end the dance, A's does.
|
||||
play(&mut game, 12, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(dancing(&game));
|
||||
play(&mut game, 5, "minecraft:air");
|
||||
step(&mut game, 1);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_a_no_ai_allay_never_dances() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command(&format!("data merge entity {ALLAY} {{NoAI:1b}}")));
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 25);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_an_allay_out_of_reach_stops_on_a_twentieth_tick() {
|
||||
let mut game = game();
|
||||
play(&mut game, 5, "minecraft:music_disc_cat");
|
||||
step(&mut game, 2);
|
||||
assert!(dancing(&game));
|
||||
assert!(game.run_command("fill 7 99 7 9 102 9 air"));
|
||||
assert!(game.run_command(&format!("tp {ALLAY} 5.5 112 8.5")));
|
||||
// Farther than 10 blocks from A's centre: stopped by the next tickCount % 20.
|
||||
step(&mut game, 20);
|
||||
assert!(!dancing(&game));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! and the hand-over interaction (`Allay.mobInteract`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
@@ -73,6 +74,9 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() != Species::Allay
|
||||
|| !self.mobs[i].alive()
|
||||
@@ -81,8 +85,26 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let pos = self.mobs[i].position;
|
||||
// The dance follows the jukebox events the allay hears
|
||||
// (`mob_listen`), `Allay.tick` and `aiStep`.
|
||||
// Dancing: a playing jukebox within 10 blocks.
|
||||
let jukeboxes = jukeboxes.get_or_insert_with(|| {
|
||||
self.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
|
||||
.map(|(k, _)| *k)
|
||||
.collect()
|
||||
});
|
||||
let nearest = jukeboxes
|
||||
.iter()
|
||||
.map(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
|
||||
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
|
||||
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
@@ -403,7 +425,3 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allay_jukebox_source_tests.rs"]
|
||||
mod allay_jukebox_source_tests;
|
||||
@@ -848,20 +848,34 @@ impl Game<'_> {
|
||||
self.armor_stands[i].last_hit = time;
|
||||
return true;
|
||||
}
|
||||
// brokenByPlayer: the stand item, with the stand's name, pops at its
|
||||
// block, then brokenByAnything: the loot table and the equipment.
|
||||
let (width, height) = stand_box(&self.armor_stands[i]);
|
||||
let p = self.armor_stands[i].position;
|
||||
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
|
||||
if let Some(name) = &self.armor_stands[i].custom_name {
|
||||
stand.data_mut().custom_name =
|
||||
Some(horizon_client::scoreboard::component_tag(&name.component()));
|
||||
}
|
||||
self.pop_resource(pos, stand);
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
|
||||
let a = self.armor_stands.remove(i);
|
||||
let (width, height) = stand_box(&a);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
|
||||
let stand = ItemStack {
|
||||
item: Arc::from("minecraft:armor_stand"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stand, center, [0.0; 3], 10);
|
||||
for stack in a.equipment.into_iter().flatten() {
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, center, v, 10);
|
||||
}
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.stand_game_event("minecraft:entity_die", at, by);
|
||||
self.world
|
||||
.modified
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
true
|
||||
}
|
||||
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
|
||||
@@ -1188,8 +1202,6 @@ impl Game<'_> {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let a_id = a.id;
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
@@ -1198,6 +1210,8 @@ impl Game<'_> {
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
@@ -1278,21 +1292,10 @@ 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
|
||||
|| self.stand_immune_to_damage(i, damage_type, causing)
|
||||
{
|
||||
if (invulnerable && !creative) || a.invisible || a.marker {
|
||||
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"),
|
||||
@@ -1303,26 +1306,21 @@ impl Game<'_> {
|
||||
tagged("minecraft:always_kills_armor_stands"),
|
||||
);
|
||||
if explosion {
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.break_armor_stand_by_anything(i);
|
||||
return false;
|
||||
}
|
||||
if ignites {
|
||||
if self.armor_stands[i].fire_ticks > 0 {
|
||||
self.cause_stand_damage(i, 0.15, by);
|
||||
self.cause_stand_damage(i, 0.15);
|
||||
} else {
|
||||
// igniteForSeconds(5.0f): at least 100 ticks, and
|
||||
// Entity.igniteForTicks calls clearFreeze.
|
||||
// igniteForSeconds(5.0f): at least 100 ticks.
|
||||
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, by);
|
||||
self.cause_stand_damage(i, 4.0);
|
||||
return false;
|
||||
}
|
||||
if !breaks && !always_kills {
|
||||
@@ -1337,85 +1335,22 @@ impl Game<'_> {
|
||||
if !may_build {
|
||||
return false;
|
||||
}
|
||||
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()),
|
||||
// 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 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
|
||||
self.break_armor_stand_by_hit(i, creative, always_kills, by)
|
||||
}
|
||||
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
|
||||
/// 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>) {
|
||||
/// at 0.5 or less.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
|
||||
let health = self.armor_stands[i].health - amount;
|
||||
if health <= 0.5 {
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.break_armor_stand_by_anything(i);
|
||||
} 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.
|
||||
|
||||
@@ -42,25 +42,12 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
let tickers = self.world.block_entity_tickers();
|
||||
let mut last_chunk = (i32::MIN, i32::MIN);
|
||||
let mut last_chunk_ticking = false;
|
||||
for slot in tickers.iter().copied() {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
let chunk = (pos.0 >> 4, pos.2 >> 4);
|
||||
let chunk_ticking = if chunk == last_chunk {
|
||||
last_chunk_ticking
|
||||
} else {
|
||||
last_chunk = chunk;
|
||||
last_chunk_ticking = ticking(pos.0, pos.2);
|
||||
last_chunk_ticking
|
||||
};
|
||||
if !chunk_ticking {
|
||||
continue;
|
||||
}
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
continue;
|
||||
@@ -223,14 +210,8 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
let has_pending = !self.world.entities_pending.is_empty();
|
||||
let pending = has_pending.then(|| self.world.entities_pending.clone());
|
||||
move |x, z| {
|
||||
levels.block_ticking_at(x, z)
|
||||
&& pending
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
|
||||
}
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
|
||||
@@ -7,13 +7,10 @@
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
fast_hash::FxHashMap,
|
||||
join_seq::ListStamp,
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders, seen_grid::SeenGrid},
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
@@ -21,35 +18,6 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// How many box queries the walk over every mob answers, once a write to the mob list has
|
||||
/// made the grid stale, before a new grid is built: about what building one costs (with
|
||||
/// 25,000 mobs, six to eight walks). It doubles with each grid of a stretch, so vehicles
|
||||
/// that each write a mob (a boat pushing the pig beside it) never pay more for grids than
|
||||
/// for their walks.
|
||||
const WALKS_BEFORE_GRID: u32 = 8;
|
||||
|
||||
/// What a stretch of vehicle ticks knows about the mobs, so a boat or a minecart reads the
|
||||
/// mobs near it and not every mob of the level ([`Game::mobs_meeting`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct VehicleMobs {
|
||||
/// The mobs' [`BoxReach`] with the mob count it was worked out for; None outside a stretch.
|
||||
reach: Cell<Option<(usize, BoxReach)>>,
|
||||
grid: RefCell<MobGrid>,
|
||||
/// The last query's candidates, kept for their allocation.
|
||||
near: Cell<Vec<u32>>,
|
||||
/// Each ridden vehicle's mobs by slot, for the mob list as it stood ([`Game::vehicle_riders`]).
|
||||
riders: RefCell<Option<(ListStamp, FxHashMap<u32, Vec<usize>>)>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MobGrid {
|
||||
/// The mobs' boxes by column and the list they were read from: they hold until that
|
||||
/// list's next write ([`ListStamp`]), which every push, pick-up, death and join is.
|
||||
built: Option<(ListStamp, SeenGrid)>,
|
||||
/// The queries walked since the grid went stale, and how many are before the next grid.
|
||||
walked: u32,
|
||||
patience: u32,
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
|
||||
/// to return false, so no vehicle ever takes them aboard.
|
||||
pub(super) fn species_can_ride(species: Species) -> bool {
|
||||
@@ -98,165 +66,32 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
|
||||
/// a vehicle's box query ([`Self::mobs_meeting`]) reads the mobs near it from a grid of
|
||||
/// their boxes, or tests the mobs' positions against their [`BoxReach`] first. A mob
|
||||
/// joining or leaving works the reach out again, and so does [`Self::vehicle_picked_up`].
|
||||
/// The first query that needs one works it out (the reach's count is one no list has), so
|
||||
/// a level without boats or carts never walks its mobs for them.
|
||||
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
|
||||
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
|
||||
pub(super) fn vehicles_ticking(&self, on: bool) {
|
||||
let reach = on.then(|| (usize::MAX, BoxReach::default()));
|
||||
self.vehicle_mobs.reach.set(reach);
|
||||
// The boats' grid serves the carts ticking right after them; an older one goes.
|
||||
let mut grid = self.vehicle_mobs.grid.borrow_mut();
|
||||
let stamp = self.mobs.stamp();
|
||||
let built = grid.built.take().filter(|(s, _)| *s == stamp);
|
||||
*grid = MobGrid {
|
||||
built,
|
||||
..Default::default()
|
||||
};
|
||||
self.vehicle_mob_reach
|
||||
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
|
||||
}
|
||||
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
|
||||
/// changes its box, so the next query works the [`BoxReach`] out again.
|
||||
pub(super) fn vehicle_picked_up(&self) {
|
||||
if let Some((_, reach)) = self.vehicle_mobs.reach.get() {
|
||||
self.vehicle_mobs.reach.set(Some((usize::MAX, reach)));
|
||||
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
|
||||
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
|
||||
}
|
||||
}
|
||||
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
|
||||
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
|
||||
let (count, reach) = self.vehicle_mobs.reach.get()?;
|
||||
let (count, reach) = self.vehicle_mob_reach.get()?;
|
||||
if count == self.mobs.len() {
|
||||
return Some(reach);
|
||||
}
|
||||
let reach = BoxReach::of(&self.mobs);
|
||||
self.vehicle_mobs.reach.set(Some((self.mobs.len(), reach)));
|
||||
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
|
||||
Some(reach)
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose vehicle is `id`, whatever their state. While
|
||||
/// vehicles tick, one walk over the mobs answers for every vehicle until the mob list is
|
||||
/// next written: each boat asks for its passengers and each cart on rails whether it is
|
||||
/// ridden, and each one walked every mob of the level.
|
||||
fn vehicle_riders(&self, id: u32) -> Vec<usize> {
|
||||
let rides = |v: u32| move |&j: &usize| self.mobs[j].extra.mount == Some(v);
|
||||
if !self.vehicle_mobs_indexed() {
|
||||
return (0..self.mobs.len()).filter(rides(id)).collect();
|
||||
}
|
||||
let stamp = self.mobs.stamp();
|
||||
let mut riders = self.vehicle_mobs.riders.borrow_mut();
|
||||
if riders.as_ref().is_none_or(|(s, _)| *s != stamp) {
|
||||
let mut by_vehicle = FxHashMap::<u32, Vec<usize>>::default();
|
||||
for (j, m) in self.mobs.iter().enumerate() {
|
||||
if let Some(v) = m.extra.mount {
|
||||
by_vehicle.entry(v).or_default().push(j);
|
||||
}
|
||||
}
|
||||
*riders = Some((stamp, by_vehicle));
|
||||
}
|
||||
let of = riders
|
||||
.as_ref()
|
||||
.and_then(|(_, by_vehicle)| by_vehicle.get(&id));
|
||||
of.cloned().unwrap_or_default()
|
||||
}
|
||||
/// Whether a mob (one not removed) rides vehicle `id`: `Entity.isVehicle` for a cart.
|
||||
pub(super) fn mob_rides(&self, id: u32) -> bool {
|
||||
let riders = self.vehicle_riders(id);
|
||||
riders.iter().any(|&j| !self.mobs[j].removed)
|
||||
}
|
||||
/// Whether what a stretch of vehicle ticks reads from the mob list may stand in for a walk
|
||||
/// over it: a stretch runs, and no native mod is loaded (a hook can move a mob unseen, as
|
||||
/// for the carts' `CartIndex`).
|
||||
fn vehicle_mobs_indexed(&self) -> bool {
|
||||
self.vehicle_mobs.reach.get().is_some() && horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose box may meet `area` into `out` (a superset: the
|
||||
/// caller still tests each box), from a grid of the mobs' boxes that the first query of a
|
||||
/// stretch of vehicle ticks builds. False: walk them all. That is outside a stretch or with
|
||||
/// native mods loaded ([`Self::vehicle_mobs_indexed`]), when the grid cannot say, and for
|
||||
/// a while after a write to the mob list ([`MobGrid`]).
|
||||
fn vehicle_mobs_near(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !self.vehicle_mobs_indexed() {
|
||||
return false;
|
||||
}
|
||||
let stamp = self.mobs.stamp();
|
||||
let mut grid = self.vehicle_mobs.grid.borrow_mut();
|
||||
if grid.built.as_ref().is_none_or(|(s, _)| *s != stamp) {
|
||||
if grid.walked < grid.patience {
|
||||
grid.walked += 1;
|
||||
return false;
|
||||
}
|
||||
grid.built = Some((stamp, SeenGrid::new(self.mobs.iter().map(Mob::bounds))));
|
||||
grid.walked = 0;
|
||||
grid.patience = (grid.patience * 2).max(WALKS_BEFORE_GRID);
|
||||
}
|
||||
let built = grid.built.as_ref();
|
||||
built.is_some_and(|(_, cells)| cells.candidates(area, out))
|
||||
}
|
||||
/// A vehicle's box query: `each` gets every mob whose box meets `area`, in slot order,
|
||||
/// with its slot and its box. While vehicles tick it reads the mobs near the area
|
||||
/// ([`Self::vehicle_mobs_near`]) or, walking them all, tests each position against the
|
||||
/// [`BoxReach`] before working the box out. The mobs and their order are those of a walk
|
||||
/// that tests every box.
|
||||
pub(super) fn mobs_meeting(&self, area: Aabb, mut each: impl FnMut(usize, &Mob, Aabb)) {
|
||||
let mut near = self.vehicle_mobs.near.take();
|
||||
if self.vehicle_mobs_near(area, &mut near) {
|
||||
for &j in &near {
|
||||
let m = &self.mobs[j as usize];
|
||||
let b = m.bounds();
|
||||
if b.intersects(area) {
|
||||
each(j as usize, m, b);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let reach = self.vehicle_mob_reach();
|
||||
for (j, m) in self.mobs.iter().enumerate() {
|
||||
if reach.is_some_and(|r| !r.may_meet(m, area)) {
|
||||
continue;
|
||||
}
|
||||
let b = m.bounds();
|
||||
if b.intersects(area) {
|
||||
each(j, m, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.vehicle_mobs.near.set(near);
|
||||
}
|
||||
/// The slots, ascending, of the mobs whose box may meet a cart, read from the mobs' grid
|
||||
/// once per cart. None when the mobs are the fewer (asking the carts' grid once per mob
|
||||
/// is then the shorter way) or the grid does not answer.
|
||||
pub(super) fn mobs_near_carts(&self) -> Option<Vec<u32>> {
|
||||
if self.mobs.len() <= self.carts.len() {
|
||||
return None;
|
||||
}
|
||||
let (mut slots, mut near) = (Vec::new(), Vec::new());
|
||||
for c in self.carts.iter() {
|
||||
if !self.vehicle_mobs_near(c.bounds(), &mut near) {
|
||||
return None;
|
||||
}
|
||||
slots.extend_from_slice(&near);
|
||||
}
|
||||
slots.sort_unstable();
|
||||
slots.dedup();
|
||||
Some(slots)
|
||||
}
|
||||
/// Whether mob `m`, whose box `reach` bounds, may touch a cart: the carts' grid (`cells`)
|
||||
/// names the carts near it, or every cart when the grid cannot (a superset either way).
|
||||
pub(super) fn mob_may_meet_cart(
|
||||
&self,
|
||||
reach: BoxReach,
|
||||
m: &Mob,
|
||||
cells: &SeenGrid,
|
||||
near: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !cells.candidates(reach.around(m.position), near) {
|
||||
near.clear();
|
||||
near.extend(0..self.carts.len() as u32);
|
||||
}
|
||||
near.iter()
|
||||
.any(|&k| reach.may_meet(m, self.carts[k as usize].bounds()))
|
||||
}
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
@@ -270,9 +105,12 @@ impl Game<'_> {
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
let mut riders = self.vehicle_riders(id);
|
||||
riders.retain(|&j| !self.mobs[j].removed && self.mobs[j].alive());
|
||||
riders
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
@@ -336,12 +174,18 @@ impl Game<'_> {
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out = Vec::new();
|
||||
self.mobs_meeting(area, |j, m, b| {
|
||||
if pushable(m) && (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id)) {
|
||||
out.push(b);
|
||||
}
|
||||
});
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
// The box first: it turns away nearly every mob of the level.
|
||||
reach.is_none_or(|r| r.may_meet(m, area))
|
||||
&& m.bounds().intersects(area)
|
||||
&& pushable(m)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
@@ -386,14 +230,19 @@ impl Game<'_> {
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// The mobs near the two boxes: the teams are only worked out when something
|
||||
// touches the boat.
|
||||
let mut near = Vec::new();
|
||||
self.mobs_meeting(area.union_bounds(bb), |j, _, mb| {
|
||||
if mb.intersects(area) || mb.intersects(bb) {
|
||||
near.push(j);
|
||||
}
|
||||
});
|
||||
// The box first: it turns away nearly every mob of the level, and the
|
||||
// teams are only worked out when something touches the boat.
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let sweep = area.union_bounds(bb);
|
||||
let near: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
|
||||
return false;
|
||||
}
|
||||
let mb = self.mobs[j].bounds();
|
||||
mb.intersects(area) || mb.intersects(bb)
|
||||
})
|
||||
.collect();
|
||||
let outside = self.players_outside(id);
|
||||
let touching = !near.is_empty()
|
||||
|| outside
|
||||
@@ -682,124 +531,4 @@ mod tests {
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
|
||||
/// A mob pushes the cart its box touches while a mob far from every cart pushes none and
|
||||
/// is not pushed, whichever grid finds them: the carts' (four carts in four of its columns,
|
||||
/// asked once per mob while the mobs are the fewer) or, in a crowd, the mobs' (asked once
|
||||
/// per cart).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn only_the_mob_touching_a_cart_pushes_it() {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
let pushed = [0, 12].map(|crowd: u32| {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
for (i, (x, z)) in [(0.5, 0.5), (20.5, 0.5), (0.5, 20.5), (20.5, 20.5)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = Vec3::new(x, 64.0, z);
|
||||
game.carts
|
||||
.push(Minecart::new(100 + i as u32, CartKind::Chest, at));
|
||||
}
|
||||
// 0.5 west of the second cart (boxes 0.9 and 0.98 wide), in the middle of them
|
||||
// all, and the crowd well away.
|
||||
let spots = [(20.0, 0.5), (10.5, 10.5)].into_iter();
|
||||
let crowd = (0..crowd).map(|i| (40.5 + 3.0 * f64::from(i), 40.5));
|
||||
for (i, (x, z)) in spots.chain(crowd).enumerate() {
|
||||
let at = Vec3::new(x, 64.0, z);
|
||||
game.mobs.push(Mob::new(1 + i as u32, pig.clone(), at, 0.0));
|
||||
}
|
||||
game.tick_carts();
|
||||
let velocity = |id| game.mobs.iter().find(|m| m.id == id).unwrap().velocity;
|
||||
assert!(velocity(1).x < 0.0, "pushed back west: {:?}", velocity(1));
|
||||
let idle = game.mobs.iter().filter(|m| m.velocity == Vec3::ZERO);
|
||||
assert_eq!(idle.count(), game.mobs.len() - 1);
|
||||
velocity(1)
|
||||
});
|
||||
assert_eq!(pushed[0], pushed[1]);
|
||||
}
|
||||
|
||||
/// A vehicle's box query during a stretch of vehicle ticks names the mobs a walk over
|
||||
/// every box names, in the same order, also right after a mob moved into the box (a
|
||||
/// push or a pick-up writes the mob list in the middle of a stretch).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_vehicles_box_query_matches_a_walk_over_every_mob() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
// A small LCG keeps the scene the same on every run.
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |range: f64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
|
||||
};
|
||||
for id in 1..=400 {
|
||||
let at = Vec3::new(next(48.0), 64.0 + next(3.0), next(48.0));
|
||||
game.mobs.push(Mob::new(id, pig.clone(), at, 0.0));
|
||||
}
|
||||
let mut met = 0;
|
||||
for round in 0..300 {
|
||||
// Three stretches, each with two writes to the mob list.
|
||||
if round % 100 == 0 {
|
||||
game.vehicles_ticking(true);
|
||||
}
|
||||
let corner = Vec3::new(next(46.0), 63.0 + next(3.0), next(46.0));
|
||||
let size = Vec3::new(0.5 + next(2.5), 0.5 + next(1.5), 0.5 + next(2.5));
|
||||
let area = Aabb::new(corner, corner.add_vector(size));
|
||||
if round % 40 == 20 {
|
||||
let j = round % game.mobs.len();
|
||||
game.mobs[j].position = area.center();
|
||||
}
|
||||
let walked: Vec<Aabb> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.map(Mob::bounds)
|
||||
.filter(|b| b.intersects(area))
|
||||
.collect();
|
||||
met += walked.len();
|
||||
assert_eq!(game.boat_obstacles(u32::MAX, area), walked, "round {round}");
|
||||
if round % 100 == 99 {
|
||||
// The last comparisons ran against the grid: it stands, for the list as it is.
|
||||
let fresh = |(stamp, _): &(ListStamp, SeenGrid)| *stamp == game.mobs.stamp();
|
||||
assert!(
|
||||
game.vehicle_mobs
|
||||
.grid
|
||||
.borrow()
|
||||
.built
|
||||
.as_ref()
|
||||
.is_some_and(fresh)
|
||||
);
|
||||
game.vehicles_ticking(false);
|
||||
}
|
||||
}
|
||||
assert!(met > 100, "the boxes met {met} mobs");
|
||||
}
|
||||
|
||||
/// Who rides a vehicle follows the mob list through a stretch of vehicle ticks: a mob that
|
||||
/// boards counts from then on, one removed no longer does, and a dead one still makes
|
||||
/// its cart a vehicle but is no passenger of a boat.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_vehicle_knows_its_mob_riders_as_soon_as_they_board() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
|
||||
for id in 1..=4 {
|
||||
game.mobs.push(Mob::new(id, pig.clone(), Vec3::ZERO, 0.0));
|
||||
}
|
||||
let riders =
|
||||
|game: &Game| [100, 101, 102].map(|id| (game.mob_rides(id), game.boat_mobs(id)));
|
||||
game.mobs[0].extra.mount = Some(100);
|
||||
game.vehicles_ticking(true);
|
||||
let none = (false, vec![]);
|
||||
assert_eq!(riders(&game), [(true, vec![0]), none.clone(), none.clone()]);
|
||||
game.mobs[0].removed = true;
|
||||
game.mobs[1].extra.mount = Some(101);
|
||||
game.mobs[3].extra.mount = Some(102);
|
||||
game.mobs[3].health = 0.0;
|
||||
let after = [none, (true, vec![1]), (true, vec![])];
|
||||
assert_eq!(riders(&game), after);
|
||||
game.vehicles_ticking(false);
|
||||
assert_eq!(riders(&game), after);
|
||||
}
|
||||
}
|
||||
@@ -1467,13 +1467,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
|
||||
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.yaw = yaw;
|
||||
b.previous_yaw = yaw;
|
||||
}
|
||||
}
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
@@ -1484,11 +1477,7 @@ impl Game<'_> {
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
|
||||
self.boats.push(boat);
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
|
||||
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
|
||||
self.set_boat_yaw(id, yaw);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_vehicle_create(
|
||||
self.boats.last().unwrap().entity_type().as_str(),
|
||||
|
||||
@@ -1594,32 +1594,20 @@ impl Game<'_> {
|
||||
&tr("gui.done", "Done"),
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: a clickable word
|
||||
// asks for the hand, and a show_text hover shows its tooltip,
|
||||
// as the chat does.
|
||||
if let Some(style) = pages.get(*page).and_then(|t| self.book_style_at(t, left)) {
|
||||
if let Some(cursor) = link_cursor(&style) {
|
||||
horizon_client::widgets::request_cursor(cursor);
|
||||
}
|
||||
if let Some(hover) = style.hover {
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
// show_text hover event, as the chat shows it.
|
||||
if let Some(hover) = pages
|
||||
.get(*page)
|
||||
.and_then(|t| self.book_style_at(t, left))
|
||||
.and_then(|s| s.hover)
|
||||
{
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `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)) {
|
||||
@@ -1649,16 +1637,6 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
|
||||
}
|
||||
}
|
||||
|
||||
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: only a word with a click event asks
|
||||
/// for the pointing hand over a book's page; a hover event alone shows its
|
||||
/// tooltip.
|
||||
fn link_cursor(style: &horizon_client::text::Style) -> Option<horizon_client::widgets::Cursor> {
|
||||
style
|
||||
.click
|
||||
.is_some()
|
||||
.then_some(horizon_client::widgets::Cursor::PointingHand)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_box_tests {
|
||||
use super::book_title_box;
|
||||
@@ -1743,7 +1721,6 @@ 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 {
|
||||
@@ -1761,55 +1738,6 @@ 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
|
||||
@@ -1848,60 +1776,6 @@ mod lectern_signal_tests {
|
||||
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
|
||||
}
|
||||
}
|
||||
impl BookScreen {
|
||||
/// `BookViewScreen.isPauseScreen`: the class inherits `Screen`'s true, so
|
||||
/// a book read in hand stops a single-player world. `LecternScreen`
|
||||
/// overrides it false, so the same pages read on a lectern leave the
|
||||
/// world ticking (its menu is an `AbstractContainerScreen`, which does
|
||||
/// not pause either).
|
||||
pub(super) fn pauses_world(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pause_tests {
|
||||
use super::BookScreen;
|
||||
use horizon_client::{text::Text, ui};
|
||||
|
||||
fn view(lectern: Option<(i32, i32, i32)>) -> BookScreen {
|
||||
BookScreen::View {
|
||||
pages: Vec::<Text>::new(),
|
||||
page: 0,
|
||||
lectern,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lecterns_book_does_not_pause_the_world() {
|
||||
// BookViewScreen keeps Screen.isPauseScreen's true; LecternScreen
|
||||
// overrides it false although both read the same pages.
|
||||
assert!(view(None).pauses_world());
|
||||
assert!(!view(Some((10, 64, -3))).pauses_world());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_being_edited_or_signed_pauses() {
|
||||
// BookEditScreen and BookSignScreen do not override isPauseScreen.
|
||||
let edit = |signing| BookScreen::Edit {
|
||||
slot: 0,
|
||||
pages: Vec::new(),
|
||||
page: 0,
|
||||
signing,
|
||||
title: String::new(),
|
||||
title_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
|
||||
page_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
|
||||
};
|
||||
assert!(edit(false).pauses_world());
|
||||
assert!(edit(true).pauses_world());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lectern_reach_tests {
|
||||
@@ -1995,26 +1869,3 @@ mod sign_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod link_cursor_tests {
|
||||
use super::link_cursor;
|
||||
use horizon_client::text::{ClickEvent, Style, Text};
|
||||
use horizon_client::widgets::Cursor;
|
||||
|
||||
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: a clickable word asks for the
|
||||
/// pointing hand; a hover event alone only shows its tooltip.
|
||||
#[test]
|
||||
fn only_a_clickable_book_word_asks_for_the_hand() {
|
||||
let link = Style {
|
||||
click: Some(ClickEvent::OpenUrl("https://example.com".into())),
|
||||
..Style::default()
|
||||
};
|
||||
let hover_only = Style {
|
||||
hover: Some(Box::new(Text::literal("tip"))),
|
||||
..Style::default()
|
||||
};
|
||||
assert_eq!(link_cursor(&link), Some(Cursor::PointingHand));
|
||||
assert_eq!(link_cursor(&hover_only), None);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 334 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user