Compare commits

...
Author SHA1 Message Date
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
17 changed files with 799 additions and 249 deletions

No files matched your search

@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
+3 -2
View File
@@ -23,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
+1 -1
View File
@@ -373,6 +373,7 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -7492,7 +7493,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
+354 -195
View File
@@ -20,9 +20,10 @@ use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
@@ -55,7 +56,8 @@ impl Baked {
#[derive(Default)]
pub(super) struct Cosmetics {
client: Option<horizon_cosmetics::Cosmetics>,
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
@@ -65,16 +67,6 @@ pub(super) struct Cosmetics {
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
@@ -449,115 +441,302 @@ pub(super) fn enabled() -> bool {
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
}
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
/// service's client, the emote feed and what the account tells the service.
#[derive(Default)]
struct Seat {
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
said: Said,
/// Kept between ticks so a tick allocates nothing: the player models in view.
models: Vec<PlayerModel>,
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// My emote while it plays: its id and whether it loops.
playing: Option<(String, bool)>,
/// A folder's first emote, which mannequins play.
first: Option<Arc<Item>>,
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
let seat = horizon_hooks::seat();
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
if all.len() <= seat {
all.resize_with(seat + 1, Seat::default);
}
f(&mut all[seat])
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, _frame: &Frame) {
service(Seat::frame);
}
fn tick(&self, models: &mut dyn PlayerModels) {
service(|s| s.tick(models));
}
}
impl Seat {
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
if self.client.is_none() {
let (client, local) = Cosmetics::client();
self.client = Some(Arc::new(client));
self.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
self.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if self.local {
self.owner.owns = true;
return;
}
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut self.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
{
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
let every = horizon_cosmetics::account::PRESENCE_EVERY;
self.mine.settle(None);
self.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy = self.owner.asking.is_some()
|| self.mine.sending.is_some()
|| self.listed.sending.is_some();
let said = &self.said;
if let Some(done) = self
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emote_out.sending = None;
match done {
Ok(()) => self.emote_out.retry = None,
Err(_) => {
self.emote_out.retry = backoff(self.emote_out.retry);
self.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
let wish = wish.map(str::to_owned);
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
}
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
fn tick(&mut self, models: &mut dyn PlayerModels) {
let mut seen = std::mem::take(&mut self.models);
seen.clear();
models.for_each(&mut |m| {
if m.kind != Kind::Own {
seen.push(m);
}
});
self.play_others(models, &mut seen);
self.models = seen;
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
let Some(first) = &self.said.first else {
return;
};
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
let age = m.age as f32;
let now = emotes::pose_playing(models.body_pose(m.id));
if now.is_none_or(|p| p.at(age).is_none()) {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_model_playing(models, m.id, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
return;
};
if !shown {
feed.ask([]);
for m in view.iter() {
emotes::set_model_playing(models, m.id, None);
}
return;
}
let mut frame = client.frame();
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
drop(frame);
feed.ask(view.iter().filter_map(|m| m.uuid));
let now = Instant::now();
for m in view.iter() {
let Some(uuid) = m.uuid else { continue };
let seen = feed.playing(uuid);
let age = m.age as f32;
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if m.moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !m.moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
}
}
}
impl Game<'_> {
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
}
let first = if self.cosmetics.local {
service(|s| s.said.first.is_none())
.then(|| self.owned_emotes().first().cloned())
.flatten()
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
}
if first.is_some() {
said.first = first;
}
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
}
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if c.local {
c.owner.owns = true;
} else {
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut c.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
}
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
@@ -572,88 +751,6 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_playing(m, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
emotes::set_playing(m, None);
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
@@ -669,7 +766,7 @@ impl Game<'_> {
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = self.cosmetics.emote_out.kept.clone();
let kept = service(|s| s.emote_out.kept.clone());
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
@@ -962,7 +1059,7 @@ impl Game<'_> {
mod tests {
use super::*;
use horizon_client::extras::Extras;
use std::sync::Mutex;
use horizon_client::mobs::anim::BodyPose;
/// A name tag style with a 1×1 mark.
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
@@ -1186,4 +1283,66 @@ mod tests {
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
}
}
/// Player models as the hooks see them, with the pose laid over each.
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
impl PlayerModels for View {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
self.0.iter().for_each(|(m, _)| f(*m));
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
*p = pose;
}
}
}
#[test]
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
let item = Arc::new(
Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
)
.unwrap(),
);
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
model(1, Kind::Own, None),
model(41, Kind::Mannequin, None),
model(2, Kind::Player, Some(7)),
]);
service.tick(&mut view);
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
assert!(Arc::ptr_eq(&playing.item, &item));
// Out of step by its id: 50 - 41 % 40.
assert_eq!(playing.start, 49.0);
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
// Still playing: the same pose stays.
let first = view.0[1].1.clone().unwrap();
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
}
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -37,10 +38,10 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
Some((&p.item.id, looping))
}
}
@@ -59,23 +60,48 @@ impl BodyPose for EmotePose {
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
}
}
@@ -229,7 +255,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote, then the other players'.
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -250,7 +276,6 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
@@ -0,0 +1,131 @@
//! Where the game hands the player models to crates above the engine
//! (`horizon_hooks::Hooks::frame` and `tick`, spec #14091): only a window's game calls them,
//! never the integrated server's thread or a dedicated server. With no hook registered nothing is
//! built.
use super::Game;
use horizon_client::join_seq::JoinList;
use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_hooks::engine;
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, None));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
impl Game<'_> {
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
engine::frame(&Frame::new(connection));
self.cosmetics_frame();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
}
+8 -9
View File
@@ -6731,7 +6731,7 @@ impl Game<'_> {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
self.client_hooks_tick();
if !dead {
self.remote_tick_using();
}
@@ -10714,17 +10714,11 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
self.remote.as_ref()?.player_uuid(id)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
r.level_player_name(id as i32, &r.player_uuid(id)?)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11505,6 +11499,11 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
+1 -1
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
self.client_hooks_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
+2
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
+3 -20
View File
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
/// ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
+17
View File
@@ -149,8 +149,20 @@ pub struct Session {
far: Arc<crate::lod_net::Inbox>,
/// The recording running, if any.
recorder: RecorderSlot,
/// This connection's number, new for each one ([`Session::serial`]).
serial: u64,
}
/// The next [`Session::serial`].
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_serial() -> u64 {
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
}
impl Session {
/// A number of its own for each connection, never 0: entity ids are per connection, so what
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
pub fn serial(&self) -> u64 {
self.serial
}
/// The negotiated wire release, once the connection has been established.
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
self.writer.lock().unwrap().as_ref().map_or(
@@ -256,6 +268,7 @@ impl Session {
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
}
}
fn start(
@@ -333,6 +346,7 @@ impl Session {
debug,
far,
recorder,
serial: next_serial(),
}
}
/// Everything received since the last poll.
@@ -1829,6 +1843,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1932,6 +1947,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
@@ -2215,6 +2231,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
+9 -3
View File
@@ -70,7 +70,8 @@ struct Shared {
/// The game's handle. Dropping it stops the thread.
pub struct Cosmetics {
shared: Arc<Shared>,
worker: Option<Box<dyn FnMut() + Send>>,
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
}
impl Cosmetics {
@@ -78,7 +79,12 @@ impl Cosmetics {
/// `cache` when given.
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
let mut work = c.worker.take().expect("worker");
let mut work = c
.worker
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("worker");
let shared = c.shared.clone();
let _ = std::thread::Builder::new()
.name("Cosmetics".into())
@@ -125,7 +131,7 @@ impl Cosmetics {
});
Self {
shared,
worker: Some(worker),
worker: Mutex::new(Some(worker)),
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
}
fn run(c: &mut Cosmetics) {
(c.worker.as_mut().unwrap())();
(c.worker.get_mut().unwrap().as_mut().unwrap())();
}
fn ids(worn: Option<Worn>) -> Vec<String> {
+107 -1
View File
@@ -19,11 +19,13 @@
pub mod hud;
pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a crate above the engine hears from the game. Every method has an empty default:
@@ -42,6 +44,14 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// Once a frame, before drawing. Only a window's game calls it (each splitscreen player in
/// turn, [`seat`]), never the integrated server's thread or a dedicated server.
fn frame(&self, _frame: &players::Frame) {}
/// Once a client tick while a world is shown: the player models in view. Called as
/// [`Hooks::frame`] is.
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
@@ -89,10 +99,27 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
@@ -130,6 +157,25 @@ pub mod engine {
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
}
/// [`Hooks::frame`].
pub fn frame(frame: &players::Frame) {
for hooks in all() {
hooks.frame(frame);
}
}
/// [`Hooks::tick`].
pub fn tick(models: &mut dyn players::PlayerModels) {
for hooks in all() {
hooks.tick(models);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -139,6 +185,7 @@ pub mod engine {
#[cfg(test)]
mod tests {
use super::*;
use players::PlayerModels as _;
use std::sync::Mutex as StdMutex;
/// Records what it heard. One process-wide list, as the game has: this test is the only
@@ -155,6 +202,55 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn frame(&self, frame: &players::Frame) {
self.0
.lock()
.unwrap()
.push(format!("frame {}", frame.connection));
}
fn tick(&self, models: &mut dyn players::PlayerModels) {
let mut ids = Vec::new();
models.for_each(&mut |m| ids.push(m.id));
for id in ids {
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
}
}
}
#[derive(Debug)]
struct Still;
impl players::BodyPose for Still {
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
false
}
}
/// Two models, poses by id.
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
impl players::PlayerModels for Two {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
for id in [3, 7] {
f(players::PlayerModel::new(
id,
players::Kind::Player,
None,
0,
false,
));
}
}
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
self.0.get(&id)
}
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
match pose {
Some(p) => self.0.insert(id, p),
None => self.0.remove(&id),
};
}
}
/// Implements nothing: the defaults do nothing.
@@ -173,9 +269,19 @@ mod tests {
engine::published(open);
}
engine::shutdown();
assert!(engine::any());
engine::frame(&players::Frame::new(4));
let mut models = Two(Default::default());
engine::tick(&mut models);
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
assert_eq!(
*RECORDER.0.lock().unwrap(),
["started 0.2.31", "published true", "published false"]
[
"started 0.2.31",
"published true",
"published false",
"frame 4"
]
);
assert_eq!(register(&Silent), Err(TooLate));
}
+92
View File
@@ -0,0 +1,92 @@
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`], and
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation. Plain types only:
//! no engine type crosses this line.
use std::sync::Arc;
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a crate lays over a player model's body after its vanilla animation (Horizon's emotes,
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
/// downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the model's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); a part
/// the model lacks holds the identity in both and is not written back. False when it poses
/// nothing at `age` (`poses` is then ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// What a player model is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Kind {
/// The player of this window, seen from outside (third person, the inventory picture).
Own,
/// Another player.
Player,
/// A mannequin: a player model with no player.
Mannequin,
}
/// One player model, as [`PlayerModels::for_each`] shows it.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct PlayerModel {
/// Its entity id on this connection ([`PlayerModels::connection`]).
pub id: u32,
pub kind: Kind,
/// The player's UUID as the server gave it; None for the window's own model and mannequins.
pub uuid: Option<u128>,
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
pub age: u32,
/// It moved sideways since the last tick (more than 0.01 blocks).
pub moved: bool,
}
impl PlayerModel {
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
Self {
id,
kind,
uuid,
age,
moved,
}
}
}
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
pub trait PlayerModels {
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
/// when this changes.
fn connection(&self) -> u64;
/// Each player model in view (other players and mannequins; the window's own model first).
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
/// The pose laid over model `id`, if any.
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
}
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Frame {
/// As [`PlayerModels::connection`]; 0 with no world.
pub connection: u64,
}
impl Frame {
pub fn new(connection: u64) -> Self {
Self { connection }
}
}
+6 -1
View File
@@ -46,7 +46,12 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
/// [`Hooks::tick`] get, and the pose a crate lays over a player's body.
pub use horizon_hooks::players::{
BODY_PARTS, BodyPose, Frame, Kind, PlayerModel, PlayerModels,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game