Merge remote-tracking branch 'upstream/main' into HEAD
This commit is contained in:
@@ -567,7 +567,9 @@ fn print_heart(
|
||||
/// The damage of a mob's direct hit on the player (others count as none).
|
||||
fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -578,7 +580,9 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
return false;
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
|
||||
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
)),
|
||||
MotionEvent::Emission {
|
||||
state,
|
||||
in_water: true,
|
||||
muffled: true,
|
||||
..
|
||||
} => Some(format!("muffled-step {}", state.0)),
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! conditions advancements use).
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
@@ -753,6 +753,41 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
|
||||
/// victim condition matched by a distinct victim, and the count of entity types.
|
||||
pub fn killed_by_arrow(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
|
||||
Entities {
|
||||
this: Some(e),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
}
|
||||
}
|
||||
if !conditions(&c["player"], &ctx(me, me), w) {
|
||||
return false;
|
||||
}
|
||||
if !c["fired_from_weapon"].is_null()
|
||||
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut left: Vec<&EntityView> = victims.iter().collect();
|
||||
for want in c["victims"].as_array().into_iter().flatten() {
|
||||
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
|
||||
return false;
|
||||
};
|
||||
left.remove(i);
|
||||
}
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
|
||||
@@ -159,6 +159,8 @@ pub struct ArrowState {
|
||||
pub pickup: Pickup,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
pub fired_from_weapon: Option<crate::items::ItemStack>,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
@@ -208,6 +210,7 @@ impl ArrowState {
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
|
||||
@@ -211,6 +211,17 @@ impl Contents {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
|
||||
/// (width, RGB), blue until the bundle is full, then red.
|
||||
pub fn bar(&self) -> Option<(i32, u32)> {
|
||||
if self.weight.n <= 0 {
|
||||
return None;
|
||||
}
|
||||
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
|
||||
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
|
||||
Some((width, color))
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
let data = bundle.data_mut();
|
||||
|
||||
@@ -36,6 +36,7 @@ mod consume;
|
||||
mod control;
|
||||
mod copper_golems;
|
||||
mod creaking;
|
||||
mod creative_hotbar;
|
||||
mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
@@ -1493,6 +1494,9 @@ struct Game<'a> {
|
||||
armor: [Option<ItemStack>; 4],
|
||||
/// The off hand (Inventory slot 40, saved as slot 150; menu slot 504 here).
|
||||
offhand: Option<ItemStack>,
|
||||
/// `ItemStack.popTime` of the nine hotbar stacks, then the off hand: set to 5
|
||||
/// when a slot gains items, counted down each tick.
|
||||
hotbar_pop: [u8; 10],
|
||||
/// The hand an item use acts with (`InteractionHand`): the off hand only
|
||||
/// for its turn in `use_item` and while its item is being used.
|
||||
hand: Hand,
|
||||
@@ -4927,15 +4931,13 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
|
||||
MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} => {
|
||||
MotionEvent::Emission { state, muffled, .. } => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Entity.playStepSound: the block's step sound at 0.15 volume,
|
||||
// muffled under the swim sound in water. Whether it plays is
|
||||
// Player.getMovementEmission's call (shift on the ground),
|
||||
// not the pose: a forced crouch still steps.
|
||||
let (volume, pitch) = if in_water {
|
||||
let (volume, pitch) = if muffled {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
(sounds.volume * 0.15, sounds.pitch)
|
||||
@@ -5562,7 +5564,10 @@ impl Game<'_> {
|
||||
let decorations = hud_overlays::ItemDecorations {
|
||||
count: stack.count,
|
||||
count_text,
|
||||
bar: hud_overlays::damage_bar(stack.damage as i32, max_damage),
|
||||
// BundleItem overrides the bar with its fullness.
|
||||
bar: horizon_client::bundle::Contents::of(self.res.items(), stack)
|
||||
.map(|c| c.bar())
|
||||
.unwrap_or_else(|| hud_overlays::damage_bar(stack.damage as i32, max_damage)),
|
||||
cooldown: self.cooldowns.percent(&cooldown, partial),
|
||||
};
|
||||
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
|
||||
@@ -5667,12 +5672,17 @@ impl Game<'_> {
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let inputs = hud_layout::HotbarInputs {
|
||||
selected: self.selected as i32,
|
||||
right_handed: !self.settings.main_hand_left,
|
||||
offhand: self.offhand.is_some(),
|
||||
slots: std::array::from_fn(|i| self.inventory[i].as_ref().map(|_| 0.0)),
|
||||
offhand_pop: 0.0,
|
||||
slots: std::array::from_fn(|i| {
|
||||
self.inventory[i]
|
||||
.as_ref()
|
||||
.map(|_| (f32::from(self.hotbar_pop[i]) - partial).max(0.0))
|
||||
}),
|
||||
offhand_pop: (f32::from(self.hotbar_pop[9]) - partial).max(0.0),
|
||||
attack: (indicator == 2).then_some(strength),
|
||||
};
|
||||
let draws = hud_layout::hotbar(wi, hi, &inputs);
|
||||
@@ -5759,17 +5769,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
hud_layout::HeartKind::Normal
|
||||
};
|
||||
let eye = p.position.y + f64::from(p.dimensions.eye_height);
|
||||
let under_water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
p.position.x.floor() as i32,
|
||||
eye.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
// Hud.extractAirBubbles: `isEyeInFluidMatching`, so the eyes are below
|
||||
// the surface, not merely in the water block.
|
||||
let under_water = p.is_eye_in_fluid(horizon_client::motion::WATER);
|
||||
let inputs = hud_layout::HealthInputs {
|
||||
tick: self.ticks as i32,
|
||||
millis: self.started.elapsed().as_millis() as i64,
|
||||
@@ -10655,7 +10657,9 @@ impl Game<'_> {
|
||||
fn tick_sleep(&mut self) {
|
||||
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
if let Some(head) = self.sleeping {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft)
|
||||
// Vanilla has no Shift wake-up: the chat screen's capitals must
|
||||
// not get the sleeper up.
|
||||
if (self.screen != Screen::Chat && self.keys.contains(&KeyCode::ShiftLeft))
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -11831,7 +11835,7 @@ impl Game<'_> {
|
||||
| KeyCode::Digit9
|
||||
if self.screen == Screen::Playing =>
|
||||
{
|
||||
self.selected = match key {
|
||||
let slot = match key {
|
||||
KeyCode::Digit1 => 0,
|
||||
KeyCode::Digit2 => 1,
|
||||
KeyCode::Digit3 => 2,
|
||||
@@ -11842,6 +11846,15 @@ impl Game<'_> {
|
||||
KeyCode::Digit8 => 7,
|
||||
_ => 8,
|
||||
};
|
||||
// Minecraft.handleKeybinds: a creative player holding the load
|
||||
// or save toolbar key loads or saves a toolbar instead (load
|
||||
// first when both are held).
|
||||
let load = self.mapped_key_down("key.loadToolbarActivator");
|
||||
if self.creative && (load || self.mapped_key_down("key.saveToolbarActivator")) {
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.selected = slot;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -13378,6 +13391,7 @@ impl<'a> Game<'a> {
|
||||
title: None,
|
||||
armor: [None, None, None, None],
|
||||
offhand: None,
|
||||
hotbar_pop: [0; 10],
|
||||
hand: Hand::Main,
|
||||
shield_cooldown: 0,
|
||||
cooldowns: Default::default(),
|
||||
|
||||
@@ -719,6 +719,19 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
pub(super) fn trigger_killed_by_arrow(
|
||||
&mut self,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemStack>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let victims = victims.to_vec();
|
||||
let weapon = weapon.map(item_view);
|
||||
self.trigger("minecraft:killed_by_arrow", move |c, w| {
|
||||
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
|
||||
});
|
||||
}
|
||||
/// `LevitationTrigger`: how far the player rose from where it started
|
||||
/// levitating, and for how long.
|
||||
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
|
||||
|
||||
@@ -1144,7 +1144,12 @@ impl Game<'_> {
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
// BookSignScreen: `owner.getName()`, the account's name on a server.
|
||||
let owner = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
atlas,
|
||||
|
||||
@@ -81,6 +81,8 @@ pub struct UiState {
|
||||
pub chat_screen: Option<ChatScreen>,
|
||||
/// When the chat screen opened (its input took the focus: the cursor blink).
|
||||
pub chat_opened: Option<std::time::Instant>,
|
||||
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
|
||||
pub in_bed_chat: bool,
|
||||
pub registries: Registries,
|
||||
pub rules: GameRules,
|
||||
pub clock: WorldClock,
|
||||
@@ -452,6 +454,12 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_chat(&mut self) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
if self.screen == Screen::Chat {
|
||||
@@ -459,6 +467,35 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
pub(super) fn tick_in_bed_chat(&mut self) {
|
||||
if self.screen != Screen::Chat {
|
||||
self.ui.in_bed_chat = false;
|
||||
}
|
||||
if self.ui.in_bed_chat {
|
||||
if self.sleeping.is_none() {
|
||||
self.ui.in_bed_chat = false;
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat();
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
}
|
||||
}
|
||||
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
|
||||
self.open_chat("");
|
||||
self.ui.in_bed_chat = self.screen == Screen::Chat;
|
||||
}
|
||||
}
|
||||
/// The Leave Bed button of `InBedChatScreen`: its bounds.
|
||||
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
|
||||
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
@@ -571,7 +608,15 @@ impl Game<'_> {
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => self.close_chat(),
|
||||
ChatAction::Submit(message) => {
|
||||
self.close_chat();
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
// false): the box empties and the screen stays.
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
screen.suggest_command(&m, &c, "");
|
||||
self.ui.chat.reset_scroll();
|
||||
self.ui.chat_screen = Some(screen);
|
||||
} else {
|
||||
self.close_chat();
|
||||
}
|
||||
if !message.is_empty() {
|
||||
self.handle_chat_input(&message);
|
||||
}
|
||||
@@ -673,7 +718,8 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
|
||||
let shift = self.has_shift_down();
|
||||
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
|
||||
self.ui.chat.scroll_by(amount as i32, true);
|
||||
}
|
||||
@@ -687,6 +733,16 @@ impl Game<'_> {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let width = self.renderer.as_ref().map_or(1280.0, |r| {
|
||||
HudBatch::new([r.config.width, r.config.height]).width
|
||||
});
|
||||
let (x, y, w, h) = Self::leave_bed_button(width, height);
|
||||
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
|
||||
return;
|
||||
@@ -815,6 +871,26 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
"Leave Bed",
|
||||
));
|
||||
let mut button = Vec::new();
|
||||
horizon_client::screen_draw::button(
|
||||
&mut button,
|
||||
&m,
|
||||
&label,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
true,
|
||||
horizon_client::screen_draw::inside(mouse, x, y, w, h),
|
||||
);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
|
||||
}
|
||||
// The hovered message part's tooltip, one row per line of its text.
|
||||
if let Some(hover) = self.ui.chat.hover_at(
|
||||
&self.gui.atlas.font,
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(super) struct CombatTracker {
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
pub(super) player_attacker: Option<([f64; 3], f32)>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
|
||||
@@ -2210,7 +2210,8 @@ impl Game<'_> {
|
||||
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
|
||||
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
|
||||
let moving = ctx.has("move");
|
||||
if !ctx.has("force") && overlap {
|
||||
// Mode.canOverlap: only the normal mode refuses overlapping regions.
|
||||
if !ctx.has("force") && !moving && overlap {
|
||||
return Err(err("commands.clone.overlap", &[]));
|
||||
}
|
||||
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
|
||||
@@ -2419,26 +2420,54 @@ impl Game<'_> {
|
||||
let levels = ctx.has("levels");
|
||||
let unit = if levels { "levels" } else { "points" };
|
||||
if ctx.has("query") {
|
||||
let _ = self.entities(ctx, "target", true)?;
|
||||
let e = &self.experience;
|
||||
let value = if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
|
||||
};
|
||||
let target = self.entities(ctx, "target", true)?[0];
|
||||
// ExperienceCommand.queryExperience: the target's own level or points.
|
||||
let value = self
|
||||
.for_player(target, |g| {
|
||||
let e = &g.experience;
|
||||
if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
|
||||
as i32
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
self.system_message(Text::translate(
|
||||
&format!("commands.experience.query.{unit}"),
|
||||
&[
|
||||
Text::literal(self.acting_player_name()),
|
||||
Text::literal(value.to_string()),
|
||||
],
|
||||
&[self.target_name(target), Text::literal(value.to_string())],
|
||||
));
|
||||
return Ok(value);
|
||||
}
|
||||
let targets = self.entities(ctx, "target", true)?;
|
||||
let amount = Self::int(ctx, "amount").unwrap_or(0);
|
||||
let op = if ctx.has("add") { "add" } else { "set" };
|
||||
if op == "add" {
|
||||
let add = ctx.has("add");
|
||||
let op = if add { "add" } else { "set" };
|
||||
// Every target changes (a set of points can be refused for some).
|
||||
let changed: Vec<Target> = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&t| {
|
||||
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
}
|
||||
let (key, who) = match changed[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(changed.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.experience.{op}.{unit}.success.{key}"),
|
||||
&[Text::literal(amount.to_string()), who],
|
||||
));
|
||||
Ok(changed.len() as i32)
|
||||
}
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
if self.experience.level == 0 && amount < 0 {
|
||||
@@ -2452,26 +2481,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
let needed = horizon_client::entities::Experience::needed(self.experience.level);
|
||||
if amount > needed - 1 {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
return false;
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
let key = format!(
|
||||
"commands.experience.{op}.{unit}.success.{}",
|
||||
if targets.len() == 1 {
|
||||
"single"
|
||||
} else {
|
||||
"multiple"
|
||||
}
|
||||
);
|
||||
self.feedback(Text::translate(
|
||||
&key,
|
||||
&[
|
||||
Text::literal(amount.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(targets.len() as i32)
|
||||
true
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
@@ -2556,21 +2570,26 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
// SetSpawnCommand: a forced point in the source's level, for each target.
|
||||
let level = self.dim;
|
||||
for &t in &targets {
|
||||
self.for_player(t, |g| {
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
g.respawn_config = None;
|
||||
} else {
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
@@ -2750,6 +2769,44 @@ impl Game<'_> {
|
||||
},
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// ClearInventoryCommands.clearInventory: each player on their own.
|
||||
let mut removed = 0i32;
|
||||
let mut found = Vec::new();
|
||||
for &t in &targets {
|
||||
let n = self
|
||||
.for_player(t, |g| g.clear_matching(&predicate, max))
|
||||
.unwrap_or(0);
|
||||
removed += n;
|
||||
if n != 0 {
|
||||
found.push(t);
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[self.target_name(targets[0])],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
let (key, who) = match found[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(found.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.{key}"),
|
||||
&[Text::literal(removed.to_string()), who],
|
||||
));
|
||||
Ok(removed)
|
||||
}
|
||||
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
|
||||
/// of the matching items go (all when negative, none and only counted
|
||||
/// when 0). Returns how many matched.
|
||||
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
|
||||
let counting = max == 0;
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
@@ -2765,10 +2822,7 @@ impl Game<'_> {
|
||||
};
|
||||
let matching: Vec<bool> = all(self)
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| self.stack_matches(&predicate, s))
|
||||
})
|
||||
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
@@ -2798,25 +2852,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[Text::literal(self.acting_player_name())],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.single"),
|
||||
&[
|
||||
Text::literal(removed.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(removed)
|
||||
removed
|
||||
}
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
|
||||
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
|
||||
use super::Game;
|
||||
use horizon_client::{hotbar, keybinds, text::Text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
|
||||
/// `index`: load puts the saved row in the hotbar (the creative slot
|
||||
/// packets follow from the inventory change), save stores the hotbar and
|
||||
/// says so on the action bar.
|
||||
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let mut rows = hotbar::load(&path, &known);
|
||||
if load {
|
||||
for (slot, stack) in rows[index].iter().enumerate() {
|
||||
self.inventory[slot] = stack.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
|
||||
let _ = hotbar::save(&path, &rows);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
|
||||
let message = Text::translate(
|
||||
"inventory.hotbarSaved",
|
||||
&[
|
||||
key("key.loadToolbarActivator"),
|
||||
key(&format!("key.hotbar.{}", index + 1)),
|
||||
],
|
||||
);
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
@@ -1057,6 +1057,18 @@ impl Game<'_> {
|
||||
MobEvent::AttackPlayer(amount, from) => {
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
}
|
||||
// doHurtTarget: the effect only when hurtServer took damage.
|
||||
MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
} => {
|
||||
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
|
||||
self.give_player_effect(effect, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
MobEvent::DamagePlayer { amount, from, kind } => {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
@@ -2674,6 +2686,17 @@ impl Game<'_> {
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
|
||||
}
|
||||
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
|
||||
// (piercing arrows are not modelled).
|
||||
if hurt
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
&& !m.alive()
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let weapon =
|
||||
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
|
||||
self.trigger_killed_by_arrow(&[victim], weapon);
|
||||
}
|
||||
}
|
||||
hurt
|
||||
}
|
||||
@@ -3269,8 +3292,9 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&item)
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map_or(2.0, |f| f.0 as f32);
|
||||
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
|
||||
.map_or(2.0, |f| f.0 as f32 * 2.0);
|
||||
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
|
||||
// then playEatingSound, which wolves leave empty.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
consume = true;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ impl Game<'_> {
|
||||
if let Some(a) = arrow.arrow.as_mut() {
|
||||
a.set_item(ammo);
|
||||
a.set_owner(Some(Who::Player));
|
||||
a.fired_from_weapon = Some(weapon.clone());
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
@@ -235,6 +236,11 @@ impl Game<'_> {
|
||||
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
|
||||
self.damage_held(if rocket { 3 } else { 1 });
|
||||
}
|
||||
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
|
||||
// is an empty stack (no match) when the shots broke it.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.trigger_item("minecraft:shot_crossbow", &stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ impl Game<'_> {
|
||||
if on { "visible" } else { "hidden" }
|
||||
)
|
||||
};
|
||||
let remote = self.remote.is_some();
|
||||
// `DebugScreenOverlay`: the single-player variants for a local server.
|
||||
let remote = !self.has_singleplayer_server();
|
||||
let c = &self.debug_charts;
|
||||
[
|
||||
format!(
|
||||
@@ -463,7 +464,7 @@ impl Game<'_> {
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
self.remote.is_some(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
|
||||
@@ -1465,7 +1465,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
|
||||
if d2 < 4096.0 {
|
||||
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
|
||||
// its 8 block height), blocks only.
|
||||
let sees = d2 < 4096.0 && {
|
||||
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
|
||||
let target_eye =
|
||||
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
|
||||
self.mob_world_base(&[])
|
||||
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
|
||||
};
|
||||
if sees {
|
||||
*charge += 1;
|
||||
let d = self.dragon.as_ref().expect("dragon");
|
||||
let r = d.yaw.to_radians();
|
||||
|
||||
@@ -1932,6 +1932,14 @@ impl Game<'_> {
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::DamagePlayer { .. }
|
||||
| MobEvent::Push {
|
||||
target: Who::Player,
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(super) struct Strike {
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
/// `getSecondsToDisableBlocking` of the attacker's weapon.
|
||||
disable_blocking: f32,
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
/// yaw), and whether the hit is a sprinting knockback attack.
|
||||
@@ -129,6 +131,14 @@ impl Game<'_> {
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
|
||||
// none while another item is in use.
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
};
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
amount,
|
||||
@@ -136,6 +146,7 @@ impl Game<'_> {
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
disable_blocking,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
knockback_attack,
|
||||
@@ -250,7 +261,9 @@ impl Game<'_> {
|
||||
// Player.attack: the hurt (with its knockback away from the
|
||||
// attacker), then causeExtraKnockback along the attacker's yaw.
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
|
||||
@@ -868,9 +868,8 @@ impl Game<'_> {
|
||||
(d * 2.0 + 0.5) as i8
|
||||
}
|
||||
};
|
||||
let y = f64::from(yaw);
|
||||
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
|
||||
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
|
||||
// calculateRotation: the arrow spins in the Nether.
|
||||
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
|
||||
let sprite = if inside {
|
||||
Some("map/decorations/player")
|
||||
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
|
||||
|
||||
@@ -1215,9 +1215,28 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row.
|
||||
/// `Screen.showsActiveEffects`: the survival and creative inventories
|
||||
/// draw their own effect list when `EffectsInInventory.canSeeEffects`
|
||||
/// (32 or more pixels right of the screen).
|
||||
fn screen_shows_active_effects(&self, batch: &HudBatch) -> bool {
|
||||
let (ox, image_width) = match self.screen {
|
||||
Screen::Inventory
|
||||
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() =>
|
||||
{
|
||||
(self.inventory_origin(batch).0, 176)
|
||||
}
|
||||
Screen::Creative => (
|
||||
Self::creative_origin(batch).0,
|
||||
horizon_client::creative::Creative::WIDTH as i32,
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
batch.width as i32 - (ox as i32 + image_width + 2) >= 32
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row;
|
||||
/// every screen but the inventories keeps them.
|
||||
pub(super) fn draw_effect_icons(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
|
||||
if self.potions.effects.list.is_empty() || self.screen_shows_active_effects(batch) {
|
||||
return;
|
||||
}
|
||||
let icons: Vec<hud_overlays::EffectIcon> = self
|
||||
|
||||
@@ -821,14 +821,11 @@ impl Game<'_> {
|
||||
if !alive {
|
||||
continue;
|
||||
}
|
||||
// Raider.updateNoActionTime adds two per tick, and a raider
|
||||
// fighting a player or golem in a raid is never idle.
|
||||
if s.raid_id.is_some() {
|
||||
m.ai.no_action_time = if target_player || target_golem {
|
||||
0
|
||||
} else {
|
||||
m.ai.no_action_time + 1
|
||||
};
|
||||
// Raider.aiStep: a raider fighting a player or golem in a raid is
|
||||
// never idle. The per-tick growth (+1 serverAiStep, +2
|
||||
// Raider.updateNoActionTime) is added by the mob's own step.
|
||||
if s.raid_id.is_some() && (target_player || target_golem) {
|
||||
m.ai.no_action_time = 0;
|
||||
}
|
||||
// Raider.aiStep: join the raid the raider stands in.
|
||||
if s.raid_id.is_none()
|
||||
|
||||
@@ -2556,6 +2556,20 @@ impl Game<'_> {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
let stack = self.to_stack(&p.item);
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
// gains items squeezes for 5 ticks.
|
||||
if p.container == 0
|
||||
&& (36..=45).contains(&p.slot)
|
||||
&& let Some(new) = &stack
|
||||
{
|
||||
let (pop, old) = match p.slot {
|
||||
45 => (9, self.offhand.as_ref()),
|
||||
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
|
||||
};
|
||||
if old.is_none_or(|old| old.count < new.count) {
|
||||
self.hotbar_pop[pop] = 5;
|
||||
}
|
||||
}
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
}
|
||||
@@ -3448,6 +3462,10 @@ impl Game<'_> {
|
||||
// a client. Ticking it after the keybinds keeps a held use from
|
||||
// trying again a tick before the server's entry ends.
|
||||
self.cooldowns.tick();
|
||||
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
}
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
@@ -3590,12 +3608,10 @@ impl Game<'_> {
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
for event in events {
|
||||
match event {
|
||||
horizon_client::motion::MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} => {
|
||||
horizon_client::motion::MotionEvent::Emission { state, muffled, .. } => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Player.playStepSound: muffled under the swim sound in water.
|
||||
let (volume, pitch) = if in_water {
|
||||
let (volume, pitch) = if muffled {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
(sounds.volume * 0.15, sounds.pitch)
|
||||
|
||||
@@ -683,7 +683,7 @@ impl Game<'_> {
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let shown = tab_list::visible(
|
||||
key,
|
||||
self.remote.is_none(),
|
||||
self.has_singleplayer_server(),
|
||||
players.len(),
|
||||
objective.is_some(),
|
||||
);
|
||||
|
||||
@@ -176,7 +176,14 @@ impl Game<'_> {
|
||||
if projectile {
|
||||
return;
|
||||
}
|
||||
let Some(id) = attacker else { return };
|
||||
let Some(id) = attacker else {
|
||||
// Another player's melee hit has no mob behind it.
|
||||
if let Some((from, disable)) = self.combat.player_attacker {
|
||||
let fully = outcome.blocked >= damage;
|
||||
self.blocked_by_attacker(&component, [from[0], from[2]], fully, disable, at);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let fully = outcome.blocked >= damage;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
@@ -219,6 +226,21 @@ impl Game<'_> {
|
||||
let _ = self.handle_mob_events(events);
|
||||
return;
|
||||
}
|
||||
self.blocked_by_attacker(&component, [mx, mz], fully, disable, at);
|
||||
}
|
||||
|
||||
/// What a blocked melee hit does to the defender once the shield took
|
||||
/// it: `LivingEntity.blockedByItem` knocks them back unless fully
|
||||
/// blocked, then `Player.blockUsingItem` disables the shield.
|
||||
fn blocked_by_attacker(
|
||||
&mut self,
|
||||
component: &horizon_client::items::BlocksAttacks,
|
||||
attacker: [f64; 2],
|
||||
fully: bool,
|
||||
disable: f32,
|
||||
at: Option<[f64; 3]>,
|
||||
) {
|
||||
let [mx, mz] = attacker;
|
||||
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker),
|
||||
// which moves the defender along -(xd, zd).
|
||||
if !fully && let Some(p) = self.player.as_mut() {
|
||||
|
||||
@@ -1768,6 +1768,11 @@ impl Game<'_> {
|
||||
};
|
||||
let gray = rgba(170, 170, 170, 255);
|
||||
let item_info = self.res.items().get(&stack.item);
|
||||
// SmithingTemplateItem.appendHoverText: `Item.appendHoverText` comes
|
||||
// before the components.
|
||||
for (text, c) in horizon_client::item_tooltip::smithing_template_lines(stack, &translate) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// InstrumentComponent.addToTooltip.
|
||||
if let Some(text) =
|
||||
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
|
||||
@@ -2023,15 +2028,19 @@ impl Game<'_> {
|
||||
&& stack.damage > 0
|
||||
&& advanced
|
||||
{
|
||||
push(
|
||||
&mut lines,
|
||||
format!(
|
||||
"Durability: {} / {}",
|
||||
info.max_damage - stack.damage,
|
||||
info.max_damage
|
||||
// `item.durability`, in the selected language.
|
||||
lines.push((
|
||||
horizon_client::text::Text::translate(
|
||||
"item.durability",
|
||||
&[
|
||||
horizon_client::text::Text::literal(
|
||||
(info.max_damage - stack.damage).to_string(),
|
||||
),
|
||||
horizon_client::text::Text::literal(info.max_damage.to_string()),
|
||||
],
|
||||
),
|
||||
u32::MAX,
|
||||
);
|
||||
));
|
||||
}
|
||||
// ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id
|
||||
// and how many components the stack has.
|
||||
|
||||
@@ -378,9 +378,10 @@ impl Game<'_> {
|
||||
if pos[1] < 63 || !self.can_see_sky(pos) {
|
||||
return;
|
||||
}
|
||||
// DifficultyInstance.isHarderThan(random * 3) at normal difficulty.
|
||||
// `getCurrentDifficultyAt(playerPos).isHarderThan(random * 3)`.
|
||||
let difficulty = self.local_difficulty([position.x, position.y, position.z]);
|
||||
let r = &mut self.world.random;
|
||||
if r.next_float() * 3.0 >= 2.0 {
|
||||
if difficulty.effective <= r.next_float() * 3.0 {
|
||||
return;
|
||||
}
|
||||
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
|
||||
|
||||
@@ -62,6 +62,7 @@ impl Game<'_> {
|
||||
/// The title, action bar and selected-item parts of `Hud.tick`.
|
||||
pub(super) fn tick_hud(&mut self) {
|
||||
self.titles.tick();
|
||||
self.tick_in_bed_chat();
|
||||
// `MinecraftServer.isCurrentlySaving` of single player's server.
|
||||
let saving = self
|
||||
.integrated
|
||||
|
||||
@@ -119,3 +119,23 @@ pub fn load(path: &Path, known: &dyn Fn(&str) -> bool) -> Vec<Hotbar> {
|
||||
_ => (0..GROUPS).map(|_| empty()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `HotbarManager.save`: the nine rows with the current data version, each
|
||||
/// stack as `ItemStack.OPTIONAL_CODEC` writes it (an empty one is `{}`).
|
||||
pub fn save(path: &Path, rows: &[Hotbar]) -> crate::Result<()> {
|
||||
let mut entries = vec![(
|
||||
JavaString::from("DataVersion"),
|
||||
Tag::Int(horizon_world::DATA_VERSION),
|
||||
)];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let stacks = row.iter().map(|s| match s {
|
||||
Some(s) => save::item_to_nbt(s),
|
||||
None => Tag::compound(Vec::<(&str, Tag)>::new()),
|
||||
});
|
||||
entries.push((
|
||||
JavaString::from(i.to_string().as_str()),
|
||||
Tag::List(stacks.collect()),
|
||||
));
|
||||
}
|
||||
save::write_dat(path, Tag::compound(entries))
|
||||
}
|
||||
|
||||
@@ -484,6 +484,37 @@ pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
|
||||
keys.len()
|
||||
}
|
||||
|
||||
/// `SmithingTemplateItem.appendHoverText`: the template's kind, what it applies to
|
||||
/// and its ingredients (gray titles over blue descriptions); none for other items.
|
||||
pub fn smithing_template_lines(
|
||||
stack: &ItemStack,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let path = stack.item.trim_start_matches("minecraft:");
|
||||
if !path.ends_with("_smithing_template") {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
let kind = if path == "netherite_upgrade_smithing_template" {
|
||||
"netherite_upgrade"
|
||||
} else {
|
||||
"armor_trim"
|
||||
};
|
||||
let (title, description) = (rgba(170, 170, 170, 255), rgba(85, 85, 255, 255));
|
||||
let described = |what: &str| {
|
||||
let key = format!("item.minecraft.smithing_template.{kind}.{what}");
|
||||
(format!(" {}", text(&key)), description)
|
||||
};
|
||||
vec![
|
||||
(text("item.minecraft.smithing_template"), title),
|
||||
(String::new(), u32::MAX),
|
||||
(text("item.minecraft.smithing_template.applies_to"), title),
|
||||
described("applies_to"),
|
||||
(text("item.minecraft.smithing_template.ingredients"), title),
|
||||
described("ingredients"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The lines of `stack` below its name that its components give, in
|
||||
/// `ItemStack.addDetailsToTooltip` order.
|
||||
pub fn detail_lines(
|
||||
@@ -494,30 +525,11 @@ pub fn detail_lines(
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
let path = stack.item.trim_start_matches("minecraft:");
|
||||
let mut lines = Vec::new();
|
||||
// SmithingTemplateItem.appendHoverText.
|
||||
if path.ends_with("_smithing_template") {
|
||||
let kind = if path == "netherite_upgrade_smithing_template" {
|
||||
"netherite_upgrade"
|
||||
} else {
|
||||
"armor_trim"
|
||||
};
|
||||
lines.push(text("item.minecraft.smithing_template"));
|
||||
lines.push(String::new());
|
||||
lines.push(text("item.minecraft.smithing_template.applies_to"));
|
||||
lines.push(format!(
|
||||
" {}",
|
||||
text(&format!(
|
||||
"item.minecraft.smithing_template.{kind}.applies_to"
|
||||
))
|
||||
));
|
||||
lines.push(text("item.minecraft.smithing_template.ingredients"));
|
||||
lines.push(format!(
|
||||
" {}",
|
||||
text(&format!(
|
||||
"item.minecraft.smithing_template.{kind}.ingredients"
|
||||
))
|
||||
));
|
||||
}
|
||||
lines.extend(
|
||||
smithing_template_lines(stack, translate)
|
||||
.into_iter()
|
||||
.map(|l| l.0),
|
||||
);
|
||||
let display = TooltipDisplay::for_stack(stack);
|
||||
lines.extend(bees_line(stack, &display, translate));
|
||||
lines.extend(honey_line(stack, &display, translate));
|
||||
|
||||
@@ -1011,6 +1011,16 @@ pub enum MobEvent {
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
},
|
||||
AttackPlayer(f32, [f64; 3]),
|
||||
/// A melee hit whose effect comes only if the hit lands (husk hunger,
|
||||
/// cave spider poison, wither skeleton wither, bee and pufferfish
|
||||
/// poison): a shield-blocked hit gives nothing.
|
||||
AttackPlayerWithEffect {
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
},
|
||||
/// Damage of a given type to the player (guardian lasers, thorns).
|
||||
DamagePlayer {
|
||||
amount: f32,
|
||||
@@ -1955,9 +1965,15 @@ impl Mob {
|
||||
amount *= 5.0;
|
||||
}
|
||||
let (worn, worn_toughness) = self.worn_armor();
|
||||
if !source.bypasses_armor() && self.kind.armor + worn > 0.0 {
|
||||
// Shulker.setRawPeekAmount: a closed shell adds the covered armor modifier.
|
||||
let covered = if self.species() == Species::Shulker && self.extra.counter == 0 {
|
||||
20.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
|
||||
// CombatRules.getDamageAfterAbsorb
|
||||
let armor = self.kind.armor + worn;
|
||||
let armor = self.kind.armor + worn + covered;
|
||||
let toughness = 2.0 + (self.kind.armor_toughness + worn_toughness) / 4.0;
|
||||
let effective = (armor - amount / toughness).clamp(armor * 0.2, 20.0);
|
||||
let mut fraction = effective / 25.0;
|
||||
|
||||
@@ -1602,6 +1602,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
let mut amount = m.attrs.attack_damage;
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
let source = Damage::mob(m.id, from);
|
||||
let first_event = cx.events.len();
|
||||
match m.species() {
|
||||
// CaveSpider.doHurtTarget: poison for 7 s on normal, 15 s on hard.
|
||||
Species::CaveSpider if who == Who::Player && cx.w.difficulty.base >= 2 => {
|
||||
@@ -1672,7 +1673,28 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
});
|
||||
}
|
||||
match who {
|
||||
Who::Player => cx.events.push(MobEvent::AttackPlayer(amount, from)),
|
||||
// The effect picked above goes with the hit: it applies only when
|
||||
// the player takes damage (a shield-blocked hit gives none).
|
||||
Who::Player => {
|
||||
let effect = cx.events[first_event..]
|
||||
.iter()
|
||||
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
|
||||
.map(|k| cx.events.remove(first_event + k));
|
||||
cx.events.push(match effect {
|
||||
Some(MobEvent::EffectPlayer {
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
}) => MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
},
|
||||
_ => MobEvent::AttackPlayer(amount, from),
|
||||
});
|
||||
}
|
||||
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
|
||||
target: id,
|
||||
amount,
|
||||
|
||||
@@ -244,11 +244,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& m.ai.no_action_time % 10 == 0
|
||||
{
|
||||
let amount = 1.0 + m.extra.size as f32;
|
||||
events.push(MobEvent::AttackPlayer(
|
||||
// Only when the sting hurt the player.
|
||||
events.push(MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
));
|
||||
events.push(MobEvent::EffectPlayer {
|
||||
from: [m.position.x, m.position.y, m.position.z],
|
||||
effect: "minecraft:poison",
|
||||
ticks: 60 * m.extra.size,
|
||||
amplifier: 0,
|
||||
|
||||
@@ -333,19 +333,23 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
let amount = (m.attrs.attack_damage as i32) as f32;
|
||||
match who {
|
||||
Who::Player => {
|
||||
cx.events.push(MobEvent::AttackPlayer(amount, from));
|
||||
// Poison only on normal (10 s) and hard (18 s).
|
||||
// Bee.doHurtTarget: the poison only when the sting hurt, and
|
||||
// only on normal (10 s) and hard (18 s).
|
||||
let seconds = match cx.w.difficulty.id {
|
||||
2 => 10,
|
||||
3 => 18,
|
||||
_ => 0,
|
||||
};
|
||||
if seconds > 0 {
|
||||
cx.events.push(MobEvent::EffectPlayer {
|
||||
cx.events.push(MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect: "minecraft:poison",
|
||||
ticks: seconds * 20,
|
||||
amplifier: 0,
|
||||
});
|
||||
} else {
|
||||
cx.events.push(MobEvent::AttackPlayer(amount, from));
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
|
||||
|
||||
@@ -13,18 +13,6 @@ use horizon_core::geometry::trig::Mth;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// The species raiders do not fight (`Raider`'s allies).
|
||||
fn raider(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Pillager
|
||||
| Species::Vindicator
|
||||
| Species::Witch
|
||||
| Species::Evoker
|
||||
| Species::Illusioner
|
||||
| Species::Ravager
|
||||
)
|
||||
}
|
||||
fn targets(m: &mut Mob, villagers_seen: bool) {
|
||||
let mut hurt = HurtByTarget::new().alert();
|
||||
hurt.ignore = vec![
|
||||
@@ -80,10 +68,11 @@ pub fn register_vindicator(m: &mut Mob) {
|
||||
g.add(9, LookAtPlayer::new(3.0).probability(1.0));
|
||||
g.add(10, LookAtPlayer::mobs(8.0));
|
||||
targets(m, true);
|
||||
// VindicatorJohnnyAttackGoal: a vindicator named Johnny attacks anything.
|
||||
// VindicatorJohnnyAttackGoal: a vindicator named Johnny attacks any
|
||||
// living entity, other raiders included.
|
||||
m.ai.targets.add(
|
||||
4,
|
||||
NearestTarget::new(Prey::Filter(|s| !raider(s.species)), true)
|
||||
NearestTarget::new(Prey::Filter(|_| true), true)
|
||||
.interval(0)
|
||||
.when(|m, _| m.custom_name.as_deref() == Some("Johnny")),
|
||||
);
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct MotionResources {
|
||||
/// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for.
|
||||
pub soul_speed_blocks: BTreeSet<String>,
|
||||
pub fall_reset: BTreeSet<String>,
|
||||
/// `BlockTags.INSIDE_STEP_SOUND_BLOCKS`.
|
||||
pub inside_step_sound: BTreeSet<String>,
|
||||
/// `BlockTags.COMBINATION_STEP_SOUND_BLOCKS`.
|
||||
pub combination_step_sound: BTreeSet<String>,
|
||||
/// `BlockTags.CRYSTAL_SOUND_BLOCKS`: blocks that chime under footsteps.
|
||||
pub crystal_sound: BTreeSet<String>,
|
||||
pub landing: BlockLandingRegistry,
|
||||
@@ -117,6 +121,8 @@ impl MotionResources {
|
||||
glide_through: tag("minecraft:can_glide_through")?,
|
||||
soul_speed_blocks: tag("minecraft:soul_speed_blocks")?,
|
||||
fall_reset: tag("minecraft:fall_damage_resetting")?,
|
||||
inside_step_sound: tag("minecraft:inside_step_sound_blocks")?,
|
||||
combination_step_sound: tag("minecraft:combination_step_sound_blocks")?,
|
||||
crystal_sound: tag("minecraft:crystal_sound_blocks")?,
|
||||
})
|
||||
}
|
||||
@@ -162,11 +168,12 @@ pub enum MotionEvent {
|
||||
position: BlockPos,
|
||||
},
|
||||
/// `Entity.walkingStepSound` on this block: `Player.playStepSound`
|
||||
/// muffles it under a swim sound when `in_water`.
|
||||
/// mutes it (`playMuffledStepSound`) under a swim sound in water or
|
||||
/// under a combination block's own step.
|
||||
Emission {
|
||||
position: BlockPos,
|
||||
state: BlockStateId,
|
||||
in_water: bool,
|
||||
muffled: bool,
|
||||
},
|
||||
/// `Entity.playAmethystStepSound`: `block.amethyst_block.chime`.
|
||||
Chime {
|
||||
@@ -2181,18 +2188,54 @@ impl MovementEmissionWorld for MotionWorld<'_, '_, '_> {
|
||||
fn in_water(&mut self) -> Result<bool> {
|
||||
Ok(self.player.fluids.was_touching_water)
|
||||
}
|
||||
/// `Player.playStepSound`: in water a swim sound, then the step muffled.
|
||||
/// `Player.playStepSound`: in water a swim sound, then the step muffled;
|
||||
/// on land the step of an inside-sound block above the floor, or of a
|
||||
/// combination block above it with the floor's step muffled.
|
||||
fn walking_step_sound(&mut self, position: BlockPos, state: BlockStateId) -> Result<()> {
|
||||
let in_water = self.player.fluids.was_touching_water;
|
||||
if in_water {
|
||||
let muffled = self.player.fluids.was_touching_water;
|
||||
let mut stepped = false;
|
||||
if muffled {
|
||||
self.water_swim_sound()?;
|
||||
} else {
|
||||
// Entity.getPrimaryStepSoundBlockPos.
|
||||
let above = BlockPos {
|
||||
y: position.y + 1,
|
||||
..position
|
||||
};
|
||||
let above_state = self.block(above);
|
||||
let name = &self
|
||||
.assets
|
||||
.registry
|
||||
.state(above_state)
|
||||
.ok_or("missing scene state")?
|
||||
.name;
|
||||
let combination = self.resources.combination_step_sound.contains(name);
|
||||
if combination || self.resources.inside_step_sound.contains(name) {
|
||||
self.player.events.push(MotionEvent::Emission {
|
||||
position: above,
|
||||
state: above_state,
|
||||
muffled: false,
|
||||
});
|
||||
if combination {
|
||||
// Entity.playCombinationStepSounds.
|
||||
self.player.events.push(MotionEvent::Emission {
|
||||
position,
|
||||
state,
|
||||
muffled: true,
|
||||
});
|
||||
}
|
||||
stepped = true;
|
||||
}
|
||||
}
|
||||
self.player.events.push(MotionEvent::Emission {
|
||||
position,
|
||||
state,
|
||||
in_water,
|
||||
});
|
||||
// Entity.walkingStepSound: the amethyst chime after the step.
|
||||
if !stepped {
|
||||
self.player.events.push(MotionEvent::Emission {
|
||||
position,
|
||||
state,
|
||||
muffled,
|
||||
});
|
||||
}
|
||||
// Entity.walkingStepSound: the amethyst chime after the step, by the
|
||||
// floor's state even when the step sound was the block above's.
|
||||
let name = &self
|
||||
.assets
|
||||
.registry
|
||||
|
||||
@@ -81,6 +81,11 @@ impl Opt {
|
||||
Opt::Narrator
|
||||
| Opt::NarratorHotkey
|
||||
| Opt::ChatDelay
|
||||
// Stored, but there are no blocked players, signed chat or
|
||||
// chat drafts yet.
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::DistortionEffects
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
@@ -96,6 +101,9 @@ impl Opt {
|
||||
Opt::Narrator => "narrator",
|
||||
Opt::TextBackground => "backgroundForChatOnly",
|
||||
Opt::ChatDelay => "chatDelay",
|
||||
Opt::HideMatchedNames => "hideMatchedNames",
|
||||
Opt::OnlySecureChat => "onlyShowSecureChat",
|
||||
Opt::SaveChatDrafts => "saveChatDrafts",
|
||||
Opt::DistortionEffects => "screenEffectScale",
|
||||
Opt::FovEffects => "fovEffectScale",
|
||||
Opt::DarknessPulsing => "darknessEffectScale",
|
||||
@@ -164,6 +172,9 @@ impl Opt {
|
||||
Opt::ChatHeightUnfocused => "options.chat.height.unfocused",
|
||||
Opt::ChatLineSpacing => "options.chat.line_spacing",
|
||||
Opt::AutoSuggestions => "options.autoSuggestCommands",
|
||||
Opt::HideMatchedNames => "options.hideMatchedNames",
|
||||
Opt::OnlySecureChat => "options.onlyShowSecureChat",
|
||||
Opt::SaveChatDrafts => "options.chat.drafts",
|
||||
Opt::ReducedDebugInfo => "options.reducedDebugInfo",
|
||||
Opt::HighContrast => "options.accessibility.high_contrast",
|
||||
Opt::ShowSubtitles => "options.showSubtitles",
|
||||
@@ -437,6 +448,9 @@ impl Opt {
|
||||
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
|
||||
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
|
||||
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
|
||||
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
|
||||
on_off(s.file.bool(self.file_key().unwrap()))
|
||||
}
|
||||
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
|
||||
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
|
||||
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
|
||||
@@ -635,7 +649,12 @@ impl Opt {
|
||||
value + 1
|
||||
};
|
||||
}
|
||||
Opt::TextBackground | Opt::HideLightningFlashes | Opt::HideSplashTexts => {
|
||||
Opt::TextBackground
|
||||
| Opt::HideLightningFlashes
|
||||
| Opt::HideSplashTexts
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
|
||||
@@ -1434,7 +1453,10 @@ impl OptionsUi {
|
||||
Opt::ChatHeightFocused,
|
||||
Opt::ChatHeightUnfocused,
|
||||
Opt::AutoSuggestions,
|
||||
Opt::HideMatchedNames,
|
||||
Opt::ReducedDebugInfo,
|
||||
Opt::OnlySecureChat,
|
||||
Opt::SaveChatDrafts,
|
||||
]),
|
||||
Page::Accessibility => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
|
||||
@@ -659,7 +659,10 @@ pub enum Opt {
|
||||
ChatHeightUnfocused,
|
||||
ChatLineSpacing,
|
||||
AutoSuggestions,
|
||||
HideMatchedNames,
|
||||
ReducedDebugInfo,
|
||||
OnlySecureChat,
|
||||
SaveChatDrafts,
|
||||
HighContrast,
|
||||
ShowSubtitles,
|
||||
MusicFrequency,
|
||||
|
||||
Reference in New Issue
Block a user