Emotes: play one from the emote wheel, on your model and in the camera

- The wheel: Emote Wheel (`key.emote`, B by default, unbound on a controller; rebindable in
  Controls) shows up to 8 emotes around the middle of the screen. To play one: hold the key and
  release on an emote, click one, or press its number.
- The emote cancels (and fades out) on movement, an attack, mining, using an item, being hurt,
  riding, sleeping, gliding or sneaking. Only a standing player starts one.
- From first person, the camera turns to the front view and eases out from close for the emote,
  then comes back.
- Bones: `anim::setup` lays the emote over vanilla's animation, keyed bones only.
- Props: drawn with the cosmetics, growing in and out with the blend.
- `horizon-ctl emote [ID|stop]`.
- HORIZON_COSMETICS_LOCAL: the wheel offers the folder's emotes, and mannequins play its first
  one (the art and the bench).

Visual only: nothing reaches the game server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 authored and Ludovic Marie committed 2026-11-05 18:08:59 +01:00
1 parent 44c14af45a
commit 2fed27fce9
21 files changed
+652 -8

No files matched your search

Generated
+1
View File
@@ -1413,6 +1413,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
@@ -314,6 +314,8 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.screenshot", None),
("key.toggleGui", None),
("key.smoothCamera", None),
// Horizon's emote wheel: no button is free by default; the stick's cursor picks.
("key.emote", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
@@ -23,6 +23,8 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.pickItem", "key.mouse.middle", "gameplay"),
("key.command", "key.keyboard.slash", "multiplayer"),
("key.friends", "key.keyboard.o", "multiplayer"),
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
("key.toggleGui", "key.keyboard.f1", "misc"),
(
+2
View File
@@ -18,6 +18,8 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
@@ -357,6 +357,10 @@
"horizon.extras.header.cosmetics": "Kosmetik",
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
"horizon.extras.cosmetics_mine.tooltip": "Ausgeblendet: Alle Horizon-Spieler sehen dich ohne deine Kosmetik, du selbst auch. Derselbe Schalter wie in der Garderobe des Launchers.",
"key.emote": "Emote-Rad",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Noch keine Emotes",
"horizon.emote_wheel.help": "Loslassen oder klicken zum Abspielen · 1–8 · Esc schließt",
"horizon.extras.cosmetics_others": "Kosmetik anderer Spieler",
"horizon.extras.cosmetics_others.tooltip": "Was andere Horizon-Spieler tragen: alles, nur ihre Namensschild-Stile oder nichts. Ausgeblendet fragt das Spiel nie, was sie tragen.",
"horizon.extras.cosmetics_auras": "Auren und Spuren anderer",
@@ -557,6 +557,10 @@
"horizon.extras.header.cosmetics": "Cosmetics",
"horizon.extras.cosmetics_mine": "My cosmetics",
"horizon.extras.cosmetics_mine.tooltip": "Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe.",
"key.emote": "Emote Wheel",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "No emotes yet",
"horizon.emote_wheel.help": "Release or click to play · 1–8 · Esc closes",
"horizon.extras.cosmetics_others": "Other players' cosmetics",
"horizon.extras.cosmetics_others.tooltip": "What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_auras": "Others' auras and trails",
@@ -394,6 +394,10 @@
"horizon.extras.header.cosmetics": "Cosmétiques",
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
"key.emote": "Roue des emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Pas encore d'emote",
"horizon.emote_wheel.help": "Relâche ou clique pour jouer · 1–8 · Échap ferme",
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
"horizon.extras.cosmetics_others.tooltip": "Ce que portent les autres joueurs Horizon : tout, seulement leur style d'étiquette de nom, ou rien. Masqués si vous n'avez pas de cosmétiques, tant que vous n'avez pas choisi. Masqués, le jeu ne demande jamais ce qu'ils portent.",
"horizon.extras.cosmetics_auras": "Auras et traînées des autres",
@@ -437,6 +437,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("look-at-entity", Move, "look-at-entity ID [--over S]", "face an entity (same as look-at entity)", "",
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities"), OVER], mcp: true, local: false, wire: "look-at entity"),
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
cmd!("emote", Act, "emote [ID|stop]", "play one of the player's emotes, stop it, or list them",
"Without an argument: the emotes the player owns (the wheel's) and the one playing. With an ID it plays that emote as the emote wheel does (the player must stand still: moving, attacking or using an item cancels it); stop fades it out. Visual only: nothing is sent to the game server (docs/cosmetics.md).",
["horizon-ctl emote", "horizon-ctl emote wave_hello", "horizon-ctl emote stop"],
&[opt("id", Ty::Str, "an owned emote's id, or stop")], mcp: true, local: false),
// Act
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
+52
View File
@@ -117,6 +117,8 @@ mod dispense_fixture;
mod dispenser;
mod dragon;
mod dynamic_lights;
mod emote_wheel;
mod emotes;
mod enchant_effects;
mod end;
mod entity;
@@ -829,6 +831,8 @@ impl Options {
enum Screen {
Playing,
GameModeSwitcher,
/// Horizon's emote wheel (`game/emote_wheel.rs`): over the world, not pausing.
EmoteWheel,
Paused,
Options,
Chat,
@@ -872,6 +876,7 @@ impl Screen {
match self {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "game_mode_switcher",
Screen::EmoteWheel => "emote_wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Chat => "chat",
@@ -2937,6 +2942,9 @@ struct Game<'a> {
skin_slots: horizon_client::skins::SkinSlots,
/// Cosmetics on players (game/cosmetics.rs), off by default.
cosmetics: cosmetics::Cosmetics,
/// Emotes: those the wheel offers and the one playing (`game/emotes.rs`).
emotes: emotes::Emotes,
emote_wheel: emote_wheel::Wheel,
/// 3D skin layers: each player's outer layer as one slab per painted
/// texel, built once per (skin, slim) (`mobs::skin_layers`).
skin_3d: RefCell<skin_models::SkinModels>,
@@ -9097,6 +9105,7 @@ impl Game<'_> {
);
}
Screen::GameModeSwitcher => self.draw_gamemode_switcher(&mut batch),
Screen::EmoteWheel => self.draw_emote_wheel(&mut batch),
Screen::Dead => self.draw_death_screen(&mut batch),
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
@@ -12387,6 +12396,7 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
@@ -16836,6 +16846,7 @@ impl Game<'_> {
self.capture(true);
}
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::EmoteWheel => self.close_emote_wheel(None),
Screen::Dead => {
if self.ui.death_confirm.is_some() {
self.death_confirm_answer(false)?;
@@ -16905,6 +16916,24 @@ impl Game<'_> {
self.release_use_for_keybind_screen();
}
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
// key.emote (Horizon's): the emote wheel, a tap again closes it.
KeyCode::KeyB if self.screen == Screen::Playing && horizon_cosmetics::enabled() => {
self.open_emote_wheel();
}
KeyCode::KeyB if self.screen == Screen::EmoteWheel => self.close_emote_wheel(None),
KeyCode::Digit1
| KeyCode::Digit2
| KeyCode::Digit3
| KeyCode::Digit4
| KeyCode::Digit5
| KeyCode::Digit6
| KeyCode::Digit7
| KeyCode::Digit8
if self.screen == Screen::EmoteWheel =>
{
let slot = (key as u32).wrapping_sub(KeyCode::Digit1 as u32) as usize;
self.close_emote_wheel(Some(slot));
}
// key.quickActions: only open if a datapack provides dialog actions.
KeyCode::KeyG if self.screen == Screen::Playing => {
if self.has_quick_actions() {
@@ -18008,6 +18037,26 @@ impl Game<'_> {
{
self.track_drag_button(*button, *state == ElementState::Pressed);
}
// The emote key let go over the wheel, a click on it.
if self.screen == Screen::EmoteWheel {
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
&& self.settings.keys.translate(key) == Some(Ok(KeyCode::KeyB))
{
self.emote_wheel_released();
return Ok(());
}
if let WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} = &event
{
self.emote_wheel_click();
return Ok(());
}
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
@@ -19642,6 +19691,8 @@ impl<'a> Game<'a> {
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: horizon_client::skins::SkinSlots::new(horizon_client::hud::SKIN_SLOTS),
cosmetics: Default::default(),
emotes: Default::default(),
emote_wheel: Default::default(),
skin_3d: Default::default(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
@@ -22708,6 +22759,7 @@ mod tests {
let all = [
Screen::Playing,
Screen::GameModeSwitcher,
Screen::EmoteWheel,
Screen::Paused,
Screen::Options,
Screen::Chat,
+1
View File
@@ -413,6 +413,7 @@ impl Game<'_> {
match self.screen {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "gamemode-switcher",
Screen::EmoteWheel => "emote-wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Inventory if self.station.is_some() => "station",
+18
View File
@@ -786,6 +786,23 @@ impl Game<'_> {
self.previous_position = to;
ok()
}
"emote" => match words.get(1).copied() {
None => {
let owned: Vec<String> =
self.owned_emotes().iter().map(|i| i.id.clone()).collect();
Ok(Some(
json!({ "owned": owned, "playing": self.emote_playing() }),
))
}
Some("stop") => {
self.stop_emote();
ok()
}
Some(id) if self.play_emote(id) => ok(),
Some(id) => {
Err(format!("can't play emote {id} now (not owned, or not standing)").into())
}
},
"hotbar" => {
let slot: usize = arg(1)?.parse()?;
if !(1..=9).contains(&slot) {
@@ -1238,6 +1255,7 @@ impl Game<'_> {
match self.screen {
Screen::Playing => "playing",
Screen::GameModeSwitcher => "gamemode-switcher",
Screen::EmoteWheel => "emote-wheel",
Screen::Paused => "paused",
Screen::Options => "options",
Screen::Inventory => "inventory",
+53 -5
View File
@@ -400,6 +400,39 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: others' emotes on their models. Only items from a folder play here for now
/// (mannequins, for the art and the bench); others' emotes show with "Other players'
/// cosmetics" on Shown only.
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"
&& m.emote.as_ref().is_none_or(|p| p.at(age).is_none())
{
m.emote = Some(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,
});
}
}
return;
}
if !shown {
for m in self.mobs.iter_mut() {
m.emote = None;
}
}
}
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
@@ -516,8 +549,14 @@ impl Game<'_> {
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
use horizon_client::mobs::{anim, render};
let worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
let worn = self.worn(m, own);
// An emote's props: its time and weight while it plays.
let ticks = m.age as f32 + alpha;
let emote = m.emote.as_ref().and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then_some((&p.item, t, w))
});
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
return None;
}
// The avatar's body, posed as it is drawn.
@@ -528,7 +567,6 @@ impl Game<'_> {
body_poses.reset(body);
let data = self.res.mob_data();
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
let ticks = m.age as f32 + alpha;
let frame = self.cosmetics.frame.get();
let mut bounds = None;
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
@@ -536,8 +574,10 @@ impl Game<'_> {
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
for item in worn.iter() {
if !bodies || covered(m, item.slot) {
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
let emote_items = emote.map(|(i, t, w)| (i, Some((t, w))));
for (item, emoting) in worn_items.chain(emote_items) {
if emoting.is_none() && (!bodies || covered(m, item.slot)) {
continue;
}
let mut baked = self.cosmetics.baked.borrow_mut();
@@ -580,6 +620,14 @@ impl Game<'_> {
};
poses.reset(model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
e.pose_props(t, |n| item.rest(n), |n, pose| *poses.get(n) = pose);
// Props grow in and shrink out with the emote's blend.
let root = poses.get(&model.name);
for s in &mut root[6..9] {
*s *= w;
}
}
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
@@ -511,6 +511,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
Screen::Inventory
| Screen::Creative
| Screen::GameModeSwitcher
| Screen::EmoteWheel
| Screen::Stats
| Screen::Advancements
| Screen::Friends
@@ -0,0 +1,189 @@
//! The emote wheel (`key.emote`, B by default; bindable on a controller): the player's emotes
//! around the middle of the screen. Hover one and release the key, click it, or press its
//! number; Escape or the key again closes it. The game doesn't pause and nothing is sent to the
//! game server.
use super::{Game, Screen};
use horizon_client::{hud::HudBatch, text::Text};
use std::time::{Duration, Instant};
/// Emotes on the wheel.
pub(super) const SLOTS: usize = 8;
/// The ring, in GUI pixels from the middle; inside `INNER` nothing is hovered.
const INNER: f32 = 30.0;
const OUTER: f32 = 92.0;
/// Held at least this long, releasing the key plays what is hovered (or closes); a tap leaves the
/// wheel open.
const HOLD: Duration = Duration::from_millis(250);
#[derive(Default)]
pub(super) struct Wheel {
opened: Option<Instant>,
hovered: Option<usize>,
}
/// The slice under a point `(dx, dy)` from the middle (GUI pixels, y down) of a wheel of `n`:
/// slice 0 is straight up, then clockwise. None in the dead middle or past the ring.
pub(super) fn slice_at(dx: f32, dy: f32, n: usize) -> Option<usize> {
let r = dx.hypot(dy);
if n == 0 || r < INNER || r > OUTER * 1.6 {
return None;
}
let turn = std::f32::consts::TAU / n as f32;
// Clockwise from up, centred on each slice.
let angle = (dx.atan2(-dy) + turn / 2.0).rem_euclid(std::f32::consts::TAU);
Some(((angle / turn) as usize).min(n - 1))
}
/// A point at `radius` and `angle` (radians clockwise from up) around `(cx, cy)`.
fn at(cx: f32, cy: f32, radius: f32, angle: f32) -> [f32; 2] {
[cx + radius * angle.sin(), cy - radius * angle.cos()]
}
impl Game<'_> {
/// Opens the wheel from play.
pub(super) fn open_emote_wheel(&mut self) {
self.emote_wheel = Wheel {
opened: Some(Instant::now()),
hovered: None,
};
self.screen = Screen::EmoteWheel;
self.keys.clear();
self.left = false;
self.right = false;
self.mining = None;
self.capture(false);
}
/// Closes the wheel; `play`: the slice to play first.
pub(super) fn close_emote_wheel(&mut self, play: Option<usize>) {
self.emote_wheel = Wheel::default();
self.screen = Screen::Playing;
self.capture(true);
let id = play.and_then(|i| self.owned_emotes().get(i).map(|e| e.id.clone()));
if let Some(id) = id {
self.play_emote(&id);
}
}
/// The emote key let go: a held press plays what is hovered, a tap keeps the wheel open.
pub(super) fn emote_wheel_released(&mut self) {
let held = self
.emote_wheel
.opened
.is_some_and(|at| at.elapsed() >= HOLD);
if held || self.emote_wheel.hovered.is_some() {
self.close_emote_wheel(self.emote_wheel.hovered);
}
}
/// A click on the wheel: plays the hovered emote, or closes.
pub(super) fn emote_wheel_click(&mut self) {
self.close_emote_wheel(self.emote_wheel.hovered);
}
pub(super) fn draw_emote_wheel(&mut self, batch: &mut HudBatch) {
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("emote_wheel", "horizon.emote_wheel.title")
});
let ids: Vec<String> = self
.owned_emotes()
.iter()
.take(SLOTS)
.map(|e| e.id.clone())
.collect();
let names: Vec<String> = ids.iter().map(|id| self.emote_name(id)).collect();
let (w, h) = (batch.width, batch.height);
let (cx, cy) = (w / 2.0, h / 2.0);
let n = names.len();
// The cursor is in GUI pixels, as the batch.
let (mx, my) = (self.cursor.0 as f32, self.cursor.1 as f32);
self.emote_wheel.hovered = slice_at(mx - cx, my - cy, n);
let hovered = self.emote_wheel.hovered;
let atlas = &self.gui.atlas;
// The ring: each slice a fan of quads, the hovered one brighter.
let slices = n.max(1);
let turn = std::f32::consts::TAU / slices as f32;
let gap = 0.03;
for i in 0..slices {
let colour = match (n, hovered == Some(i)) {
(0, _) => 0x9010_1018,
(_, true) => 0xD0A9_8FFF,
_ => 0xA010_1018,
};
let from = i as f32 * turn - turn / 2.0 + gap;
let span = turn - 2.0 * gap;
for k in 0..6 {
let (a0, a1) = (
from + span * k as f32 / 6.0,
from + span * (k + 1) as f32 / 6.0,
);
batch.fill_quad(
atlas,
[
at(cx, cy, INNER, a0),
at(cx, cy, OUTER, a0),
at(cx, cy, OUTER, a1),
at(cx, cy, INNER, a1),
],
colour,
);
}
}
// Each slice's number and name, in the middle of its band.
for (i, name) in names.iter().enumerate() {
let [x, y] = at(cx, cy, (INNER + OUTER) / 2.0, i as f32 * turn);
let label = format!("{} {name}", i + 1);
let lw = HudBatch::text_width(atlas, &label);
let colour = if hovered == Some(i) {
u32::MAX
} else {
0xFFC8_C8D0
};
batch.text(atlas, &label, x - lw / 2.0, y - 4.0, colour, true);
}
let title = match (n, hovered) {
(0, _) => Text::translate("horizon.emote_wheel.empty", &[]),
(_, Some(i)) => Text::literal(&names[i]),
_ => Text::translate("horizon.emote_wheel.title", &[]),
};
let plain = title.plain();
let tw = HudBatch::text_width(atlas, &plain);
batch.text_styled(atlas, &title, cx - tw / 2.0, cy - 4.0, u32::MAX, true);
let help = Text::translate("horizon.emote_wheel.help", &[]);
let hw = HudBatch::text_width(atlas, &help.plain());
batch.text_styled(
atlas,
&help,
cx - hw / 2.0,
cy + OUTER + 10.0,
0xFFA0_A0A8,
true,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slices_go_clockwise_from_the_top_with_a_dead_middle() {
// Eight slices: up, up-right, right... each centred on its direction.
assert_eq!(slice_at(0.0, -60.0, 8), Some(0));
assert_eq!(slice_at(42.0, -42.0, 8), Some(1));
assert_eq!(slice_at(60.0, 0.0, 8), Some(2));
assert_eq!(slice_at(0.0, 60.0, 8), Some(4));
assert_eq!(slice_at(-60.0, 0.0, 8), Some(6));
// Just left of up is still the top slice; a bit more is the last.
assert_eq!(slice_at(-10.0, -60.0, 8), Some(0));
assert_eq!(slice_at(-35.0, -60.0, 8), Some(7));
// The middle and far away: nothing. No emotes: nothing.
assert_eq!(slice_at(5.0, 5.0, 8), None);
assert_eq!(slice_at(0.0, -400.0, 8), None);
assert_eq!(slice_at(0.0, -60.0, 0), None);
// Two: top and bottom halves.
assert_eq!(slice_at(50.0, -10.0, 2), Some(0));
assert_eq!(slice_at(50.0, 10.0, 2), Some(1));
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Emotes ([docs/cosmetics.md](../../../../docs/cosmetics.md)): short animations of the player
//! model, played from the emote wheel. Visual only: the game protocol carries nothing, a game
//! server sees a player standing still. Moving, attacking, using an item, being hurt, riding,
//! sleeping, gliding or sneaking cancels one; the camera pulls back to the front view while it
//! plays (from first person only) and comes back when it ends.
use super::Game;
use super::perspective::CameraType;
use horizon_cosmetics::{Item, Playing, Slot};
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
const DRIFT: f64 = 0.01;
#[derive(Default)]
pub(super) struct Emotes {
/// The emotes the wheel offers, in order.
pub(super) owned: Vec<Arc<Item>>,
/// Their names in the player's language, by id (the catalogue's), when known.
pub(super) names: std::collections::HashMap<String, String>,
/// `owned` was read from `HORIZON_COSMETICS_LOCAL`'s folder.
local_read: bool,
playing: Option<Playing>,
/// The camera the emote pulled back from, put back when it ends (unless the player chose
/// another meanwhile).
camera_from: Option<CameraType>,
/// Where the player stood when it started.
from: Option<horizon_core::geometry::Vec3>,
}
/// The emotes among a folder's item files, by id.
fn read_folder(dir: &std::path::Path) -> Vec<Arc<Item>> {
let mut emotes: Vec<Arc<Item>> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| Item::parse(&std::fs::read(e.path()).ok()?).ok())
.filter(|i| i.slot == Slot::Emote)
.map(Arc::new)
.collect();
emotes.sort_by(|a, b| a.id.cmp(&b.id));
emotes
}
impl Game<'_> {
/// The emotes the wheel offers: a local folder's (`HORIZON_COSMETICS_LOCAL`), else those the
/// account owns.
pub(super) fn owned_emotes(&mut self) -> &[Arc<Item>] {
if !self.emotes.local_read {
self.emotes.local_read = true;
if let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") {
self.emotes.owned = read_folder(std::path::Path::new(&dir));
}
}
&self.emotes.owned
}
/// An emote's name: the catalogue's, else its id in words (`wave_hello`: "Wave hello").
pub(super) fn emote_name(&self, id: &str) -> String {
if let Some(name) = self.emotes.names.get(id) {
return name.clone();
}
let words = id.replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map_or_else(String::new, |c| c.to_uppercase().chain(chars).collect())
}
/// Whether the player could start an emote now: in a world, standing, alive, not a spectator.
fn may_emote(&self) -> bool {
let Some(p) = self.player.as_ref() else {
return false;
};
horizon_cosmetics::enabled()
&& self.player_model.is_some()
&& !p.health.dead_or_dying()
&& !p.fall_flying
&& p.pose == horizon_client::pose::Pose::Standing
&& self.riding.is_none()
&& self.sleeping.is_none()
&& !self.spectator()
}
/// Plays the owned emote `id` (again from its start if it plays already). False if it isn't
/// owned or the player can't emote now.
pub(super) fn play_emote(&mut self, id: &str) -> bool {
let Some(item) = self.owned_emotes().iter().find(|i| i.id == id).cloned() else {
return false;
};
if !self.may_emote() {
return false;
}
let (Some(m), Some(p)) = (self.player_model.as_ref(), self.player.as_ref()) else {
return false;
};
let start = m.age as f32;
self.emotes.from = Some(p.position);
self.emotes.playing = Some(Playing {
item,
start,
stop: None,
});
if self.camera_type.first_person() {
self.emotes.camera_from = Some(self.camera_type);
self.camera_type = CameraType::ThirdPersonFront;
self.check_entity_post_effect(false);
}
true
}
/// Stops the emote playing, fading it out.
pub(super) fn stop_emote(&mut self) {
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if let Some(p) = self.emotes.playing.as_mut()
&& p.stop.is_none()
{
p.stop = Some(now);
}
}
/// The emote playing, if any (its id).
pub(super) fn emote_playing(&self) -> Option<&str> {
let p = self.emotes.playing.as_ref()?;
p.stop.is_none().then_some(p.item.id.as_str())
}
/// Once a tick after the player's model: cancels the emote on movement or action, ends it,
/// puts the camera back, and hands it to the model.
fn tick_emote(&mut self) {
if self.emotes.playing.is_none() {
if let Some(m) = self.player_model.as_mut() {
m.emote = None;
}
return;
}
let moved = match (self.player.as_ref(), self.emotes.from) {
(Some(p), Some(from)) => {
let (dx, dy, dz) = (
p.position.x - from.x,
p.position.y - from.y,
p.position.z - from.z,
);
dx * dx + dy * dy + dz * dz > DRIFT * DRIFT
}
_ => true,
};
let hurt = self
.player
.as_ref()
.is_some_and(|p| p.health.hurt_animation_time > 0);
if moved || hurt || self.swing.swinging() || self.using.is_some() || !self.may_emote() {
self.stop_emote();
}
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if self
.emotes
.playing
.as_ref()
.and_then(|p| p.at(now))
.is_none()
{
self.emotes.playing = None;
self.emotes.from = None;
// The pulled-back view goes back to first person, unless the player turned it.
if let Some(from) = self.emotes.camera_from.take()
&& self.camera_type == CameraType::ThirdPersonFront
{
self.camera_type = from;
self.check_entity_post_effect(from.first_person());
}
}
if let Some(m) = self.player_model.as_mut() {
m.emote = self.emotes.playing.clone();
}
}
/// Once a tick: my emote, then the other players'.
pub(super) fn tick_emotes(&mut self) {
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(super) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
@@ -259,6 +259,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/emotes.rs`).
let pull = self.emote_camera_pull(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -287,7 +289,7 @@ impl Game<'_> {
partial,
kind,
|distance| {
let mut distance = distance;
let mut distance = distance * pull;
self.native_camera_distance(partial, kind.is_mirrored(), &entity, &mut distance);
distance
},
+1
View File
@@ -6394,6 +6394,7 @@ impl Game<'_> {
}
// The player's own model (third person, the inventory's preview).
self.tick_player_model();
self.tick_emotes();
if !dead {
self.remote_tick_using();
}
@@ -30,6 +30,7 @@ impl Screen {
| Self::DebugOptions => true,
Self::Playing
| Self::GameModeSwitcher
| Self::EmoteWheel
| Self::Chat
| Self::Creative
| Self::Inventory
+4
View File
@@ -1442,6 +1442,9 @@ pub struct Mob {
/// `PlayerSkin.cape`: a player's cape texture, once every download of
/// its skin is ready.
pub cape: Option<String>,
/// A Horizon emote this player plays (docs/cosmetics.md), timed on `age`: it poses the bones
/// after the vanilla animation (`anim::setup`). Visual only.
pub emote: Option<horizon_cosmetics::Playing>,
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
@@ -3072,6 +3075,7 @@ impl Mob {
slim: false,
skin_3d: None,
cape: None,
emote: None,
profile: None,
random_textures: Default::default(),
attrs: Attrs {
+90 -1
View File
@@ -759,12 +759,49 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// Apply the mob's model animation to `p`.
/// Apply the mob's model animation to `p` (at rest), then the emote it plays, if any.
pub fn setup(
m: &Mob,
s: &State,
p: &mut Poses,
w_anims: &HashMap<String, super::keyframes::Animation>,
) {
let Some(playing) = &m.emote else {
return vanilla_setup(m, s, p, w_anims);
};
let rest = horizon_cosmetics::BODY.map(|b| p.has(b.part()).then(|| *p.get(b.part())));
vanilla_setup(m, s, p, w_anims);
emote(playing, s.age, rest, p);
}
/// Poses the bones `rest` names (those the model has; at rest) as `playing` has them at `age`.
fn emote(
playing: &horizon_cosmetics::Playing,
age: f32,
rest: [Option<[f32; 9]>; 6],
p: &mut Poses,
) {
let (Some(emote), Some((t, weight))) = (&playing.item.emote, playing.at(age)) else {
return;
};
const IDENTITY: [f32; 9] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let bones = horizon_cosmetics::BODY;
let at_rest = rest.map(|r| r.unwrap_or(IDENTITY));
let mut poses: [[f32; 9]; 6] =
std::array::from_fn(|i| rest[i].map_or(IDENTITY, |_| *p.get(bones[i].part())));
emote.pose_body(t, weight, &at_rest, &mut poses);
for (i, pose) in poses.into_iter().enumerate() {
if rest[i].is_some() {
*p.get(bones[i].part()) = pose;
}
}
}
fn vanilla_setup(
m: &Mob,
s: &State,
p: &mut Poses,
w_anims: &HashMap<String, super::keyframes::Animation>,
) {
if super::body_anim::anim(m, s, p, w_anims) {
return;
@@ -1088,6 +1125,58 @@ fn player_model_visibility(p: &mut Poses, hidden: u8, spectator: bool) {
p.visible(part, hidden & (1 << bit) == 0);
}
}
#[cfg(test)]
mod emote_tests {
use super::*;
#[test]
fn an_emote_poses_the_bones_the_model_has_after_its_animation() {
// The right arm up to 90° about z at tick 10, the body hopping 4 px; the model has no legs.
let item = horizon_cosmetics::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]}]},
{"part": "root", "keys": [{"t": 0, "offset": [0, -4, 0]}]}]}}"#,
)
.unwrap();
let rest = |x: f32, y: f32| [x, y, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let mut p = Poses {
map: [("head", rest(0.0, 0.0)), ("right_arm", rest(-5.0, 2.0))]
.into_iter()
.map(|(n, pose)| (key(n), pose))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let at_rest = horizon_cosmetics::BODY.map(|b| p.has(b.part()).then(|| *p.get(b.part())));
// What the walk animation did: the head looks aside, the arm swings.
p.get("head")[4] = 0.4;
p.get("right_arm")[3] = 0.7;
let playing = horizon_cosmetics::Playing {
item: std::sync::Arc::new(item),
start: 100.0,
stop: None,
};
emote(&playing, 110.0, at_rest, &mut p);
let arm = *p.get("right_arm");
assert!(
(arm[5] - std::f32::consts::FRAC_PI_2).abs() < 1e-5 && arm[3].abs() < 1e-5,
"{arm:?}"
);
assert!((arm[0] + 5.0).abs() < 1e-5 && (arm[1] - (2.0 - 4.0)).abs() < 1e-5);
// The head keeps looking aside, lifted with the body.
let head = *p.get("head");
assert!((head[4] - 0.4).abs() < 1e-5 && (head[1] + 4.0).abs() < 1e-5);
// Nothing was added for the bones the model lacks, and past its end the emote is gone.
assert_eq!(p.map.len(), 2);
let before = *p.get("right_arm");
emote(&playing, 125.0, at_rest, &mut p);
assert_eq!(*p.get("right_arm"), before);
}
}
#[cfg(test)]
mod flight_pose_tests {
use super::*;
+20 -1
View File
@@ -124,6 +124,24 @@ Limits, checked by `Item::parse` with the item's own (vertices, parts, texture):
`Emote::pose_body` poses the six bones at a time and weight (the blend), `Emote::pose_props` the
props; `Playing` is an emote someone plays (its start, its cancel) and gives its time and weight.
### Playing an emote (`game/emotes.rs`, `game/emote_wheel.rs`)
- **The wheel**: *Emote Wheel* (`key.emote`, **B** by default: no vanilla key uses it; Controls
rebinds it, and so does the controller's page, with no button by default: the stick's cursor
picks a slice). Up to 8 emotes around the middle of the screen, the game running behind. Hold
the key, point at one and let go; or tap the key and click one, or press its number (1–8).
Escape or the key again closes it. The wheel lists the emotes the account owns
(`HORIZON_COSMETICS_LOCAL`: the folder's).
- **Cancelled** by moving more than a hundredth of a block, attacking or mining, using an item,
being hurt, riding, sleeping, gliding, sneaking, swimming or dying: it fades out over its blend.
Only a standing player starts one. A loop plays until cancelled.
- **The camera**: from first person it turns to the front view (third person, facing you) for
the emote and eases out from close to its usual distance over a quarter second; when the emote
ends it goes back to first person, unless you changed the view meanwhile. From a third person
view it stays as it is.
- **Visual only**: it changes nothing of the game; the game server receives nothing and sees a
player standing still. `horizon-ctl emote [ID|stop]` plays one as the wheel does.
## What other players wear (`horizon_cosmetics::Cosmetics`)
The game asks Horizon's cosmetics service (HTTPS, JSON) what the players in view wear, and draws
@@ -176,7 +194,8 @@ same cached `/v1/worn` answers as what they wear (no request of its own, nothing
everything off for one run (no thread, no request, no setting, no drawing), `=1` on;
`HORIZON_COSMETICS_API=<url>` points the game at another service, and
`HORIZON_COSMETICS_LOCAL=<folder>` reads items from a folder instead: every player and every
mannequin wears its first item of each slot (the art and the bench, without the network).
mannequin wears its first item of each slot, and every mannequin plays the folder's first
emote over and over (the art and the bench, without the network).
- **Settings** (Extras, under "Cosmetics", shown only with the flag on), the same three as the
launcher's Wardrobe (`horizon_cosmetics::prefs`, lines of `horizon-extras.txt`):
- *My cosmetics: Shown / Hidden* is the account's (`PUT /v1/visible`): hidden, every Horizon