mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
44c14af45a
commit
2fed27fce9
21 files changed
+652
-8
No files matched your search
Generated
+1
@@ -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"),
|
||||
(
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user