mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498d9bebcf | ||
|
|
f0cb1e42c0 | ||
|
|
1498fdecd3 | ||
|
|
93e0536aca |
No files matched your search
@@ -198,3 +198,5 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -2882,19 +2882,8 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// Horizon Studio: replay playback, its free camera, timeline and top bar (game/studio.rs).
|
||||
pub(super) studio: studio::Studio,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5942,7 +5931,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let 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
|
||||
@@ -6284,19 +6273,19 @@ impl Game<'_> {
|
||||
}
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
&& (self.studio.replay.is_some() || self.studio.freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
self.studio.topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
self.studio.freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
self.studio.timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
&self.studio.freecam,
|
||||
);
|
||||
}
|
||||
if show_hud {
|
||||
@@ -6521,12 +6510,12 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.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.
|
||||
@@ -6733,8 +6722,8 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
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()
|
||||
};
|
||||
@@ -9236,7 +9225,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
if self.shows_panorama() || self.studio.freecam.active {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9414,9 +9403,9 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
if self.studio.freecam.active || self.studio.replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&self.studio.freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
@@ -9443,7 +9432,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio_freecam.active {
|
||||
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)
|
||||
@@ -9569,7 +9558,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.freecam.active);
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10463,19 +10452,19 @@ impl Game<'_> {
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
&& (self.studio.timeline.dragging_playhead
|
||||
|| self.studio.timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
let action = self.studio.timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
&mut self.studio.freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10527,7 +10516,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
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)),
|
||||
@@ -10805,44 +10794,44 @@ impl Game<'_> {
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
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())
|
||||
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 self.studio.timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
&self.studio.freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
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;
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
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;
|
||||
} else if self.studio.rmb_down {
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10850,7 +10839,7 @@ impl Game<'_> {
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
&& (self.studio.replay.is_some() || self.studio.freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -10858,8 +10847,8 @@ impl Game<'_> {
|
||||
.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(
|
||||
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,
|
||||
@@ -10868,26 +10857,26 @@ impl Game<'_> {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
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(
|
||||
if self.studio.timeline.visible {
|
||||
let action = self.studio.timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
&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() {
|
||||
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11357,7 +11346,7 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
if self.studio_freecam.active
|
||||
if self.studio.freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
@@ -11367,7 +11356,7 @@ impl Game<'_> {
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
self.studio.freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
@@ -12157,16 +12146,7 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
studio: Default::default(),
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
|
||||
@@ -135,8 +135,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
if self.studio_freecam.active {
|
||||
return self.studio_freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio.freecam.active {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
}
|
||||
let fov = self.view.calculate_fov(
|
||||
partial,
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Game<'_> {
|
||||
// replay player's hands remain visible in first person; freecam has
|
||||
// no player-view hand.
|
||||
if !fov::studio_draws_hand(
|
||||
self.studio_freecam.active,
|
||||
self.studio.freecam.active,
|
||||
self.ui.hands_drawn(
|
||||
self.spectator(),
|
||||
self.sleeping.is_some(),
|
||||
|
||||
@@ -280,11 +280,11 @@ impl Game<'_> {
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.freecam.active {
|
||||
return Some((
|
||||
self.studio_freecam.position,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
self.studio.freecam.position,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
));
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
@@ -643,8 +643,9 @@ impl Game<'_> {
|
||||
/// The model's old pitch is the player's `xRotO`, which the turn moves
|
||||
/// with `xRot` (`turn_rotation`).
|
||||
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam
|
||||
if self.studio.freecam.active {
|
||||
self.studio
|
||||
.freecam
|
||||
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
|
||||
return;
|
||||
}
|
||||
@@ -697,7 +698,7 @@ impl Game<'_> {
|
||||
/// `LevelExtractor`'s skip of the camera entity: the player's own body
|
||||
/// is drawn unless the camera is in first person, or while it sleeps.
|
||||
pub(super) fn own_body_in_view(&self) -> bool {
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.freecam.active
|
||||
}
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
|
||||
if !studio_freecam_movement_key(key) || !self.studio.freecam.active {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ impl Game<'_> {
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
self.direct_craft_stop();
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
self.close_studio_replay();
|
||||
@@ -25,19 +25,19 @@ impl Game<'_> {
|
||||
KeyCode::KeyF => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::ToggleFreecam,
|
||||
),
|
||||
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
|
||||
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
|
||||
KeyCode::KeyM => self.add_studio_marker(),
|
||||
KeyCode::Home => self.handle_studio_topbar_action(
|
||||
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
|
||||
),
|
||||
KeyCode::KeyL => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
}
|
||||
KeyCode::KeyK => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::AddCameraKeyframe,
|
||||
),
|
||||
KeyCode::Delete => {
|
||||
if let Some(index) = self.studio_timeline.selected_keyframe {
|
||||
if let Some(index) = self.studio.timeline.selected_keyframe {
|
||||
self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
|
||||
);
|
||||
@@ -78,7 +78,7 @@ impl Game<'_> {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
if self.studio.replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -213,7 +213,7 @@ impl Game<'_> {
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio.replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Game<'_> {
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.player.is_some() || self.studio_freecam.active);
|
||||
&& (self.player.is_some() || self.studio.freecam.active);
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
@@ -76,8 +76,8 @@ impl Game<'_> {
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !studio_suppresses_focus_pause(
|
||||
self.studio_replay.is_some(),
|
||||
self.studio_freecam.active,
|
||||
self.studio.replay.is_some(),
|
||||
self.studio.freecam.active,
|
||||
)
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
@@ -149,7 +149,7 @@ impl Game<'_> {
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
@@ -353,9 +353,9 @@ impl Game<'_> {
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if self.studio_replay.is_some() || self.studio_freecam.active {
|
||||
if self.studio.replay.is_some() || self.studio.freecam.active {
|
||||
self.regrab_on_refocus = false;
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2433,7 +2433,7 @@ impl Game<'_> {
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.remote
|
||||
@@ -6185,7 +6185,7 @@ impl Game<'_> {
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
}
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.previous_position = position;
|
||||
}
|
||||
self.yaw = yaw;
|
||||
@@ -6521,7 +6521,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
player.fluids.boat = boat;
|
||||
if !riding && self.studio_replay.is_none() {
|
||||
if !riding && self.studio.replay.is_none() {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
let before = (
|
||||
@@ -6728,7 +6728,7 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.tick_emotes();
|
||||
@@ -10528,7 +10528,7 @@ impl Game<'_> {
|
||||
/// A connection's screen is over the world: connecting, reconfiguring, or
|
||||
/// the level loading screen, a portal's included (`remote_overlay`).
|
||||
pub(super) fn remote_screen_up(&self) -> bool {
|
||||
self.studio_replay.is_none()
|
||||
self.studio.replay.is_none()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -10612,7 +10612,7 @@ impl Game<'_> {
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
return false;
|
||||
}
|
||||
use horizon_client::level_loading::Reason;
|
||||
|
||||
@@ -15,6 +15,26 @@ use std::{
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Studio's state in a `Game`: the replay playing, its free camera, timeline and top bar. Studio is
|
||||
/// a Horizon extra (docs/layers.md: the replay editor); the engine only asks it whether a replay
|
||||
/// plays or the free camera flies.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Studio {
|
||||
/// The packet replay playing, if any.
|
||||
pub(super) replay: Option<ReplayDriver>,
|
||||
pub(super) freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) rmb_down: bool,
|
||||
pub(super) camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
pub(super) replay_previous_camera_type: Option<super::perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
pub(super) replay_standalone: bool,
|
||||
pub(super) last_replay_tick: Option<std::time::Instant>,
|
||||
pub(super) virtual_tick_ms: f32,
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
|
||||
"studio record stop stop recording and finalize .mcpr archive",
|
||||
@@ -109,13 +129,13 @@ impl Game<'_> {
|
||||
match words[0] {
|
||||
"freecam" => match words.get(1).copied() {
|
||||
Some("on") => {
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.freecam.replay_required",
|
||||
&[],
|
||||
));
|
||||
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
|
||||
self.studio_freecam.activate(
|
||||
self.studio.freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -131,24 +151,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("off") => {
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
|
||||
},
|
||||
"keyframe" => {
|
||||
let Some(driver) = self.studio_replay.as_ref() else {
|
||||
let Some(driver) = self.studio.replay.as_ref() else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
return true;
|
||||
};
|
||||
let time_ms = driver.position_ms();
|
||||
match words.get(1).copied() {
|
||||
Some("add") => {
|
||||
if !self.studio_freecam.active
|
||||
if !self.studio.freecam.active
|
||||
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
|
||||
{
|
||||
self.studio_freecam.activate(
|
||||
self.studio.freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -156,16 +176,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.capture(true);
|
||||
self.studio_freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.added",
|
||||
&[&time_ms.to_string()],
|
||||
));
|
||||
}
|
||||
Some("clear") => {
|
||||
self.studio_freecam.track.clear();
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.clear();
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.cleared",
|
||||
&[],
|
||||
@@ -181,15 +201,15 @@ impl Game<'_> {
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if index >= self.studio_freecam.track.keyframes.len() {
|
||||
if index >= self.studio.freecam.track.keyframes.len() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.index_invalid",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
}
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.removed",
|
||||
&[&index.to_string()],
|
||||
@@ -199,14 +219,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"path" => match words.get(1).copied() {
|
||||
Some("lock") if !self.studio_freecam.track.is_empty() => {
|
||||
self.studio_freecam.active = true;
|
||||
self.studio_freecam.locked_to_path = true;
|
||||
Some("lock") if !self.studio.freecam.track.is_empty() => {
|
||||
self.studio.freecam.active = true;
|
||||
self.studio.freecam.locked_to_path = true;
|
||||
self.capture(true);
|
||||
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
|
||||
}
|
||||
Some("unlock") => {
|
||||
self.studio_freecam.locked_to_path = false;
|
||||
self.studio.freecam.locked_to_path = false;
|
||||
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
|
||||
@@ -299,7 +319,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
|
||||
} else {
|
||||
@@ -307,7 +327,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
|
||||
} else {
|
||||
@@ -323,7 +343,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(spd) = spd_str.parse::<f64>() {
|
||||
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
|
||||
let new_speed = if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Some(r.speed())
|
||||
} else {
|
||||
@@ -357,11 +377,12 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(ms) = ms_str.parse::<u32>() {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
match self.seek_studio_replay(ms) {
|
||||
Ok(()) => {
|
||||
let actual_ms = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(ms, |replay| ms.min(replay.duration_ms()));
|
||||
let ms_text = actual_ms.to_string();
|
||||
@@ -400,7 +421,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
if let Some(r) = self.studio.replay.as_ref() {
|
||||
let position = r.position_ms().to_string();
|
||||
let duration = r.duration_ms().to_string();
|
||||
let progress = format!("{:.1}", r.progress() * 100.0);
|
||||
@@ -435,7 +456,7 @@ impl Game<'_> {
|
||||
|
||||
/// Reset playback tick accumulator and align positions on seek.
|
||||
pub(super) fn reset_studio_playback_ticks(&mut self) {
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
self.studio.virtual_tick_ms = 0.0;
|
||||
if let Some(player) = &self.player {
|
||||
self.previous_position = player.position;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
@@ -452,12 +473,14 @@ impl Game<'_> {
|
||||
/// forward because its pre-replay world must remain intact.
|
||||
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
|
||||
let current_ms = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms);
|
||||
let packets = if self.studio_replay_standalone {
|
||||
let packets = if self.studio.replay_standalone {
|
||||
let packets = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_and_collect(target_ms)?;
|
||||
@@ -471,7 +494,8 @@ impl Game<'_> {
|
||||
"backward seek requires a standalone replay",
|
||||
));
|
||||
}
|
||||
self.studio_replay
|
||||
self.studio
|
||||
.replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_forward(target_ms)?
|
||||
@@ -479,7 +503,7 @@ impl Game<'_> {
|
||||
|
||||
self.dispatch_studio_replay_packets(packets);
|
||||
self.reset_studio_playback_ticks();
|
||||
self.last_replay_tick = Some(std::time::Instant::now());
|
||||
self.studio.last_replay_tick = Some(std::time::Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -510,19 +534,19 @@ impl Game<'_> {
|
||||
|
||||
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
|
||||
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
|
||||
let Some(driver) = self.studio_replay.as_mut() else {
|
||||
self.last_replay_tick = None;
|
||||
let Some(driver) = self.studio.replay.as_mut() else {
|
||||
self.studio.last_replay_tick = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let delta = match self.last_replay_tick {
|
||||
let delta = match self.studio.last_replay_tick {
|
||||
Some(last) => now
|
||||
.saturating_duration_since(last)
|
||||
.min(Duration::from_millis(250)),
|
||||
None => Duration::from_millis(16),
|
||||
};
|
||||
self.last_replay_tick = Some(now);
|
||||
self.studio.last_replay_tick = Some(now);
|
||||
|
||||
let ready_packets = match driver.advance(delta) {
|
||||
Ok(pkts) => pkts,
|
||||
@@ -533,16 +557,18 @@ impl Game<'_> {
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(ready_packets);
|
||||
if self.studio_freecam.locked_to_path
|
||||
if self.studio.freecam.locked_to_path
|
||||
&& let Some(position) = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map(|replay| replay.position_ms())
|
||||
{
|
||||
self.studio_freecam.update_spline_playback(position);
|
||||
self.studio.freecam.update_spline_playback(position);
|
||||
}
|
||||
let paused = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.is_some_and(|replay| replay.is_paused());
|
||||
if self.player_model.is_none() {
|
||||
@@ -550,13 +576,14 @@ impl Game<'_> {
|
||||
}
|
||||
if !paused {
|
||||
let speed = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(1.0, |r| r.speed() as f32)
|
||||
.max(0.1);
|
||||
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.studio_virtual_tick_ms >= 50.0 {
|
||||
self.studio_virtual_tick_ms -= 50.0;
|
||||
self.studio.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.studio.virtual_tick_ms >= 50.0 {
|
||||
self.studio.virtual_tick_ms -= 50.0;
|
||||
if let Some(player) = &self.player {
|
||||
let current_pos = player.position;
|
||||
let (dx, dz) = (
|
||||
@@ -607,7 +634,7 @@ impl Game<'_> {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
}
|
||||
if let Some((position, duration, paused, markers)) =
|
||||
self.studio_replay.as_ref().map(|replay| {
|
||||
self.studio.replay.as_ref().map(|replay| {
|
||||
(
|
||||
replay.position_ms(),
|
||||
replay.duration_ms(),
|
||||
@@ -616,10 +643,10 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
{
|
||||
self.studio_timeline.current_time_ms = u64::from(position);
|
||||
self.studio_timeline.duration_ms = u64::from(duration);
|
||||
self.studio_timeline.playing = !paused;
|
||||
self.studio_timeline.markers = markers;
|
||||
self.studio.timeline.current_time_ms = u64::from(position);
|
||||
self.studio.timeline.duration_ms = u64::from(duration);
|
||||
self.studio.timeline.playing = !paused;
|
||||
self.studio.timeline.markers = markers;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -639,10 +666,10 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
TopBarAction::ExitStudio => {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
} else {
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -668,42 +695,46 @@ impl Game<'_> {
|
||||
} else {
|
||||
(glam::DVec3::ZERO, 0.0, 0.0)
|
||||
};
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
TopBarAction::ToggleLockToPath => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
}
|
||||
TopBarAction::ToggleFreecam => {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
TopBarAction::AddKeyframe => {
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::AddMarker => self.add_studio_marker(),
|
||||
TopBarAction::ClearKeyframes => {
|
||||
self.studio_freecam.track.keyframes.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.keyframes.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::ToggleTimeline => {
|
||||
self.studio_timeline.visible = !self.studio_timeline.visible;
|
||||
self.studio.timeline.visible = !self.studio.timeline.visible;
|
||||
}
|
||||
TopBarAction::SplitClip => {
|
||||
self.studio_timeline
|
||||
.split_at(self.studio_timeline.current_time_ms);
|
||||
self.studio
|
||||
.timeline
|
||||
.split_at(self.studio.timeline.current_time_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,93 +747,97 @@ impl Game<'_> {
|
||||
use horizon_client::studio::TimelineAction;
|
||||
match action {
|
||||
TimelineAction::PauseForSeek => {
|
||||
self.studio_timeline.playing = false;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
self.studio.timeline.playing = false;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_paused(true);
|
||||
}
|
||||
}
|
||||
TimelineAction::Seek(time_ms) => {
|
||||
let time_ms = if self.studio_replay_standalone {
|
||||
let time_ms = if self.studio.replay_standalone {
|
||||
time_ms
|
||||
} else {
|
||||
let current =
|
||||
self.studio_replay
|
||||
self.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms) as u64;
|
||||
time_ms.max(current)
|
||||
};
|
||||
self.studio_timeline.current_time_ms = time_ms;
|
||||
self.studio.timeline.current_time_ms = time_ms;
|
||||
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
|
||||
log::warn!("Studio timeline seek failed: {error}");
|
||||
}
|
||||
}
|
||||
TimelineAction::TogglePlay => {
|
||||
self.studio_timeline.playing = !self.studio_timeline.playing;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
replay.set_paused(!self.studio_timeline.playing);
|
||||
self.studio.timeline.playing = !self.studio.timeline.playing;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_paused(!self.studio.timeline.playing);
|
||||
}
|
||||
}
|
||||
TimelineAction::StepBack => {
|
||||
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
|
||||
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::StepForward => {
|
||||
let time_ms = self
|
||||
.studio_timeline
|
||||
.studio
|
||||
.timeline
|
||||
.current_time_ms
|
||||
.saturating_add(1000)
|
||||
.min(self.studio_timeline.duration_ms);
|
||||
.min(self.studio.timeline.duration_ms);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::SeekStart => {
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(0))
|
||||
}
|
||||
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
|
||||
self.studio_timeline.duration_ms,
|
||||
self.studio.timeline.duration_ms,
|
||||
)),
|
||||
TimelineAction::SetSpeed(speed) => {
|
||||
self.studio_timeline.speed = speed;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
self.studio.timeline.speed = speed;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_speed(speed);
|
||||
}
|
||||
}
|
||||
TimelineAction::ToggleFreecam => {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
}
|
||||
TimelineAction::AddCameraKeyframe => {
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::RemoveCameraKeyframe(index) => {
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
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;
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
}
|
||||
TimelineAction::SelectKeyframe(index) => {
|
||||
self.studio_timeline.selected_keyframe = Some(index);
|
||||
self.studio.timeline.selected_keyframe = Some(index);
|
||||
}
|
||||
TimelineAction::KeyframeMoved => {
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
|
||||
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
|
||||
TimelineAction::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
|
||||
pub(super) fn add_studio_marker(&mut self) {
|
||||
let Some(replay) = self.studio_replay.as_mut() else {
|
||||
let Some(replay) = self.studio.replay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let label = Text::translate(
|
||||
@@ -811,7 +846,7 @@ impl Game<'_> {
|
||||
)
|
||||
.plain();
|
||||
match replay.add_marker(label) {
|
||||
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
|
||||
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
|
||||
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -845,7 +880,7 @@ impl Game<'_> {
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
Ok(Some(json!({ "ok": true, "paused": true })))
|
||||
} else {
|
||||
@@ -853,7 +888,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
Ok(Some(json!({ "ok": true, "paused": false })))
|
||||
} else {
|
||||
@@ -863,7 +898,7 @@ impl Game<'_> {
|
||||
Some("speed") => {
|
||||
let spd_str = words.get(3).ok_or("missing speed value")?;
|
||||
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
|
||||
} else {
|
||||
@@ -873,7 +908,7 @@ impl Game<'_> {
|
||||
Some("seek") => {
|
||||
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
|
||||
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
|
||||
let pos = if let Some(r) = self.studio_replay.as_mut() {
|
||||
let pos = if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.seek(ms).map_err(|e| format!("{e}"))?;
|
||||
Some(r.position_ms())
|
||||
} else {
|
||||
@@ -887,7 +922,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
if let Some(r) = self.studio.replay.as_ref() {
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"active": true,
|
||||
@@ -999,13 +1034,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(active) = self.studio_replay.as_mut()
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(active) = self.studio.replay.as_mut()
|
||||
{
|
||||
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
|
||||
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
self.studio_camera_track_dirty = false;
|
||||
self.studio.camera_track_dirty = false;
|
||||
}
|
||||
// The recordings browser can be opened while the title screen is
|
||||
// available but the slower world tables are still loading. Replay
|
||||
@@ -1028,7 +1063,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
|
||||
let standalone = self.studio_replay.is_none() && self.remote.is_none();
|
||||
let standalone = self.studio.replay.is_none() && self.remote.is_none();
|
||||
if self.remote.is_none()
|
||||
&& let Err(error) = self.init_remote_for_replay()
|
||||
{
|
||||
@@ -1038,17 +1073,17 @@ impl Game<'_> {
|
||||
|
||||
let duration_ms = driver.duration_ms();
|
||||
let packets = driver.packet_count();
|
||||
self.studio_timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = !driver.is_paused();
|
||||
self.studio_timeline.markers = driver.markers().to_vec();
|
||||
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio_camera_track_dirty = false;
|
||||
if self.studio_replay.is_none() {
|
||||
self.studio_replay_previous_camera_type = Some(self.camera_type);
|
||||
self.studio_replay_standalone = standalone;
|
||||
self.studio.timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = !driver.is_paused();
|
||||
self.studio.timeline.markers = driver.markers().to_vec();
|
||||
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio.camera_track_dirty = false;
|
||||
if self.studio.replay.is_none() {
|
||||
self.studio.replay_previous_camera_type = Some(self.camera_type);
|
||||
self.studio.replay_standalone = standalone;
|
||||
}
|
||||
self.studio_replay = Some(driver);
|
||||
self.studio.replay = Some(driver);
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
self.title = None;
|
||||
self.screen = Screen::Playing;
|
||||
@@ -1066,14 +1101,14 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
pub(super) fn close_studio_replay(&mut self) -> bool {
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(driver) = self.studio_replay.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(driver) = self.studio.replay.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
|
||||
{
|
||||
log::warn!("Studio: could not save camera path: {error}");
|
||||
}
|
||||
self.studio_camera_track_dirty = false;
|
||||
let closed = self.studio_replay.take().is_some();
|
||||
self.studio.camera_track_dirty = false;
|
||||
let closed = self.studio.replay.take().is_some();
|
||||
if !closed {
|
||||
return false;
|
||||
}
|
||||
@@ -1081,23 +1116,23 @@ impl Game<'_> {
|
||||
// A standalone replay owns a synthetic Remote connection. Treat that
|
||||
// connection as replay state even if the saved origin flag was lost;
|
||||
// sending it through leave_server() opens the Multiplayer screen.
|
||||
let standalone = self.studio_replay_standalone
|
||||
let standalone = self.studio.replay_standalone
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.brand == "horizon-studio");
|
||||
self.studio_replay_standalone = false;
|
||||
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
|
||||
self.studio.replay_standalone = false;
|
||||
if let Some(camera_type) = self.studio.replay_previous_camera_type.take() {
|
||||
self.camera_type = camera_type;
|
||||
}
|
||||
self.last_replay_tick = None;
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = false;
|
||||
self.studio_timeline.markers.clear();
|
||||
self.studio_timeline.split_points.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
self.studio.last_replay_tick = None;
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = false;
|
||||
self.studio.timeline.markers.clear();
|
||||
self.studio.timeline.split_points.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.freecam.deactivate();
|
||||
self.studio.virtual_tick_ms = 0.0;
|
||||
self.capture(false);
|
||||
if standalone {
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ impl View {
|
||||
}
|
||||
struct Settings { fov: f32 }
|
||||
struct StudioFreecam { active: bool, fov: f32 }
|
||||
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio_freecam: StudioFreecam }
|
||||
struct Studio { freecam: StudioFreecam }
|
||||
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio: Studio }
|
||||
impl Game {
|
||||
fn fov_effect_scale(&self) -> f32 { 1.0 }
|
||||
fn death_time(&self) -> Option<i32> { None }
|
||||
@@ -44,14 +45,14 @@ fn unchanged_narrow_wide_and_invalid() {
|
||||
(Some(1.0), 1.0), (Some(179.0), 179.0), (Some(0.0), 35.0), (Some(180.0), 35.0),
|
||||
(Some(-1.0), 35.0), (Some(f32::NAN), 35.0), (Some(f32::INFINITY), 35.0)] {
|
||||
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: value,
|
||||
studio_freecam: StudioFreecam { active: false, fov: 70.0 } };
|
||||
studio: Studio { freecam: StudioFreecam { active: false, fov: 70.0 } } };
|
||||
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), expected);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn studio_freecam_uses_its_fov() {
|
||||
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: None,
|
||||
studio_freecam: StudioFreecam { active: true, fov: 42.0 } };
|
||||
studio: Studio { freecam: StudioFreecam { active: true, fov: 42.0 } } };
|
||||
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), 42.0);
|
||||
}
|
||||
""".replace("METHOD", actual)
|
||||
|
||||
@@ -119,6 +119,8 @@ struct Remote;
|
||||
#[derive(Default)]
|
||||
struct StudioFreecam { active: bool }
|
||||
#[derive(Default)]
|
||||
struct Studio { replay: Option<()>, freecam: StudioFreecam, rmb_down: bool }
|
||||
#[derive(Default)]
|
||||
struct Options { offscreen: bool }
|
||||
#[derive(Default)]
|
||||
struct ServerPacks { prompt: Option<()> }
|
||||
@@ -127,7 +129,7 @@ struct Game {
|
||||
keyboard_mouse: bool, split_window: bool, view_at: [u32; 2],
|
||||
captured: bool, capture_mode: Option<CursorGrabMode>, regrab_on_refocus: bool, title: Option<()>, screen: Screen,
|
||||
remote: Option<Remote>,
|
||||
studio_replay: Option<()>, studio_freecam: StudioFreecam, studio_rmb_down: bool,
|
||||
studio: Studio,
|
||||
mining: bool, lan_acting: Option<u32>, renderer: Option<Renderer>, cursor: (f32, f32),
|
||||
server_packs: ServerPacks, command_prompt: Option<()>, options: Options,
|
||||
unclaimed_window: bool, scripted: bool, focus_after_run: Option<bool>, applied_focus: Vec<bool>,
|
||||
@@ -137,8 +139,7 @@ impl Default for Game {
|
||||
Self { focused: true, agent_pointer: false, window: Some(Window::default()),
|
||||
keyboard_mouse: true, split_window: false, view_at: [0, 0],
|
||||
mouse: Mouse::default(), captured: false, capture_mode: None, regrab_on_refocus: false, title: None,
|
||||
screen: Screen::Playing, remote: None, studio_replay: None,
|
||||
studio_freecam: StudioFreecam::default(), studio_rmb_down: false,
|
||||
screen: Screen::Playing, remote: None, studio: Studio::default(),
|
||||
mining: false, lan_acting: None,
|
||||
renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default(),
|
||||
command_prompt: None, options: Options::default(),
|
||||
@@ -273,8 +274,8 @@ fn main() {
|
||||
// only captures it while the right mouse button is held.
|
||||
{
|
||||
let mut game = Game::default();
|
||||
game.studio_replay = Some(());
|
||||
game.studio_rmb_down = true;
|
||||
game.studio.replay = Some(());
|
||||
game.studio.rmb_down = true;
|
||||
game.capture(true);
|
||||
assert!(game.captured);
|
||||
game.regrab_on_refocus = true;
|
||||
@@ -283,7 +284,7 @@ fn main() {
|
||||
game.focused = true;
|
||||
game.regrab_on_refocus();
|
||||
assert!(!game.captured);
|
||||
assert!(!game.studio_rmb_down);
|
||||
assert!(!game.studio.rmb_down);
|
||||
}
|
||||
// A controller-only player's screens leave the shared native pointer alone.
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user