mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2aea3efae | ||
|
|
21e1eb181b | ||
|
|
696d7dd60a | ||
|
|
8f213a4598 | ||
|
|
6d6e8818bd | ||
|
|
67e260b206 | ||
|
|
e1844d064e | ||
|
|
13d7d9995d | ||
|
|
7fa0fe50f7 | ||
|
|
21773ca7f5 | ||
|
|
49cf77a312 | ||
|
|
92c462a9b1 | ||
|
|
77bfff8eb5 | ||
|
|
8ea9a2c849 | ||
|
|
f220157a0f | ||
|
|
3f28d6dbd1 | ||
|
|
3451e4e29a | ||
|
|
fb27a63dce | ||
|
|
b9449b2348 | ||
|
|
9d7b4e8a2d | ||
|
|
797482183c | ||
|
|
736842d40f | ||
|
|
ba37fa75d6 | ||
|
|
e9fbcfef78 | ||
|
|
206b7acee4 | ||
|
|
2dc154ff7f | ||
|
|
8401cde519 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,13 +23,13 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
@@ -48,7 +48,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -139,6 +139,11 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
|
||||
+111
-30
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -104,37 +104,49 @@ More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -244,6 +256,20 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
@@ -268,6 +294,16 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
@@ -444,6 +480,16 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -452,9 +498,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
|
||||
More: [docs/modpacks.md](docs/modpacks.md)
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -468,16 +514,6 @@ Block entities and menus: ticking machines with slots, server menu logic and the
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
@@ -536,15 +572,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
#### A new launcher design
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -708,6 +742,18 @@ Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
@@ -720,6 +766,18 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
@@ -732,13 +790,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -784,6 +842,16 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -834,7 +902,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -862,6 +930,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -968,6 +1048,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +716,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +787,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +842,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +892,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -1002,7 +1087,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1100,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1138,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1171,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1238,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1303,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1374,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1384,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1409,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1418,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1434,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -570,6 +570,15 @@ impl OptionsFile {
|
||||
self.values[i] = V::Int(p as i32);
|
||||
}
|
||||
}
|
||||
/// `Options.modelCustomisation`: the PlayerModelPart bits the player shows
|
||||
/// (cape first, as `modelPart_*` is in `MODEL_PARTS`).
|
||||
pub fn model_customisation(&self) -> u8 {
|
||||
MODEL_PARTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| self.bool(&format!("modelPart_{part}")))
|
||||
.fold(0, |bits, (bit, _)| bits | 1 << bit)
|
||||
}
|
||||
/// Key mapping value by mapping name.
|
||||
pub fn key_binding(&self, name: &str) -> String {
|
||||
self.string(&format!("key_{name}"))
|
||||
|
||||
@@ -45,6 +45,16 @@ pub(crate) enum Kind {
|
||||
Slider(f32),
|
||||
Cycle,
|
||||
}
|
||||
/// `PlayerModelPart` keys, in vanilla's bit order (`modelPart_*` in options.txt).
|
||||
const MODEL_PART_KEYS: [&str; 7] = [
|
||||
"modelPart_cape",
|
||||
"modelPart_jacket",
|
||||
"modelPart_left_sleeve",
|
||||
"modelPart_right_sleeve",
|
||||
"modelPart_left_pants_leg",
|
||||
"modelPart_right_pants_leg",
|
||||
"modelPart_hat",
|
||||
];
|
||||
/// The Accessibility Settings page:
|
||||
/// `AccessibilityOptionsScreen.options`, with the Controls link inserted separately.
|
||||
pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
@@ -97,9 +107,15 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::DistortionEffects
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::SharePresence
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::MusicToast
|
||||
// Saved, but nothing in rendering reads them yet.
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
@@ -128,6 +144,13 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
Opt::InGameNotification => "inGameNotification",
|
||||
Opt::SharePresence => "sharePresence",
|
||||
Opt::DirectionalAudio => "directionalAudio",
|
||||
Opt::MusicToast => "musicToast",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -225,6 +248,22 @@ impl Opt {
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
"options.modelPart.left_sleeve",
|
||||
"options.modelPart.right_sleeve",
|
||||
"options.modelPart.left_pants_leg",
|
||||
"options.modelPart.right_pants_leg",
|
||||
"options.modelPart.hat",
|
||||
][i],
|
||||
Opt::MainHand => "options.mainHand",
|
||||
Opt::AllowServerListing => "options.allowServerListing",
|
||||
Opt::RealmsNotifications => "options.realmsNotifications.button",
|
||||
Opt::InGameNotification => "options.inGameNotification",
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -460,9 +499,29 @@ impl Opt {
|
||||
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
|
||||
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
|
||||
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
|
||||
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
|
||||
on_off(s.file.bool(self.file_key().unwrap()))
|
||||
}
|
||||
Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => on_off(s.file.bool(self.file_key().unwrap())),
|
||||
Opt::MainHand => tr(if s.main_hand_left {
|
||||
"options.mainHand.left"
|
||||
} else {
|
||||
"options.mainHand.right"
|
||||
}),
|
||||
Opt::SharePresence => tr([
|
||||
"options.sharePresence.none",
|
||||
"options.sharePresence.limited",
|
||||
"options.sharePresence.all",
|
||||
][s.file.int("sharePresence").clamp(0, 2) as usize]),
|
||||
Opt::MusicToast => tr([
|
||||
"options.musicToast.never",
|
||||
"options.musicToast.pauseMenu",
|
||||
"options.musicToast.pauseMenuAndToast",
|
||||
][s.file.int("musicToast").clamp(0, 2) as usize]),
|
||||
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
|
||||
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
|
||||
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
|
||||
@@ -579,12 +638,14 @@ impl Opt {
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v).powi(2)),
|
||||
),
|
||||
Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => {
|
||||
s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
)
|
||||
}
|
||||
Opt::DistortionEffects
|
||||
| Opt::DamageTilt
|
||||
| Opt::PanoramaSpeed
|
||||
| Opt::GlintSpeed
|
||||
| Opt::GlintStrength => s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
@@ -669,7 +730,12 @@ impl Opt {
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts => {
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
|
||||
@@ -718,6 +784,17 @@ impl Opt {
|
||||
s.file
|
||||
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MainHand => s.main_hand_left = !s.main_hand_left,
|
||||
Opt::SharePresence => {
|
||||
let n = next(s.file.int("sharePresence").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("sharePresence", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MusicToast => {
|
||||
let n = next(s.file.int("musicToast").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("musicToast", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
@@ -855,6 +932,10 @@ pub enum Page {
|
||||
Horizon,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
/// SkinCustomizationScreen: the skin layers and Main Hand.
|
||||
Skin,
|
||||
/// OnlineOptionsScreen: server listing, Realms and friends notifications.
|
||||
Online,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -1173,6 +1254,8 @@ impl OptionsUi {
|
||||
Page::Main => "options.title",
|
||||
Page::Video => "options.videoTitle",
|
||||
Page::Sound => "options.sounds.title",
|
||||
Page::Skin => "options.skinCustomisation.title",
|
||||
Page::Online => "options.online.title",
|
||||
Page::Controls => "controls.title",
|
||||
Page::KeyBinds => "controls.keybinds.title",
|
||||
Page::Mouse => "options.mouse_settings.title",
|
||||
@@ -1432,13 +1515,29 @@ impl OptionsUi {
|
||||
self.filter_option_rows(rows)
|
||||
}
|
||||
Page::Sound => {
|
||||
// SoundOptionsScreen, without Audio Device: Horizon opens the
|
||||
// output device itself and cannot switch it.
|
||||
let mut rows = vec![Row::Big(Opt::Volume(0))];
|
||||
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles]));
|
||||
// SoundOptionsScreen: Music Frequency starts the next row (with
|
||||
// Music Toast, not in Horizon yet).
|
||||
rows.extend(pairs(&[Opt::MusicFrequency]));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
// SkinCustomizationScreen: the seven layers, then Main Hand.
|
||||
let mut opts: Vec<Opt> = (0..MODEL_PART_KEYS.len()).map(Opt::ModelPart).collect();
|
||||
opts.push(Opt::MainHand);
|
||||
pairs(&opts)
|
||||
}
|
||||
Page::Online => {
|
||||
// OnlineOptionsScreen without the Xbox and friends-list parts.
|
||||
let mut rows = vec![Row::Header("options.online.friends.header")];
|
||||
rows.extend(pairs(&[Opt::InGameNotification, Opt::SharePresence]));
|
||||
rows.push(Row::Header("options.online.servers.header"));
|
||||
rows.push(Row::Big(Opt::AllowServerListing));
|
||||
rows.push(Row::Header("options.online.realms.header"));
|
||||
rows.push(Row::Big(Opt::RealmsNotifications));
|
||||
rows
|
||||
}
|
||||
Page::Controls => {
|
||||
@@ -1633,15 +1732,15 @@ impl OptionsUi {
|
||||
list: false,
|
||||
});
|
||||
out.push(option_widget(s, Opt::Fov, cx - 154.0, 29.0, 150.0));
|
||||
out.push(inactive(button(
|
||||
out.push(button(
|
||||
cx + 4.0,
|
||||
29.0,
|
||||
150.0,
|
||||
tr("options.online"),
|
||||
Act::None,
|
||||
)));
|
||||
Act::Push(Page::Online),
|
||||
));
|
||||
let entries = [
|
||||
("options.skinCustomisation", None),
|
||||
("options.skinCustomisation", Some(Page::Skin)),
|
||||
("options.sounds", Some(Page::Sound)),
|
||||
("options.video", Some(Page::Video)),
|
||||
("options.controls", Some(Page::Controls)),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! The option screens live in `options_ui`.
|
||||
use crate::keybinds::KeyBinds;
|
||||
use crate::options_file::{OptionsFile, V};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::sound_source::SOUND_SOURCES;
|
||||
@@ -448,6 +449,16 @@ impl Settings {
|
||||
file: f,
|
||||
}
|
||||
}
|
||||
/// `Options.createInformation`: what the server learns about this player.
|
||||
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
|
||||
ClientInformation {
|
||||
chat_visibility: self.chat_visibility as i32,
|
||||
model_customisation: self.file.model_customisation(),
|
||||
main_hand: i32::from(!self.main_hand_left),
|
||||
allows_listing: self.file.bool("allowServerListing"),
|
||||
..ClientInformation::new(view_distance)
|
||||
}
|
||||
}
|
||||
/// The options file reflecting the typed fields; values the game did not
|
||||
/// change keep their stored form.
|
||||
pub fn to_file(&self) -> OptionsFile {
|
||||
@@ -706,6 +717,15 @@ pub enum Opt {
|
||||
PadGlyphs,
|
||||
PadMenuNav,
|
||||
PadKeyboard,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
MainHand,
|
||||
AllowServerListing,
|
||||
RealmsNotifications,
|
||||
InGameNotification,
|
||||
SharePresence,
|
||||
DirectionalAudio,
|
||||
MusicToast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -259,6 +259,8 @@ pub struct Streaming {
|
||||
deferred: Vec<Done>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
/// server nobody is on).
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
@@ -1018,7 +1020,9 @@ impl Streaming {
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& (self.radius < 0
|
||||
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
|
||||
> r4 * r4)
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
@@ -1466,6 +1470,16 @@ impl Streaming {
|
||||
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
self.ranges().in_range(x, z, margin)
|
||||
}
|
||||
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
|
||||
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
|
||||
/// chunk ranked before.
|
||||
pub fn rank_full_task(&mut self, x: i32, z: i32) {
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
self.decoration_order.rank_or_append(x + dx, z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn band(&self) -> Option<Band> {
|
||||
self.band
|
||||
}
|
||||
@@ -1763,7 +1777,7 @@ impl Ranges<'_> {
|
||||
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
|
||||
let r = i64::from(self.radius + 1);
|
||||
// The extra areas are squares: their finished square grown by `reach`.
|
||||
dx * dx + dz * dz <= r * r
|
||||
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|
||||
|| self.in_extra(x, z, 1 + reach)
|
||||
|| self.in_band(x, z, 1 + reach)
|
||||
}
|
||||
@@ -1796,7 +1810,7 @@ impl Ranges<'_> {
|
||||
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
@@ -2386,6 +2400,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle dedicated server keeps no area around its centre: only a spawn
|
||||
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
|
||||
/// forced chunks.
|
||||
#[test]
|
||||
fn no_area_around_an_idle_servers_centre() {
|
||||
let forced = HashSet::from([(30, 30)]);
|
||||
let extra = [((5, -3), -1)];
|
||||
let r = Ranges {
|
||||
center: (5, -3),
|
||||
radius: -1,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
for z in -13i32..=7 {
|
||||
let d = (x - 5).abs().max((z + 3).abs());
|
||||
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
assert!(r.in_range(30, 30, 1));
|
||||
let idle = Ranges { extra: &[], ..r };
|
||||
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
|
||||
}
|
||||
|
||||
/// A server player's area (`lan_update_streaming`: the server view
|
||||
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
|
||||
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
|
||||
|
||||
@@ -199,6 +199,7 @@ mod trading;
|
||||
mod trial_spawner;
|
||||
mod trial_visuals;
|
||||
mod trident;
|
||||
mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod vault;
|
||||
@@ -1664,6 +1665,8 @@ struct Game<'a> {
|
||||
attack_clicks: u32,
|
||||
hand_strength: horizon_client::attack_strength::AttackStrength,
|
||||
settings: Settings,
|
||||
tutorial: tutorial::Tutorial,
|
||||
tutorial_toasts: Vec<tutorial::HintToast>,
|
||||
/// Where the options screen returns: true = title, false = pause menu.
|
||||
options_from_title: bool,
|
||||
dragging: bool,
|
||||
@@ -2348,6 +2351,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Inventory.add: returns the stack that did not fit.
|
||||
fn give(&mut self, mut stack: ItemStack) -> Option<ItemStack> {
|
||||
let tutorial_item = matches!(
|
||||
self.tutorial.step,
|
||||
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
|
||||
)
|
||||
.then(|| stack.clone());
|
||||
let mut added = false;
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
@@ -2372,7 +2381,9 @@ impl Game<'_> {
|
||||
let moved = (max - slot.count).min(stack.count);
|
||||
slot.count += moved;
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -2385,11 +2396,16 @@ impl Game<'_> {
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
@@ -5177,6 +5193,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
self.tick_tutorial();
|
||||
if self.remote.is_some() {
|
||||
return self.tick_remote();
|
||||
}
|
||||
@@ -5757,6 +5774,7 @@ impl Game<'_> {
|
||||
.mouse
|
||||
.handle_accumulated_movement(time, active, grabbed, look)
|
||||
{
|
||||
self.tutorial.mouse(xo, yo);
|
||||
let mut rotation = Rotation {
|
||||
x_rot: self.pitch,
|
||||
y_rot: self.yaw,
|
||||
@@ -6359,6 +6377,7 @@ impl Game<'_> {
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_tutorial_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -9136,6 +9155,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn finish_open_world(&mut self, prepared: world_loading::PreparedWorld) -> Result<()> {
|
||||
self.reset_tutorial();
|
||||
let world_loading::PreparedWorld {
|
||||
save_root,
|
||||
level,
|
||||
@@ -12369,6 +12389,9 @@ impl Game<'_> {
|
||||
Screen::Playing => {
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
@@ -13065,6 +13088,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Chat && d != 0.0 {
|
||||
self.chat_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock && d != 0.0 {
|
||||
self.command_block_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
}
|
||||
@@ -13899,6 +13925,9 @@ impl<'a> Game<'a> {
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| GameCpuResources::load(&root))?;
|
||||
let world = World::new(res.facts().air, 0);
|
||||
let tutorial = tutorial::Tutorial::new(tutorial::Step::from_option(
|
||||
settings.file.int("tutorialStep"),
|
||||
));
|
||||
let creative = options.creative;
|
||||
let inventory = vec![None; 36];
|
||||
let now = Instant::now();
|
||||
@@ -14086,6 +14115,8 @@ impl<'a> Game<'a> {
|
||||
attack_clicks: 0,
|
||||
hand_strength: Default::default(),
|
||||
settings,
|
||||
tutorial,
|
||||
tutorial_toasts: Vec::new(),
|
||||
options_from_title: false,
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
|
||||
@@ -1493,7 +1493,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
fn chat_tree<'a>(
|
||||
pub(super) fn chat_tree<'a>(
|
||||
remote: &'a Option<super::remote::Remote>,
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
|
||||
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
|
||||
use horizon_client::{
|
||||
chat::{ChatScreen, Commands},
|
||||
command_block::{self, CommandBlock, Mode},
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
screen_draw::Measure,
|
||||
text::{self, Text},
|
||||
ui::{self, EditBox},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
level::flags,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use winit::{
|
||||
@@ -28,7 +31,10 @@ type Pos = (i32, i32, i32);
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
command: EditBox,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
opened: std::time::Instant,
|
||||
previous: EditBox,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
@@ -93,6 +99,9 @@ impl CommandBlockScreen {
|
||||
Button::Cancel => tr("gui.cancel"),
|
||||
}
|
||||
}
|
||||
pub(super) fn pos(&self) -> Pos {
|
||||
self.pos
|
||||
}
|
||||
/// `updatePreviousOutput`.
|
||||
fn show_previous(&mut self, block: &CommandBlock) {
|
||||
let shown = if self.track_output {
|
||||
@@ -446,10 +455,14 @@ impl Game<'_> {
|
||||
let Some(state) = self.block_state_at(pos) else {
|
||||
return;
|
||||
};
|
||||
let mut command = EditBox::new(0.0, 50.0, 300.0, 20.0);
|
||||
command.max_length = 32500;
|
||||
command.set_value(&block.command);
|
||||
command.focused = true;
|
||||
let (w, h) = self.gui_size();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
|
||||
command.auto_suggestions = self.settings.auto_suggestions;
|
||||
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
|
||||
previous.max_length = 32500;
|
||||
previous.text_color = UNEDITABLE;
|
||||
@@ -457,6 +470,7 @@ impl Game<'_> {
|
||||
let mut screen = CommandBlockScreen {
|
||||
pos,
|
||||
command,
|
||||
opened: std::time::Instant::now(),
|
||||
previous,
|
||||
track_output: block.track_output,
|
||||
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
|
||||
@@ -485,7 +499,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let mid = (w / 2.0).floor();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
// Screen.extractBackground of an isInGameUi screen: the dimmed world,
|
||||
// with no blur and no menu background.
|
||||
batch.fill_gradient(
|
||||
atlas,
|
||||
(0.0, 0.0, w, h),
|
||||
rgba(0x10, 0x10, 0x10, 0xC0),
|
||||
rgba(0x10, 0x10, 0x10, 0xD0),
|
||||
);
|
||||
let title = Text::translate("advMode.setCommand", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
@@ -499,8 +520,6 @@ impl Game<'_> {
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
let label = Text::translate("advMode.command", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
|
||||
s.command.x = mid - 150.0;
|
||||
s.command.draw(batch, atlas, None);
|
||||
if !s.previous.value.is_empty() {
|
||||
let label = Text::translate("advMode.previousOutput", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
|
||||
@@ -511,12 +530,65 @@ impl Game<'_> {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
|
||||
}
|
||||
// The command box, then its suggestions over the widgets.
|
||||
let m = Measure(&atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.resize(&m, &c, w as i32, h as i32);
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
let blink = s.opened.elapsed().as_millis() as u64;
|
||||
let draws = s.command.draw(&m, mouse, blink, 0);
|
||||
horizon_client::screen_draw::paint(batch, atlas, &draws);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The wheel over the suggestion list (`CommandSuggestions.mouseScrolled`).
|
||||
pub(super) fn command_block_scroll(&mut self, amount: f32) {
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
if let Some(s) = self.command_block_screen.as_mut() {
|
||||
s.command.scroll(mouse, f64::from(amount));
|
||||
}
|
||||
}
|
||||
/// The command box's completion request for an `ask_server` node.
|
||||
pub(super) fn command_block_suggestion_request(&mut self) -> Option<(i32, String)> {
|
||||
self.command_block_screen.as_mut()?.command.take_request()
|
||||
}
|
||||
/// The server's completions for the command box's `ask_server` request.
|
||||
pub(super) fn command_block_server_suggestions(
|
||||
&mut self,
|
||||
id: i32,
|
||||
range: (usize, usize),
|
||||
list: Vec<(String, Option<String>)>,
|
||||
) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.server_suggestions(&m, &c, id, range, list);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
pub(super) fn command_block_click(&mut self) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
// CommandSuggestions.mouseClicked first, then the command box.
|
||||
{
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
if s.command.click(&m, &c, x, y) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -552,8 +624,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: Enter is Done, Escape
|
||||
/// cancels, the rest edits the command.
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: the suggestions first,
|
||||
/// then Enter is Done, Escape cancels, the rest edits the command.
|
||||
pub(super) fn command_block_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -561,27 +633,56 @@ impl Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
self.command_block_key_code(key, shortcut, event.text.as_deref());
|
||||
}
|
||||
/// A key pressed on the command block screen, with the edit shortcut it
|
||||
/// makes and the text it types.
|
||||
pub(super) fn command_block_key_code(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if s.command.suggestions_key(&m, &c, key, shift) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
let typed = match key {
|
||||
KeyCode::Escape | KeyCode::Enter | KeyCode::NumpadEnter => false,
|
||||
_ => {
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(&m, &c, shortcut, &mut self.clipboard);
|
||||
false
|
||||
} else {
|
||||
!s.command.field_key(&m, &c, key, shift, ctrl) && !ctrl
|
||||
}
|
||||
}
|
||||
};
|
||||
self.command_block_screen = Some(s);
|
||||
if typed {
|
||||
self.command_block_type(text.unwrap_or(""));
|
||||
}
|
||||
match key {
|
||||
KeyCode::Escape => return self.close_command_block(),
|
||||
KeyCode::Escape => self.close_command_block(),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => {
|
||||
if let Some(s) = self.command_block_screen.take() {
|
||||
self.set_command_block(&s);
|
||||
}
|
||||
return self.close_command_block();
|
||||
self.close_command_block();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let Some(s) = self.command_block_screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !s.command.key(key, ctrl, shift) && !ctrl {
|
||||
self.command_block_type(event.text.as_deref().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
/// Characters typed into the command box (`StringUtil.isAllowedChatCharacter`).
|
||||
pub(super) fn command_block_type(&mut self, typed: &str) {
|
||||
@@ -589,11 +690,19 @@ impl Game<'_> {
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
|
||||
.collect();
|
||||
if let Some(s) = self.command_block_screen.as_mut()
|
||||
&& !allowed.is_empty()
|
||||
{
|
||||
s.command.insert_text(&allowed);
|
||||
if allowed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.type_text(&m, &c, &allowed);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The edit screen's Done: a server's client sends the packet, the
|
||||
/// window's own world applies it (`handleSetCommandBlock`).
|
||||
@@ -604,7 +713,7 @@ impl Game<'_> {
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value.clone(),
|
||||
command: s.command.value().to_owned(),
|
||||
track_output: s.track_output,
|
||||
mode: s.mode,
|
||||
conditional: s.conditional,
|
||||
@@ -653,6 +762,46 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.openCommandBlock`: the command block's data
|
||||
/// (`saveCustomOnly`) goes to the guest whose use opened it, as the
|
||||
/// chunk's update tag leaves it out and the edit screen shows it.
|
||||
pub(super) fn lan_open_command_block(&mut self, host: &mut LanHost, conn: u32, pos: Pos) {
|
||||
if !host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.is_some_and(|g| g.creative() && g.permission >= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(block) = self.command_block(pos) else {
|
||||
return;
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
let state = self.world.block(pos.0, pos.1, pos.2).0;
|
||||
let Some(kind) = host.hub.encoder.entity_type(state) else {
|
||||
return;
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::BlockEntityData(play::BlockEntityData {
|
||||
pos: [pos.0, pos.1, pos.2],
|
||||
kind,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
|
||||
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
let open = self.screen == Screen::CommandBlock
|
||||
&& self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == pos);
|
||||
if open {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
|
||||
@@ -349,7 +349,10 @@ impl Game<'_> {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock
|
||||
);
|
||||
let key = if typing && arg(2)? != "up" {
|
||||
key
|
||||
} else {
|
||||
@@ -854,6 +857,10 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Options {
|
||||
return self.options_key(key, None);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock {
|
||||
self.command_block_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.insert(key) {
|
||||
self.key_pressed(key)?;
|
||||
}
|
||||
|
||||
@@ -586,7 +586,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Nothing is kept around the spawn while nobody is on.
|
||||
self.streaming.radius = -1;
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
@@ -749,10 +750,39 @@ impl Game<'_> {
|
||||
self.stop_dedicated()
|
||||
}
|
||||
|
||||
/// A dedicated server of its own, not single player's server thread
|
||||
/// (which runs with `options.dedicated` too).
|
||||
pub(super) fn standalone_server(&self) -> bool {
|
||||
self.options.dedicated.is_some() && self.serving.is_none()
|
||||
}
|
||||
/// Load the spawn area and find the world spawn as single player does,
|
||||
/// without keeping a player there.
|
||||
/// without keeping a player there. A dedicated server only finds the world
|
||||
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
|
||||
/// one joins.
|
||||
pub(super) fn settle_spawn(&mut self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
if self.standalone_server() {
|
||||
if self.level.spawn.is_none() {
|
||||
let [x, y, z] = loop {
|
||||
self.update_world();
|
||||
if let Some(pos) = self.find_world_spawn()? {
|
||||
break pos;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("the spawn area did not load".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
};
|
||||
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
self.streaming.extra.clear();
|
||||
}
|
||||
self.spawn = self
|
||||
.level
|
||||
.spawn
|
||||
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
|
||||
return Ok(());
|
||||
}
|
||||
while self.player.is_none() {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
@@ -765,24 +795,28 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming), or
|
||||
/// the spawn chunk while nobody is online.
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming). While
|
||||
/// nobody is online a dedicated server keeps none (vanilla has no spawn
|
||||
/// ticket), an integrated one the spawn chunk's.
|
||||
pub(super) fn update_dedicated_streaming(&mut self) {
|
||||
let center = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
|
||||
});
|
||||
if let Some(c) = center {
|
||||
let guest = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
});
|
||||
if self.standalone_server() {
|
||||
self.streaming.radius = if guest.is_some() {
|
||||
self.settings.render_distance
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
}
|
||||
let spawn = self
|
||||
.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
|
||||
if let Some(c) = guest.or(spawn) {
|
||||
self.streaming.center = c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
|
||||
impl Game<'_> {
|
||||
/// Opening a screen stops breaking before the next game tick (which may pause).
|
||||
pub(super) fn stop_mining(&mut self) {
|
||||
if self.clicks.destroying.active {
|
||||
let pos = self.clicks.destroying.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, -1.0);
|
||||
}
|
||||
let mut destroying = std::mem::take(&mut self.clicks.destroying);
|
||||
destroying.stop(&mut GameHands {
|
||||
game: self,
|
||||
@@ -112,6 +117,12 @@ impl Game<'_> {
|
||||
progress: d.progress,
|
||||
ticks: d.ticks as u32,
|
||||
});
|
||||
if let Some(mining) = &self.mining {
|
||||
let pos = mining.pos;
|
||||
let progress = mining.progress;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, progress);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
@@ -527,6 +538,8 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let state = self.game.world.block(pos[0], pos[1], pos[2]);
|
||||
self.game.tutorial_destroy(state, 1.0);
|
||||
let r = self.game.break_block(pos);
|
||||
self.keep(r);
|
||||
true
|
||||
|
||||
@@ -110,7 +110,17 @@ impl Game<'_> {
|
||||
while let Some((dx, dz)) = self.spawn_spiral.current() {
|
||||
if self.spawn_spiral.tested() {
|
||||
let (cx, cz) = (ox + dx, oz + dz);
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
if self.standalone_server() {
|
||||
// getChunk(FULL) of the tested chunk alone: its 3x3 is decorated.
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
let area = [((cx, cz), -1)];
|
||||
if self.streaming.extra != area {
|
||||
self.streaming.extra = area.to_vec();
|
||||
self.streaming.rank_full_task(cx, cz);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
} else if !self.streaming.is_lit(cx, cz) {
|
||||
self.streaming.center = (cx, cz);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -2152,6 +2152,7 @@ impl Game<'_> {
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
|
||||
@@ -626,7 +626,9 @@ impl Game<'_> {
|
||||
}) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
self.command_block_screen = host_command_block;
|
||||
// A command block the guest's use opened (Player.openCommandBlock).
|
||||
let guest_command_block =
|
||||
std::mem::replace(&mut self.command_block_screen, host_command_block).map(|s| s.pos());
|
||||
// A mount inventory the guest's action opened (shift + use, or the
|
||||
// inventory key while riding).
|
||||
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
|
||||
@@ -779,6 +781,9 @@ impl Game<'_> {
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if let Some(pos) = guest_command_block {
|
||||
self.lan_open_command_block(host, conn, pos);
|
||||
}
|
||||
if let Some(pos) = opened_table {
|
||||
self.lan_open_crafting(host, conn, pos);
|
||||
}
|
||||
|
||||
@@ -424,6 +424,7 @@ impl Game<'_> {
|
||||
m.extra.fall_fly_ticks = fly_ticks;
|
||||
m.extra.auto_spin_attack = spinning;
|
||||
m.extra.main_hand_left = self.settings.main_hand_left;
|
||||
m.extra.hidden_model_parts = !self.settings.file.model_customisation() & 0x7F;
|
||||
m.extra.using_item = using;
|
||||
m.extra.using_off_hand = using_off_hand;
|
||||
m.extra.blocking = blocking;
|
||||
|
||||
@@ -141,11 +141,9 @@ pub(super) struct Remote {
|
||||
/// the first teleport, which takes them then.
|
||||
abilities: Option<play::PlayerAbilities>,
|
||||
loaded_sent: bool,
|
||||
/// The view distance last sent in ClientInformation.
|
||||
sent_view: i8,
|
||||
/// The Chat option's visibility last sent in ClientInformation
|
||||
/// (`Options.broadcastOptions` sends it again when it changes).
|
||||
sent_chat_visibility: i32,
|
||||
/// The ClientInformation last sent (`Options.broadcastOptions` sends it
|
||||
/// again when a view distance, Chat, skin or Main Hand option changes).
|
||||
sent_info: horizon_protocol::packets::configuration::ClientInformation,
|
||||
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
|
||||
/// with the sequence of the last action that changed them and the state the server
|
||||
/// last said they have. Until the server acknowledges that sequence its block updates
|
||||
@@ -345,6 +343,7 @@ impl Game<'_> {
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
self.reset_tutorial();
|
||||
self.perf.world_opening();
|
||||
let root = self.options.resources.clone();
|
||||
let builtin = Builtin::find(&root)
|
||||
@@ -476,8 +475,7 @@ impl Game<'_> {
|
||||
carried: None,
|
||||
abilities: None,
|
||||
loaded_sent: false,
|
||||
sent_view: view,
|
||||
sent_chat_visibility: self.settings.chat_visibility as i32,
|
||||
sent_info: self.settings.client_information(view),
|
||||
predictions: HashMap::new(),
|
||||
menu: None,
|
||||
menu_title: None,
|
||||
@@ -554,6 +552,7 @@ impl Game<'_> {
|
||||
self.gui.atlas.object_skins.clear();
|
||||
self.server_packs_disconnect();
|
||||
self.code_of_conduct = None;
|
||||
self.reset_tutorial();
|
||||
self.close_dialogs();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
@@ -981,7 +980,12 @@ impl Game<'_> {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some((id, command)) = self.ui.chat_screen.as_mut().and_then(|s| s.take_request())
|
||||
let Some((id, command)) = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_mut()
|
||||
.and_then(|s| s.take_request())
|
||||
.or_else(|| self.command_block_suggestion_request())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1021,7 +1025,8 @@ impl Game<'_> {
|
||||
})();
|
||||
if let Ok((id, start, length, list)) = decoded {
|
||||
let range = (start.max(0) as usize, length.max(0) as usize);
|
||||
self.chat_server_suggestions(id, range, list);
|
||||
self.chat_server_suggestions(id, range, list.clone());
|
||||
self.command_block_server_suggestions(id, range, list);
|
||||
}
|
||||
}
|
||||
fn set_registries(&mut self, registries: Arc<Dynamic>) {
|
||||
@@ -1215,10 +1220,14 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
if command_block {
|
||||
self.command_block_data_arrived(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleAnimate`: a player wakes up, or an entity gets the critical
|
||||
@@ -1792,13 +1801,12 @@ impl Game<'_> {
|
||||
self.streaming.radius =
|
||||
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
|
||||
self.enter_remote_dimension(&p.spawn, false);
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
let info = self
|
||||
.settings
|
||||
.client_information(client_view_distance(self.settings.render_distance));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.sent_view = view;
|
||||
r.sent_info = info.clone();
|
||||
}
|
||||
let mut info =
|
||||
horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = self.settings.chat_visibility as i32;
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
@@ -2162,6 +2170,13 @@ impl Game<'_> {
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
|
||||
// the player shows (PlayerModelPart bits).
|
||||
if let (16, Value::Byte(bits)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
|
||||
@@ -2712,6 +2727,7 @@ impl Game<'_> {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
// gains items squeezes for 5 ticks.
|
||||
if p.container == 0
|
||||
@@ -2729,9 +2745,14 @@ impl Game<'_> {
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
}
|
||||
C::SetCursorItem(p) => self.carried = self.to_stack(&p.item),
|
||||
C::SetCursorItem(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
self.carried = stack;
|
||||
}
|
||||
C::SetPlayerInventory(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
|
||||
@@ -3691,16 +3712,14 @@ impl Game<'_> {
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
let visibility = self.settings.chat_visibility as i32;
|
||||
let info = self
|
||||
.settings
|
||||
.client_information(client_view_distance(self.settings.render_distance));
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.joined
|
||||
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
|
||||
&& r.sent_info != info
|
||||
{
|
||||
r.sent_view = view;
|
||||
r.sent_chat_visibility = visibility;
|
||||
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = visibility;
|
||||
r.sent_info = info.clone();
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
@@ -4520,6 +4539,8 @@ impl Game<'_> {
|
||||
/// the attack strength again.
|
||||
pub(super) fn stop_remote_mining(&mut self) {
|
||||
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, -1.0);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 2,
|
||||
pos,
|
||||
@@ -4590,6 +4611,8 @@ impl Game<'_> {
|
||||
}
|
||||
if !same {
|
||||
if let Some((old, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
let old_state = self.world.block(old[0], old[1], old[2]);
|
||||
self.tutorial_destroy(old_state, -1.0);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 2,
|
||||
pos: old,
|
||||
@@ -4604,6 +4627,7 @@ impl Game<'_> {
|
||||
direction: face,
|
||||
sequence: seq,
|
||||
}));
|
||||
self.tutorial_destroy(state, 0.0);
|
||||
if progress >= 1.0 {
|
||||
// startDestroyBlock: instant survival breaks have no destroy delay.
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
@@ -4627,6 +4651,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let done = done + progress;
|
||||
self.tutorial_destroy(state, done.clamp(0.0, 1.0));
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
@@ -4663,6 +4688,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// MultiPlayerGameMode.destroyBlock: the predicted break.
|
||||
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
|
||||
self.tutorial_destroy(state, 1.0);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
@@ -5543,6 +5569,9 @@ impl Game<'_> {
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
// The inventory opens without telling the server.
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
self.recipe_ui.reset_screen();
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Client tutorial progression (`net.minecraft.client.tutorial`).
|
||||
|
||||
/// The numeric values are the order in `options_file::TUTORIAL_STEPS`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Step {
|
||||
Movement = 0,
|
||||
FindTree = 1,
|
||||
PunchTree = 2,
|
||||
OpenInventory = 3,
|
||||
CraftPlanks = 4,
|
||||
None = 5,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub(super) fn from_option(value: i32) -> Self {
|
||||
match value {
|
||||
0 => Self::Movement,
|
||||
1 => Self::FindTree,
|
||||
2 => Self::PunchTree,
|
||||
3 => Self::OpenInventory,
|
||||
4 => Self::CraftPlanks,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Hint {
|
||||
Move,
|
||||
Look,
|
||||
FindTree,
|
||||
PunchTree,
|
||||
OpenInventory,
|
||||
CraftPlanks,
|
||||
}
|
||||
|
||||
/// The observations needed on the first tick of each step.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Previous {
|
||||
pub(super) tree_item: bool,
|
||||
pub(super) log: bool,
|
||||
pub(super) plank: bool,
|
||||
pub(super) mined_tree: bool,
|
||||
pub(super) crafted_plank: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Tutorial {
|
||||
pub(super) step: Step,
|
||||
pub(super) waiting: u32,
|
||||
moved: bool,
|
||||
turned: bool,
|
||||
move_ticks: u32,
|
||||
look_ticks: u32,
|
||||
move_finished: Option<u32>,
|
||||
look_finished: bool,
|
||||
reset_count: u32,
|
||||
pub(super) break_progress: f32,
|
||||
}
|
||||
|
||||
impl Tutorial {
|
||||
pub(super) fn new(step: Step) -> Self {
|
||||
Self {
|
||||
step,
|
||||
waiting: 0,
|
||||
moved: false,
|
||||
turned: false,
|
||||
move_ticks: 0,
|
||||
look_ticks: 0,
|
||||
move_finished: None,
|
||||
look_finished: false,
|
||||
reset_count: 0,
|
||||
break_progress: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the persisted step changed.
|
||||
fn set_step(&mut self, step: Step) -> bool {
|
||||
if self.step == step {
|
||||
return false;
|
||||
}
|
||||
*self = Self::new(step);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn input(&mut self) {
|
||||
self.moved = true;
|
||||
}
|
||||
pub(super) fn mouse(&mut self, x: f64, y: f64) {
|
||||
self.turned |= x.abs() > 0.01 || y.abs() > 0.01;
|
||||
}
|
||||
|
||||
pub(super) fn tick(&mut self, survival: bool, previous: &Previous) -> bool {
|
||||
self.waiting = self.waiting.saturating_add(1);
|
||||
if self.step == Step::Movement {
|
||||
self.move_ticks += u32::from(std::mem::take(&mut self.moved));
|
||||
self.look_ticks += u32::from(std::mem::take(&mut self.turned));
|
||||
if self.move_ticks > 40 && self.move_finished.is_none() {
|
||||
self.move_finished = Some(self.waiting);
|
||||
}
|
||||
self.look_finished |= self.look_ticks > 40;
|
||||
if self.move_finished.is_some() && self.look_finished {
|
||||
return self.set_step(if survival { Step::FindTree } else { Step::None });
|
||||
}
|
||||
} else if self.step != Step::None && !survival {
|
||||
return self.set_step(Step::None);
|
||||
} else if self.waiting == 1 {
|
||||
match self.step {
|
||||
Step::FindTree if previous.tree_item || previous.mined_tree => {
|
||||
return self.set_step(Step::CraftPlanks);
|
||||
}
|
||||
Step::PunchTree if previous.log || previous.mined_tree => {
|
||||
return self.set_step(Step::CraftPlanks);
|
||||
}
|
||||
Step::CraftPlanks if previous.plank || previous.crafted_plank => {
|
||||
return self.set_step(Step::None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn look_at_tree(&mut self) -> bool {
|
||||
self.step == Step::FindTree && self.set_step(Step::PunchTree)
|
||||
}
|
||||
pub(super) fn destroy(&mut self, is_log: bool, progress: f32) -> bool {
|
||||
if self.step != Step::PunchTree {
|
||||
return false;
|
||||
}
|
||||
if is_log && progress > 0.0 {
|
||||
self.break_progress = progress;
|
||||
if progress >= 1.0 {
|
||||
return self.set_step(Step::OpenInventory);
|
||||
}
|
||||
} else {
|
||||
self.break_progress = 0.0;
|
||||
if is_log {
|
||||
self.reset_count = self.reset_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub(super) fn open_inventory(&mut self) -> bool {
|
||||
self.step == Step::OpenInventory && self.set_step(Step::CraftPlanks)
|
||||
}
|
||||
pub(super) fn get_item(&mut self, tree: bool, log: bool, plank: bool) -> bool {
|
||||
match self.step {
|
||||
Step::FindTree if tree => self.set_step(Step::CraftPlanks),
|
||||
Step::PunchTree if log => self.set_step(Step::CraftPlanks),
|
||||
Step::CraftPlanks if plank => self.set_step(Step::None),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(super) fn hint(&self) -> Option<(Hint, Option<f32>)> {
|
||||
match self.step {
|
||||
Step::Movement if self.waiting >= 100 && self.move_finished.is_none() => {
|
||||
Some((Hint::Move, Some(self.move_ticks as f32 / 40.0)))
|
||||
}
|
||||
Step::Movement
|
||||
if !self.look_finished
|
||||
&& self
|
||||
.move_finished
|
||||
.is_some_and(|t| self.waiting.saturating_sub(t) >= 20)
|
||||
&& self.waiting >= 100 =>
|
||||
{
|
||||
Some((Hint::Look, Some(self.look_ticks as f32 / 40.0)))
|
||||
}
|
||||
Step::FindTree if self.waiting >= 6000 => Some((Hint::FindTree, None)),
|
||||
Step::PunchTree if self.waiting >= 600 || self.reset_count > 3 => {
|
||||
Some((Hint::PunchTree, Some(self.break_progress)))
|
||||
}
|
||||
Step::OpenInventory if self.waiting >= 600 => Some((Hint::OpenInventory, None)),
|
||||
Step::CraftPlanks if self.waiting >= 1200 => Some((Hint::CraftPlanks, None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
/// `Tutorial.setStep` persists immediately so it survives leaving a world.
|
||||
pub(super) fn persist_tutorial_step(&mut self) {
|
||||
self.settings.file.set(
|
||||
"tutorialStep",
|
||||
horizon_client::options_file::V::Int(self.tutorial.step as i32),
|
||||
);
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn reset_tutorial(&mut self) {
|
||||
self.tutorial = Tutorial::new(Step::from_option(self.settings.file.int("tutorialStep")));
|
||||
self.tutorial_toasts.clear();
|
||||
}
|
||||
|
||||
fn tutorial_tagged(&self, item: bool, tag: &str, id: &str) -> bool {
|
||||
let items = self.res.items();
|
||||
let tags = if item {
|
||||
&items.item_tags
|
||||
} else {
|
||||
&items.block_tags
|
||||
};
|
||||
tags.get(tag).is_some_and(|members| members.contains(id))
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_get_item(&mut self, stack: Option<&horizon_client::items::ItemStack>) {
|
||||
if self.options.dedicated.is_some() || self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = stack else { return };
|
||||
let id = &*stack.item;
|
||||
let tree = self.tutorial_tagged(true, "minecraft:completes_find_tree_tutorial", id);
|
||||
let log = self.tutorial_tagged(true, "minecraft:logs", id);
|
||||
let plank = self.tutorial_tagged(true, "minecraft:planks", id);
|
||||
if self.tutorial.get_item(tree, log, plank) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_destroy(
|
||||
&mut self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
progress: f32,
|
||||
) {
|
||||
if self.options.dedicated.is_some() || self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let name = &self.res.facts().get(state).name;
|
||||
let is_log = self.tutorial_tagged(false, "minecraft:logs", name);
|
||||
if self.tutorial.destroy(is_log, progress) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tick_tutorial(&mut self) {
|
||||
use winit::keyboard::KeyCode;
|
||||
if self.title.is_some() || self.player.is_none() || self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.screen == super::Screen::Playing
|
||||
&& ([
|
||||
KeyCode::KeyW,
|
||||
KeyCode::KeyA,
|
||||
KeyCode::KeyS,
|
||||
KeyCode::KeyD,
|
||||
KeyCode::Space,
|
||||
]
|
||||
.iter()
|
||||
.any(|key| self.keys.contains(key))
|
||||
|| self
|
||||
.pad_move()
|
||||
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
|
||||
{
|
||||
self.tutorial.input();
|
||||
}
|
||||
let items = self.res.items();
|
||||
let in_tag =
|
||||
|tag: &str, id: &str| items.item_tags.get(tag).is_some_and(|set| set.contains(id));
|
||||
let mut previous = Previous::default();
|
||||
if self.tutorial.waiting == 0 && !matches!(self.tutorial.step, Step::Movement | Step::None)
|
||||
{
|
||||
for stack in self
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(self.armor.iter())
|
||||
.flatten()
|
||||
.chain(self.offhand.iter())
|
||||
{
|
||||
previous.tree_item |= in_tag("minecraft:completes_find_tree_tutorial", &stack.item);
|
||||
previous.log |= in_tag("minecraft:logs", &stack.item);
|
||||
previous.plank |= in_tag("minecraft:planks", &stack.item);
|
||||
}
|
||||
if let Some(mined) = self.stats.values.get("minecraft:mined")
|
||||
&& let Some(tree) = items
|
||||
.block_tags
|
||||
.get("minecraft:completes_find_tree_tutorial")
|
||||
{
|
||||
previous.mined_tree = mined
|
||||
.iter()
|
||||
.any(|(id, count)| *count > 0 && tree.contains(id));
|
||||
}
|
||||
if let Some(crafted) = self.stats.values.get("minecraft:crafted")
|
||||
&& let Some(planks) = items.item_tags.get("minecraft:planks")
|
||||
{
|
||||
previous.crafted_plank = crafted
|
||||
.iter()
|
||||
.any(|(id, count)| *count > 0 && planks.contains(id));
|
||||
}
|
||||
}
|
||||
let survival = self.mode == horizon_client::command::args::GameMode::Survival;
|
||||
if self.tutorial.tick(survival, &previous) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.tutorial.step == Step::FindTree
|
||||
&& self.pick_entity_hit().is_none()
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
if self.tutorial_tagged(false, "minecraft:completes_find_tree_tutorial", name)
|
||||
&& self.tutorial.look_at_tree()
|
||||
{
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct HintToast {
|
||||
hint: Hint,
|
||||
lines: Vec<(horizon_client::text::Text, bool)>,
|
||||
title_lines: usize,
|
||||
progress: Option<f32>,
|
||||
smooth: f32,
|
||||
last_smooth: i64,
|
||||
wanted: bool,
|
||||
slot: Option<usize>,
|
||||
slots: usize,
|
||||
slide: horizon_client::toasts::Slide,
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn tutorial_toast_slots(&self) -> u32 {
|
||||
self.tutorial_toasts
|
||||
.iter()
|
||||
.filter_map(|t| t.slot.map(|s| ((1u32 << t.slots) - 1) << s))
|
||||
.fold(0, |a, b| a | b)
|
||||
}
|
||||
|
||||
fn tutorial_toast(&self, hint: Hint, progress: Option<f32>) -> HintToast {
|
||||
use horizon_client::{keybinds, text::Text};
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
|
||||
let (title_key, fallback, description_key, description, icon_args) = match hint {
|
||||
Hint::Move => (
|
||||
"tutorial.move.title",
|
||||
"Move with %1$s, %2$s, %3$s, %4$s",
|
||||
"tutorial.move.description",
|
||||
"Jump with %1$s",
|
||||
vec![
|
||||
key("key.forward"),
|
||||
key("key.left"),
|
||||
key("key.back"),
|
||||
key("key.right"),
|
||||
],
|
||||
),
|
||||
Hint::Look => (
|
||||
"tutorial.look.title",
|
||||
"Look around",
|
||||
"tutorial.look.description",
|
||||
"Use your mouse to turn",
|
||||
vec![],
|
||||
),
|
||||
Hint::FindTree => (
|
||||
"tutorial.find_tree.title",
|
||||
"Find a tree",
|
||||
"tutorial.find_tree.description",
|
||||
"Punch it to collect wood",
|
||||
vec![],
|
||||
),
|
||||
Hint::PunchTree => (
|
||||
"tutorial.punch_tree.title",
|
||||
"Punch the tree",
|
||||
"tutorial.punch_tree.description",
|
||||
"Hold %1$s to break",
|
||||
vec![],
|
||||
),
|
||||
Hint::OpenInventory => (
|
||||
"tutorial.open_inventory.title",
|
||||
"Open inventory",
|
||||
"tutorial.open_inventory.description",
|
||||
"Press %1$s",
|
||||
vec![],
|
||||
),
|
||||
Hint::CraftPlanks => (
|
||||
"tutorial.craft_planks.title",
|
||||
"Craft wooden planks",
|
||||
"tutorial.craft_planks.description",
|
||||
"The recipe book can help",
|
||||
vec![],
|
||||
),
|
||||
};
|
||||
let substitute = |mut text: String, values: &[String]| {
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
text = text.replace(&format!("%{}$s", i + 1), value);
|
||||
text = text.replacen("%s", value, 1);
|
||||
}
|
||||
text
|
||||
};
|
||||
let title = substitute(self.translate(title_key, fallback), &icon_args);
|
||||
let desc_args = match hint {
|
||||
Hint::Move => vec![key("key.jump")],
|
||||
Hint::PunchTree => vec![key("key.attack")],
|
||||
Hint::OpenInventory => vec![key("key.inventory")],
|
||||
_ => vec![],
|
||||
};
|
||||
let description = substitute(self.translate(description_key, description), &desc_args);
|
||||
let font = &self.gui.atlas.font;
|
||||
let mut lines = font.split_lines(&Text::literal(title), 126.0);
|
||||
let title_lines = lines.len();
|
||||
lines.extend(font.split_lines(&Text::literal(description), 126.0));
|
||||
let height = 10 + 11 * lines.len().max(2);
|
||||
let slots = height.div_ceil(32);
|
||||
HintToast {
|
||||
hint,
|
||||
lines,
|
||||
title_lines,
|
||||
progress,
|
||||
smooth: 0.0,
|
||||
last_smooth: 0,
|
||||
wanted: true,
|
||||
slot: None,
|
||||
slots,
|
||||
slide: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_tutorial_toasts(
|
||||
&mut self,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
hidden: bool,
|
||||
) {
|
||||
let wanted = self.tutorial.hint();
|
||||
for t in &mut self.tutorial_toasts {
|
||||
t.wanted = wanted.is_some_and(|(hint, _)| hint == t.hint);
|
||||
if t.wanted {
|
||||
t.progress = wanted.and_then(|(_, progress)| progress);
|
||||
}
|
||||
}
|
||||
if let Some((hint, progress)) = wanted
|
||||
&& !self
|
||||
.tutorial_toasts
|
||||
.iter()
|
||||
.any(|t| t.wanted && t.hint == hint)
|
||||
{
|
||||
let toast = self.tutorial_toast(hint, progress);
|
||||
self.tutorial_toasts.push(toast);
|
||||
}
|
||||
let mut used = self.toast_slots_used();
|
||||
for t in &mut self.tutorial_toasts {
|
||||
if t.slot.is_none() && t.wanted && t.slots <= Self::TOAST_SLOTS {
|
||||
let mask = (1u32 << t.slots) - 1;
|
||||
if let Some(slot) =
|
||||
(0..=Self::TOAST_SLOTS - t.slots).find(|s| used & (mask << s) == 0)
|
||||
{
|
||||
t.slot = Some(slot);
|
||||
used |= mask << slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut i = 0;
|
||||
while i < self.tutorial_toasts.len() {
|
||||
let t = &mut self.tutorial_toasts[i];
|
||||
if t.slot.is_none() {
|
||||
if !t.wanted {
|
||||
self.tutorial_toasts.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (sound, finished) = t.slide.update_now(|_| t.wanted);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
self.tutorial_toasts.remove(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(progress) = t.progress {
|
||||
let delta = ((t.slide.fully_for - t.last_smooth) as f32 / 100.0).clamp(0.0, 1.0);
|
||||
t.smooth += (progress - t.smooth) * delta;
|
||||
t.last_smooth = t.slide.fully_for;
|
||||
}
|
||||
if !hidden {
|
||||
let height = (10 + 11 * t.lines.len().max(2)) as f32;
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let y = 32.0 * t.slot.unwrap_or_default() as f32;
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/toast/tutorial",
|
||||
x,
|
||||
y,
|
||||
160.0,
|
||||
height,
|
||||
u32::MAX,
|
||||
);
|
||||
let icon = match t.hint {
|
||||
Hint::Move => "movement_keys",
|
||||
Hint::Look => "mouse",
|
||||
Hint::FindTree | Hint::PunchTree => "tree",
|
||||
Hint::OpenInventory => "recipe_book",
|
||||
Hint::CraftPlanks => "wooden_planks",
|
||||
};
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("gui/sprites/toast/{icon}"),
|
||||
x + 6.0,
|
||||
y + 6.0,
|
||||
20.0,
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let text_y = y + 7.0 + ((t.lines.len().max(2) - t.lines.len()) * 11) as f32 / 2.0;
|
||||
for (n, (line, _)) in t.lines.iter().enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x + 30.0,
|
||||
text_y + 11.0 * n as f32,
|
||||
if n < t.title_lines {
|
||||
0xFF50_0050
|
||||
} else {
|
||||
0xFF00_0000
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
if let Some(progress) = t.progress {
|
||||
batch.fill(atlas, x + 3.0, y + height - 4.0, 154.0, 1.0, u32::MAX);
|
||||
let color = if progress >= t.smooth {
|
||||
0xFF00_5500
|
||||
} else {
|
||||
0xFF55_0000
|
||||
};
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 3.0,
|
||||
y + height - 4.0,
|
||||
154.0 * t.smooth.clamp(0.0, 1.0),
|
||||
1.0,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,16 @@ fn modifier_line(
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// TranslatableContents: `%s` and numbered `%1$s` arguments, `%%` a percent sign.
|
||||
Some(
|
||||
text(&format!("attribute.modifier.{kind}.{operation}"))
|
||||
.replacen("%s", &format_amount(shown), 1)
|
||||
.replacen("%s", &text(&format!("attribute.name.{attribute}")), 1)
|
||||
.replace("%%", "%"),
|
||||
crate::text::format_translation(
|
||||
&text(&format!("attribute.modifier.{kind}.{operation}")),
|
||||
&[
|
||||
crate::text::Text::literal(format_amount(shown)),
|
||||
crate::text::Text::literal(text(&format!("attribute.name.{attribute}"))),
|
||||
],
|
||||
)
|
||||
.plain(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1096,4 +1101,27 @@ mod tests {
|
||||
);
|
||||
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_lines_support_positional_format() {
|
||||
let lang = |k: &str| {
|
||||
Some(
|
||||
match k {
|
||||
"attribute.modifier.equals.0" => "%2$s: %1$s",
|
||||
"attribute.modifier.plus.2" => "+%1$s%% %2$s",
|
||||
"attribute.name.attack_damage" => "Damage",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "attack_damage", 5.0, 0, true).as_deref(),
|
||||
Some("Damage: 5")
|
||||
);
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "attack_damage", 0.2, 2, false).as_deref(),
|
||||
Some("+20% Damage")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -769,6 +769,9 @@ pub struct Extra {
|
||||
/// `LivingEntity.isAutoSpinAttack` (Riptide).
|
||||
pub auto_spin_attack: bool,
|
||||
pub main_hand_left: bool,
|
||||
/// The skin layers a player hides (`PlayerModelPart` bits, set to
|
||||
/// `Avatar.DATA_PLAYER_MODE_CUSTOMISATION` inverted); 0 shows all.
|
||||
pub hidden_model_parts: u8,
|
||||
pub elytra: ElytraAnimation,
|
||||
pub swim: SwimAnimation,
|
||||
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
|
||||
|
||||
@@ -802,6 +802,20 @@ pub fn setup(
|
||||
h.spear_ticks[using_side] = m.extra.using_item as f32 + s.partial;
|
||||
}
|
||||
humanoid(p, s, &h);
|
||||
if player {
|
||||
// PlayerModel.setupAnim: the layers the player hid (PlayerModelPart).
|
||||
let hidden = m.extra.hidden_model_parts;
|
||||
for (bit, part) in [
|
||||
(1, "jacket"),
|
||||
(2, "left_sleeve"),
|
||||
(3, "right_sleeve"),
|
||||
(4, "left_pants"),
|
||||
(5, "right_pants"),
|
||||
(6, "hat"),
|
||||
] {
|
||||
p.visible(part, hidden & (1 << bit) == 0);
|
||||
}
|
||||
}
|
||||
if m.ai.aggressive && !bow {
|
||||
// SkeletonModel melee pose.
|
||||
let sw = s.swing;
|
||||
|
||||
@@ -338,6 +338,12 @@ impl Decoration {
|
||||
};
|
||||
let mut stats = DecorateStats::default();
|
||||
let blocks = self.blocks;
|
||||
// The blocks' facts: `BlockState.isSolidRender` and the rest, the
|
||||
// structure pieces ask them of the blocks around them.
|
||||
let tree_blocks = self
|
||||
.bindings
|
||||
.tree_blocks()
|
||||
.map_err(|e| resource(e.to_string()))?;
|
||||
// ChunkGenerator.getWritableArea: the chunk's minimum Y + 1 to its maximum Y.
|
||||
let chunk_box = BoundingBox::new(
|
||||
[center.x * 16, min_y + 1, center.z * 16],
|
||||
@@ -375,6 +381,7 @@ impl Decoration {
|
||||
start,
|
||||
world,
|
||||
&structures.resources,
|
||||
&tree_blocks,
|
||||
random,
|
||||
chunk_box,
|
||||
&mut place_feature,
|
||||
|
||||
@@ -182,11 +182,29 @@ impl<W: FeatureWorld + PlacementContext> PieceWorld for W {
|
||||
pub struct PieceContext<'a> {
|
||||
pub world: &'a mut dyn PieceWorld,
|
||||
pub resources: &'a StructureResources,
|
||||
/// `BlockState.isSolidRender` of the blocks around the piece.
|
||||
pub blocks: &'a dyn SolidRender,
|
||||
pub random: &'a mut dyn PieceRandom,
|
||||
pub chunk_box: BoundingBox,
|
||||
pub chunk: ChunkPos,
|
||||
pub reference: [i32; 3],
|
||||
}
|
||||
/// `BlockState.isSolidRender`: whether a block draws as a solid block. The
|
||||
/// structure pieces ask it of the blocks around them, the way
|
||||
/// `StructurePiece.reorient` does.
|
||||
pub trait SolidRender {
|
||||
fn solid_render(&self, state: BlockStateId) -> bool;
|
||||
}
|
||||
impl SolidRender for crate::worldgen::tree_blocks::TreeBlocks {
|
||||
fn solid_render(&self, state: BlockStateId) -> bool {
|
||||
self.has(state, crate::worldgen::tree_blocks::SOLID_RENDER)
|
||||
}
|
||||
}
|
||||
impl<T: SolidRender + ?Sized> SolidRender for std::sync::Arc<T> {
|
||||
fn solid_render(&self, state: BlockStateId) -> bool {
|
||||
(**self).solid_render(state)
|
||||
}
|
||||
}
|
||||
impl PieceContext<'_> {
|
||||
pub fn state(&self, name: &str) -> BlockStateId {
|
||||
state(self.resources, name, &[])
|
||||
@@ -679,8 +697,9 @@ pub fn offset(pos: [i32; 3], d: Direction, n: i32) -> [i32; 3] {
|
||||
let s = d.normal();
|
||||
[pos[0] + s[0] * n, pos[1] + s[1] * n, pos[2] + s[2] * n]
|
||||
}
|
||||
/// `BlockState.isSolidRender` of a state: whether it draws as a solid block.
|
||||
pub fn solid_render(c: &PieceContext<'_>, state: BlockStateId) -> bool {
|
||||
c.resources.transforms.full_block(state) && !c.is_liquid(state)
|
||||
c.blocks.solid_render(state)
|
||||
}
|
||||
/// StructurePiece.createChest with an absolute position.
|
||||
pub fn create_chest(
|
||||
@@ -700,47 +719,72 @@ pub fn create_chest(
|
||||
c.loot_table(pos, "minecraft:chest", table)?;
|
||||
Ok(true)
|
||||
}
|
||||
/// StructurePiece.reorient for chests (solid-render approximated by the
|
||||
/// full collision cube of non-liquid blocks).
|
||||
/// `StructurePiece.reorient` for chests: the facing a chest takes from the
|
||||
/// blocks around it.
|
||||
fn reorient(c: &mut PieceContext<'_>, pos: [i32; 3], state: BlockStateId) -> Result<BlockStateId> {
|
||||
let mut solid: Option<Direction> = None;
|
||||
for d in HORIZONTAL {
|
||||
let n = c.world.block(offset(pos, d, 1))?;
|
||||
if c.name(n) == "minecraft:chest" {
|
||||
return Ok(state);
|
||||
}
|
||||
if solid_render(c, n) {
|
||||
if solid.is_some() {
|
||||
solid = None;
|
||||
break;
|
||||
}
|
||||
solid = Some(d);
|
||||
}
|
||||
let mut states = [BlockStateId(0); 4];
|
||||
for (index, d) in HORIZONTAL.iter().enumerate() {
|
||||
states[index] = c.world.block(offset(pos, *d, 1))?;
|
||||
}
|
||||
if let Some(d) = solid {
|
||||
return Ok(c.with_property(state, "facing", direction_name(opposite(d))));
|
||||
}
|
||||
let mut facing = match c.property(state, "facing") {
|
||||
let facing = match c.property(state, "facing") {
|
||||
Some("south") => Direction::South,
|
||||
Some("east") => Direction::East,
|
||||
Some("west") => Direction::West,
|
||||
_ => Direction::North,
|
||||
};
|
||||
let solid_at = |c: &mut PieceContext<'_>, d: Direction| -> Result<bool> {
|
||||
let s = c.world.block(offset(pos, d, 1))?;
|
||||
Ok(solid_render(c, s))
|
||||
let blocks = c.blocks;
|
||||
let is_chest = |s: BlockStateId| c.name(s) == "minecraft:chest";
|
||||
let Some(facing) = reorient_facing(facing, states, &is_chest, blocks) else {
|
||||
return Ok(state);
|
||||
};
|
||||
if solid_at(c, facing)? {
|
||||
facing = opposite(facing);
|
||||
}
|
||||
if solid_at(c, facing)? {
|
||||
facing = clockwise(facing);
|
||||
}
|
||||
if solid_at(c, facing)? {
|
||||
facing = opposite(facing);
|
||||
}
|
||||
Ok(c.with_property(state, "facing", direction_name(facing)))
|
||||
}
|
||||
/// `StructurePiece.reorient`: the facing of a chest, from the blocks around
|
||||
/// it. `states` are the four horizontal neighbours (north, east, south, west).
|
||||
/// `is_chest` tells whether a state is a chest, and `blocks` whether it draws
|
||||
/// as solid (`BlockState.isSolidRender`). None: a chest is next to it, and
|
||||
/// the state is kept.
|
||||
fn reorient_facing(
|
||||
facing: Direction,
|
||||
states: [BlockStateId; 4],
|
||||
is_chest: &dyn Fn(BlockStateId) -> bool,
|
||||
blocks: &dyn SolidRender,
|
||||
) -> Option<Direction> {
|
||||
let index = |d: Direction| match d {
|
||||
Direction::North => 0,
|
||||
Direction::East => 1,
|
||||
Direction::South => 2,
|
||||
Direction::West => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let mut solid_neighbour: Option<Direction> = None;
|
||||
for (i, d) in HORIZONTAL.iter().enumerate() {
|
||||
if is_chest(states[i]) {
|
||||
return None;
|
||||
}
|
||||
if blocks.solid_render(states[i]) {
|
||||
if solid_neighbour.is_some() {
|
||||
solid_neighbour = None;
|
||||
break;
|
||||
}
|
||||
solid_neighbour = Some(*d);
|
||||
}
|
||||
}
|
||||
if let Some(d) = solid_neighbour {
|
||||
return Some(opposite(d));
|
||||
}
|
||||
let mut lock = facing;
|
||||
if blocks.solid_render(states[index(lock)]) {
|
||||
lock = opposite(lock);
|
||||
}
|
||||
if blocks.solid_render(states[index(lock)]) {
|
||||
lock = clockwise(lock);
|
||||
}
|
||||
if blocks.solid_render(states[index(lock)]) {
|
||||
lock = opposite(lock);
|
||||
}
|
||||
Some(lock)
|
||||
}
|
||||
pub fn opposite(d: Direction) -> Direction {
|
||||
match d {
|
||||
Direction::Down => Direction::Up,
|
||||
@@ -976,3 +1020,69 @@ fn filter_blocks(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AIR: BlockStateId = BlockStateId(0);
|
||||
const GLASS: BlockStateId = BlockStateId(1);
|
||||
const STONE: BlockStateId = BlockStateId(2);
|
||||
const CHEST: BlockStateId = BlockStateId(3);
|
||||
|
||||
/// `BlockState.isSolidRender`: only the stone draws as a solid block;
|
||||
/// glass, leaves, ice, a spawner and a barrier do not.
|
||||
struct Blocks;
|
||||
impl SolidRender for Blocks {
|
||||
fn solid_render(&self, state: BlockStateId) -> bool {
|
||||
state == STONE
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructurePiece.reorient`: the chest's facing from the blocks around
|
||||
/// it, as vanilla has it. The solidity is `BlockState.isSolidRender`:
|
||||
/// glass is not solid to render, stone is.
|
||||
#[test]
|
||||
fn a_chest_next_to_glass_faces_away_from_the_stone() {
|
||||
// North: glass, east: stone.
|
||||
assert_eq!(
|
||||
reorient_facing(
|
||||
Direction::North,
|
||||
[GLASS, STONE, AIR, AIR],
|
||||
&|s| s == CHEST,
|
||||
&Blocks
|
||||
),
|
||||
Some(Direction::West)
|
||||
);
|
||||
}
|
||||
|
||||
/// Two solid neighbours: the chest keeps its own facing, turned away from
|
||||
/// the one it faced.
|
||||
#[test]
|
||||
fn two_solid_neighbours_turn_the_chest_away_from_one_of_them() {
|
||||
// North and east are solid.
|
||||
assert_eq!(
|
||||
reorient_facing(
|
||||
Direction::North,
|
||||
[STONE, STONE, AIR, AIR],
|
||||
&|s| s == CHEST,
|
||||
&Blocks
|
||||
),
|
||||
Some(Direction::South)
|
||||
);
|
||||
}
|
||||
|
||||
/// A chest next to it: the state is kept.
|
||||
#[test]
|
||||
fn a_chest_next_to_a_chest_keeps_its_facing() {
|
||||
assert_eq!(
|
||||
reorient_facing(
|
||||
Direction::North,
|
||||
[CHEST, AIR, AIR, AIR],
|
||||
&|s| s == CHEST,
|
||||
&Blocks
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1389,6 +1389,7 @@ pub fn place_in_chunk<W: FeatureWorld + PlacementContext, R: RandomSource + 'sta
|
||||
start: &StructureStart,
|
||||
world: &mut W,
|
||||
resources: &StructureResources,
|
||||
blocks: &dyn crate::worldgen::structure::legacy::SolidRender,
|
||||
random: &mut R,
|
||||
chunk_box: BoundingBox,
|
||||
place_feature: &mut PlaceFeature<'_, W, R>,
|
||||
@@ -1416,6 +1417,7 @@ pub fn place_in_chunk<W: FeatureWorld + PlacementContext, R: RandomSource + 'sta
|
||||
let mut context = PieceContext {
|
||||
world,
|
||||
resources,
|
||||
blocks,
|
||||
random,
|
||||
chunk_box,
|
||||
chunk: ChunkPos::new(chunk_box.min[0] >> 4, chunk_box.min[2] >> 4),
|
||||
@@ -1431,6 +1433,7 @@ pub fn place_in_chunk<W: FeatureWorld + PlacementContext, R: RandomSource + 'sta
|
||||
let mut context = PieceContext {
|
||||
world,
|
||||
resources,
|
||||
blocks,
|
||||
random,
|
||||
chunk_box,
|
||||
chunk: ChunkPos::new(chunk_box.min[0] >> 4, chunk_box.min[2] >> 4),
|
||||
|
||||
+3
-2
@@ -40,8 +40,9 @@ mod shows one.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
|
||||
then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
|
||||
In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads
|
||||
Rust and can take 20 to 30 minutes; later builds are faster) and turns on Settings → Game → **Native mods
|
||||
(experimental)**, which stays off for players who never install one; `mods install` does the same. Then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
|
||||
row shows each step, the error if one fails, and the build the instance plays. From the command line:
|
||||
`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked
|
||||
`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
|
||||
|
||||
@@ -181,9 +181,11 @@
|
||||
"These mods will run with full access to your computer": "Diese Mods erhalten vollen Zugriff auf deinen Computer",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Sie können deine Dateien lesen, ändern oder löschen und deine Konten sowie dein Netzwerk nutzen wie jedes andere Programm, das du ausführst. Kompiliere sie nur, wenn du ihren Autoren vertraust: Die Mods-Seite nennt sie.",
|
||||
"Mods: {mods}": "Mods: {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal etwa zehn Minuten und einige GB Speicherplatz, danach einige Minuten.",
|
||||
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal 20 bis 30 Minuten und einige GB Speicherplatz (er lädt Rust herunter), danach wenige Minuten.",
|
||||
"Delete old versions automatically": "Alte Versionen automatisch löschen",
|
||||
"Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Behält die {count} neuesten, die verwendete und die von deinen Instanzen genutzten. Gelöschte Versionen lassen sich auf der Seite Versionen wieder installieren.",
|
||||
"Installed versions use {size}.": "Installierte Versionen belegen {size}.",
|
||||
"{size} on disk": "{size} auf dem Datenträger"
|
||||
"{size} on disk": "{size} auf dem Datenträger",
|
||||
"Install and turn on native mods": "Installieren und native Mods einschalten",
|
||||
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller."
|
||||
}
|
||||
@@ -185,7 +185,9 @@
|
||||
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
|
||||
"Mods: {mods}": "Mods : {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite.",
|
||||
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilation construit le jeu : 20 à 30 minutes et quelques Go de disque la première fois (elle télécharge Rust), quelques minutes ensuite.",
|
||||
"Install and turn on native mods": "Installer et activer les mods natifs",
|
||||
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Les mods natifs sont compilés dans un jeu à part. En installer un active les mods natifs : vous compilez ensuite le jeu avec lui dans Paramètres > Mods natifs. La première compilation télécharge Rust et peut prendre 20 à 30 minutes ; les suivantes sont plus rapides.",
|
||||
"Delete…": "Supprimer…",
|
||||
"Keep": "Garder",
|
||||
"Move to Trash": "Mettre à la corbeille",
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
|
||||
"Mods: {mods}": "Моды: {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут.",
|
||||
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Сборка компилирует игру: 20–30 минут и несколько ГБ на диске в первый раз (она скачивает Rust), затем несколько минут.",
|
||||
"Install and turn on native mods": "Установить и включить нативные моды",
|
||||
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативные моды компилируются в отдельную игру. Установка такого мода включает нативные моды: затем вы собираете с ним игру в «Настройки > Нативные моды». Первая сборка скачивает Rust и может занять 20–30 минут; следующие быстрее.",
|
||||
"Delete…": "Удалить…",
|
||||
"Keep": "Оставить",
|
||||
"Move to Trash": "В корзину",
|
||||
|
||||
@@ -2865,10 +2865,14 @@ pub struct Settings {
|
||||
/// The window's language. Unset follows the system's (`i18n::Language::system`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<crate::i18n::Language>,
|
||||
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
|
||||
/// a game of their own. Off by default.
|
||||
/// Native mods (experimental) are on (`native_mods`): mixin mods compiled into a game of
|
||||
/// their own. Off until the player installs a native mod (`native_mods::turns_on`), or turns
|
||||
/// them on in Settings.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub native_mods: bool,
|
||||
/// The player has read what installing a native mod starts (`native_mods::needs_notice`).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub native_mods_noticed: bool,
|
||||
/// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them):
|
||||
/// offline names play without an account that owns the game, in the window only
|
||||
/// (`account::Accounts::play_as_with`, `account::launch_for_game`). Never a download: the game still
|
||||
|
||||
@@ -60,6 +60,14 @@ pub fn known_index() -> Option<Arc<Index>> {
|
||||
.map(|(_, index)| index.clone())
|
||||
}
|
||||
|
||||
/// Whether the registry's project `id` is a native mod, by the index already read (never the
|
||||
/// network); false when none was read.
|
||||
pub fn is_native_mod(id: &str) -> bool {
|
||||
known_index()
|
||||
.and_then(|index| Some(index.project(id)?.kind == index::Kind::NativeMod))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The index: the one fetched in the last ten minutes, else fetched again (and kept in the game
|
||||
/// folder), else the copy kept last time.
|
||||
pub fn index() -> Result<Arc<Index>, String> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
|
||||
use horizon_launcher::account::{self, Accounts};
|
||||
use horizon_launcher::backend::{self, Release};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::native_mods;
|
||||
use horizon_launcher::server_list;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
@@ -2111,6 +2112,17 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
|
||||
println!(" not ported: {s}");
|
||||
}
|
||||
}
|
||||
// A native mod turns Native mods on, with the window's notice, once.
|
||||
let mut settings = backend::Settings::load();
|
||||
let list = installed::Installed::load(&home).entries;
|
||||
if !settings.native_mods && native_mods::turns_on(false, &list) {
|
||||
if !settings.native_mods_noticed {
|
||||
eprintln!("{}", native_mods::NOTICE);
|
||||
}
|
||||
settings.native_mods = true;
|
||||
settings.native_mods_noticed = true;
|
||||
settings.save();
|
||||
}
|
||||
}
|
||||
ModsCmd::Update { project, check, .. } => {
|
||||
let list = installed::Installed::load(&home).entries;
|
||||
|
||||
@@ -3244,7 +3244,7 @@ impl Launcher {
|
||||
.child(div().text_color(t.danger).child(tr("These mods will run with full access to your computer").to_string()))
|
||||
.child(tr("They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.").to_string())
|
||||
.child(trf("Mods: {mods}", &[("mods", &list)]))
|
||||
.child(tr("The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.").to_string());
|
||||
.child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.").to_string());
|
||||
let mut buttons = div().flex().gap(px(8.)).mt(px(4.));
|
||||
if mods.is_ok() {
|
||||
buttons = buttons.child(
|
||||
|
||||
@@ -83,6 +83,8 @@ pub(super) struct Mods {
|
||||
message: Option<Result<String, String>>,
|
||||
updates: HashMap<(SourceId, String), String>,
|
||||
confirm_remove: Option<String>,
|
||||
/// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`).
|
||||
native_notice: Option<(SourceId, String, Option<Project>)>,
|
||||
/// Java mods' checks, by source and project.
|
||||
pub(super) checks: HashMap<(SourceId, String), Checking>,
|
||||
/// Horizon registry projects' trust and capabilities, by id ([`horizon::trust_and_capabilities`]).
|
||||
@@ -164,6 +166,7 @@ impl Mods {
|
||||
world: None,
|
||||
busy: None,
|
||||
message: None,
|
||||
native_notice: None,
|
||||
updates: HashMap::new(),
|
||||
confirm_remove: None,
|
||||
checks: HashMap::new(),
|
||||
@@ -269,6 +272,7 @@ impl Launcher {
|
||||
let mut to_check = Vec::new();
|
||||
let mut loaded = Vec::new();
|
||||
let mut done = false;
|
||||
let mut native_on = false;
|
||||
while let Ok(event) = self.mods.rx.try_recv() {
|
||||
changed = true;
|
||||
let m = &mut self.mods;
|
||||
@@ -299,6 +303,7 @@ impl Launcher {
|
||||
m.busy = None;
|
||||
m.message = Some(result);
|
||||
m.installed = installed::Installed::load(&m.home()).entries;
|
||||
native_on = native_mods::turns_on(native_on, &m.installed);
|
||||
m.installed_list.reset();
|
||||
if modpack {
|
||||
// The Modpacks page reads its list again.
|
||||
@@ -326,6 +331,12 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A native mod installed: Native mods turn on, so Settings offers its Build.
|
||||
if native_on && !self.settings.native_mods {
|
||||
self.settings.native_mods = true;
|
||||
self.settings.native_mods_noticed = true;
|
||||
self.settings.save();
|
||||
}
|
||||
// `HORIZON_LAUNCHER_PROJECT=<slug>` (with `HORIZON_LAUNCHER_PAGE=mods`): opens that
|
||||
// project's page once a search finds it, for screenshots.
|
||||
if self.mods.browse.open.is_none() {
|
||||
@@ -578,6 +589,18 @@ impl Launcher {
|
||||
if self.mods.busy.is_some() {
|
||||
return;
|
||||
}
|
||||
// The first native mod: what it starts, before anything is installed.
|
||||
let first_native = source == SourceId::Horizon
|
||||
&& native_mods::needs_notice(
|
||||
self.settings.native_mods,
|
||||
self.settings.native_mods_noticed,
|
||||
)
|
||||
&& horizon::is_native_mod(&id);
|
||||
if first_native {
|
||||
self.mods.native_notice = Some((source, id, known));
|
||||
return;
|
||||
}
|
||||
self.mods.native_notice = None;
|
||||
self.mods.message = None;
|
||||
self.mods.busy = Some((id.clone(), format!("Asking {}…", source.label()), 0.));
|
||||
self.mods.updates.remove(&(source, id.clone()));
|
||||
@@ -849,6 +872,7 @@ impl Launcher {
|
||||
.child(header)
|
||||
.child(div().flex().flex_col().child(kinds).child(body))
|
||||
};
|
||||
let body = body.children(self.native_notice(cx));
|
||||
if let Some(project) = self.mods_project_page(cx) {
|
||||
return page(body.children(self.mods_busy_plate()).child(project));
|
||||
}
|
||||
@@ -926,6 +950,56 @@ impl Launcher {
|
||||
}
|
||||
|
||||
/// The install under way and the last install's answer.
|
||||
/// The one-time notice before the first native mod is installed: what the build takes. Its
|
||||
/// button installs the mod; Native mods then turn on.
|
||||
fn native_notice(&self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
self.mods.native_notice.as_ref()?;
|
||||
let t = self.theme;
|
||||
Some(
|
||||
plate(&t)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(10.))
|
||||
.p(px(16.))
|
||||
.text_size(px(theme::UI.px))
|
||||
.text_color(t.ink)
|
||||
.child(tr(native_mods::NOTICE).to_string())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap(px(8.))
|
||||
.child(
|
||||
button(
|
||||
"native-notice-install",
|
||||
"Install and turn on native mods",
|
||||
Kind::Primary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.native_mods_noticed = true;
|
||||
this.settings.save();
|
||||
if let Some((source, id, known)) =
|
||||
this.mods.native_notice.take()
|
||||
{
|
||||
this.mods_install(source, id, known);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
button("native-notice-cancel", "Cancel", Kind::Secondary, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.mods.native_notice = None;
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn mods_busy_plate(&self) -> Vec<Div> {
|
||||
let t = self.theme;
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -33,6 +33,26 @@ pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06";
|
||||
const PROFILE: &str = "release";
|
||||
const FEATURES: &str = "horizon-client/mods";
|
||||
|
||||
/// Whether Native mods are on after an install into a game folder whose list is `installed`:
|
||||
/// installing a native mod turns them on, so the player only has to build. A player who never
|
||||
/// installs one keeps them off; one already on stays on.
|
||||
pub fn turns_on(on: bool, installed: &[crate::browser::installed::Entry]) -> bool {
|
||||
on || installed.iter().any(|e| {
|
||||
e.file
|
||||
.strip_prefix(INSTALLED)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether installing a native mod shows its notice first (what the first build takes): once,
|
||||
/// for a player whose Native mods are still off.
|
||||
pub fn needs_notice(on: bool, noticed: bool) -> bool {
|
||||
!on && !noticed
|
||||
}
|
||||
|
||||
/// The notice before the first native mod is installed.
|
||||
pub const NOTICE: &str = "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.";
|
||||
|
||||
/// Whether an installed version is a native-mods build.
|
||||
pub fn is_native(version: &str) -> bool {
|
||||
version.starts_with(PREFIX)
|
||||
@@ -778,6 +798,42 @@ pub fn prune(keep: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ludovic's decision (2026-10-29): installing a native mod turns Native mods on, after a
|
||||
/// notice shown once; players who never install one keep them off.
|
||||
#[test]
|
||||
fn installing_a_native_mod_turns_native_mods_on() {
|
||||
let entry = |file: &str| {
|
||||
serde_json::from_value::<crate::browser::installed::Entry>(serde_json::json!({
|
||||
"source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod",
|
||||
"version": "1", "number": "1", "file": file, "checksum": "", "page": ""
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
let plain = [
|
||||
entry("horizon-mods/zoom.hmod"),
|
||||
entry("resourcepacks/x.zip"),
|
||||
];
|
||||
assert!(!turns_on(false, &[]));
|
||||
assert!(!turns_on(false, &plain), "no native mod: they stay off");
|
||||
assert!(!turns_on(
|
||||
false,
|
||||
&[entry("horizon-native-mods-old/x.hnmod")]
|
||||
));
|
||||
let native = [
|
||||
plain[0].clone(),
|
||||
entry("horizon-native-mods/veinminer.hnmod"),
|
||||
];
|
||||
assert!(turns_on(false, &native));
|
||||
assert!(turns_on(true, &plain), "on stays on");
|
||||
// The notice: before the first one only.
|
||||
assert!(needs_notice(false, false));
|
||||
assert!(!needs_notice(false, true));
|
||||
assert!(!needs_notice(true, false));
|
||||
// Settings without the fields read as off and not noticed.
|
||||
let s: backend::Settings = serde_json::from_str("{}").unwrap();
|
||||
assert!(!s.native_mods && !s.native_mods_noticed);
|
||||
}
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("horizon-native-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
|
||||
+130
-29
@@ -191,6 +191,50 @@
|
||||
"focus": true,
|
||||
"announce": true
|
||||
},
|
||||
{
|
||||
"id": "native-mods",
|
||||
"title": "Native mods, built by the launcher",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.",
|
||||
"dev": "Stable hook points in the game (game/native_hooks.rs, #3345, #3346; v2 with the item panel and recipe pages, #3434, #3435), horizon-mixins applied by the launcher with its own portable Rust (#3388), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed (#3350, #3351, #3353, #3371).",
|
||||
"links": [
|
||||
"#3345",
|
||||
"#3346",
|
||||
"#3388",
|
||||
"#3434",
|
||||
"#3435",
|
||||
"docs/native-mods.md"
|
||||
],
|
||||
"ongoing": true
|
||||
},
|
||||
{
|
||||
"id": "java-instances",
|
||||
"title": "Minecraft Java instances in the launcher",
|
||||
"status": "now",
|
||||
"area": "launcher",
|
||||
"plain": "Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.",
|
||||
"dev": "For an account that owns the game: any version downloaded with the Java it needs (#3406), Java instances, the version list and the CLI (#3395), the start command read from the version JSON (#3419). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.",
|
||||
"links": [
|
||||
"#3395",
|
||||
"#3406",
|
||||
"#3419",
|
||||
"#3506"
|
||||
],
|
||||
"ongoing": true
|
||||
},
|
||||
{
|
||||
"id": "launcher-redesign",
|
||||
"title": "A new launcher design",
|
||||
"status": "next",
|
||||
"area": "launcher",
|
||||
"plain": "A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.",
|
||||
"dev": "Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in crates/horizon-theme and rules in docs/design-system.md, the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.",
|
||||
"links": [
|
||||
"docs/design-system.md",
|
||||
"crates/horizon-theme"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server-dialogs",
|
||||
"title": "Server dialogs and login screens",
|
||||
@@ -267,6 +311,20 @@
|
||||
],
|
||||
"ongoing": true
|
||||
},
|
||||
{
|
||||
"id": "port-ledger",
|
||||
"title": "Every vanilla method, tracked",
|
||||
"status": "now",
|
||||
"area": "project",
|
||||
"plain": "A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.",
|
||||
"dev": "tools/port_ledger.py and tools/port_map/ (#3139): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it (#3526), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).",
|
||||
"links": [
|
||||
"#3139",
|
||||
"#3526",
|
||||
"tools/port_ledger.py"
|
||||
],
|
||||
"ongoing": true
|
||||
},
|
||||
{
|
||||
"id": "native-shaders",
|
||||
"title": "Horizon's own shader format and kit",
|
||||
@@ -290,11 +348,10 @@
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.",
|
||||
"dev": "API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md.",
|
||||
"dev": "The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 (#1833) is the reference for what to cover.",
|
||||
"links": [
|
||||
"#1833",
|
||||
"docs/api/mods.md",
|
||||
"docs/mods.md"
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -302,11 +359,12 @@
|
||||
"title": "Screens, input and drawing for mods",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.",
|
||||
"dev": "Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').",
|
||||
"plain": "Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.",
|
||||
"dev": "On native hook points (docs/native-mods.md): settings pages under Options > Mods and the item panel are there (#3346, #3435). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.",
|
||||
"links": [
|
||||
"#1864",
|
||||
"docs/mods.md"
|
||||
"#3346",
|
||||
"#3435",
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -314,13 +372,11 @@
|
||||
"title": "Tools for people who make mods",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.",
|
||||
"dev": "`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.",
|
||||
"plain": "Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.",
|
||||
"dev": "Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.",
|
||||
"links": [
|
||||
"#1816",
|
||||
"#1837",
|
||||
"docs/api/stability.md",
|
||||
"https://playhorizon.dev/docs/test-a-mod"
|
||||
"#3388",
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -336,15 +392,16 @@
|
||||
},
|
||||
{
|
||||
"id": "microsoft-sign-in",
|
||||
"title": "Microsoft sign-in (coming soon)",
|
||||
"status": "next",
|
||||
"title": "Microsoft sign-in",
|
||||
"status": "shipped",
|
||||
"area": "launcher",
|
||||
"plain": "Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.",
|
||||
"dev": "Device-code sign-in in the launcher and online-mode login in the client (docs/microsoft-account.md). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows \"Coming soon\".",
|
||||
"plain": "Sign in with your Microsoft account in the launcher to play on online-mode servers.",
|
||||
"dev": "Device-code sign-in in the launcher, tokens in the system keychain (long secrets split over several items, #2879), the session handed to the game and online-mode login in the client (docs/microsoft-account.md). Release builds carry the app's client id; Play needs an account that owns Java Edition.",
|
||||
"links": [
|
||||
"docs/microsoft-account.md",
|
||||
"#1270"
|
||||
"#2879"
|
||||
],
|
||||
"shipped_in": "[email protected]",
|
||||
"announce": true
|
||||
},
|
||||
{
|
||||
@@ -595,9 +652,9 @@
|
||||
"status": "next",
|
||||
"area": "mods",
|
||||
"plain": "Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.",
|
||||
"dev": "After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. docs/api/mods.md ('Not there yet').",
|
||||
"dev": "Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.",
|
||||
"links": [
|
||||
"docs/api/mods.md",
|
||||
"docs/native-mods.md",
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
@@ -615,13 +672,13 @@
|
||||
},
|
||||
{
|
||||
"id": "mod-languages",
|
||||
"title": "Lua and other languages for mods",
|
||||
"status": "next",
|
||||
"title": "Scripts for mods",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.",
|
||||
"dev": "Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. docs/api/mods.md ('Ways in').",
|
||||
"plain": "Small mods written as scripts, in the style of KubeJS, with no compiler.",
|
||||
"dev": "Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.",
|
||||
"links": [
|
||||
"docs/api/mods.md"
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -808,6 +865,17 @@
|
||||
"docs/microsoft-account.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "older-servers",
|
||||
"title": "Join servers on older versions",
|
||||
"status": "later",
|
||||
"area": "servers",
|
||||
"plain": "Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.",
|
||||
"dev": "Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.",
|
||||
"links": [
|
||||
"#3023"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friends-online-mode",
|
||||
"title": "Online-mode checks for Play with Friends",
|
||||
@@ -835,11 +903,11 @@
|
||||
"title": "Native versions of popular client mods",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.",
|
||||
"dev": "Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.",
|
||||
"plain": "Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.",
|
||||
"dev": "Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.",
|
||||
"links": [
|
||||
"docs/extras.md",
|
||||
"docs/mods.md"
|
||||
"docs/native-mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -903,7 +971,7 @@
|
||||
"status": "later",
|
||||
"area": "rendering",
|
||||
"plain": "More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.",
|
||||
"dev": "Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.",
|
||||
"dev": "Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.",
|
||||
"links": [
|
||||
"docs/graphics.md"
|
||||
]
|
||||
@@ -930,6 +998,17 @@
|
||||
"docs/controllers.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "splitscreen",
|
||||
"title": "Splitscreen",
|
||||
"status": "later",
|
||||
"area": "gameplay",
|
||||
"plain": "Play with a friend on the same screen, each with a controller.",
|
||||
"dev": "Issue #2054; a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.",
|
||||
"links": [
|
||||
"#2054"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "debug-screen",
|
||||
"title": "The full debug screen",
|
||||
@@ -961,6 +1040,17 @@
|
||||
"#1709"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "windows-arm64",
|
||||
"title": "Horizon on Windows ARM64",
|
||||
"status": "later",
|
||||
"area": "platforms",
|
||||
"plain": "A native Horizon for Windows on ARM computers, such as Snapdragon laptops.",
|
||||
"dev": "Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.",
|
||||
"links": [
|
||||
"docs/release-flow.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "opengl-fallback",
|
||||
"title": "OpenGL for old computers",
|
||||
@@ -1224,6 +1314,17 @@
|
||||
"docs/hosting.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server-panels",
|
||||
"title": "Ready-made setups for hosting panels",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.",
|
||||
"dev": "Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml (docs/hosting.md).",
|
||||
"links": [
|
||||
"docs/hosting.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "launcher-look-menus",
|
||||
"title": "Game menus in the launcher's style",
|
||||
|
||||
+103
-57
@@ -158,6 +158,35 @@ and /mods/submit (#1635), and in the launcher the registry as a source (#1636),
|
||||
(#1637), the Mods page (#1638) and modpacks as their own profiles (#1640)."""
|
||||
links = ["#1634", "#1642", "#1659", "#1596", "#1635", "#1636", "#1637", "#1638", "#1640", "docs/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "native-mods"
|
||||
title = "Native mods, built by the launcher"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
ongoing = true
|
||||
plain = """Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next."""
|
||||
dev = """Stable hook points in the game (game/native_hooks.rs, #3345, #3346; v2 with the item panel and recipe pages, #3434, #3435), horizon-mixins applied by the launcher with its own portable Rust (#3388), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed (#3350, #3351, #3353, #3371)."""
|
||||
links = ["#3345", "#3346", "#3388", "#3434", "#3435", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "java-instances"
|
||||
title = "Minecraft Java instances in the launcher"
|
||||
status = "now"
|
||||
area = "launcher"
|
||||
ongoing = true
|
||||
plain = """Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers."""
|
||||
dev = """For an account that owns the game: any version downloaded with the Java it needs (#3406), Java instances, the version list and the CLI (#3395), the start command read from the version JSON (#3419). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format."""
|
||||
links = ["#3395", "#3406", "#3419", "#3506"]
|
||||
|
||||
[[item]]
|
||||
id = "launcher-redesign"
|
||||
title = "A new launcher design"
|
||||
status = "next"
|
||||
area = "launcher"
|
||||
plain = """A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup."""
|
||||
dev = """Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in crates/horizon-theme and rules in docs/design-system.md, the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher."""
|
||||
links = ["docs/design-system.md", "crates/horizon-theme"]
|
||||
|
||||
[[item]]
|
||||
id = "server-dialogs"
|
||||
title = "Server dialogs and login screens"
|
||||
@@ -218,6 +247,16 @@ dev = """A review of the whole tree: dead code and stubs removed (#1432, #1658),
|
||||
feature to its files and harnesses."""
|
||||
links = ["#1432", "#1658", "#1453", "#1454", "#1448", "#1707", "docs/architecture.md"]
|
||||
|
||||
[[item]]
|
||||
id = "port-ledger"
|
||||
title = "Every vanilla method, tracked"
|
||||
status = "now"
|
||||
area = "project"
|
||||
ongoing = true
|
||||
plain = """A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix."""
|
||||
dev = """tools/port_ledger.py and tools/port_map/ (#3139): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it (#3526), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity)."""
|
||||
links = ["#3139", "#3526", "tools/port_ledger.py"]
|
||||
|
||||
[[item]]
|
||||
id = "native-shaders"
|
||||
title = "Horizon's own shader format and kit"
|
||||
@@ -240,40 +279,27 @@ id = "server-mods"
|
||||
title = "Mods that run on the server"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react \
|
||||
to players joining, chat and blocks being broken or placed, and that add commands."""
|
||||
dev = """API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and \
|
||||
the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or \
|
||||
left), game rules and server commands with typed arguments. The world, item and entity interfaces come after \
|
||||
(see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md."""
|
||||
links = ["#1833", "docs/api/mods.md", "docs/mods.md"]
|
||||
plain = """Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands."""
|
||||
dev = """The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 (#1833) is the reference for what to cover."""
|
||||
links = ["#1833", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-screens"
|
||||
title = "Screens, input and drawing for mods"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger \
|
||||
mods, such as the Orbital Laser, can run on the normal game."""
|
||||
dev = """Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from \
|
||||
vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a \
|
||||
budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance \
|
||||
test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game')."""
|
||||
links = ["#1864", "docs/mods.md"]
|
||||
plain = """Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game."""
|
||||
dev = """On native hook points (docs/native-mods.md): settings pages under Options > Mods and the item panel are there (#3346, #3435). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test."""
|
||||
links = ["#3346", "#3435", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "modder-tools"
|
||||
title = "Tools for people who make mods"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and \
|
||||
clear promises about what keeps working from one version to the next."""
|
||||
dev = """`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server \
|
||||
mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, \
|
||||
docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a \
|
||||
frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference \
|
||||
entry is removed without a new major version, and the SDK on crates.io once the API is stable."""
|
||||
links = ["#1816", "#1837", "docs/api/stability.md", "https://playhorizon.dev/docs/test-a-mod"]
|
||||
plain = """Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next."""
|
||||
dev = """Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods."""
|
||||
links = ["#3388", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "pregen"
|
||||
@@ -291,16 +317,14 @@ links = ["#1805"]
|
||||
|
||||
[[item]]
|
||||
id = "microsoft-sign-in"
|
||||
title = "Microsoft sign-in (coming soon)"
|
||||
status = "next"
|
||||
title = "Microsoft sign-in"
|
||||
status = "shipped"
|
||||
area = "launcher"
|
||||
announce = true
|
||||
plain = """Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it \
|
||||
waits for Microsoft's approval."""
|
||||
dev = """Device-code sign-in in the launcher and online-mode login in the client (docs/microsoft-account.md). It is \
|
||||
turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows \
|
||||
"Coming soon"."""
|
||||
links = ["docs/microsoft-account.md", "#1270"]
|
||||
plain = """Sign in with your Microsoft account in the launcher to play on online-mode servers."""
|
||||
dev = """Device-code sign-in in the launcher, tokens in the system keychain (long secrets split over several items, #2879), the session handed to the game and online-mode login in the client (docs/microsoft-account.md). Release builds carry the app's client id; Play needs an account that owns Java Edition."""
|
||||
links = ["docs/microsoft-account.md", "#2879"]
|
||||
shipped_in = "[email protected]"
|
||||
|
||||
[[item]]
|
||||
id = "curseforge"
|
||||
@@ -566,14 +590,9 @@ id = "mod-world-items"
|
||||
title = "Mods that read and change the world"
|
||||
status = "next"
|
||||
area = "mods"
|
||||
plain = """Mods that can see and change blocks, read inventories and items, and find and affect entities, all through \
|
||||
the server so every player sees the same thing."""
|
||||
dev = """After server mods: read blocks, states and biomes in loaded chunks and change them through the server's \
|
||||
block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, \
|
||||
open container and item stacks with components; entity queries near a position, a safe subset of entity data \
|
||||
and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot \
|
||||
paths. docs/api/mods.md ('Not there yet')."""
|
||||
links = ["docs/api/mods.md", "docs/modpacks.md"]
|
||||
plain = """Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing."""
|
||||
dev = """Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths."""
|
||||
links = ["docs/native-mods.md", "docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "machines-api"
|
||||
@@ -591,17 +610,12 @@ links = ["#1189", "docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-languages"
|
||||
title = "Lua and other languages for mods"
|
||||
status = "next"
|
||||
title = "Scripts for mods"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass \
|
||||
the tests. Every language runs in the same sandbox."""
|
||||
dev = """Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest \
|
||||
names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest \
|
||||
can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or \
|
||||
TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a \
|
||||
template, an example and a CI test. docs/api/mods.md ('Ways in')."""
|
||||
links = ["docs/api/mods.md"]
|
||||
plain = """Small mods written as scripts, in the style of KubeJS, with no compiler."""
|
||||
dev = """Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed."""
|
||||
links = ["docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "shader-pack-examples"
|
||||
@@ -804,6 +818,15 @@ dev = """Issue #1270 asks for Ely.by and Yggdrasil-compatible servers with an ac
|
||||
builds on the Microsoft sign-in (docs/microsoft-account.md)."""
|
||||
links = ["#1270", "docs/microsoft-account.md"]
|
||||
|
||||
[[item]]
|
||||
id = "older-servers"
|
||||
title = "Join servers on older versions"
|
||||
status = "later"
|
||||
area = "servers"
|
||||
plain = """Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon."""
|
||||
dev = """Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game."""
|
||||
links = ["#3023"]
|
||||
|
||||
[[item]]
|
||||
id = "friends-online-mode"
|
||||
title = "Online-mode checks for Play with Friends"
|
||||
@@ -834,13 +857,9 @@ id = "client-mod-natives"
|
||||
title = "Native versions of popular client mods"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world \
|
||||
map and schematic overlays."""
|
||||
dev = """Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a \
|
||||
recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a \
|
||||
schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` \
|
||||
in the mod API, or they go into the game directly."""
|
||||
links = ["docs/extras.md", "docs/mods.md"]
|
||||
plain = """Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays."""
|
||||
dev = """Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays."""
|
||||
links = ["docs/extras.md", "docs/native-mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "create-physics"
|
||||
@@ -903,8 +922,8 @@ area = "rendering"
|
||||
plain = """More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. \
|
||||
Today Horizon has FXAA and borderless fullscreen."""
|
||||
dev = """Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs \
|
||||
close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen \
|
||||
where the backend supports it."""
|
||||
close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are \
|
||||
asked for too; exclusive fullscreen where the backend supports it."""
|
||||
links = ["docs/graphics.md"]
|
||||
|
||||
[[item]]
|
||||
@@ -933,6 +952,15 @@ DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real
|
||||
navigation in the GPUI launcher."""
|
||||
links = ["docs/controllers.md"]
|
||||
|
||||
[[item]]
|
||||
id = "splitscreen"
|
||||
title = "Splitscreen"
|
||||
status = "later"
|
||||
area = "gameplay"
|
||||
plain = """Play with a friend on the same screen, each with a controller."""
|
||||
dev = """Issue #2054; a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer."""
|
||||
links = ["#2054"]
|
||||
|
||||
[[item]]
|
||||
id = "debug-screen"
|
||||
title = "The full debug screen"
|
||||
@@ -965,6 +993,15 @@ machine with its Vulkan driver. The server build already drops audio and control
|
||||
(`--no-default-features`)."""
|
||||
links = ["#1709"]
|
||||
|
||||
[[item]]
|
||||
id = "windows-arm64"
|
||||
title = "Horizon on Windows ARM64"
|
||||
status = "later"
|
||||
area = "platforms"
|
||||
plain = """A native Horizon for Windows on ARM computers, such as Snapdragon laptops."""
|
||||
dev = """Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on."""
|
||||
links = ["docs/release-flow.md"]
|
||||
|
||||
[[item]]
|
||||
id = "opengl-fallback"
|
||||
title = "OpenGL for old computers"
|
||||
@@ -1202,6 +1239,15 @@ dev = """A player's idea (#horizon-ideas). `horizon-server` already runs headles
|
||||
player's own game files and starts the server, documented in docs/hosting.md."""
|
||||
links = ["docs/hosting.md"]
|
||||
|
||||
[[item]]
|
||||
id = "server-panels"
|
||||
title = "Ready-made setups for hosting panels"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel."""
|
||||
dev = """Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml (docs/hosting.md)."""
|
||||
links = ["docs/hosting.md"]
|
||||
|
||||
[[item]]
|
||||
id = "launcher-look-menus"
|
||||
title = "Game menus in the launcher's style"
|
||||
|
||||
@@ -102,6 +102,8 @@ impl Game {
|
||||
self.audio.play("hit", None, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
// Tutorial.onDestroyBlock (tutorial.rs): the hints play no part in mining.
|
||||
fn tutorial_destroy<T>(&mut self, _: T, _: f32) {}
|
||||
METHOD
|
||||
}
|
||||
fn main() {
|
||||
|
||||
Reference in new issue
Block a user