mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef8fa7e6d | ||
|
|
de8ffbb294 | ||
|
|
c177fef871 | ||
|
|
45ecf7bef0 | ||
|
|
181040539d | ||
|
|
57d4801bff | ||
|
|
0050a65600 | ||
|
|
0914c90455 | ||
|
|
2dd4903b5e | ||
|
|
a3b4d135aa | ||
|
|
8474ac55c9 | ||
|
|
bcb3d50624 | ||
|
|
9c62d4f850 | ||
|
|
ed45e27d15 | ||
|
|
edd340a621 | ||
|
|
9c15e18c6f | ||
|
|
e5338ff202 | ||
|
|
b13b4f2c7f | ||
|
|
2730949b41 | ||
|
|
73cbd863af | ||
|
|
9c67dd3b76 | ||
|
|
702f7e2b93 | ||
|
|
765cdb6bb0 | ||
|
|
4103382062 | ||
|
|
7f9cc4084d | ||
|
|
89f949155c | ||
|
|
129ad4fdf8 | ||
|
|
ea3c3532b6 | ||
|
|
46a5e18630 | ||
|
|
703ba156da | ||
|
|
dd40744480 | ||
|
|
cca5298864 | ||
|
|
44c88b6c7b | ||
|
|
6a9ff4a38f | ||
|
|
4c33a0fdfe | ||
|
|
2e01c336a3 | ||
|
|
d26740a103 | ||
|
|
fdcefc0d90 | ||
|
|
1c87226ac9 | ||
|
|
9c86860e40 | ||
|
|
ca3d2d32af | ||
|
|
1b0923d8cf | ||
|
|
68f50bbd41 | ||
|
|
47bce76078 | ||
|
|
125e540126 | ||
|
|
1578a97c79 | ||
|
|
dbd5fb7f62 | ||
|
|
2a8dc29c69 | ||
|
|
81a0e7e9a5 | ||
|
|
ce980a90de | ||
|
|
dd32d3dc38 | ||
|
|
5667e9ebd1 | ||
|
|
39592697c7 | ||
|
|
d785041833 | ||
|
|
08c0fc1286 | ||
|
|
e81e23e64e | ||
|
|
5005a7ad9f | ||
|
|
dc66e37847 | ||
|
|
16090d3d4a | ||
|
|
31cfdb4b95 | ||
|
|
5c18ae844b | ||
|
|
584a59ad8c | ||
|
|
cdefed7a19 | ||
|
|
db8fa8377d | ||
|
|
70a6aa45aa | ||
|
|
8c0b674fe8 | ||
|
|
4bd3210f78 | ||
|
|
6255c02096 | ||
|
|
cf0b5a8e63 | ||
|
|
6a59002283 | ||
|
|
113cafd2d1 | ||
|
|
b2a5abb885 | ||
|
|
1da38a5c46 | ||
|
|
2bb3903b49 | ||
|
|
c8915be58c | ||
|
|
8dffd2b847 | ||
|
|
4245540711 | ||
|
|
6bd34b64d6 | ||
|
|
a690ccef3c | ||
|
|
3c7e4ba574 | ||
|
|
168bc8ffd5 | ||
|
|
0352ad0e19 | ||
|
|
526887be89 | ||
|
|
a5ae075a5d | ||
|
|
14a4d2726d | ||
|
|
d372e9ece9 | ||
|
|
21ba38e633 | ||
|
|
940b642233 | ||
|
|
d192946a6f | ||
|
|
af3aa00377 | ||
|
|
bf7179c48b | ||
|
|
dc44f98818 | ||
|
|
90014f63d2 | ||
|
|
2882f0764d | ||
|
|
e8896336da | ||
|
|
8f00332979 | ||
|
|
0e2b244120 | ||
|
|
835674f45d | ||
|
|
2acf1d7798 | ||
|
|
c9bd057d70 | ||
|
|
22a4620643 | ||
|
|
8422225047 | ||
|
|
f52af64e89 | ||
|
|
150a29974d | ||
|
|
eff8ae41c6 | ||
|
|
c5dead28a0 | ||
|
|
15099724ed | ||
|
|
52a820e708 | ||
|
|
85f2a9ad87 | ||
|
|
16d71b0a24 | ||
|
|
f83310a891 | ||
|
|
d321b656f6 | ||
|
|
41f60562fd | ||
|
|
136b352e4f | ||
|
|
ff1b01616f | ||
|
|
758d66bf44 | ||
|
|
3788dff6a1 | ||
|
|
4e96037c78 | ||
|
|
5b066b1b78 | ||
|
|
4eb95c4d3e |
No files matched your search
@@ -54,6 +54,9 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
|
||||
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
|
||||
pub target_point: Option<[String; 3]>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
|
||||
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
|
||||
}
|
||||
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
|
||||
}
|
||||
/// `ServerPlayer.die`: whether a player's death message reaches the player
|
||||
/// themselves (the team broadcasts leave out the dying player).
|
||||
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
|
||||
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
|
||||
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
|
||||
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
|
||||
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
|
||||
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
|
||||
let Some(team) = board.players_team(dying) else {
|
||||
return true;
|
||||
};
|
||||
let same_team = board
|
||||
.players_team(other)
|
||||
.is_some_and(|t| t.name == team.name);
|
||||
match team.death_message_visibility {
|
||||
Visibility::Always => true,
|
||||
Visibility::Never => false,
|
||||
Visibility::HideForOtherTeams => same_team && other != dying,
|
||||
Visibility::HideForOwnTeam => !same_team,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- objectives and scores ---------------------------------------------
|
||||
|
||||
@@ -300,9 +300,8 @@ impl Default for KeyBinds {
|
||||
}
|
||||
}
|
||||
/// Each default key once, in order: two mappings that share a default key
|
||||
/// (Advancements and the debug profiler both on L) are one press of it, not
|
||||
/// two (the second would close the screen the first opened). Vanilla clicks
|
||||
/// both mappings, but the profiler one only acts with F3 held.
|
||||
/// (Pick Block and the spectator hotbar both on the middle button) are one
|
||||
/// press of it, not two.
|
||||
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
for k in keys {
|
||||
@@ -368,6 +367,25 @@ impl KeyBinds {
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
|
||||
/// all but the debug keys. Those act only with F3 held
|
||||
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
|
||||
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
|
||||
/// would otherwise turn the key back into its old gameplay meaning.
|
||||
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.2 != "debug")
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// Whether a mapping (other than a debug key) whose default is `key` was
|
||||
/// bound elsewhere.
|
||||
fn default_moved(&self, key: &str) -> bool {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
}
|
||||
/// Translate a physical key into the default key of the mapping it is
|
||||
/// bound to, so game code written against vanilla defaults follows the
|
||||
/// player's bindings. `None` means the key now does nothing; mouse
|
||||
@@ -384,7 +402,7 @@ impl KeyBinds {
|
||||
return vec![Ok(key)];
|
||||
};
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(name)
|
||||
.translated_mappings(name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match key_code(default) {
|
||||
@@ -397,10 +415,7 @@ impl KeyBinds {
|
||||
return distinct(bound);
|
||||
}
|
||||
// An unbound key whose default meaning was moved elsewhere is inert.
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Ok(key)]
|
||||
@@ -413,7 +428,7 @@ impl KeyBinds {
|
||||
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
|
||||
let name = mouse_name(button);
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(&name)
|
||||
.translated_mappings(&name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match mouse_button(default) {
|
||||
@@ -425,10 +440,7 @@ impl KeyBinds {
|
||||
if !bound.is_empty() {
|
||||
return distinct(bound);
|
||||
}
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(&name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Err(button)]
|
||||
@@ -508,6 +520,29 @@ mod tests {
|
||||
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
|
||||
}
|
||||
#[test]
|
||||
fn a_moved_key_keeps_no_default_action() {
|
||||
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
|
||||
// and must not hand them back their gameplay meaning.
|
||||
let mut k = KeyBinds::default();
|
||||
k.set("key.hotbar.1", "key.keyboard.q");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit1), []);
|
||||
assert_eq!(
|
||||
k.translate_all(KeyCode::KeyQ),
|
||||
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
|
||||
);
|
||||
k.set("key.inventory", "key.keyboard.a");
|
||||
k.set("key.left", "key.keyboard.e");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
|
||||
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
|
||||
k.set("key.back", "key.keyboard.down");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyS), []);
|
||||
// Keys left alone still act, and a moved debug key changes nothing.
|
||||
k.set("key.debug.fpsCharts", "key.keyboard.9");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
|
||||
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
|
||||
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
|
||||
}
|
||||
#[test]
|
||||
fn display_names() {
|
||||
assert_eq!(display_name("key.keyboard.w").plain(), "W");
|
||||
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
|
||||
|
||||
@@ -654,10 +654,20 @@ fn sign_text_calls(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
|
||||
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
|
||||
/// eight one-pixel offsets under the text.
|
||||
///
|
||||
/// `TextFeatureRenderer.renderText` draws the outline glyphs NORMAL and the
|
||||
/// text over them POLYGON_OFFSET, so the coplanar text always wins the depth
|
||||
/// test. Here both share one mesh, so the text is lifted a hair towards the
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, argb: u32| {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
@@ -672,7 +682,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -697,10 +707,15 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, call.outline);
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
}
|
||||
}
|
||||
emit(0.0, 0.0, call.color);
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -2258,6 +2273,69 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
|
||||
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
|
||||
/// and plain text stays on the sign's plane.
|
||||
#[test]
|
||||
fn outlined_sign_text_sits_in_front_of_its_outline() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
(Some(rect), 6.0)
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
// Eight outline copies, then the text, four corners a glyph.
|
||||
assert_eq!(out.len(), 9 * 2 * 4);
|
||||
let (outline, text) = out.split_at(8 * 2 * 4);
|
||||
let front = outline.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
|
||||
assert!(
|
||||
text.iter().all(|v| v.pos[2] > front),
|
||||
"text z {:?} not in front of the outline at {front}",
|
||||
text.iter().map(|v| v.pos[2]).collect::<Vec<_>>()
|
||||
);
|
||||
call.outline = 0;
|
||||
out.clear();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
assert_eq!(out.len(), 2 * 4);
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
|
||||
@@ -386,4 +386,23 @@ mod tests {
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
assert_eq!(stand.brew_time, 0);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
#[test]
|
||||
fn effect_lines_read_the_selected_language() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("assets/minecraft/lang/ru_ru.json").exists() {
|
||||
return;
|
||||
}
|
||||
let ru = crate::lang::Language::load(&[&root], "ru_ru");
|
||||
let translate = |key: &str| ru.get(key).map(str::to_owned);
|
||||
let lines = tooltip(Some("minecraft:turtle_master"), 0.125, &translate);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
|
||||
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,22 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
fog
|
||||
}
|
||||
/// Horizon's Fog option turned off (`horizon-graphics.txt` `fog:false`):
|
||||
/// no distance fog, in any dimension: the dimension's and biome's own fog
|
||||
/// (`visual/fog_start_distance`, `fog_end_distance`: the Nether's ends at 96
|
||||
/// blocks) and the render-distance fog. The fog of the fluid or powder snow
|
||||
/// the camera is in, blindness, darkness and a boss's world fog keep their
|
||||
/// limits. Vanilla has no such option.
|
||||
pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
if input.fog_type == FogType::Atmospheric
|
||||
&& input.darkness.is_none()
|
||||
&& !input.boss_world_fog
|
||||
&& input.blindness.is_none()
|
||||
{
|
||||
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
pub fn update_rain_fog(
|
||||
state: &mut FogState,
|
||||
@@ -452,6 +468,56 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_clears_the_nether_fog_too() {
|
||||
// The Nether's visual/fog_start_distance and fog_end_distance (10, 96)
|
||||
// end before the render distance; the Fog option still clears them.
|
||||
let nether = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&nether);
|
||||
input.skybox = false;
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.environmental_end, 96.0);
|
||||
clear_distance_fog(&mut fog, &input);
|
||||
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_keeps_fluid_and_effect_fog() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let kept = |input: &FogInputs| {
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), input);
|
||||
let before = fog;
|
||||
clear_distance_fog(&mut fog, input);
|
||||
fog == before
|
||||
};
|
||||
for fog_type in [FogType::Lava, FogType::PowderSnow, FogType::Water] {
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = fog_type;
|
||||
assert!(kept(&input), "{fog_type:?}");
|
||||
}
|
||||
let mut input = inputs(&sky);
|
||||
input.blindness = Some((100, false));
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.darkness = Some(0.5);
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.boss_world_fog = true;
|
||||
assert!(kept(&input));
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
|
||||
@@ -879,6 +879,14 @@ fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
|
||||
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
|
||||
/// minimized window. Past `max`: Windows lets a program size a window up to 65535 pixels
|
||||
/// (`SetWindowPos`), a 984x65496 client area, when an RTX 5080 takes 32768. Vanilla's
|
||||
/// `Window.onFramebufferResize` likewise keeps the last size for one that is not positive.
|
||||
fn drawable_size(width: u32, height: u32, max: u32) -> bool {
|
||||
(1..=max).contains(&width) && (1..=max).contains(&height)
|
||||
}
|
||||
/// The swapchain format: a non-sRGB target, because vanilla blends and tints in gamma
|
||||
/// space, and 8-bit, because wgpu gives `Rgba16Float` the linear `ExtendedSrgb` color
|
||||
/// space: the compositor would read these gamma-encoded values as linear light and wash
|
||||
@@ -1126,9 +1134,14 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.get_default_config(
|
||||
&adapter,
|
||||
size.width.clamp(1, max_side),
|
||||
size.height.clamp(1, max_side),
|
||||
)
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
@@ -1167,8 +1180,8 @@ impl Renderer {
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
format,
|
||||
width: size.width.max(1),
|
||||
height: size.height.max(1),
|
||||
width: size.width.clamp(1, max_side),
|
||||
height: size.height.clamp(1, max_side),
|
||||
present_mode: wgpu::PresentMode::Immediate,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
@@ -2710,7 +2723,7 @@ impl Renderer {
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
suspended: size.width == 0 || size.height == 0,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -2870,8 +2883,11 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Follows the window to `width` x `height`. A size the swapchain can't take
|
||||
/// ([`drawable_size`]) keeps the last one and draws nothing until the next.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended = width == 0 || height == 0;
|
||||
let max = self.device.limits().max_texture_dimension_2d;
|
||||
self.suspended = !drawable_size(width, height, max);
|
||||
if self.suspended {
|
||||
return;
|
||||
}
|
||||
@@ -5553,6 +5569,23 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn drawable_size_skips_minimized_and_oversized_windows() {
|
||||
use super::drawable_size;
|
||||
assert!(drawable_size(1280, 720, 8192));
|
||||
assert!(drawable_size(1, 1, 8192));
|
||||
assert!(drawable_size(8192, 8192, 8192));
|
||||
// Minimized.
|
||||
assert!(!drawable_size(0, 720, 8192));
|
||||
assert!(!drawable_size(1280, 0, 8192));
|
||||
assert!(!drawable_size(0, 0, 8192));
|
||||
// One past the device's largest texture.
|
||||
assert!(!drawable_size(8193, 720, 8192));
|
||||
assert!(!drawable_size(1280, 8193, 8192));
|
||||
// The crash: a window `SetWindowPos` made 65535 pixels tall, on an RTX 5080 (32768).
|
||||
assert!(!drawable_size(984, 65496, 32768));
|
||||
assert!(!drawable_size(984, u32::MAX, 32768));
|
||||
}
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
|
||||
@@ -1162,6 +1162,33 @@ impl HudBatch {
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
|
||||
/// many vertices at the start of the batch are the background.
|
||||
pub fn name_tag(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
y: f32,
|
||||
color: u32,
|
||||
background: u32,
|
||||
) -> usize {
|
||||
let x = -atlas.font.width_text(text) / 2.0;
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, x, y, color, false);
|
||||
if background >> 24 == 0 {
|
||||
return 0;
|
||||
}
|
||||
// PreparedTextBuilder.markBackground: one pixel round the advances,
|
||||
// from a pixel above the line to 9 below its top.
|
||||
let glyphs = self.vertices.split_off(start);
|
||||
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
|
||||
let behind = self.vertices.len() - start;
|
||||
self.vertices.extend(glyphs);
|
||||
behind
|
||||
}
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -1877,6 +1904,65 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
fs::write(
|
||||
root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[
|
||||
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
|
||||
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
|
||||
.collect();
|
||||
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let text = crate::text::Text::literal("aй").bold();
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
|
||||
// Each quad's box: x0, y0, x1, y1.
|
||||
let boxes: Vec<[f32; 4]> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
|
||||
.collect();
|
||||
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
|
||||
let x = -7.5;
|
||||
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
|
||||
// box from 3 pixels above the line, each bold quad 0.1 larger.
|
||||
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
|
||||
let expected = [
|
||||
[x - 0.1, -0.1, x + 8.1, 8.1],
|
||||
[x + 0.9, -0.1, x + 9.1, 8.1],
|
||||
[x + 5.9, -3.1, x + 15.1, 9.1],
|
||||
[x + 6.9, -3.1, x + 16.1, 9.1],
|
||||
];
|
||||
for (got, want) in boxes[1..].iter().zip(expected) {
|
||||
assert!(near(*got, want), "{got:?} != {want:?}");
|
||||
}
|
||||
assert_eq!(boxes.len(), 1 + expected.len());
|
||||
// The background first: a pixel round the advances, 10 pixels tall.
|
||||
assert_eq!(behind, 6);
|
||||
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
|
||||
|
||||
@@ -25,6 +25,16 @@ const MAX_SAVED: usize = 1024;
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Threads that sample far tiles beside the mesher. Machines with 12 or more
|
||||
/// threads use up to 6 (measured: a cold 1024-chunk LoD loads about a quarter
|
||||
/// sooner than with 3); the rest keep a quarter of their threads, at most 3.
|
||||
fn sampling_threads(cores: usize) -> usize {
|
||||
if cores >= 12 {
|
||||
(cores / 2).min(6)
|
||||
} else {
|
||||
(cores / 4).clamp(1, 3)
|
||||
}
|
||||
}
|
||||
/// While the near terrain loads on a machine with four threads or fewer, far
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
@@ -550,6 +560,8 @@ pub struct Lod {
|
||||
detail_request_at: std::time::Instant,
|
||||
/// Four threads or fewer: far work waits for the near terrain (`hold`).
|
||||
small: bool,
|
||||
/// Sample jobs that may be queued or running at once.
|
||||
sample_jobs: usize,
|
||||
/// The near terrain is loading (`set_near_loading`).
|
||||
near_loading: bool,
|
||||
/// When `request_samples` last started a job while held.
|
||||
@@ -570,6 +582,7 @@ pub struct Lod {
|
||||
}
|
||||
impl Lod {
|
||||
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
observed: Mutex::new(FxHashMap::default()),
|
||||
@@ -583,10 +596,10 @@ impl Lod {
|
||||
let handle = thread::Builder::new()
|
||||
.name("Far terrain".into())
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut sampler = source.sampler();
|
||||
let count = thread::available_parallelism()
|
||||
.map_or(1, |n| n.get() / 4)
|
||||
.clamp(1, 3);
|
||||
let count = sampling_threads(cores);
|
||||
let mut sampling = Vec::new();
|
||||
for _ in 0..count {
|
||||
let Some(mut sample) = sampler.fork() else {
|
||||
@@ -598,6 +611,7 @@ impl Lod {
|
||||
thread::Builder::new()
|
||||
.name("Far sampling".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
loop {
|
||||
let key = {
|
||||
let mut jobs = shared.jobs.lock().unwrap();
|
||||
@@ -746,7 +760,8 @@ impl Lod {
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
small: thread::available_parallelism().map_or(1, |n| n.get()) <= 4,
|
||||
small: cores <= 4,
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
ready: FxHashMap::default(),
|
||||
@@ -1243,7 +1258,13 @@ impl Lod {
|
||||
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
|
||||
)
|
||||
} else {
|
||||
SAMPLE_JOBS.saturating_sub(self.pending.len())
|
||||
// The extra sampling threads wait until the near terrain is in.
|
||||
let slots = if self.near_loading {
|
||||
SAMPLE_JOBS
|
||||
} else {
|
||||
self.sample_jobs
|
||||
};
|
||||
slots.saturating_sub(self.pending.len())
|
||||
};
|
||||
if free == 0 {
|
||||
return;
|
||||
@@ -1291,8 +1312,8 @@ impl Lod {
|
||||
}
|
||||
// Only the first `free` keys not already pending are taken. A key is
|
||||
// listed at most twice (as a root and as a node), so the best
|
||||
// `2 * (SAMPLE_JOBS + pending)` entries hold them, in order.
|
||||
let best = 2 * (SAMPLE_JOBS + self.pending.len());
|
||||
// `2 * (sample_jobs + pending)` entries hold them, in order.
|
||||
let best = 2 * (self.sample_jobs + self.pending.len());
|
||||
if wanted.len() > best {
|
||||
wanted.select_nth_unstable(best);
|
||||
wanted.truncate(best);
|
||||
@@ -2000,6 +2021,7 @@ mod tests {
|
||||
lod.ready
|
||||
.insert(parent, Arc::new(vec![Column::default(); 256]));
|
||||
lod.active = [parent].into_iter().collect();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2058,6 +2080,7 @@ mod tests {
|
||||
}
|
||||
lod.active = [near, far].into_iter().collect();
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2091,6 +2114,34 @@ mod tests {
|
||||
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
|
||||
"one unfinished fine tile must not starve neighbouring requests"
|
||||
);
|
||||
|
||||
// The extra sampling threads' slots open once the near terrain is in.
|
||||
lod.small = false;
|
||||
lod.sample_jobs = SAMPLE_JOBS + 3;
|
||||
lod.plan = Plan::new([-256, -256], 2, 64);
|
||||
lod.ready.clear();
|
||||
lod.active.clear();
|
||||
lod.pending.clear();
|
||||
lod.meshing = true;
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
let queued = |lod: &mut Lod, near_loading: bool| {
|
||||
lod.set_near_loading(near_loading);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(queued(&mut lod, true), SAMPLE_JOBS);
|
||||
assert_eq!(queued(&mut lod, false), SAMPLE_JOBS + 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sampling_threads_only_on_large_machines() {
|
||||
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 2, 2]);
|
||||
assert_eq!([12, 16, 64].map(sampling_threads), [6, 6, 6]);
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1022,9 @@ pub enum Event {
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
|
||||
/// mod and refreshes the page.
|
||||
NativeButton(usize, usize),
|
||||
@@ -2714,6 +2717,7 @@ impl OptionsUi {
|
||||
events.push(Event::ButtonClick);
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
@@ -3332,6 +3336,50 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_native_mods_page_asks_the_game_for_its_labels_again() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "native".into(),
|
||||
title: "Native 1.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(crate::mod_list::NativePage::default()),
|
||||
});
|
||||
ui.show(Page::Mods);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let settings = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("a native mod's row has a Settings button");
|
||||
let events = ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
settings.x + 1.0,
|
||||
settings.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// Its labels were made with the list, maybe in another language.
|
||||
assert_eq!(events.last(), Some(&Event::NativeSettings(0)));
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -380,11 +380,45 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
if target.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
fs::copy(target, at).map(|_| ())
|
||||
// The mirror only reads these files, and unix links them: a hardlink gives
|
||||
// the same view without writing the 639 MB prepared root again, which was
|
||||
// 33 s of a cold Windows launch with a pack on. Another volume (or a
|
||||
// filesystem without hardlinks) still copies.
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// A directory junction, which Windows creates without privileges (a directory symlink needs
|
||||
/// developer mode). The mirror falls back to copying the tree when it fails.
|
||||
#[cfg(windows)]
|
||||
fn junction(target: &Path, at: &Path) -> io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
|
||||
// unquoted path would split or change the command. A Windows path cannot hold `"`.
|
||||
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
|
||||
let mut quoted = std::ffi::OsString::from("\"");
|
||||
quoted.push(std::path::absolute(path)?);
|
||||
quoted.push("\"");
|
||||
Ok(quoted)
|
||||
};
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(at)?)
|
||||
.raw_arg(quoted(target)?)
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| io::Error::other("mklink /J failed"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
Err(io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
|
||||
/// load order, last wins). Untouched directories are symlinks into vanilla.
|
||||
/// load order, last wins). Untouched directories are links into vanilla.
|
||||
pub fn build_overlay(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
|
||||
cache::build(vanilla, packs, out)
|
||||
}
|
||||
@@ -516,11 +550,17 @@ fn build_overlay_uncached(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
&& vanilla.join(&child).is_dir()
|
||||
&& junction(&vanilla.join(&child), &out.join(&child)).is_ok()
|
||||
{
|
||||
// Linked whole, like the unix symlink: no file in it is visited or copied.
|
||||
} else if touched.contains(&child)
|
||||
// Windows cannot link a directory the way unix does (`link` above), so an
|
||||
// untouched one has to be mirrored: skipping it hid textures/font and the
|
||||
// title panorama from the overlay, and a font json that names those sheets
|
||||
// then failed to load (#1688).
|
||||
// Without a junction Windows cannot link a directory the way unix does (`link`
|
||||
// above), so an untouched one has to be mirrored: skipping it hid textures/font
|
||||
// and the title panorama from the overlay, and a font json that names those
|
||||
// sheets then failed to load (#1688).
|
||||
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
|
||||
{
|
||||
mirror(
|
||||
@@ -939,7 +979,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-{}", std::process::id()));
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
|
||||
@@ -960,6 +1001,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(vanilla.join("facts.json"), b"[]").unwrap();
|
||||
// A directory no pack touches (#1688: a font json names sheets in it).
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/font/ascii.png"),
|
||||
b"font",
|
||||
)
|
||||
.unwrap();
|
||||
let pack_dir = base.join("packs/Mine");
|
||||
fs::create_dir_all(pack_dir.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
@@ -997,6 +1045,17 @@ mod tests {
|
||||
br#"{"a":"vanilla","b":"pack"}"#
|
||||
);
|
||||
assert_eq!(fs::read(out.join("facts.json")).unwrap(), b"[]");
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/font/ascii.png")).unwrap(),
|
||||
b"font"
|
||||
);
|
||||
// Linked, not mirrored: a junction on Windows, a symlink on unix.
|
||||
assert!(
|
||||
fs::symlink_metadata(out.join("assets/minecraft/textures/font"))
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -8,8 +8,9 @@ use std::io::Read;
|
||||
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
|
||||
// Bump when overlay/merge semantics change. Markers are disposable cache data.
|
||||
// Version 4 mirrored every untouched directory file by file on Windows.
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
const VERSION: u32 = 4;
|
||||
const VERSION: u32 = 5;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -527,6 +527,53 @@ impl Creative {
|
||||
pub fn scroller_offset(&self) -> f32 {
|
||||
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
|
||||
}
|
||||
/// The tabs whose `CreativeModeTab.contains(stack)` holds, in
|
||||
/// `CreativeModeTabs.tabs()` order, the search tab left out: the stack is
|
||||
/// among the tab's search-tab items, by item and components. The export
|
||||
/// lists each tab's own items; the search tab's stacks that no tab shows
|
||||
/// (the enchanted books below their top level, `SEARCH_TAB_ONLY`) belong
|
||||
/// to the tabs that show their item.
|
||||
pub fn tabs_containing(&self, stack: &ItemStack) -> Vec<usize> {
|
||||
let category = |t: &Tab| !t.search && !t.inventory;
|
||||
let tabs_where = |shows: &dyn Fn(&ItemStack) -> bool| -> Vec<usize> {
|
||||
(0..self.tabs.len())
|
||||
.filter(|&i| category(&self.tabs[i]) && self.tabs[i].items.iter().any(shows))
|
||||
.collect()
|
||||
};
|
||||
let found = tabs_where(&|s| same_components(s, stack));
|
||||
if found.is_empty() && self.search_items.iter().any(|s| same_components(s, stack)) {
|
||||
return tabs_where(&|s| s.item == stack.item);
|
||||
}
|
||||
found
|
||||
}
|
||||
/// The `#namespace:path` lines `getTooltipFromContainerItem` adds for a
|
||||
/// search tab stack while the search is a `#` query: the item tags
|
||||
/// `updateVisibleTags` keeps for the text after `#` (the path contains it;
|
||||
/// with a colon, the namespace and the path each contain their part) that
|
||||
/// the stack has.
|
||||
pub fn search_tag_lines(&self, stack: &ItemStack) -> Vec<String> {
|
||||
let Some(text) = self.search.value.strip_prefix('#') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(entry) = self
|
||||
.search_items
|
||||
.iter()
|
||||
.position(|s| same_components(s, stack))
|
||||
.and_then(|i| self.search_index.get(i))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let visible = |(ns, path): &(String, String)| match text.split_once(':') {
|
||||
None => path.contains(text),
|
||||
Some((n, p)) => ns.contains(java_trim(n)) && path.contains(java_trim(p)),
|
||||
};
|
||||
entry
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| visible(t))
|
||||
.map(|(ns, path)| format!("#{ns}:{path}"))
|
||||
.collect()
|
||||
}
|
||||
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
let mut x = tab.column as f32 * 27.0;
|
||||
@@ -543,6 +590,21 @@ impl Creative {
|
||||
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
|
||||
strings.into_iter().any(|s| s.contains(text))
|
||||
}
|
||||
/// `ItemStack.isSameItemSameComponents`, the count left out, as the
|
||||
/// tabs' `ItemStackLinkedSet` compares: no components and empty ones match.
|
||||
fn same_components(a: &ItemStack, b: &ItemStack) -> bool {
|
||||
let none = crate::item_data::StackData::default();
|
||||
a.item == b.item
|
||||
&& a.damage == b.damage
|
||||
&& a.data().unwrap_or(&none) == b.data().unwrap_or(&none)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the plain
|
||||
/// tooltip only for a category tab's own grid slots (`CustomCreativeSlot`);
|
||||
/// everywhere else in the screen the tab names (and, on the search tab's
|
||||
/// grid, the tag lines) follow the item's name.
|
||||
pub fn tooltip_lists_tabs(category_tab: bool, grid_slot: bool) -> bool {
|
||||
!(category_tab && grid_slot)
|
||||
}
|
||||
/// String.toLowerCase(Locale.ROOT).
|
||||
fn java_lower(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
@@ -690,6 +752,109 @@ mod tests {
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
/// Tabs as the export lists them: two block tabs sharing the oak log,
|
||||
/// ingredients with the top-level book only, the search tab with every
|
||||
/// stack (a level 1 book only there) and its tags, the inventory.
|
||||
fn tooltip_tabs() -> Creative {
|
||||
let book = |level: u8| {
|
||||
format!(
|
||||
r#"{{components:{{"minecraft:stored_enchantments":{{"minecraft:sharpness":{level}}}}},count:1,id:"minecraft:enchanted_book"}}"#
|
||||
)
|
||||
};
|
||||
let log = r#"{count:1,id:"minecraft:oak_log"}"#;
|
||||
let dirt = r#"{count:1,id:"minecraft:dirt"}"#;
|
||||
let tab = |id: &str, kind: &str, items: Vec<String>| serde_json::json!({"id": id, "type": kind, "title": id, "icon": log, "items": items});
|
||||
let mut search = tab(
|
||||
"minecraft:search",
|
||||
"SEARCH",
|
||||
vec![log.into(), dirt.into(), book(1), book(5)],
|
||||
);
|
||||
search["lines"] = serde_json::json!([[], [], [], []]);
|
||||
search["tags"] = serde_json::json!([
|
||||
[
|
||||
"minecraft:logs",
|
||||
"minecraft:oak_logs",
|
||||
"minecraft:logs_that_burn"
|
||||
],
|
||||
["minecraft:dirt"],
|
||||
[],
|
||||
[]
|
||||
]);
|
||||
Creative::from_json(&serde_json::json!({"tabs": [
|
||||
tab("minecraft:building_blocks", "CATEGORY", vec![log.into()]),
|
||||
tab("minecraft:natural_blocks", "CATEGORY", vec![log.into(), dirt.into()]),
|
||||
tab("minecraft:hotbar", "HOTBAR", vec![]),
|
||||
search,
|
||||
tab("minecraft:ingredients", "CATEGORY", vec![book(5)]),
|
||||
tab("minecraft:inventory", "INVENTORY", vec![]),
|
||||
]}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_lists_the_tabs_that_contain_the_stack() {
|
||||
let c = tooltip_tabs();
|
||||
let ids = |s: &ItemStack| -> Vec<&str> {
|
||||
c.tabs_containing(s)
|
||||
.into_iter()
|
||||
.map(|i| c.tabs[i].id.as_str())
|
||||
.collect()
|
||||
};
|
||||
// In tab order, the search and inventory tabs left out; the count
|
||||
// doesn't matter.
|
||||
assert_eq!(
|
||||
ids(&stack("oak_log", 37)),
|
||||
["minecraft:building_blocks", "minecraft:natural_blocks"]
|
||||
);
|
||||
assert_eq!(ids(&stack("dirt", 1)), ["minecraft:natural_blocks"]);
|
||||
// A stack no tab holds: other components, or no creative item.
|
||||
let mut damaged = stack("oak_log", 1);
|
||||
damaged.damage = 3;
|
||||
assert!(ids(&damaged).is_empty());
|
||||
assert!(ids(&stack("barrier", 1)).is_empty());
|
||||
// A level 1 book is a search-tab-only stack of the ingredients tab.
|
||||
let books: Vec<&ItemStack> = c
|
||||
.search_items()
|
||||
.iter()
|
||||
.filter(|s| &*s.item == "minecraft:enchanted_book")
|
||||
.collect();
|
||||
assert_eq!(ids(books[0]), ["minecraft:ingredients"]);
|
||||
assert_eq!(ids(books[1]), ["minecraft:ingredients"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_tab_names_skip_only_a_category_tab_grid() {
|
||||
assert!(!tooltip_lists_tabs(true, true));
|
||||
// The hotbar row of a category tab, the search tab's grid and hotbar,
|
||||
// the inventory tab.
|
||||
assert!(tooltip_lists_tabs(true, false));
|
||||
assert!(tooltip_lists_tabs(false, true));
|
||||
assert!(tooltip_lists_tabs(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_search_lists_the_matching_tags_of_the_stack() {
|
||||
let mut c = tooltip_tabs();
|
||||
let log = stack("oak_log", 1);
|
||||
assert!(c.search_tag_lines(&log).is_empty());
|
||||
c.search.set_value("logs");
|
||||
assert!(c.search_tag_lines(&log).is_empty());
|
||||
c.search.set_value("#logs");
|
||||
assert_eq!(
|
||||
c.search_tag_lines(&log),
|
||||
[
|
||||
"#minecraft:logs",
|
||||
"#minecraft:oak_logs",
|
||||
"#minecraft:logs_that_burn"
|
||||
]
|
||||
);
|
||||
c.search.set_value("#oak");
|
||||
assert_eq!(c.search_tag_lines(&log), ["#minecraft:oak_logs"]);
|
||||
c.search.set_value("#mine : burn");
|
||||
assert_eq!(c.search_tag_lines(&log), ["#minecraft:logs_that_burn"]);
|
||||
assert!(c.search_tag_lines(&stack("dirt", 1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
|
||||
let filled = Hover::Display { filled: true };
|
||||
|
||||
@@ -152,6 +152,14 @@ impl DebugCharts {
|
||||
}
|
||||
self.packet_tick = (self.packet_tick + 1) % 20;
|
||||
}
|
||||
/// Whether `draw` (or the lightmap view) puts something in the bottom
|
||||
/// left and the bottom right corner, for a `remote` server or not.
|
||||
pub fn corners(&self, remote: bool) -> [bool; 2] {
|
||||
[
|
||||
self.fps || (self.network && remote),
|
||||
(self.fps && !self.tick_times.is_empty()) || self.network || self.lightmap,
|
||||
]
|
||||
}
|
||||
/// The charts along the bottom of the screen (`extractRenderState`):
|
||||
/// frame times left and tick times right, or bandwidth left (remote
|
||||
/// servers only) and ping right. `mspt` is the level's milliseconds
|
||||
@@ -525,6 +533,26 @@ mod tests {
|
||||
assert!(overlay && c.fps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_are_where_the_charts_are_drawn() {
|
||||
let mut c = DebugCharts::default();
|
||||
assert_eq!(c.corners(true), [false, false]);
|
||||
// Frame times left; tick times right once the server logged a tick.
|
||||
c.fps = true;
|
||||
assert_eq!(c.corners(false), [true, false]);
|
||||
c.tick_times.log(1);
|
||||
assert_eq!(c.corners(false), [true, true]);
|
||||
// Ping right; bandwidth left on a remote server only.
|
||||
c.fps = false;
|
||||
c.network = true;
|
||||
assert_eq!(c.corners(false), [false, true]);
|
||||
assert_eq!(c.corners(true), [true, true]);
|
||||
// The lightmap view, bottom right.
|
||||
c.network = false;
|
||||
c.lightmap = true;
|
||||
assert_eq!(c.corners(true), [false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lightmap_puts_full_sky_light_at_the_top_left() {
|
||||
let mut pixels = [0u8; 16 * 16 * 4];
|
||||
|
||||
@@ -953,7 +953,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
// A mount's rolled health and speed stay attribute bases, which
|
||||
// the server syncs (its hearts over the rider's hotbar).
|
||||
let equine = crate::mobs::equine::is_equine(m.species());
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MaxHealth,
|
||||
v,
|
||||
),
|
||||
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MovementSpeed,
|
||||
v,
|
||||
),
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ItemPose {
|
||||
Swing,
|
||||
/// A STAB swing (`SwingAnimationType` 2): a spear's jab.
|
||||
Stab,
|
||||
Using,
|
||||
Eat {
|
||||
remaining: f32,
|
||||
@@ -86,6 +88,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
|
||||
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::Stab) {
|
||||
return m * crate::spear::first_person_attack(attack, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
@@ -172,8 +177,94 @@ impl MapHold {
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
|
||||
/// or right arm.
|
||||
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let pi = std::f32::consts::PI;
|
||||
let root = attack.sqrt();
|
||||
let x = -0.3 * (root * pi).msin();
|
||||
let y = 0.4 * (root * std::f32::consts::TAU).msin();
|
||||
let z = -0.4 * (attack * pi).msin();
|
||||
let z_rot = (attack * attack * pi).msin();
|
||||
let y_rot = (root * pi).msin();
|
||||
let deg = |d: f32| d.to_radians();
|
||||
Mat4::from_translation(Vec3::new(
|
||||
f * (x + 0.640_000_05),
|
||||
y - 0.6 + inverse_height * -0.6,
|
||||
z + -0.719_999_97,
|
||||
)) * Mat4::from_rotation_y(deg(f * 45.0))
|
||||
* Mat4::from_rotation_y(deg(f * y_rot * 70.0))
|
||||
* Mat4::from_rotation_z(deg(f * z_rot * -20.0))
|
||||
* Mat4::from_translation(Vec3::new(-f, 3.6, 3.5))
|
||||
* Mat4::from_rotation_z(deg(f * 120.0))
|
||||
* Mat4::from_rotation_x(deg(200.0))
|
||||
* Mat4::from_rotation_y(deg(f * -135.0))
|
||||
* Mat4::from_translation(Vec3::new(f * 5.6, 0.0, 0.0))
|
||||
}
|
||||
|
||||
/// `renderTwoHandedMap` up to its arms: the swing, the tilt from the view
|
||||
/// pitch (`calculateMapTilt`) and the turn towards the player.
|
||||
pub fn two_handed_map_base(pitch: f32, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let pi = std::f32::consts::PI;
|
||||
let y_swing = -0.2 * (attack * pi).msin();
|
||||
let z_swing = -0.4 * (attack.sqrt() * pi).msin();
|
||||
let tilt = (1.0 - pitch / 45.0 + 0.1).clamp(0.0, 1.0);
|
||||
let tilt = -(tilt * pi).mcos() * 0.5 + 0.5;
|
||||
Mat4::from_translation(Vec3::new(0.0, -y_swing / 2.0, z_swing))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
0.0,
|
||||
0.04 + inverse_height * -1.2 + tilt * -0.5,
|
||||
-0.72,
|
||||
))
|
||||
* Mat4::from_rotation_x((tilt * -85.0).to_radians())
|
||||
}
|
||||
|
||||
/// The arms under a held map, as (left arm, pose before `renderPlayerHand`):
|
||||
/// `renderOneHandedMap`'s arm turned 10 degrees outwards, or
|
||||
/// `renderTwoHandedMap`'s two `renderMapHand` arms under the map's
|
||||
/// `two_handed_map_base`. Vanilla skips them for an invisible player.
|
||||
pub fn map_arm_poses(
|
||||
hold: MapHold,
|
||||
left: bool,
|
||||
pitch: f32,
|
||||
inverse_height: f32,
|
||||
attack: f32,
|
||||
) -> Vec<(bool, glam::Mat4)> {
|
||||
use glam::{Mat4, Vec3};
|
||||
let deg = |d: f32| d.to_radians();
|
||||
match hold {
|
||||
MapHold::OneHanded => {
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let m = Mat4::from_translation(Vec3::new(f * 0.125, -0.125, 0.0))
|
||||
* Mat4::from_rotation_z(deg(f * 10.0))
|
||||
* player_arm_pose(left, inverse_height, attack);
|
||||
vec![(left, m)]
|
||||
}
|
||||
MapHold::TwoHanded => {
|
||||
let base = two_handed_map_base(pitch, inverse_height, attack)
|
||||
* Mat4::from_rotation_y(deg(90.0));
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.map(|left| {
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let hand = Mat4::from_rotation_y(deg(92.0))
|
||||
* Mat4::from_rotation_x(deg(45.0))
|
||||
* Mat4::from_rotation_z(deg(f * -41.0))
|
||||
* Mat4::from_translation(Vec3::new(f * 0.3, -1.1, 0.45));
|
||||
(left, base * hand)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
|
||||
/// the `left` or right arm (the arm under it is not drawn).
|
||||
/// the `left` or right arm (its arm: [`map_arm_poses`]).
|
||||
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
@@ -945,6 +1036,83 @@ mod item_pose_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn maps_draw_the_arms_under_them_as_vanilla() {
|
||||
// PoseStack.last().pose() (column-major) where renderOneHandedMap's
|
||||
// renderPlayerArm and renderTwoHandedMap's renderMapHand call
|
||||
// renderPlayerHand, replayed with vanilla's PoseStack, Axis and Mth.
|
||||
let near = |got: glam::Mat4, want: [f32; 16], what: &str| {
|
||||
let got = got.to_cols_array();
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!((g - w).abs() < 1e-5, "{what}: {got:?}");
|
||||
}
|
||||
};
|
||||
// (left, inverse height, attack, arm)
|
||||
#[rustfmt::skip]
|
||||
let one: [(bool, f32, f32, [f32; 16]); 3] = [
|
||||
(false, 0.0, 0.0, [
|
||||
-0.2350201, -0.7860474, -0.5717472, 0.0,
|
||||
0.24694103, 0.52063686, -0.8172866, 0.0,
|
||||
0.9400987, -0.33326668, 0.07174723, 0.0,
|
||||
0.65913033, -1.1543381, -0.73980355, 1.0,
|
||||
]),
|
||||
(true, 0.25, 0.3, [
|
||||
-0.4967606, 0.84289914, -0.20676011, 0.0,
|
||||
0.7202682, 0.2674863, -0.6400506, 0.0,
|
||||
-0.4841926, -0.46687466, -0.7399901, 0.0,
|
||||
-0.59715486, -1.4160433, -0.7328731, 1.0,
|
||||
]),
|
||||
(false, 0.6, 0.85, [
|
||||
-0.47358897, -0.8403256, -0.2637542, 0.0,
|
||||
0.081554025, 0.25634, -0.96313995, 0.0,
|
||||
0.87696207, -0.47764266, -0.052867785, 0.0,
|
||||
0.61887383, -1.6482887, -0.7229744, 1.0,
|
||||
]),
|
||||
];
|
||||
for (left, inverse_height, attack, want) in one {
|
||||
let arms = map_arm_poses(MapHold::OneHanded, left, 0.0, inverse_height, attack);
|
||||
assert_eq!(arms.len(), 1, "one arm under a one-handed map");
|
||||
assert_eq!(arms[0].0, left, "the arm of the hand holding it");
|
||||
near(
|
||||
arms[0].1,
|
||||
want,
|
||||
&format!("one {left} {inverse_height} {attack}"),
|
||||
);
|
||||
}
|
||||
// (pitch, inverse height, attack, right arm, left arm)
|
||||
#[rustfmt::skip]
|
||||
let two: [(f32, f32, f32, [f32; 16], [f32; 16]); 2] = [
|
||||
(0.0, 0.0, 0.0, [
|
||||
-0.7380599, 0.44766384, 0.50484115, 0.0,
|
||||
-0.67428374, -0.46198538, -0.57611704, 0.0,
|
||||
-0.024677532, -0.76561517, 0.64282537, 0.0,
|
||||
0.5091893, -0.16204369, 0.3544525, 1.0,
|
||||
], [
|
||||
-0.77043974, -0.3951866, -0.50025004, 0.0,
|
||||
0.637035, -0.5076031, -0.58010805, 0.0,
|
||||
-0.024677532, -0.76561517, 0.64282537, 0.0,
|
||||
-0.48071152, -0.12760738, 0.35746527, 1.0,
|
||||
]),
|
||||
(30.0, 0.2, 0.5, [
|
||||
-0.7380599, -0.11453738, 0.6649426, 0.0,
|
||||
-0.67428374, 0.16124588, -0.72065324, 0.0,
|
||||
-0.024677532, -0.98024535, -0.1962398, 0.0,
|
||||
0.5091893, -0.9508533, -0.13439482, 1.0,
|
||||
], [
|
||||
-0.77043974, 0.14373614, -0.6210978, 0.0,
|
||||
0.637035, 0.13586378, -0.75876695, 0.0,
|
||||
-0.024677532, -0.98024535, -0.1962398, 0.0,
|
||||
-0.48071152, -0.9316926, -0.1056232, 1.0,
|
||||
]),
|
||||
];
|
||||
for (pitch, inverse_height, attack, right, left) in two {
|
||||
let arms = map_arm_poses(MapHold::TwoHanded, false, pitch, inverse_height, attack);
|
||||
assert_eq!(arms.len(), 2, "both arms under a two-handed map");
|
||||
assert_eq!((arms[0].0, arms[1].0), (false, true), "right, then left");
|
||||
near(arms[0].1, right, &format!("two right {pitch}"));
|
||||
near(arms[1].1, left, &format!("two left {pitch}"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ranged_draw_uses_native_charge_curves_and_caps() {
|
||||
for (ticks, stretch) in [
|
||||
(0.0, 1.0),
|
||||
|
||||
@@ -1200,9 +1200,10 @@ struct WorldRest {
|
||||
nether_environment: Environment,
|
||||
end_environment: Environment,
|
||||
behaviours: Behaviours,
|
||||
player_arm: Option<horizon_client::mobs::Part>,
|
||||
/// The slim model's right arm (3 pixels wide).
|
||||
player_arm_slim: Option<horizon_client::mobs::Part>,
|
||||
/// The player model's right and left arms.
|
||||
player_arm: [Option<horizon_client::mobs::Part>; 2],
|
||||
/// The slim model's right and left arms (3 pixels wide).
|
||||
player_arm_slim: [Option<horizon_client::mobs::Part>; 2],
|
||||
player_type: Option<Arc<horizon_client::mobs::MobType>>,
|
||||
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
|
||||
spin_attack: Option<horizon_client::mobs::Part>,
|
||||
@@ -1275,12 +1276,13 @@ impl Resources {
|
||||
fn threads(&self) -> usize {
|
||||
self.core.threads
|
||||
}
|
||||
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
if slim {
|
||||
fn player_arm(&self, slim: bool, left: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
let arms = if slim {
|
||||
&self.rest().player_arm_slim
|
||||
} else {
|
||||
&self.rest().player_arm
|
||||
}
|
||||
};
|
||||
&arms[usize::from(left)]
|
||||
}
|
||||
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
@@ -1384,6 +1386,23 @@ fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
|
||||
world.chunks.get(&(x, z)).is_some_and(|c| c.lit)
|
||||
&& (-1..=1).all(|dz| (-1..=1).all(|dx| world.chunks.contains_key(&(x + dx, z + dz))))
|
||||
}
|
||||
/// Key events carry the scancode's US-layout code, which ignores XKB options like
|
||||
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
|
||||
/// from the keysym the keymap resolved instead.
|
||||
fn follow_keymap(event: &mut WindowEvent) {
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
if let WindowEvent::KeyboardInput { event, .. } = event
|
||||
&& let Key::Named(named) = &event.logical_key
|
||||
{
|
||||
let code = match named {
|
||||
NamedKey::Escape => KeyCode::Escape,
|
||||
NamedKey::CapsLock => KeyCode::CapsLock,
|
||||
_ => return,
|
||||
};
|
||||
event.physical_key = PhysicalKey::Code(code);
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_whole_stack(physical_keys: &HashSet<KeyCode>) -> bool {
|
||||
physical_keys.contains(&KeyCode::ControlLeft) || physical_keys.contains(&KeyCode::ControlRight)
|
||||
}
|
||||
@@ -1432,6 +1451,9 @@ struct Game<'a> {
|
||||
lod: Option<horizon_client::lod::Lod>,
|
||||
lod_source_attempted: bool,
|
||||
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Beacon beam sections kept after their chunk unloads, so far terrain
|
||||
/// LOD still draws their beams (`beacon_geometry` refreshes them).
|
||||
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
|
||||
/// The detail revision, centre and radius `Lod::update` last had covered for.
|
||||
lod_covered_at: Option<(u64, (i32, i32), i32)>,
|
||||
options: Options,
|
||||
@@ -8091,7 +8113,7 @@ impl Game<'_> {
|
||||
for m in &self.mobs {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
if (m.fire_ticks > 0 || blazing) && !m.removed {
|
||||
if (m.is_on_fire() || blazing) && !m.removed {
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
@@ -8223,11 +8245,8 @@ impl Game<'_> {
|
||||
p.z + (q.z - p.z) * a,
|
||||
)
|
||||
};
|
||||
let mut casters: Vec<(glam::DVec3, &str)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| (lerp(m.previous, m.position), m.kind.id.as_str()))
|
||||
.collect();
|
||||
let mut casters: Vec<(glam::DVec3, &str)> =
|
||||
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
|
||||
casters.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -10644,6 +10663,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.damage_held(1);
|
||||
}
|
||||
/// `renderPlayerHand` (`AvatarRenderer.renderHand`): the player's own
|
||||
/// `left` or right arm with its skin at pose `m`, in its rest pose
|
||||
/// turned 0.1 radians outwards.
|
||||
fn first_person_arm(&self, left: bool, m: glam::Mat4, light: u32, out: &mut Vec<Vertex>) {
|
||||
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
|
||||
let Some(arm) = self.res.player_arm(slim, left).as_ref() else {
|
||||
return;
|
||||
};
|
||||
// The player's own skin, as the inventory's model shows it.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let Some(r) = self
|
||||
.player_model
|
||||
.as_ref()
|
||||
.and_then(|m| m.skin.as_deref())
|
||||
.and_then(|skin| sprites.get(skin))
|
||||
.or_else(|| sprites.get("entity/player/wide/steve"))
|
||||
.copied()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let z_rot = if left { -0.1 } else { 0.1 };
|
||||
let pose = |p: &horizon_client::mobs::Part| {
|
||||
let mut pose = p.pose;
|
||||
pose[3] = 0.0;
|
||||
pose[4] = 0.0;
|
||||
pose[5] = if p.name == arm.name { z_rot } else { 0.0 };
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(arm, m, &pose, [r.u0, r.v0, r.u1, r.v1], light, 0, out);
|
||||
}
|
||||
/// FirstPersonHandsAndItemsRenderer: the right arm or the held items of
|
||||
/// both hands in view space, converted to camera-relative world space.
|
||||
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
|
||||
@@ -10687,7 +10736,6 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let shown = self.hands_to_render();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
@@ -10701,7 +10749,6 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let (attack, inverse_height) = (attacks[i], inverse_heights[i]);
|
||||
let sqrt_attack = attack.sqrt();
|
||||
// The main hand on its arm, the off hand on the other one.
|
||||
let left = self.settings.main_hand_left != (hand == Hand::Off);
|
||||
let Some(stack) = item else {
|
||||
@@ -10709,63 +10756,24 @@ impl Game<'_> {
|
||||
if hand == Hand::Off {
|
||||
continue;
|
||||
}
|
||||
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
|
||||
let Some(arm) = self.res.player_arm(slim).clone() else {
|
||||
continue;
|
||||
};
|
||||
// The player's own skin, as the inventory's model shows it.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let Some(r) = self
|
||||
.player_model
|
||||
.as_ref()
|
||||
.and_then(|m| m.skin.as_deref())
|
||||
.and_then(|skin| sprites.get(skin))
|
||||
.or_else(|| sprites.get("entity/player/wide/steve"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// renderPlayerArm (right arm)
|
||||
let x_swing = -0.3 * (sqrt_attack * std::f32::consts::PI).sin();
|
||||
let y_swing = 0.4 * (sqrt_attack * std::f32::consts::TAU).sin();
|
||||
let z_swing = -0.4 * (attack * std::f32::consts::PI).sin();
|
||||
let z_rot = (attack * attack * std::f32::consts::PI).sin();
|
||||
let y_rot = (sqrt_attack * std::f32::consts::PI).sin();
|
||||
let m = to_world
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
x_swing + 0.64,
|
||||
y_swing - 0.6 + inverse_height * -0.6,
|
||||
z_swing - 0.72,
|
||||
))
|
||||
* glam::Mat4::from_rotation_y(deg(45.0))
|
||||
* glam::Mat4::from_rotation_y(deg(y_rot * 70.0))
|
||||
* glam::Mat4::from_rotation_z(deg(z_rot * -20.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-1.0, 3.6, 3.5))
|
||||
* glam::Mat4::from_rotation_z(deg(120.0))
|
||||
* glam::Mat4::from_rotation_x(deg(200.0))
|
||||
* glam::Mat4::from_rotation_y(deg(-135.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(5.6, 0.0, 0.0));
|
||||
let pose = |p: &horizon_client::mobs::Part| {
|
||||
let mut pose = p.pose;
|
||||
pose[3] = 0.0;
|
||||
pose[4] = 0.0;
|
||||
pose[5] = 0.0;
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
&arm,
|
||||
m,
|
||||
&pose,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
* horizon_client::first_person::player_arm_pose(false, inverse_height, attack);
|
||||
self.first_person_arm(false, m, packed, &mut out);
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = maps::map_id(&stack) {
|
||||
use horizon_client::first_person::{MapHold, one_handed_map_pose};
|
||||
match MapHold::of(hand, off_hand_empty) {
|
||||
use horizon_client::first_person::{MapHold, map_arm_poses, one_handed_map_pose};
|
||||
let hold = MapHold::of(hand, off_hand_empty);
|
||||
// renderOneHandedMap and renderTwoHandedMap: the arms under
|
||||
// the map, unless the player is invisible.
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
|
||||
{
|
||||
self.first_person_arm(arm, to_world * pose, packed, &mut out);
|
||||
}
|
||||
}
|
||||
match hold {
|
||||
MapHold::TwoHanded => self.two_handed_map_geometry(
|
||||
to_world,
|
||||
attack,
|
||||
@@ -10846,6 +10854,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
_ if spinning => ItemPose::SpinAttack,
|
||||
// ItemInHandRenderer: a STAB swing of this hand jabs.
|
||||
_ if self
|
||||
.swing
|
||||
.current
|
||||
.is_some_and(|c| c.hand == hand && c.animation.kind == 2) =>
|
||||
{
|
||||
ItemPose::Stab
|
||||
}
|
||||
_ => ItemPose::Swing,
|
||||
};
|
||||
let m = to_world
|
||||
@@ -11286,6 +11302,38 @@ impl Game<'_> {
|
||||
batch.fill(atlas, x - 3.0, y + 10.0, w + 6.0, 1.0, rgba(40, 0, 127, 80));
|
||||
batch.text(atlas, text, x, y, u32::MAX, true);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
|
||||
/// that follow the item's name, none for a category tab's own grid slots
|
||||
/// (`grid`): the names of the tabs containing the stack, in blue, then on
|
||||
/// the search tab's grid the tags a `#` search shows, in dark purple.
|
||||
fn creative_tooltip_tabs(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
grid: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let screen = &self.creative_screen;
|
||||
let tab = screen.tab();
|
||||
let category = !tab.search && !tab.inventory;
|
||||
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
|
||||
return Vec::new();
|
||||
}
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
let dark_purple = rgba(170, 0, 170, 255);
|
||||
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
|
||||
.tabs_containing(stack)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let title = self.creative_tab_title(&screen.tabs[i]);
|
||||
(horizon_client::text::Text::literal(title), blue)
|
||||
})
|
||||
.collect();
|
||||
if tab.search && grid {
|
||||
for tag in screen.search_tag_lines(stack) {
|
||||
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let selected = self.creative_screen.selected;
|
||||
@@ -11368,7 +11416,8 @@ impl Game<'_> {
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
let mut hovered: Option<ItemStack> = None;
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
@@ -11418,7 +11467,7 @@ impl Game<'_> {
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = Some(stack.clone());
|
||||
hovered = Some((stack.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11432,7 +11481,7 @@ impl Game<'_> {
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = self.inventory[c].clone();
|
||||
hovered = self.inventory[c].clone().map(|s| (s, false));
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -11467,13 +11516,18 @@ impl Game<'_> {
|
||||
if tab.inventory {
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
self.tooltip(batch, "Destroy Item", self.cursor.0, self.cursor.1);
|
||||
} else {
|
||||
self.draw_slot_tooltip(batch);
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(batch, &stack, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(item) = hovered
|
||||
&& let Some((item, grid)) = hovered
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
self.draw_stack_tooltip_with(batch, &item, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
@@ -12089,21 +12143,7 @@ impl Game<'_> {
|
||||
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
|
||||
cave_culling,
|
||||
water_fog,
|
||||
fog: Some({
|
||||
let mut fog = self.frame_fog(camera, &sky, biome, water_fog);
|
||||
// Horizon's fog setting: no distance fog (fluids keep theirs).
|
||||
if !self.settings.horizon.fog
|
||||
&& !underwater
|
||||
&& self.darkness_fog().is_none()
|
||||
&& !self.boss_overlay.should_create_world_fog()
|
||||
&& self.potions.effects.get("blindness").is_none()
|
||||
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
|
||||
{
|
||||
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
fog
|
||||
}),
|
||||
fog: Some(self.frame_fog(camera, &sky, biome, water_fog)),
|
||||
dark_disc: self.dark_disc(camera),
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
@@ -12587,6 +12627,7 @@ impl Game<'_> {
|
||||
self.lod = None;
|
||||
self.lod_source_attempted = false;
|
||||
self.lod_observe.clear();
|
||||
self.far_beacons.clear();
|
||||
self.lod_covered_at = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.clear_lods();
|
||||
@@ -12836,10 +12877,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.apply_settings();
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
|
||||
if self.window.as_ref().is_none_or(|w| w.id() != id) {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
@@ -13955,6 +13997,7 @@ impl<'a> Game<'a> {
|
||||
streaming,
|
||||
lod: None,
|
||||
lod_source_attempted: false,
|
||||
far_beacons: HashMap::new(),
|
||||
lod_observe: Default::default(),
|
||||
lod_covered_at: None,
|
||||
options,
|
||||
@@ -14883,10 +14926,8 @@ fn load_world_rest(
|
||||
.collect(),
|
||||
mob_data,
|
||||
player_type: perspective::player_type(root),
|
||||
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
player_arm: player_arms(root, "minecraft:player#main"),
|
||||
player_arm_slim: player_arms(root, "minecraft:player_slim#main"),
|
||||
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
})
|
||||
}
|
||||
@@ -15334,10 +15375,11 @@ impl Loading {
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.window = Some(window);
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
|
||||
if self.window.as_ref().is_none_or(|w| w.id() != id) {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
if let Err(error) = self.menu_event(event_loop, event) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
@@ -15732,6 +15774,17 @@ pub fn run() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The right and left arms of a player model layer (`PlayerModel.rightArm`
|
||||
/// and `leftArm`, with their sleeves).
|
||||
fn player_arms(root: &std::path::Path, key: &str) -> [Option<horizon_client::mobs::Part>; 2] {
|
||||
let model = horizon_client::mobs::load_model(root, key);
|
||||
["right_arm", "left_arm"].map(|name| {
|
||||
model
|
||||
.as_ref()
|
||||
.and_then(|m| m.children.iter().find(|c| c.name == name).cloned())
|
||||
})
|
||||
}
|
||||
|
||||
// Player.tick: cap the sleeping fade at 100, then fade out over 10 waking ticks.
|
||||
fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
|
||||
if sleeping {
|
||||
|
||||
@@ -588,10 +588,17 @@ impl Game<'_> {
|
||||
.announced
|
||||
.push(Text::translate(key, &[player, name]));
|
||||
}
|
||||
None => self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
)),
|
||||
None => {
|
||||
let text = Text::translate(key, &[self.player_display_text(), name]);
|
||||
// The guests hear of the host's advancement too.
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(
|
||||
&text.component(),
|
||||
));
|
||||
}
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +536,9 @@ impl Game<'_> {
|
||||
title
|
||||
.recent_worlds()
|
||||
.iter()
|
||||
.map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative }))
|
||||
.map(
|
||||
|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 }),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
match &title.mode {
|
||||
@@ -546,7 +548,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
} => json!({
|
||||
"mode": "select-world",
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative })).collect::<Vec<_>>(),
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
|
||||
"selected": selected,
|
||||
}),
|
||||
Mode::Create {
|
||||
|
||||
@@ -616,9 +616,6 @@ impl Game<'_> {
|
||||
};
|
||||
for a in &self.armor_stands {
|
||||
let entity_start = out.len();
|
||||
if a.invisible {
|
||||
continue;
|
||||
}
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
@@ -690,16 +687,20 @@ impl Game<'_> {
|
||||
a.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
|
||||
// layers (held items, custom head, armour) are submitted all the same.
|
||||
if !a.invisible {
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
|
||||
@@ -105,10 +105,28 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
|
||||
return;
|
||||
};
|
||||
self.tick_beacon(key, &mut b);
|
||||
self.tick_beacon(key, &mut b, false);
|
||||
self.world.block_entities.put(key, BlockEntity::Beacon(b));
|
||||
}
|
||||
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon) {
|
||||
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
|
||||
/// carries no beam sections, so the client builds them too; effects,
|
||||
/// sounds and the advancement stay with the server.
|
||||
pub(super) fn client_tick_beacons(&mut self) {
|
||||
let keys: Vec<_> = self
|
||||
.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter_map(|(k, e)| matches!(e, BlockEntity::Beacon(_)).then_some(*k))
|
||||
.collect();
|
||||
for key in keys {
|
||||
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
|
||||
continue;
|
||||
};
|
||||
self.tick_beacon(key, &mut b, true);
|
||||
self.world.block_entities.put(key, BlockEntity::Beacon(b));
|
||||
}
|
||||
}
|
||||
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon, client: bool) {
|
||||
let mut check = if b.last_check_y < y {
|
||||
b.checking.clear();
|
||||
b.last_check_y = y - 1;
|
||||
@@ -153,7 +171,7 @@ impl Game<'_> {
|
||||
if !b.sections.is_empty() {
|
||||
b.levels = self.beacon_base(x, y, z);
|
||||
}
|
||||
if b.levels > 0 && !b.sections.is_empty() {
|
||||
if !client && b.levels > 0 && !b.sections.is_empty() {
|
||||
self.beacon_effects([x, y, z], b);
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.ambient");
|
||||
}
|
||||
@@ -163,10 +181,12 @@ impl Game<'_> {
|
||||
b.sections = std::mem::take(&mut b.checking);
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
} else if was && !is {
|
||||
if !client {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
}
|
||||
} else if was && !is && !client {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
|
||||
}
|
||||
}
|
||||
@@ -551,7 +571,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
pub(super) fn beacon_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
pub(super) fn beacon_geometry(
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(s) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -563,15 +588,28 @@ impl Game<'_> {
|
||||
};
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
let view = f64::from(self.settings.render_distance * 16);
|
||||
for (k, e) in &self.world.block_entities {
|
||||
let BlockEntity::Beacon(b) = e else { continue };
|
||||
// `getBeamSections` is empty while Levels is 0.
|
||||
if b.levels == 0 || b.sections.is_empty() {
|
||||
continue;
|
||||
// `BeaconRenderer.shouldRender` culls at the render distance; far
|
||||
// terrain extends that to its own distance.
|
||||
let view = f64::from(self.terrain_distance() * 16);
|
||||
let world = &self.world;
|
||||
if self.lod.is_some() {
|
||||
// The beam stays after its chunk unloads; once the chunk is back,
|
||||
// its block entity decides whether the beam is still there.
|
||||
self.far_beacons.retain(|&pos, _| {
|
||||
let (x, _, z) = pos;
|
||||
!world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
|| world.block_entities.get(&pos).and_then(beam).is_some()
|
||||
});
|
||||
} else {
|
||||
self.far_beacons.clear();
|
||||
}
|
||||
for (k, e) in &world.block_entities {
|
||||
if let Some(sections) = beam(e) {
|
||||
self.far_beacons.insert(*k, sections.to_vec());
|
||||
}
|
||||
}
|
||||
for (k, sections) in &self.far_beacons {
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
|
||||
let offset = base - camera;
|
||||
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
|
||||
if horizontal >= view {
|
||||
@@ -586,14 +624,30 @@ impl Game<'_> {
|
||||
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
origin,
|
||||
&b.sections,
|
||||
time,
|
||||
scale,
|
||||
s,
|
||||
out,
|
||||
);
|
||||
horizon_client::block_entity_render::beacon_beam(origin, sections, time, scale, s, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
|
||||
/// a beacon without a pyramid or with all sections cleared).
|
||||
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
|
||||
match e {
|
||||
BlockEntity::Beacon(b) if b.levels > 0 && !b.sections.is_empty() => Some(&b.sections),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_active_beacons_have_beams() {
|
||||
let mut b = Beacon::default();
|
||||
assert!(beam(&BlockEntity::Beacon(Box::new(b.clone()))).is_none());
|
||||
b.levels = 1;
|
||||
b.sections.push((0xFFFFFF, 10));
|
||||
assert_eq!(
|
||||
beam(&BlockEntity::Beacon(Box::new(b))),
|
||||
Some(&[(0xFFFFFF, 10)][..])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -428,8 +428,18 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
/// The block the player is looking at, for coordinate suggestions.
|
||||
/// `ClientSuggestionProvider.getRelevantCoordinates` and
|
||||
/// `getAbsoluteCoordinates` read `Minecraft.hitResult` only when it is a
|
||||
/// block: an entity in front of the block leaves the `~` defaults.
|
||||
fn refresh_dynamic_registries(&mut self) {
|
||||
self.ui.registries.target_block = self.pick().map(|h| h.pos);
|
||||
let hit = if self.pick_entity_hit().is_some() {
|
||||
None
|
||||
} else {
|
||||
self.pick()
|
||||
};
|
||||
self.ui.registries.target_block = hit.as_ref().map(|h| h.pos);
|
||||
self.ui.registries.target_point =
|
||||
hit.map(|h| h.point.map(horizon_client::java_text::format_fixed2));
|
||||
// ClientPacketListener.getOnlinePlayerNames on a server.
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
@@ -1138,9 +1148,19 @@ impl Game<'_> {
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
// ServerPlayer.die: a team that hides death messages keeps it out of
|
||||
// the dying player's own chat (the death screen still shows it).
|
||||
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
|
||||
// ServerPlayer.die: the team's death message visibility decides who
|
||||
// hears it, the dying player's own chat included (the death screen
|
||||
// still shows it).
|
||||
let board = &self.score.board;
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.system_message_to(
|
||||
horizon_client::lan::data::component_nbt(&message.component()),
|
||||
|other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
|
||||
},
|
||||
);
|
||||
}
|
||||
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,24 +925,7 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
"say" | "me" | "msg" => self.cmd_say(id, ctx),
|
||||
"list" => {
|
||||
let names = if ctx.has("uuids") {
|
||||
Text::translate(
|
||||
"commands.list.nameAndId",
|
||||
&[
|
||||
Text::literal(PLAYER_NAME),
|
||||
Text::literal(self.player_uuid_string()),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
Text::literal(PLAYER_NAME)
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"commands.list.players",
|
||||
&[Text::literal("1"), Text::literal("8"), names],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
"list" => self.cmd_list(ctx),
|
||||
_ => Err(err("command.failed", &[])),
|
||||
}
|
||||
}
|
||||
@@ -3099,6 +3082,63 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// ListPlayersCommand: every online player, the host of a hosted world
|
||||
/// first and then the guests in join order, with the count and the max.
|
||||
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
|
||||
// A single-player owner is a placed guest of the in-process server, so
|
||||
// it is the host and needs no entry of its own.
|
||||
let owner_listed = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
|
||||
// The host is online unless this is a dedicated server, which has none.
|
||||
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
|
||||
Some(h) => (h.host_uuid, h.host_name.clone()),
|
||||
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
|
||||
});
|
||||
let guests = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.clone()));
|
||||
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
|
||||
let max = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(horizon_client::lan::MAX_PLAYERS, |h| {
|
||||
h.hub.lan.shared.max_players()
|
||||
});
|
||||
let uuids = ctx.has("uuids");
|
||||
let mut names = Text::empty();
|
||||
for (k, (uuid, name)) in players.iter().enumerate() {
|
||||
if k > 0 {
|
||||
// ComponentUtils.formatList: gray commas.
|
||||
names.push(Text::colored(", ", text::GRAY));
|
||||
}
|
||||
if uuids {
|
||||
names.push(Text::translate(
|
||||
"commands.list.nameAndId",
|
||||
&[
|
||||
Text::literal(name.as_str()),
|
||||
Text::literal(horizon_protocol::crypto::uuid_string(*uuid)),
|
||||
],
|
||||
));
|
||||
} else {
|
||||
names.push(self.player_text(name, *uuid));
|
||||
}
|
||||
}
|
||||
let count = players.len();
|
||||
self.system_message(Text::translate(
|
||||
"commands.list.players",
|
||||
&[
|
||||
Text::literal(count.to_string()),
|
||||
Text::literal(max.to_string()),
|
||||
names,
|
||||
],
|
||||
));
|
||||
Ok(count as i32)
|
||||
}
|
||||
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
|
||||
@@ -1115,10 +1115,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The player's profile id as `UUID.toString()`: the saved id, else the
|
||||
/// offline id of the player's name.
|
||||
pub(in crate::game) fn player_uuid_string(&self) -> String {
|
||||
let id = self.level.player_uuid.map_or_else(
|
||||
/// The player's profile id: the saved id, else the offline id of the
|
||||
/// player's name.
|
||||
pub(in crate::game) fn player_uuid(&self) -> u128 {
|
||||
self.level.player_uuid.map_or_else(
|
||||
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|
||||
|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
@@ -1126,15 +1126,6 @@ impl Game<'_> {
|
||||
| (u128::from(c as u32) << 32)
|
||||
| u128::from(d as u32)
|
||||
},
|
||||
);
|
||||
let h = format!("{id:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
impl Game<'_> {
|
||||
/// ComponentArgument with selectors resolved for the source
|
||||
/// (ComponentUtils.updateForEntity: names joined by ", ").
|
||||
fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
|
||||
pub(in crate::game) fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
|
||||
let Some(Arg::Str(json)) = ctx.get(name) else {
|
||||
return Ok(Text::empty());
|
||||
};
|
||||
|
||||
@@ -124,6 +124,21 @@ impl Game<'_> {
|
||||
"freeze" => DamageKind::Freeze,
|
||||
"in_wall" => DamageKind::InWall,
|
||||
"thorns" => DamageKind::Thorns,
|
||||
"cramming" => DamageKind::Cramming,
|
||||
"falling_anvil" => DamageKind::FallingAnvil,
|
||||
"falling_block" => DamageKind::FallingBlock,
|
||||
"fireworks" => DamageKind::Fireworks,
|
||||
"generic_kill" => DamageKind::GenericKill,
|
||||
"lightning_bolt" => DamageKind::LightningBolt,
|
||||
"out_of_world" => DamageKind::OutOfWorld,
|
||||
"outside_border" => DamageKind::OutsideBorder,
|
||||
"sonic_boom" => DamageKind::SonicBoom,
|
||||
"spit" => DamageKind::Spit,
|
||||
"stalagmite" => DamageKind::Stalagmite,
|
||||
"starve" => DamageKind::Starve,
|
||||
"sulfur_cube_hot" => DamageKind::SulfurCubeHot,
|
||||
"wind_charge" => DamageKind::WindCharge,
|
||||
"wither" => DamageKind::Wither,
|
||||
_ => DamageKind::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
use super::{Dim, Game, view_vector};
|
||||
use super::{Dim, Game, dispenser, view_vector};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -857,7 +857,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.score_entity_removed(m.id);
|
||||
self.raider_died(m, by_player);
|
||||
self.villager_murdered(m, by_player);
|
||||
self.villager_murdered(m, by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
@@ -1019,7 +1019,13 @@ impl Game<'_> {
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
|
||||
// Every death drop is its own ItemEntity, and the drop
|
||||
// entity's own random (not the level's) gives it the
|
||||
// constructor's horizontal push (its motion otherwise
|
||||
// stays [0, 0.2, 0] and the drops land stacked at the
|
||||
// mob's centre, without disturbing the level random that
|
||||
// the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
@@ -1125,7 +1131,9 @@ impl Game<'_> {
|
||||
.loot_table(table, false, None, &mut self.world.random),
|
||||
};
|
||||
for d in stacks {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
// The ItemEntity constructor's motion, from the entity's own
|
||||
// random (not the level's).
|
||||
self.spawn_item(d, at, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
}
|
||||
/// Adds a projectile, giving it an id from the entity counter
|
||||
@@ -1469,7 +1477,11 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
[0.0, 0.2, 0.0],
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
),
|
||||
MobEvent::DropGift { table, at } => {
|
||||
@@ -1478,14 +1490,9 @@ impl Game<'_> {
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for stack in stacks {
|
||||
// The ItemEntity constructor's random motion.
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
0.2,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, at, v, 10);
|
||||
// The ItemEntity constructor's motion, from the
|
||||
// entity's own random (not the level's).
|
||||
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
}
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
@@ -4877,6 +4884,17 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Freeze => "minecraft:freeze",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
K::Cramming => "minecraft:cramming",
|
||||
K::FallingAnvil => "minecraft:falling_anvil",
|
||||
K::Fireworks => "minecraft:fireworks",
|
||||
K::GenericKill => "minecraft:generic_kill",
|
||||
K::LightningBolt => "minecraft:lightning_bolt",
|
||||
K::OutsideBorder => "minecraft:outside_border",
|
||||
K::Spit => "minecraft:spit",
|
||||
K::Stalagmite => "minecraft:stalagmite",
|
||||
K::Starve => "minecraft:starve",
|
||||
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::Wither => "minecraft:wither",
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
|
||||
@@ -572,6 +572,75 @@ impl Game<'_> {
|
||||
.chain(self.fns.enabled.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
/// `FileUtil.isPathPartPortable`: no trailing dot and no Windows device
|
||||
/// name (CON, PRN, AUX, NUL, COM and LPT with a digit from 1 to 9).
|
||||
fn is_portable_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default();
|
||||
let device = match stem.len() {
|
||||
3 => matches!(stem, "com" | "con" | "prn" | "aux" | "nul"),
|
||||
4 => matches!(&stem[..3], "com" | "lpt") && matches!(stem.as_bytes()[3], b'1'..=b'9'),
|
||||
_ => false,
|
||||
};
|
||||
!device && !name.ends_with('.')
|
||||
}
|
||||
/// `DataPackCommand.createPack`: a pack folder with its `data` folder and a
|
||||
/// `pack.mcmeta` in the world's datapacks folder.
|
||||
fn create_pack(&mut self, ctx: &Context) -> R<i32> {
|
||||
let id = match ctx.get("id") {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let description = self.component_arg(ctx, "description")?;
|
||||
// FileUtil.isValidPathSegment: lowercase letters, digits, '-', '.' and '_'.
|
||||
let valid = !id.is_empty()
|
||||
&& id != "."
|
||||
&& id != ".."
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'));
|
||||
if !valid {
|
||||
return Err(err("commands.datapack.create.invalid_name", &[&id]));
|
||||
}
|
||||
if !Self::is_portable_name(&id) {
|
||||
return Err(err("commands.datapack.create.invalid_full_name", &[&id]));
|
||||
}
|
||||
// Without a world there is no datapacks folder to write to.
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return Err(err("commands.datapack.create.io_failure", &[&id]));
|
||||
}
|
||||
let datapacks = self.world_root.join("datapacks");
|
||||
let pack = datapacks.join(&id);
|
||||
if pack.exists() {
|
||||
return Err(err("commands.datapack.create.already_exists", &[&id]));
|
||||
}
|
||||
// The data pack version (SharedConstants), written as a bare major
|
||||
// number for both bounds, as vanilla does.
|
||||
let pack_format = horizon_core::version::DATA_PACK_FORMAT.0;
|
||||
let mcmeta = serde_json::json!({
|
||||
"pack": {
|
||||
"description": description.component(),
|
||||
"min_format": pack_format,
|
||||
"max_format": pack_format,
|
||||
}
|
||||
});
|
||||
let written = std::fs::create_dir_all(&datapacks)
|
||||
.and_then(|()| std::fs::create_dir(&pack))
|
||||
.and_then(|()| std::fs::create_dir(pack.join("data")))
|
||||
.and_then(|()| {
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
serde_json::to_string_pretty(&mcmeta).unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
if written.is_err() {
|
||||
return Err(err("commands.datapack.create.io_failure", &[&id]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.datapack.create.success",
|
||||
&[Text::literal(id)],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
pub(super) fn cmd_datapack(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
self.ensure_datapacks();
|
||||
let name = match ctx.get("name") {
|
||||
@@ -590,6 +659,7 @@ impl Game<'_> {
|
||||
|| g.fns.packs.iter().any(|p| p.id == n)
|
||||
};
|
||||
match id {
|
||||
"datapack.create" => self.create_pack(ctx),
|
||||
"datapack.list" => {
|
||||
let want_enabled = ctx.has("enabled") || (!ctx.has("available"));
|
||||
let want_available = ctx.has("available") || (!ctx.has("enabled"));
|
||||
|
||||
@@ -10,12 +10,14 @@ use horizon_client::{
|
||||
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
|
||||
const OFFHAND: usize = usize::MAX;
|
||||
|
||||
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
|
||||
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
|
||||
/// `AttackRange.effectiveMinRange`, `effectiveMaxRange` (for a player) and
|
||||
/// `hitboxMargin` of an `attack_range` component.
|
||||
fn attack_reach(range: &serde_json::Value, creative: bool) -> (f32, f32, f32) {
|
||||
let value = |key: &str, default: f32| range[key].as_f64().map_or(default, |v| v as f32);
|
||||
let (min, max) = if creative {
|
||||
(
|
||||
@@ -25,7 +27,34 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
|
||||
} else {
|
||||
(value("min_reach", 0.0), value("max_reach", 3.0))
|
||||
};
|
||||
let margin = value("hitbox_margin", 0.3);
|
||||
(min, max, value("hitbox_margin", 0.3))
|
||||
}
|
||||
|
||||
fn v3(v: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(v[0], v[1], v[2])
|
||||
}
|
||||
|
||||
/// `LocalPlayer.raycastHitResult` with an item that has an attack range
|
||||
/// (`AttackRange.getClosesetHit`): the distance from the eye to the nearest
|
||||
/// box hit along the item's own `reach`, which goes past the plain pick's
|
||||
/// 3 blocks (a spear's 4.5). A miss falls back to the plain pick (`None`).
|
||||
fn reach_pick(
|
||||
boxes: &[(usize, Aabb)],
|
||||
eye: Vec3,
|
||||
look: Vec3,
|
||||
movement: Vec3,
|
||||
reach: (f32, f32, f32),
|
||||
clip: impl Fn(Vec3, Vec3) -> Option<Vec3>,
|
||||
) -> Option<f64> {
|
||||
super::spear::hits_in_reach(boxes, eye, look, movement, reach, clip)
|
||||
.into_iter()
|
||||
.map(|(_, at)| eye.distance_squared(at).sqrt())
|
||||
.reduce(f64::min)
|
||||
}
|
||||
|
||||
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
|
||||
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
|
||||
let (min, max, margin) = attack_reach(range, creative);
|
||||
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
|
||||
}
|
||||
|
||||
@@ -261,10 +290,6 @@ impl Game<'_> {
|
||||
/// it is alive (the nearest entity under the crosshair, before any block).
|
||||
pub(super) fn crosshair_living(&self) -> Option<bool> {
|
||||
let nearest = |hits: &[Option<f64>]| hits.iter().flatten().copied().reduce(f64::min);
|
||||
let living = nearest(&[
|
||||
self.mob_hit().map(|h| h.1),
|
||||
self.armor_stand_hit().map(|h| h.1),
|
||||
])?;
|
||||
let other = nearest(&[
|
||||
self.painting_hit().map(|h| h.1),
|
||||
self.frame_hit().map(|h| h.1),
|
||||
@@ -272,24 +297,49 @@ impl Game<'_> {
|
||||
self.cart_hit().map(|h| h.1),
|
||||
self.dragon_hit().map(|h| h.1),
|
||||
]);
|
||||
if !self.before_block(living) || other.is_some_and(|o| o <= living) {
|
||||
return None;
|
||||
}
|
||||
let active = self
|
||||
.using
|
||||
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
|
||||
.or_else(|| self.held());
|
||||
if let Some(stack) = active {
|
||||
let components = self
|
||||
.pack
|
||||
let range = active.and_then(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack));
|
||||
if let Some(range) = components.get("minecraft:attack_range")
|
||||
&& !attack_range_contains(range, living, self.creative)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
let spear = range.as_ref().and_then(|r| {
|
||||
let eye = self.eye_position()?;
|
||||
let look = super::view_vector(self.yaw, self.pitch);
|
||||
let reach = attack_reach(r, self.creative);
|
||||
let boxes = self.hittable_mob_boxes();
|
||||
let clip = |from: Vec3, to: Vec3| {
|
||||
let length = from.distance_squared(to).sqrt();
|
||||
let d = to.subtract(from).scale(1.0 / length);
|
||||
(length > 0.0)
|
||||
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
|
||||
.flatten()
|
||||
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
|
||||
};
|
||||
let movement = self.known_player_movement();
|
||||
reach_pick(&boxes, v3(eye), v3(look), movement, reach, clip)
|
||||
});
|
||||
let living = match spear {
|
||||
Some(distance) => distance,
|
||||
None => nearest(&[
|
||||
self.mob_hit().map(|h| h.1),
|
||||
self.armor_stand_hit().map(|h| h.1),
|
||||
])?,
|
||||
};
|
||||
if (spear.is_none() && !self.before_block(living)) || other.is_some_and(|o| o <= living) {
|
||||
return None;
|
||||
}
|
||||
// Hud.extractCrosshair: with an attack range, the hit must be in it.
|
||||
if let Some(range) = &range
|
||||
&& !attack_range_contains(range, living, self.creative)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Only alive mobs are hit, so a picked living entity is alive.
|
||||
Some(true)
|
||||
@@ -564,6 +614,40 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A zombie-sized box whose near face is `distance` blocks ahead of an
|
||||
/// eye at the origin looking along +z.
|
||||
fn mob_ahead(distance: f64) -> Vec<(usize, Aabb)> {
|
||||
let min = Vec3::new(-0.3, -1.0, distance);
|
||||
vec![(0, Aabb::new(min, min.add_vector(Vec3::new(0.6, 1.95, 0.6))))]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spear_picks_a_mob_past_the_plain_reach() {
|
||||
// #3209: a mob 4 blocks ahead is out of the plain pick's 3 blocks but
|
||||
// within a spear's attack range (min 2, max 4.5, margin 0.125), so the
|
||||
// crosshair picks it and the attack indicator shows.
|
||||
let spear = serde_json::json!({
|
||||
"min_reach": 2.0, "max_reach": 4.5,
|
||||
"min_creative_reach": 2.0, "max_creative_reach": 6.5,
|
||||
"hitbox_margin": 0.125,
|
||||
});
|
||||
let (eye, look) = (Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0));
|
||||
let boxes = mob_ahead(4.0);
|
||||
let reach = attack_reach(&spear, false);
|
||||
let pick = reach_pick(&boxes, eye, look, Vec3::ZERO, reach, |_, _| None);
|
||||
assert_eq!(pick, Some(4.0));
|
||||
assert!(attack_range_contains(&spear, 4.0, false));
|
||||
// Past the maximum plus the margin: no pick.
|
||||
let far = mob_ahead(4.7);
|
||||
assert_eq!(
|
||||
reach_pick(&far, eye, look, Vec3::ZERO, reach, |_, _| None),
|
||||
None
|
||||
);
|
||||
// A block nearer than the minimum reach takes the pick.
|
||||
let wall = |from: Vec3, _: Vec3| Some(from.add_vector(Vec3::new(0.0, 0.0, 1.0)));
|
||||
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_range_uses_inclusive_float_bounds() {
|
||||
let range = serde_json::json!({
|
||||
|
||||
@@ -328,7 +328,7 @@ fn mob_meta(
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
|
||||
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
|
||||
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0)
|
||||
let shared = i8::from(m.is_on_fire())
|
||||
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
|
||||
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
|
||||
| i8::from(m.is_currently_glowing()) << 6;
|
||||
@@ -801,6 +801,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
|
||||
// the shaking of the freezing conversion.
|
||||
"minecraft:skeleton" => {
|
||||
if e.counter2 > 0 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
|
||||
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
|
||||
@@ -1877,8 +1877,11 @@ impl Game<'_> {
|
||||
// ListPlayersCommand needs no permission and lists every player.
|
||||
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
|
||||
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
|
||||
let me = (self.options.dedicated.is_none() && self.player.is_some())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
// A single-player owner is a placed guest already: no host entry.
|
||||
let me = (self.options.dedicated.is_none()
|
||||
&& self.player.is_some()
|
||||
&& host.hub.singleplayer_owner.is_none())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
let content = host.hub.list_reply(me, words.len() == 2);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -2361,14 +2364,25 @@ impl Game<'_> {
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
let board = &self.score.board;
|
||||
host.hub.system_message_to(message.clone(), |other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, &name, other)
|
||||
});
|
||||
if self.options.dedicated.is_some() {
|
||||
// Only a message for everyone is logged (`broadcastSystemMessage`).
|
||||
if board.players_team(&name).is_none_or(|t| {
|
||||
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
|
||||
}) {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
} else if self.player.is_some()
|
||||
&& horizon_client::scoreboard::death_message_reaches(
|
||||
board,
|
||||
&name,
|
||||
super::chat::PLAYER_NAME,
|
||||
)
|
||||
{
|
||||
self.system_message(text);
|
||||
}
|
||||
message
|
||||
} else {
|
||||
|
||||
@@ -167,6 +167,24 @@ impl Game<'_> {
|
||||
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
e.meta.extend(stand_poses(a));
|
||||
// Entity.DATA_CUSTOM_NAME and DATA_CUSTOM_NAME_VISIBLE: a hologram's text.
|
||||
if let Some(name) = &a.custom_name {
|
||||
e.meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(horizon_client::scoreboard::json_to_tag(
|
||||
&name.component(),
|
||||
))),
|
||||
));
|
||||
if a.name_visible {
|
||||
e.meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// A name taken away or hidden again (`/data`) is sent as none / false.
|
||||
e.defaults
|
||||
.push((2, serializer::OPTIONAL_COMPONENT, Value::OptText(None)));
|
||||
e.defaults
|
||||
.push((3, serializer::BOOLEAN, Value::Bool(false)));
|
||||
// A pose put back to its default (`/data`) is sent as that default.
|
||||
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
|
||||
|
||||
@@ -1308,7 +1308,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
/// arms under it are not drawn).
|
||||
/// arms under it: `first_person::map_arm_poses`).
|
||||
pub(super) fn two_handed_map_geometry(
|
||||
&mut self,
|
||||
to_world: glam::Mat4,
|
||||
@@ -1318,22 +1318,11 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let sqrt_attack = attack.sqrt();
|
||||
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
|
||||
let z_swing = -0.4 * (sqrt_attack * std::f32::consts::PI).sin();
|
||||
// calculateMapTilt
|
||||
let tilt = (1.0 - self.pitch / 45.0 + 0.1).clamp(0.0, 1.0);
|
||||
let tilt = -(tilt * std::f32::consts::PI).cos() * 0.5 + 0.5;
|
||||
let base = to_world
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -y_swing / 2.0, z_swing))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
0.0,
|
||||
0.04 + inverse_height * -1.2 + tilt * -0.5,
|
||||
-0.72,
|
||||
))
|
||||
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
|
||||
let swing = (sqrt_attack * std::f32::consts::PI).sin();
|
||||
* horizon_client::first_person::two_handed_map_base(self.pitch, inverse_height, attack);
|
||||
let swing = (attack.sqrt() * std::f32::consts::PI).msin();
|
||||
let m = base
|
||||
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
|
||||
|
||||
@@ -1227,6 +1227,20 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
diffuse: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
|
||||
}
|
||||
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn block_model_vertices_tinted(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
m: glam::Mat4,
|
||||
light: u32,
|
||||
diffuse: bool,
|
||||
tint: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
@@ -1253,6 +1267,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
0xFF00_0000 | shade << 16 | shade << 8 | shade
|
||||
};
|
||||
let color = if face.quad.tint_index >= 0 {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
horizon_client::hud_layout::multiply(color, tint)
|
||||
} else {
|
||||
color
|
||||
};
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as baked block models.
|
||||
//! Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! `MushroomCowMushroomLayer`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
@@ -9,46 +9,6 @@ use horizon_client::{
|
||||
mobs::{Mob, Species, render},
|
||||
};
|
||||
|
||||
/// The six faces of the unit cube with their shade: top, bottom, the two
|
||||
/// z faces, the two x faces.
|
||||
const FACES: [([[f32; 3]; 4], f32, u8); 6] = [
|
||||
(
|
||||
[[0., 1., 1.], [1., 1., 1.], [1., 1., 0.], [0., 1., 0.]],
|
||||
1.0,
|
||||
0,
|
||||
),
|
||||
(
|
||||
[[0., 0., 0.], [1., 0., 0.], [1., 0., 1.], [0., 0., 1.]],
|
||||
0.5,
|
||||
0,
|
||||
),
|
||||
(
|
||||
[[1., 0., 0.], [0., 0., 0.], [0., 1., 0.], [1., 1., 0.]],
|
||||
0.8,
|
||||
1,
|
||||
),
|
||||
(
|
||||
[[0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
|
||||
0.8,
|
||||
1,
|
||||
),
|
||||
(
|
||||
[[0., 0., 0.], [0., 0., 1.], [0., 1., 1.], [0., 1., 0.]],
|
||||
0.6,
|
||||
2,
|
||||
),
|
||||
(
|
||||
[[1., 0., 1.], [1., 0., 0.], [1., 1., 0.], [1., 1., 1.]],
|
||||
0.6,
|
||||
2,
|
||||
),
|
||||
];
|
||||
/// The sprites of a cube's top, z sides and x sides, and the top's tint.
|
||||
struct Cube<'a> {
|
||||
sprites: [&'a str; 3],
|
||||
tint: u32,
|
||||
}
|
||||
|
||||
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
|
||||
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
|
||||
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
@@ -178,38 +138,6 @@ impl Game<'_> {
|
||||
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
|
||||
}
|
||||
}
|
||||
fn push_cube(
|
||||
&self,
|
||||
t: glam::Mat4,
|
||||
cube: &Cube,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
for (corners, shade, side) in FACES {
|
||||
let name = cube.sprites[usize::from(side)];
|
||||
let Some(r) = self.gui.atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let tint = if side == 0 { cube.tint } else { 0xFF_FFFF };
|
||||
let c = |sft: u32| (((tint >> sft) & 255) as f32 * shade) as u32;
|
||||
let color = 0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16);
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
for (corner, uv) in corners.iter().zip(uvs) {
|
||||
let p = t.transform_point3(glam::Vec3::from(*corner));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The blocks one mob holds or carries.
|
||||
pub(super) fn mob_block_geometry(
|
||||
&self,
|
||||
@@ -222,11 +150,13 @@ impl Game<'_> {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.block_face_sprite(state)
|
||||
&& let Some((_, tint)) = self.block_face_sprite(state)
|
||||
{
|
||||
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint.
|
||||
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint;
|
||||
// the block's baked model (seed 42) on the unit cube, not centred.
|
||||
let base = render::base_transform(mob, alpha as f32, camera);
|
||||
let t = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
|
||||
@@ -234,20 +164,43 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
|
||||
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
|
||||
self.push_cube(
|
||||
t,
|
||||
&Cube {
|
||||
sprites: [&sprite, &sprite, &sprite],
|
||||
tint,
|
||||
},
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
* glam::Mat4::from_rotation_y(90.0f32.to_radians());
|
||||
self.block_model_vertices_tinted(state, 42, t, light, true, tint, out);
|
||||
}
|
||||
}
|
||||
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
|
||||
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
|
||||
fn golem_flower_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
if mob.species() != Species::IronGolem
|
||||
|| mob.extra.counter <= 0
|
||||
|| horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(state) = self.pack.assets.registry.default_state("minecraft:poppy") else {
|
||||
return;
|
||||
};
|
||||
let Some(arm) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "right_arm")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let t = arm
|
||||
* Mat4::from_translation(Vec3::new(-1.1875, 1.0625, -0.9375))
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_x((-90.0f32).to_radians())
|
||||
* Mat4::from_translation(Vec3::splat(-0.5));
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
}
|
||||
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
|
||||
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
|
||||
/// and head, as baked block models.
|
||||
@@ -387,9 +340,10 @@ impl Game<'_> {
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
use horizon_assets::item_model::DisplayContext as Context;
|
||||
// PandaHoldsItemLayer shows the item as it lies on the ground.
|
||||
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
|
||||
// on the ground.
|
||||
let (context, ordinal) = match (mob.species(), left) {
|
||||
(Species::Panda, _) => (Context::Ground, 7),
|
||||
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
|
||||
(_, true) => (Context::ThirdPersonLeftHand, 1),
|
||||
(_, false) => (Context::ThirdPersonRightHand, 2),
|
||||
};
|
||||
|
||||
@@ -1074,26 +1074,15 @@ impl Game<'_> {
|
||||
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
|
||||
}
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
let first = (scroll.amount / 12.0) as usize;
|
||||
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
|
||||
.min(l.rows.len());
|
||||
for (vi, ri) in (first..last).enumerate() {
|
||||
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
|
||||
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
|
||||
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
|
||||
}
|
||||
if l.selected == Some(ri as u32) {
|
||||
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
|
||||
}
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(l.rows[ri].clone()),
|
||||
x + 6.0,
|
||||
ry + 2.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
draw_list_rows(
|
||||
batch,
|
||||
atlas,
|
||||
self.cursor,
|
||||
&l.rows,
|
||||
l.selected,
|
||||
scroll.amount,
|
||||
(x, wy, wh),
|
||||
);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -1231,6 +1220,51 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod screen's `list` rows in its frame at (`x`, `wy`), `wh` high, scrolled by `scroll`: the
|
||||
/// rows in view, the hovered one lit and the selected one marked. As AbstractSelectionList draws
|
||||
/// its entries inside `enableScissor`, they are cut at the frame's inside: the row a scroll shows
|
||||
/// in part, and the one below a full view, never draw over the frame or below it.
|
||||
#[cfg(feature = "mods")]
|
||||
fn draw_list_rows(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
rows: &[String],
|
||||
selected: Option<u32>,
|
||||
scroll: f32,
|
||||
(x, wy, wh): (f32, f32, f32),
|
||||
) {
|
||||
let rows_top = wy + 4.0;
|
||||
let rows_bottom = wy + wh - 4.0;
|
||||
let first = (scroll / 12.0) as usize;
|
||||
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1).min(rows.len());
|
||||
let start = batch.vertices.len();
|
||||
for (vi, ri) in (first..last).enumerate() {
|
||||
let ry = rows_top + vi as f32 * 12.0 - (scroll % 12.0);
|
||||
if ui::hovered(cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
|
||||
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
|
||||
}
|
||||
if selected == Some(ri as u32) {
|
||||
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
|
||||
}
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(rows[ri].clone()),
|
||||
x + 6.0,
|
||||
ry + 2.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
batch.clip_since(
|
||||
start,
|
||||
x + 1.0,
|
||||
wy + 1.0,
|
||||
x + MOD_UI_COLUMN - 1.0,
|
||||
wy + wh - 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_TOP: f32 = 40.0;
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -1238,3 +1272,45 @@ const MOD_UI_COLUMN: f32 = 200.0;
|
||||
/// A key row's height with its gap, as the widgets below it space out.
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_KEY_ROW: f32 = 26.0;
|
||||
|
||||
#[cfg(all(test, feature = "mods"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::hud::HudAtlas;
|
||||
|
||||
#[test]
|
||||
fn list_rows_stay_inside_the_frame_scrolled_or_not() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
// #3237: 22 rows in a frame of 6 (mod_ui_layout's height for a long list). The 7th row
|
||||
// (index 6), drawn for a scroll that shows it in part, is selected and under the
|
||||
// cursor, so its fills reach past the frame unless they are cut.
|
||||
let rows: Vec<String> = (0..22).map(|i| format!("Row {i}")).collect();
|
||||
let (x, wy, wh) = (100.0, 40.0, 6.0 * 12.0 + 8.0);
|
||||
for scroll in [0.0, 6.0, 30.0] {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
let seventh_row = wy + 4.0 + 6.0 * 12.0 - scroll % 12.0;
|
||||
draw_list_rows(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
(x + 10.0, seventh_row + 2.0),
|
||||
&rows,
|
||||
Some(6 + (scroll / 12.0) as u32),
|
||||
scroll,
|
||||
(x, wy, wh),
|
||||
);
|
||||
assert!(!batch.vertices.is_empty(), "scroll {scroll}: rows drawn");
|
||||
let (top, bottom) = batch
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| v.pos[1])
|
||||
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
|
||||
assert!(
|
||||
top >= wy + 1.0 && bottom <= wy + wh - 1.0,
|
||||
"scroll {scroll}: rows span {top}..{bottom}, the frame's inside is {}..{}",
|
||||
wy + 1.0,
|
||||
wy + wh - 1.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light, mesher::Vertex, mobs::Mob, save::uuid_text,
|
||||
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
|
||||
scoreboard as sb, text::Text,
|
||||
};
|
||||
|
||||
@@ -289,7 +289,7 @@ impl Game<'_> {
|
||||
/// faded, on its background; any other in full over the world, its faded
|
||||
/// copy and background in `see_through`, over everything.
|
||||
pub(super) fn name_tag_geometry(
|
||||
&self,
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
@@ -298,26 +298,31 @@ impl Game<'_> {
|
||||
// The text alpha that goes with getBackgroundOpacity(0.25):
|
||||
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
|
||||
const FADED: u32 = 0x7F;
|
||||
let mut patches = Vec::new();
|
||||
for tag in self.name_tags(camera, alpha) {
|
||||
let o = tag.at - camera;
|
||||
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
|
||||
// background, blends.
|
||||
if tag.discrete {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
|
||||
let light = vertex_light(tag.light);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
|
||||
continue;
|
||||
}
|
||||
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
|
||||
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
|
||||
self.mark_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
|
||||
let light = vertex_light(lit);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
|
||||
}
|
||||
self.map_patches.extend(patches);
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
/// `text_alpha` in its layer, on the translucent black background
|
||||
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
|
||||
/// Returns the glyph pixels the atlas still needs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn name_tag_quads(
|
||||
&self,
|
||||
@@ -328,7 +333,7 @@ impl Game<'_> {
|
||||
text_layer: u32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
) -> Vec<horizon_client::font::Patch> {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
// The camera's facing (Camera.rotation()), turned round in third-person
|
||||
@@ -342,85 +347,50 @@ impl Game<'_> {
|
||||
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let white = self.gui.atlas.font.white();
|
||||
let away = forward.as_vec3() * 0.002;
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
for (text, lift) in lines {
|
||||
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
|
||||
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
|
||||
// Text space: x right, y down (scale 0.025, -0.025).
|
||||
let at = |x: f32, y: f32| o + r * x - u * y;
|
||||
// Component.getVisualOrderText.
|
||||
let shown = horizon_client::bidi::visual(text);
|
||||
let chars: Vec<(char, u32)> = shown
|
||||
.spans
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let rgb = s.style.color.unwrap_or(0xFF_FF_FF);
|
||||
// RGBA8, alpha in the top byte.
|
||||
let color = text_alpha << 24
|
||||
| ((rgb & 0xFF) << 16)
|
||||
| (rgb & 0xFF00)
|
||||
| ((rgb >> 16) & 0xFF);
|
||||
s.text.chars().map(move |c| (c, color))
|
||||
})
|
||||
.collect();
|
||||
let width: f32 = chars
|
||||
.iter()
|
||||
.map(|(c, _)| self.gui.atlas.glyph(*c).map_or(6.0, |g| g.1))
|
||||
.sum();
|
||||
let x0 = -(width / 2.0).trunc();
|
||||
let y0 = if text.plain() == "deadmau5" {
|
||||
-10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut quad = |a: [f32; 4], uv: [f32; 4], color: u32, behind: bool, layer: u32| {
|
||||
let corners = [(a[0], a[3]), (a[2], a[3]), (a[2], a[1]), (a[0], a[1])];
|
||||
let uvs = [
|
||||
[uv[0], uv[3]],
|
||||
[uv[2], uv[3]],
|
||||
[uv[2], uv[1]],
|
||||
[uv[0], uv[1]],
|
||||
];
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
batch.vertices.clear();
|
||||
// Font.prepareText, as any text: bold, italic, fonts and each
|
||||
// glyph's own box (accented.png's are 12 pixels tall).
|
||||
let behind = batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
);
|
||||
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
|
||||
let shift = if i * 6 < behind {
|
||||
away
|
||||
} else {
|
||||
glam::Vec3::ZERO
|
||||
};
|
||||
// The corners from the bottom left, as the world draws quads.
|
||||
for v in [quad[5], quad[2], quad[1], quad[0]] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (at(x, y) + if behind { away } else { glam::Vec3::ZERO }).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
pos: (at(v.pos[0], v.pos[1]) + shift).to_array(),
|
||||
uv: v.uv,
|
||||
layer: text_layer,
|
||||
color: v.color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
if background {
|
||||
quad(
|
||||
[x0 - 1.0, y0 - 1.0, x0 + width, y0 + 8.0],
|
||||
[white.u0, white.v0, white.u1, white.v1],
|
||||
BACKGROUND,
|
||||
true,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
let mut x = x0;
|
||||
for (ch, color) in chars {
|
||||
let Some((g, advance)) = self.gui.atlas.glyph(ch) else {
|
||||
x += 6.0;
|
||||
continue;
|
||||
};
|
||||
if ch != ' ' {
|
||||
quad(
|
||||
[x, y0, x + 8.0, y0 + 8.0],
|
||||
[g.u0, g.v0, g.u1, g.v1],
|
||||
color,
|
||||
false,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
x += advance;
|
||||
}
|
||||
}
|
||||
// Glyphs rasterized on first use (Unifont's) upload with the frame.
|
||||
batch.patches
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -305,7 +305,8 @@ impl Game<'_> {
|
||||
let _ = mods;
|
||||
}
|
||||
/// The buttons of mod `mod_id`'s settings page, under its keys: push
|
||||
/// their labels when it is yours. Asked again after each click.
|
||||
/// their labels when it is yours. Asked again each time the page opens
|
||||
/// and after each click.
|
||||
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
let _ = (mod_id, buttons);
|
||||
}
|
||||
@@ -341,9 +342,12 @@ impl Game<'_> {
|
||||
pub(super) fn native_window(&self) -> bool {
|
||||
self.options.execution_role == ExecutionRole::Client
|
||||
}
|
||||
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
|
||||
/// Whether a box in `corner` shows: F3's debug screen takes both top
|
||||
/// corners (its left and right columns), and a bottom corner while one
|
||||
/// of its charts is drawn there.
|
||||
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
|
||||
!self.debug || corner != Corner::TopLeft
|
||||
let charts = self.debug_charts.corners(!self.has_singleplayer_server());
|
||||
!debug_takes(corner, self.debug, charts)
|
||||
}
|
||||
/// The width of `text` in GUI pixels.
|
||||
pub(super) fn native_text_width(&self, text: &str) -> f32 {
|
||||
@@ -533,6 +537,14 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
self.native_settings_click(&id, button);
|
||||
self.native_settings_refresh(i);
|
||||
}
|
||||
/// Native mod `i`'s page opened: its labels asked again, so that they
|
||||
/// follow the language (and the mod's state) since the list was made.
|
||||
pub(super) fn native_settings_refresh(&mut self, i: usize) {
|
||||
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&id, &mut buttons);
|
||||
let row = self.settings.mods.installed.get_mut(i);
|
||||
@@ -602,6 +614,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether F3's debug screen, open when `debug`, covers `corner`
|
||||
/// (`DebugScreenOverlay.extractRenderState`): its left and right columns
|
||||
/// the top corners, and its charts the bottom corners `charts` (bottom left,
|
||||
/// bottom right) says they are drawn in.
|
||||
fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool {
|
||||
debug
|
||||
&& match corner {
|
||||
Corner::TopLeft | Corner::TopRight => true,
|
||||
Corner::BottomLeft => charts[0],
|
||||
Corner::BottomRight => charts[1],
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` within `lo..=hi`; `lo` when it is not a number.
|
||||
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { lo }
|
||||
@@ -625,4 +650,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's F3 draws a column in each top corner (`extractLines`, left
|
||||
/// and right aligned), and its charts along the bottom.
|
||||
#[test]
|
||||
fn the_debug_screen_takes_both_top_corners_and_its_charts_corners() {
|
||||
use super::{Corner, debug_takes};
|
||||
for corner in Corner::ALL {
|
||||
assert!(!debug_takes(corner, false, [true, true]), "{corner:?}");
|
||||
}
|
||||
assert!(debug_takes(Corner::TopLeft, true, [false, false]));
|
||||
assert!(debug_takes(Corner::TopRight, true, [false, false]));
|
||||
assert!(!debug_takes(Corner::BottomLeft, true, [false, false]));
|
||||
assert!(!debug_takes(Corner::BottomRight, true, [false, false]));
|
||||
assert!(debug_takes(Corner::BottomLeft, true, [true, false]));
|
||||
assert!(!debug_takes(Corner::BottomRight, true, [true, false]));
|
||||
assert!(debug_takes(Corner::BottomRight, true, [false, true]));
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,7 @@ impl Game<'_> {
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeSettings(i) => self.native_settings_refresh(i),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ impl Game<'_> {
|
||||
self.player_model = Some(Mob::new(0, kind, p.position, self.yaw));
|
||||
}
|
||||
let crouching = p.pose == Pose::Crouching;
|
||||
let (position, flying) = (p.position, p.flying || p.fall_flying);
|
||||
let position = p.position;
|
||||
let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity);
|
||||
let spinning = p.auto_spin_attack_ticks > 0;
|
||||
let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0);
|
||||
@@ -380,6 +380,7 @@ impl Game<'_> {
|
||||
at.y - m.position.y,
|
||||
at.z - m.position.z,
|
||||
);
|
||||
let walked = player_walk_distance(m.position, at);
|
||||
m.previous = m.position;
|
||||
m.position = at;
|
||||
if dx * dx + dy * dy + dz * dz > 100.0 {
|
||||
@@ -414,9 +415,8 @@ impl Game<'_> {
|
||||
m.body_yaw += diff - diff.signum() * 50.0;
|
||||
}
|
||||
// WalkAnimationState.update(min(distance * 4, 1), 0.4).
|
||||
let dist = (dx * dx + dz * dz + if flying { dy * dy } else { 0.0 }).sqrt() as f32;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
m.anim.attack_anim_o = attack.0;
|
||||
@@ -446,6 +446,7 @@ impl Game<'_> {
|
||||
m.extra.avatar.cloak_o[1] -= 0.125;
|
||||
}
|
||||
m.extra.swing_right_arm = swing_right_arm;
|
||||
m.extra.swing_stab = self.swing.current.is_some_and(|c| c.animation.kind == 2);
|
||||
m.hurt_time = hurt;
|
||||
}
|
||||
/// `LevelExtractor`'s skip of the camera entity: the player's own body
|
||||
@@ -792,10 +793,30 @@ fn player_preview_quads(
|
||||
quads
|
||||
}
|
||||
|
||||
/// The distance `LivingEntity.calculateEntityAnimation` walks the player's
|
||||
/// model for a move from `from` to `to`. `omnidirectionalAirMover()` is false
|
||||
/// for every player (`Entity`'s, not overridden), so only the horizontal
|
||||
/// distance counts, flying and gliding included: rising or sinking straight
|
||||
/// up or down leaves the legs and arms still.
|
||||
fn player_walk_distance(from: Vec3, to: Vec3) -> f32 {
|
||||
let (dx, dz) = (to.x - from.x, to.z - from.z);
|
||||
(dx * dx + dz * dz).sqrt() as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn flying_straight_up_does_not_walk_the_player_model() {
|
||||
let from = Vec3::new(0.5, 80.0, 0.5);
|
||||
// Creative flight straight up (#3302) and straight down.
|
||||
assert_eq!(player_walk_distance(from, Vec3::new(0.5, 80.375, 0.5)), 0.0);
|
||||
assert_eq!(player_walk_distance(from, Vec3::new(0.5, 78.5, 0.5)), 0.0);
|
||||
// Moving sideways while rising walks only the horizontal part.
|
||||
let d = player_walk_distance(from, Vec3::new(0.8, 81.0, 0.9));
|
||||
assert!((d - 0.5).abs() < 1e-6, "{d}");
|
||||
}
|
||||
#[test]
|
||||
fn preview_keeps_crossing_faces_depth_and_clips_without_dropping_faces() {
|
||||
use horizon_client::{
|
||||
hud::{Blend, HudBatch},
|
||||
|
||||
@@ -549,7 +549,10 @@ impl Game<'_> {
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.hud_disconnected();
|
||||
// Vanilla's Hud also ticks on the title and connect screens, so the
|
||||
// action bar has run out by the next world; Horizon's ticks in one.
|
||||
self.titles.overlay_time = 0;
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -574,8 +577,6 @@ impl Game<'_> {
|
||||
self.clock = Default::default();
|
||||
self.adv = Default::default();
|
||||
self.stats = Default::default();
|
||||
self.debug_charts.reset();
|
||||
self.tab_list.reset();
|
||||
self.score.board = Default::default();
|
||||
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
|
||||
self.streaming.paused = true;
|
||||
@@ -619,6 +620,17 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
Ok(())
|
||||
}
|
||||
/// `Hud.onDisconnected`, from `Minecraft.disconnect` and `clearClientLevel`
|
||||
/// (a proxy's reconfiguration too), but the chat: joining clears it and a
|
||||
/// reconfiguration keeps it (`ChatComponent.storeState`).
|
||||
fn hud_disconnected(&mut self) {
|
||||
self.tab_list.reset();
|
||||
self.boss_overlay.reset();
|
||||
self.adv.toasts.clear();
|
||||
self.debug_charts.reset();
|
||||
self.titles.clear();
|
||||
self.titles.reset_times();
|
||||
}
|
||||
|
||||
pub(super) fn send(&self, packet: S) {
|
||||
if let Some(r) = &self.remote {
|
||||
@@ -910,7 +922,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
self.boss_overlay.reset();
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard.
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
}
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
@@ -1796,8 +1816,9 @@ impl Game<'_> {
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
|
||||
// new LocalPlayer (slot 0).
|
||||
// new LocalPlayer (slot 0, no effects).
|
||||
self.selected = 0;
|
||||
self.potions.effects.clear();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
@@ -2559,7 +2580,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.swing_hand_modified(hand == Hand::Off, animation.duration);
|
||||
m.swing_hand_modified(hand == Hand::Off, animation);
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
@@ -3770,6 +3791,7 @@ impl Game<'_> {
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
self.tick_block_entity_anims();
|
||||
self.client_tick_beacons();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
|
||||
@@ -246,6 +246,11 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
|
||||
m.extra.counter2 = i32::from(*converting);
|
||||
}
|
||||
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
|
||||
// the freezing conversion's shaking.
|
||||
(Species::Skeleton, 16, Value::Bool(converting)) => {
|
||||
m.extra.counter2 = i32::from(*converting);
|
||||
}
|
||||
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
|
||||
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
|
||||
zombie_villager::set_converting(m, *converting);
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Game<'_> {
|
||||
yaw.cos() * pitch.cos(),
|
||||
]
|
||||
}
|
||||
/// `FogRenderer.setupFog` for the frame's camera.
|
||||
/// `FogRenderer.setupFog` for the frame's camera, and Horizon's Fog option.
|
||||
pub(super) fn frame_fog(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
@@ -90,39 +90,40 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.res.fog()
|
||||
};
|
||||
horizon_client::fog::setup_fog(
|
||||
tables,
|
||||
&self.fog,
|
||||
&FogInputs {
|
||||
fog_type,
|
||||
sky,
|
||||
water_fog_color,
|
||||
biome,
|
||||
render_distance_chunks: self.terrain_distance(),
|
||||
distant_terrain: self.lod.is_some(),
|
||||
cloud_range_chunks: self.settings.cloud_range,
|
||||
camera_y: camera.y,
|
||||
min_y: self.level_min_y(),
|
||||
flat: self.level_flat(),
|
||||
forward: forward.map(|c| c as f32),
|
||||
rain,
|
||||
thunder,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: self
|
||||
.potions
|
||||
.effects
|
||||
.get("blindness")
|
||||
.map(|effect| (effect.duration, effect.duration == -1)),
|
||||
darkness: self.darkness_fog(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
spectator: self.spectator(),
|
||||
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
|
||||
night_vision: self.fog_night_vision(),
|
||||
},
|
||||
)
|
||||
let input = FogInputs {
|
||||
fog_type,
|
||||
sky,
|
||||
water_fog_color,
|
||||
biome,
|
||||
render_distance_chunks: self.terrain_distance(),
|
||||
distant_terrain: self.lod.is_some(),
|
||||
cloud_range_chunks: self.settings.cloud_range,
|
||||
camera_y: camera.y,
|
||||
min_y: self.level_min_y(),
|
||||
flat: self.level_flat(),
|
||||
forward: forward.map(|c| c as f32),
|
||||
rain,
|
||||
thunder,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: self
|
||||
.potions
|
||||
.effects
|
||||
.get("blindness")
|
||||
.map(|effect| (effect.duration, effect.duration == -1)),
|
||||
darkness: self.darkness_fog(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
spectator: self.spectator(),
|
||||
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
|
||||
night_vision: self.fog_night_vision(),
|
||||
};
|
||||
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
|
||||
if !self.settings.horizon.fog {
|
||||
horizon_client::fog::clear_distance_fog(&mut fog, &input);
|
||||
}
|
||||
fog
|
||||
}
|
||||
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
|
||||
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"folder": w.folder,
|
||||
"name": w.name,
|
||||
"mode": if w.creative { "creative" } else { "survival" },
|
||||
"mode": if w.game_mode == 1 { "creative" } else { "survival" },
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
|
||||
@@ -118,14 +118,12 @@ fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
// DeathScreen.init: `width / 2 - 100`, `height / 4 + 72` and `+ 96`, in
|
||||
// int GUI pixels.
|
||||
let (x, y) = ((w.ceil() / 2.0).floor() - 100.0, (h.ceil() / 4.0).floor());
|
||||
[
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
|
||||
(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0,
|
||||
"deathScreen.titleScreen",
|
||||
1,
|
||||
),
|
||||
(x, y + 72.0, respawn, 0),
|
||||
(x, y + 96.0, "deathScreen.titleScreen", 1),
|
||||
]
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -469,7 +467,16 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, pause_buttons, pause_icons};
|
||||
use super::{Screen, death_buttons, pause_buttons, pause_icons};
|
||||
|
||||
#[test]
|
||||
fn death_buttons_sit_on_whole_gui_pixels() {
|
||||
// DeathScreen.init in ints: a 427x253 GUI (1709x1009 at scale 4) puts
|
||||
// Respawn at (113, 63 + 72), not (113.5, 63.25 + 72).
|
||||
let at = |w, h| death_buttons(w, h, false).map(|b| (b.0, b.1));
|
||||
assert_eq!(at(427.0, 253.0), [(113.0, 135.0), (113.0, 159.0)]);
|
||||
assert_eq!(at(320.0, 334.0), [(60.0, 155.0), (60.0, 179.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
|
||||
@@ -204,7 +204,7 @@ mod run {
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
out.push(ServerPlayer {
|
||||
id: self.player_uuid_string(),
|
||||
id: uuid_string(self.player_uuid()),
|
||||
name: crate::game::chat::PLAYER_NAME.to_owned(),
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
@@ -272,7 +272,7 @@ mod run {
|
||||
for event in std::mem::take(&mut self.mods.events) {
|
||||
let who = |w: &Who| {
|
||||
let id = match w {
|
||||
Who::Local => self.player_uuid_string(),
|
||||
Who::Local => uuid_string(self.player_uuid()),
|
||||
Who::Uuid(u) => uuid_string(*u),
|
||||
Who::Conn(c) => {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
@@ -490,7 +490,7 @@ mod run {
|
||||
let local = self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& self.player.is_some();
|
||||
if local && self.player_uuid_string() == id {
|
||||
if local && uuid_string(self.player_uuid()) == id {
|
||||
self.add_mod_chat_line(line);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,15 @@ fn sign_box() -> EditBox {
|
||||
edit
|
||||
}
|
||||
|
||||
/// AbstractSignEditScreen.init's Done button: `width / 2 - 100`,
|
||||
/// `height / 4 + 144`, int GUI pixels.
|
||||
fn done_button(w: f32, h: f32) -> (f32, f32) {
|
||||
(
|
||||
(w.ceil() / 2.0).floor() - 100.0,
|
||||
(h.ceil() / 4.0).floor() + 144.0,
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
|
||||
/// front of the sign's text (within 90 degrees of its facing).
|
||||
@@ -464,7 +473,7 @@ impl Game<'_> {
|
||||
/// or a click on a line (`EditBox.onClick`).
|
||||
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
|
||||
let b = HudBatch::new(size);
|
||||
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
|
||||
let (x, y) = done_button(b.width, b.height);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
|
||||
self.audio.play_button_click();
|
||||
@@ -628,7 +637,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
|
||||
let (bx, by) = done_button(w, h);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
"gui/sprites/widget/button",
|
||||
@@ -642,10 +651,20 @@ impl Game<'_> {
|
||||
batch.text(
|
||||
atlas,
|
||||
"Done",
|
||||
bx + (200.0 - tw) / 2.0,
|
||||
bx + 100.0 - (tw / 2.0).floor(),
|
||||
by + 6.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn done_button_sits_on_whole_gui_pixels() {
|
||||
// `width / 2 - 100, height / 4 + 144` in ints: 334 high is 83 + 144.
|
||||
assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0));
|
||||
assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0));
|
||||
}
|
||||
}
|
||||
@@ -99,36 +99,16 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` for the mobs: those whose box the
|
||||
/// segment `from`..`to` meets, or comes within the margin of with no
|
||||
/// block between.
|
||||
fn mobs_along(&self, from: Vec3, to: Vec3, margin: f64) -> Vec<usize> {
|
||||
let grow = Vec3::new(margin, margin, margin);
|
||||
let mut hits = Vec::new();
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
// PiercingWeapon.canHitEntity: alive, and not the vehicle ridden.
|
||||
if !m.alive() || m.removed || self.riding == Some(m.id) {
|
||||
continue;
|
||||
}
|
||||
let bounds: Aabb = m.bounds();
|
||||
let hit = bounds.contains(from)
|
||||
|| bounds.clip(from, to).is_some()
|
||||
|| (margin > 0.0
|
||||
&& bounds.inflate(grow).clip(from, to).is_some_and(|outside| {
|
||||
let centre = bounds.center();
|
||||
let toward = self.clip_collider(outside, centre).unwrap_or(centre);
|
||||
bounds.clip(outside, toward).is_some()
|
||||
}));
|
||||
if hit {
|
||||
hits.push(i);
|
||||
}
|
||||
}
|
||||
hits
|
||||
/// The boxes of the mobs a spear can hit: alive, and not the vehicle
|
||||
/// ridden (`PiercingWeapon.canHitEntity`), with their index.
|
||||
pub(super) fn hittable_mob_boxes(&self) -> Vec<(usize, Aabb)> {
|
||||
(self.mobs.iter().enumerate())
|
||||
.filter(|(_, m)| m.alive() && !m.removed && self.riding != Some(m.id))
|
||||
.map(|(i, m)| (i, m.bounds()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the
|
||||
/// mobs along the view from the minimum reach to the maximum plus the
|
||||
/// attacker's own movement, none when a block is closer than the minimum.
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`.
|
||||
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
|
||||
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
|
||||
let (min, max) = if self.creative {
|
||||
@@ -136,16 +116,13 @@ impl Game<'_> {
|
||||
} else {
|
||||
(REACH.min, REACH.max)
|
||||
};
|
||||
let from = eye.add_vector(look.scale(f64::from(min)));
|
||||
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
|
||||
// A block closer than the minimum reach takes the whole stab.
|
||||
if let Some(block) = self.clip_collider(eye, to) {
|
||||
to = block;
|
||||
if eye.distance_squared(to) < eye.distance_squared(from) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
self.mobs_along(from, to, f64::from(REACH.hitbox_margin))
|
||||
let reach = (min, max, REACH.hitbox_margin);
|
||||
let clip = |from, to| self.clip_collider(from, to);
|
||||
let boxes = self.hittable_mob_boxes();
|
||||
hits_in_reach(&boxes, eye, look, movement, reach, clip)
|
||||
.into_iter()
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks`
|
||||
@@ -411,3 +388,60 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getManyEntityHitResult` over `boxes`: those the segment
|
||||
/// `from`..`to` meets, or comes within the margin of with no block between
|
||||
/// (`clip`: where a segment first meets a block), each with where it is hit.
|
||||
fn hits_along(
|
||||
boxes: &[(usize, Aabb)],
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
margin: f64,
|
||||
clip: &impl Fn(Vec3, Vec3) -> Option<Vec3>,
|
||||
) -> Vec<(usize, Vec3)> {
|
||||
let grow = Vec3::new(margin, margin, margin);
|
||||
let mut hits = Vec::new();
|
||||
for &(i, bounds) in boxes {
|
||||
let hit = if bounds.contains(from) {
|
||||
Some(from)
|
||||
} else if let Some(at) = bounds.clip(from, to) {
|
||||
Some(at)
|
||||
} else if margin > 0.0 {
|
||||
bounds.inflate(grow).clip(from, to).and_then(|outside| {
|
||||
let centre = bounds.center();
|
||||
let toward = clip(outside, centre).unwrap_or(centre);
|
||||
bounds.clip(outside, toward)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(at) = hit {
|
||||
hits.push((i, at));
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with an `AttackRange` of `reach` (its
|
||||
/// effective min and max range and hitbox margin): the boxes along `look`
|
||||
/// from the minimum reach to the maximum plus the attacker's own movement,
|
||||
/// none when a block (`clip`) is closer than the minimum.
|
||||
pub(super) fn hits_in_reach(
|
||||
boxes: &[(usize, Aabb)],
|
||||
eye: Vec3,
|
||||
look: Vec3,
|
||||
movement: Vec3,
|
||||
(min, max, margin): (f32, f32, f32),
|
||||
clip: impl Fn(Vec3, Vec3) -> Option<Vec3>,
|
||||
) -> Vec<(usize, Vec3)> {
|
||||
let from = eye.add_vector(look.scale(f64::from(min)));
|
||||
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
|
||||
// A block closer than the minimum reach takes the whole stab.
|
||||
if let Some(block) = clip(eye, to) {
|
||||
to = block;
|
||||
if eye.distance_squared(to) < eye.distance_squared(from) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
hits_along(boxes, from, to, f64::from(margin), &clip)
|
||||
}
|
||||
@@ -396,7 +396,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) | Event::NativeButton(..) => {}
|
||||
Event::ModSettings(_) | Event::NativeSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -1875,9 +1875,9 @@ impl Game<'_> {
|
||||
"minecraft:tipped_arrow" => 0.125,
|
||||
_ => 1.0,
|
||||
};
|
||||
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
e.translate(k).map(str::to_owned)
|
||||
}) {
|
||||
// PotionContents.getPotionDescription: the effect names are
|
||||
// translatable, read in the selected language like every other line.
|
||||
for (text, cat) in brewing::tooltip(stack.potion(), scale, &translate) {
|
||||
let c = match cat {
|
||||
brewing::Category::Beneficial => rgba(85, 85, 255, 255),
|
||||
brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
@@ -1902,18 +1902,15 @@ impl Game<'_> {
|
||||
{
|
||||
push(
|
||||
&mut lines,
|
||||
e.translate("item.minecraft.smithing_template.upgrade")
|
||||
.unwrap_or("Upgrade:")
|
||||
.to_owned(),
|
||||
translate("item.minecraft.smithing_template.upgrade")
|
||||
.unwrap_or_else(|| "Upgrade:".to_owned()),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
|
||||
let p = e
|
||||
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
|
||||
.map_or_else(|| short(&pattern), str::to_owned);
|
||||
let m = e
|
||||
.translate(&format!("trim_material.minecraft.{}", short(&material)))
|
||||
.map_or_else(|| short(&material), str::to_owned);
|
||||
let p = translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
|
||||
.unwrap_or_else(|| short(&pattern));
|
||||
let m = translate(&format!("trim_material.minecraft.{}", short(&material)))
|
||||
.unwrap_or_else(|| short(&material));
|
||||
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
|
||||
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
|
||||
}
|
||||
@@ -2251,10 +2248,24 @@ impl Game<'_> {
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let lines = Self::resolved(&self.stack_tooltip(stack));
|
||||
self.draw_stack_tooltip_with(batch, stack, x, y, Vec::new());
|
||||
}
|
||||
/// [`Self::draw_stack_tooltip`] with `after_name` inserted from line 1,
|
||||
/// as the creative screen adds its tab names.
|
||||
pub(super) fn draw_stack_tooltip_with(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
after_name: Vec<(horizon_client::text::Text, u32)>,
|
||||
) {
|
||||
let mut lines = self.stack_tooltip(stack);
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
lines.splice(1..1, after_name);
|
||||
let lines = Self::resolved(&lines);
|
||||
// The component's height does not depend on where it is drawn, so a
|
||||
// probe at the origin gives the box its extra height.
|
||||
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
|
||||
|
||||
@@ -364,14 +364,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Villager.setLastHurtByMob` and `ServerLevel.onReputationEvent`: a hit
|
||||
/// from the player counts against it with the villager that was hit.
|
||||
pub(super) fn villager_reputation_tick(&mut self) {
|
||||
let Some(uuid) = self.level.player_uuid else {
|
||||
return;
|
||||
/// The UUID of the player who last hurt a mob (`last_hurt_by_player`:
|
||||
/// None for the host, else the guest's connection): the reputation a
|
||||
/// villager keeps is that player's, the one trading looks up.
|
||||
fn player_uuid_of(&self, who: Option<u32>) -> Option<[i32; 4]> {
|
||||
let uuid = match who {
|
||||
None => return self.level.player_uuid,
|
||||
// While that guest acts its own UUID is swapped in.
|
||||
Some(conn) if self.lan_acting == Some(conn) => self.lan_acting_uuid?,
|
||||
Some(conn) => self.lan.as_ref()?.hub.guest(conn)?.uuid,
|
||||
};
|
||||
Some(horizon_client::lan::playerdata::uuid_ints(uuid))
|
||||
}
|
||||
/// `Villager.setLastHurtByMob` and `ServerLevel.onReputationEvent`: a hit
|
||||
/// from a player counts against that player with the villager that was hit.
|
||||
pub(super) fn villager_reputation_tick(&mut self) {
|
||||
let mut angry = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Villager
|
||||
|| m.ai.last_hurt_by != Some(horizon_client::mobs::Who::Player)
|
||||
{
|
||||
@@ -380,17 +390,22 @@ impl Game<'_> {
|
||||
let time = m.ai.last_hurt_by_time;
|
||||
let alive = m.alive();
|
||||
let at = m.id;
|
||||
let e = villager::ext_mut(m);
|
||||
if e.hurt_seen != time {
|
||||
e.hurt_seen = time;
|
||||
e.gossips.add(
|
||||
uuid,
|
||||
GossipType::MinorNegative,
|
||||
gossip::REPUTATION_CHANGE_PER_EVENT,
|
||||
);
|
||||
if alive {
|
||||
angry.push(at);
|
||||
}
|
||||
if villager::ext(m).is_some_and(|e| e.hurt_seen == time) {
|
||||
continue;
|
||||
}
|
||||
let hurt_by = self.last_hurt_by_player.get(&at).copied().flatten();
|
||||
let Some(uuid) = self.player_uuid_of(hurt_by) else {
|
||||
continue;
|
||||
};
|
||||
let e = villager::ext_mut(&mut self.mobs[i]);
|
||||
e.hurt_seen = time;
|
||||
e.gossips.add(
|
||||
uuid,
|
||||
GossipType::MinorNegative,
|
||||
gossip::REPUTATION_CHANGE_PER_EVENT,
|
||||
);
|
||||
if alive {
|
||||
angry.push(at);
|
||||
}
|
||||
}
|
||||
// Villager.setLastHurtByMob: entity event 13.
|
||||
@@ -400,11 +415,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
|
||||
/// player kill one remember it as a major wrong.
|
||||
pub(super) fn villager_murdered(&mut self, victim: &Mob, by_player: bool) {
|
||||
/// `killer` is the guest connection that last hurt it (None: the host).
|
||||
pub(super) fn villager_murdered(&mut self, victim: &Mob, by_player: bool, killer: Option<u32>) {
|
||||
if victim.species() != Species::Villager || !by_player {
|
||||
return;
|
||||
}
|
||||
let Some(uuid) = self.level.player_uuid else {
|
||||
let Some(uuid) = self.player_uuid_of(killer) else {
|
||||
return;
|
||||
};
|
||||
let eye = Vec3::new(victim.position.x, victim.eye_y(), victim.position.z);
|
||||
|
||||
@@ -493,6 +493,18 @@ impl Hub {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
/// A system message for the guests `hears` picks by name
|
||||
/// (`PlayerList.broadcastSystemToTeam` and its kin).
|
||||
pub fn system_message_to(&self, content: Tag, hears: impl Fn(&str) -> bool) {
|
||||
let body = C::SystemChat(play::SystemChat {
|
||||
content,
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
for g in self.guests.iter().filter(|g| g.placed && hears(&g.name)) {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendOverlayMessage` to every guest (the action bar).
|
||||
pub fn overlay_message(&self, content: Tag) {
|
||||
self.broadcast(&C::SystemChat(play::SystemChat {
|
||||
|
||||
@@ -539,8 +539,36 @@ pub enum DamageKind {
|
||||
Freeze,
|
||||
/// `fell_out_of_world`: below the world's floor.
|
||||
OutOfWorld,
|
||||
/// `cramming`: too many entities in one place (`maxEntityCramming`).
|
||||
/// `cramming`: crushed by the mobs around it.
|
||||
Cramming,
|
||||
/// `falling_anvil`: an anvil landing on it (it damages a helmet).
|
||||
FallingAnvil,
|
||||
/// `fireworks`: a firework rocket's burst (an explosion for Blast Protection).
|
||||
Fireworks,
|
||||
/// `generic_kill`: a kill that bypasses invulnerability.
|
||||
GenericKill,
|
||||
/// `lightning_bolt`.
|
||||
LightningBolt,
|
||||
/// `outside_border`: past the world border.
|
||||
OutsideBorder,
|
||||
/// `spit`: a llama's spit.
|
||||
Spit,
|
||||
/// `stalagmite`: a fall onto a stalagmite (a fall for Feather Falling).
|
||||
Stalagmite,
|
||||
/// `starve`: no food left.
|
||||
Starve,
|
||||
/// `sulfur_cube_hot`: a sulfur cube's hot floor (a fire).
|
||||
SulfurCubeHot,
|
||||
/// `wind_charge`: a wind charge's burst (a projectile).
|
||||
WindCharge,
|
||||
/// `wither`: the wither effect.
|
||||
Wither,
|
||||
}
|
||||
impl DamageKind {
|
||||
/// DamageTypeTags.BYPASSES_INVULNERABILITY
|
||||
pub fn bypasses_invulnerability(self) -> bool {
|
||||
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
|
||||
}
|
||||
}
|
||||
/// A damage source: its type, the attacking entity and where it came from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -604,6 +632,15 @@ impl Damage {
|
||||
| DamageKind::Generic
|
||||
| DamageKind::InWall
|
||||
| DamageKind::SweetBerryBush
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::FallingBlock
|
||||
| DamageKind::FallingAnvil
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Spit
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_ARMOR
|
||||
@@ -620,12 +657,21 @@ impl Damage {
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
pub fn is_fire(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Fireball
|
||||
| DamageKind::SulfurCubeHot
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.NO_KNOCKBACK
|
||||
@@ -646,13 +692,20 @@ impl Damage {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::LightningBolt
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Stalagmite
|
||||
| DamageKind::Starve
|
||||
| DamageKind::SulfurCubeHot
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -779,6 +832,8 @@ pub struct Extra {
|
||||
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
|
||||
/// for the right arm.
|
||||
pub swing_right_arm: Option<bool>,
|
||||
/// The current swing is a STAB (`SwingAnimationType` 2): a spear's jab.
|
||||
pub swing_stab: bool,
|
||||
pub flag: bool,
|
||||
pub flag2: bool,
|
||||
/// Species scratch values (the wither's head targets and clocks).
|
||||
@@ -1569,6 +1624,11 @@ impl Mob {
|
||||
.sum();
|
||||
(base - 0.15 * f64::from(protection)).max(0.0)
|
||||
}
|
||||
/// Entity.isOnFire: fire-immune mobs never count as burning, even on the
|
||||
/// tick a sun or fire check set their timer before `base_tick` clears it.
|
||||
pub fn is_on_fire(&self) -> bool {
|
||||
!self.kind.flags.fire_immune && self.fire_ticks > 0
|
||||
}
|
||||
pub fn ignite_for_ticks(&mut self, ticks: i32) {
|
||||
self.fire_ticks = self
|
||||
.fire_ticks
|
||||
@@ -2093,7 +2153,11 @@ impl Mob {
|
||||
) -> bool {
|
||||
axolotl::before_damage(self, amount, &source);
|
||||
raider::note_hurt(self);
|
||||
if !self.alive() || self.removed || self.invulnerable_flag {
|
||||
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
|
||||
if !self.alive()
|
||||
|| self.removed
|
||||
|| (self.invulnerable_flag && !source.kind.bypasses_invulnerability())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Ghast.isReflectedFireball: a fireball a player turned back is
|
||||
@@ -2111,9 +2175,10 @@ impl Mob {
|
||||
if self.species() == Species::Armadillo {
|
||||
amount = armadillo::reduce_damage(self, amount);
|
||||
}
|
||||
// #damages_helmet: falling anvils, blocks and stalactites.
|
||||
if matches!(
|
||||
source.kind,
|
||||
DamageKind::FallingBlock | DamageKind::FallingStalactite
|
||||
DamageKind::FallingBlock | DamageKind::FallingAnvil | DamageKind::FallingStalactite
|
||||
) && !species::body_slot(self.species())
|
||||
&& self.extra.head.is_some()
|
||||
{
|
||||
@@ -2269,6 +2334,10 @@ impl Mob {
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::Generic
|
||||
| DamageKind::GenericKill
|
||||
| DamageKind::OutsideBorder
|
||||
| DamageKind::Starve
|
||||
| DamageKind::Wither
|
||||
)
|
||||
}
|
||||
/// `Wolf.actuallyHurt`: the armor loses `ceil(damage)` durability, cracks
|
||||
@@ -2364,6 +2433,11 @@ impl Mob {
|
||||
pub fn is_currently_glowing(&self) -> bool {
|
||||
self.glowing || self.has_effect("minecraft:glowing")
|
||||
}
|
||||
/// `Entity.isInvisible`: the shared flag 0x20 a server sends (`invisible`),
|
||||
/// or the Invisibility effect on a mob this game ticks itself.
|
||||
pub fn is_invisible(&self) -> bool {
|
||||
self.invisible || self.has_effect("minecraft:invisibility")
|
||||
}
|
||||
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
|
||||
// MobEffectInstance.update: a stronger effect replaces with its
|
||||
@@ -2866,12 +2940,12 @@ mod teams_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod sensor_scan_tests {
|
||||
pub(crate) mod sensor_scan_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn kind() -> Arc<MobType> {
|
||||
pub(crate) fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:cow".into(),
|
||||
species: Species::Cow,
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Mob {
|
||||
pub fn swing(&mut self) {
|
||||
if self.start_swing(6) {
|
||||
self.extra.swing_right_arm = None;
|
||||
self.extra.swing_stab = false;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.swing(hand, animation, false)` for a swing of `duration`
|
||||
@@ -90,24 +91,25 @@ impl Mob {
|
||||
pub fn swing_hand(&mut self, off_hand: bool, duration: i32) {
|
||||
if self.start_swing(duration) {
|
||||
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
|
||||
self.extra.swing_stab = false;
|
||||
}
|
||||
}
|
||||
/// `swing_hand` for a swing of `base` ticks, lengthened or shortened by
|
||||
/// `swing_hand` for a swing of `animation`, lengthened or shortened by
|
||||
/// this mob's haste, conduit power or mining fatigue
|
||||
/// (`LivingEntity.getModifiedSwingDuration`).
|
||||
pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) {
|
||||
pub fn swing_hand_modified(
|
||||
&mut self,
|
||||
off_hand: bool,
|
||||
animation: crate::first_person::SwingAnimation,
|
||||
) {
|
||||
let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2);
|
||||
let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power"));
|
||||
let fatigue = amplifier("minecraft:mining_fatigue");
|
||||
let duration = crate::first_person::swing_duration(
|
||||
crate::first_person::SwingAnimation {
|
||||
kind: 1,
|
||||
duration: base,
|
||||
},
|
||||
dig_speed,
|
||||
fatigue,
|
||||
);
|
||||
self.swing_hand(off_hand, duration);
|
||||
let duration = crate::first_person::swing_duration(animation, dig_speed, fatigue);
|
||||
if self.start_swing(duration) {
|
||||
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
|
||||
self.extra.swing_stab = animation.kind == 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The module implementing a species' behaviour.
|
||||
@@ -472,6 +474,11 @@ pub fn tick(
|
||||
if m.removed {
|
||||
return false;
|
||||
}
|
||||
if m.species() == Species::ZombieVillager {
|
||||
// ZombieVillager.tick counts the cure down before super.tick, with
|
||||
// or without AI (NoAI only skips serverAiStep).
|
||||
super::zombie_villager::step(m, &mut Cx { w, events });
|
||||
}
|
||||
super::warden::pre_tick(m, w, events);
|
||||
if m.species() == Species::Strider {
|
||||
super::strider::pre_tick(m, w, events);
|
||||
@@ -489,7 +496,9 @@ pub fn tick(
|
||||
}
|
||||
if m.no_ai {
|
||||
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
|
||||
// so the endermite's life keeps counting.
|
||||
// so undead still burn in daylight and the endermite's life keeps
|
||||
// counting.
|
||||
burn_undead(m, w);
|
||||
if m.species() == Species::Endermite {
|
||||
endermite_lifetime(m);
|
||||
}
|
||||
@@ -610,6 +619,7 @@ pub fn tick_swing(m: &mut Mob) {
|
||||
m.ai.swinging = false;
|
||||
m.ai.swing_duration = 0;
|
||||
m.extra.swing_right_arm = None;
|
||||
m.extra.swing_stab = false;
|
||||
}
|
||||
} else {
|
||||
m.ai.swing_time = 0;
|
||||
@@ -1044,10 +1054,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mob.aiStep: undead burn in daylight.
|
||||
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
|
||||
m.ignite_for_ticks(160);
|
||||
}
|
||||
burn_undead(m, w);
|
||||
species_ai_step(m, w, level_random, events);
|
||||
}
|
||||
impl Mob {
|
||||
@@ -1146,6 +1153,13 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Mob.aiStep` -> `Mob.burnUndead`: undead burn in daylight. It runs with
|
||||
/// or without AI (`NoAI` only skips `serverAiStep`).
|
||||
fn burn_undead(m: &mut Mob, w: &MobWorld) {
|
||||
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
|
||||
m.ignite_for_ticks(160);
|
||||
}
|
||||
}
|
||||
fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
// Mob.isSunBurnTick: the monsters_burn attribute, not the sky darkness.
|
||||
if !w.monsters_burn {
|
||||
|
||||
@@ -295,6 +295,8 @@ pub struct Hum {
|
||||
pub using_item: bool,
|
||||
/// The arm the current swing moves (`Some(true)` for the right one).
|
||||
pub swing_right_arm: Option<bool>,
|
||||
/// The swing is a STAB (`SwingAnimationType` 2): a spear's jab.
|
||||
pub stab: bool,
|
||||
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
|
||||
pub left_main: bool,
|
||||
/// The spear each arm (right, left) holds, and how long that arm has
|
||||
@@ -318,6 +320,7 @@ impl Hum {
|
||||
swim: 0.0,
|
||||
using_item: false,
|
||||
swing_right_arm: None,
|
||||
stab: false,
|
||||
left_main: false,
|
||||
spear: [None; 2],
|
||||
spear_ticks: [0.0; 2],
|
||||
@@ -453,7 +456,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
|
||||
pose_arm(p, h.right, true, h);
|
||||
}
|
||||
}
|
||||
// setupAttackAnimation (WHACK swing with the arm of the swinging hand).
|
||||
// setupAttackAnimation: a WHACK or STAB swing of the swinging hand's arm.
|
||||
let hx = p.get("head")[3];
|
||||
let swing = s.swing;
|
||||
let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right);
|
||||
@@ -476,17 +479,23 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
|
||||
l[2] = -body_y.msin() * 5.0 * age_scale;
|
||||
l[0] = body_y.mcos() * 5.0 * age_scale;
|
||||
}
|
||||
p.get("right_arm")[4] += body_y;
|
||||
p.get("left_arm")[4] += body_y;
|
||||
p.get("left_arm")[3] += body_y;
|
||||
// Ease.outQuart
|
||||
let eased = 1.0 - (1.0 - swing).powi(4);
|
||||
let aa = (eased * PI).msin();
|
||||
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
|
||||
let r = p.get(if swing_left { "left_arm" } else { "right_arm" });
|
||||
r[3] -= aa * 1.2 + bb;
|
||||
r[4] += body_y * 2.0;
|
||||
r[5] += (swing * PI).msin() * -0.4;
|
||||
let arm = if swing_left { "left_arm" } else { "right_arm" };
|
||||
if h.stab {
|
||||
// SpearAnimations.thirdPersonAttackHand: the arms keep their turn.
|
||||
p.get(arm)[3] += crate::spear::third_person_attack_arm(swing);
|
||||
} else {
|
||||
p.get("right_arm")[4] += body_y;
|
||||
p.get("left_arm")[4] += body_y;
|
||||
p.get("left_arm")[3] += body_y;
|
||||
// Ease.outQuart
|
||||
let eased = 1.0 - (1.0 - swing).powi(4);
|
||||
let aa = (eased * PI).msin();
|
||||
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
|
||||
let r = p.get(arm);
|
||||
r[3] -= aa * 1.2 + bb;
|
||||
r[4] += body_y * 2.0;
|
||||
r[5] += (swing * PI).msin() * -0.4;
|
||||
}
|
||||
}
|
||||
if let Some(aggressive) = h.zombie {
|
||||
// AnimationUtils.animateZombieArms
|
||||
@@ -789,6 +798,7 @@ pub fn setup(
|
||||
h.swim = m.extra.swim.amount(s.partial);
|
||||
h.using_item = m.extra.using_item > 0;
|
||||
h.swing_right_arm = m.extra.swing_right_arm;
|
||||
h.stab = m.extra.swing_stab;
|
||||
h.left_main = m.main_arm_left();
|
||||
let main_side = usize::from(m.main_arm_left());
|
||||
h.spear[main_side] = main_spear;
|
||||
@@ -1047,6 +1057,27 @@ mod flight_pose_tests {
|
||||
);
|
||||
assert!(swinging.get("right_arm")[4] < 1.0);
|
||||
assert!(same_angle(swinging.get("left_arm")[4], PI));
|
||||
// A STAB swing turns that arm's pitch alone, by the spear's curve.
|
||||
let mut stabbing = make();
|
||||
h.swim = 0.0;
|
||||
h.stab = true;
|
||||
let mut still = make();
|
||||
humanoid(
|
||||
&mut still,
|
||||
&state,
|
||||
&Hum::new(ArmPose::Empty, ArmPose::Empty),
|
||||
);
|
||||
let stab = State {
|
||||
swing: 0.2,
|
||||
..state
|
||||
};
|
||||
humanoid(&mut stabbing, &stab, &h);
|
||||
let arm_pitch = crate::spear::third_person_attack_arm(0.2);
|
||||
assert!(same_angle(
|
||||
stabbing.get("right_arm")[3],
|
||||
still.get("right_arm")[3] + arm_pitch
|
||||
));
|
||||
assert_eq!(stabbing.get("left_arm")[3], still.get("left_arm")[3]);
|
||||
// Out of the pose nothing changes.
|
||||
let mut dry = make();
|
||||
humanoid(&mut dry, &state, &Hum::new(ArmPose::Empty, ArmPose::Empty));
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn tear_down(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
/// invulnerability animation and the heart's reaction. Returns whether the
|
||||
/// normal damage path should run.
|
||||
pub fn accepts_damage(m: &mut Mob, source: &Damage) -> bool {
|
||||
if !heart_bound(m) || source.kind == DamageKind::Generic {
|
||||
if !heart_bound(m) || source.kind.bypasses_invulnerability() {
|
||||
return true;
|
||||
}
|
||||
if source.is_fire() || !m.alive() || m.extra.ints[INVULNERABLE] > 0 {
|
||||
|
||||
@@ -182,9 +182,10 @@ pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
|
||||
// Being angry (target set) makes the head "creepy" (render).
|
||||
m.extra.flag = m.ai.target.is_some();
|
||||
}
|
||||
/// Enderman.hurtServer: projectiles miss (teleport away), others may teleport.
|
||||
/// Enderman.hurtServer: projectiles miss (teleport away) unless it rides
|
||||
/// something (`!isPassenger()`), others may teleport.
|
||||
pub fn on_damage(m: &mut Mob, source: &Damage) -> bool {
|
||||
if source.is_projectile() {
|
||||
if source.is_projectile() && !m.extra.passenger {
|
||||
// repeatedlyTryToTeleport is applied by the next AI step.
|
||||
m.extra.counter2 = 64;
|
||||
return false;
|
||||
@@ -434,3 +435,32 @@ impl Goal for LeaveBlock {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::DamageKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn enderman() -> Mob {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.species = Species::Enderman;
|
||||
Mob::new(1, Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// Enderman.hurtServer: a projectile makes a free enderman teleport away
|
||||
/// unhurt, but one that rides something (`isPassenger`) takes the hit.
|
||||
#[test]
|
||||
fn projectiles_hit_a_seated_enderman() {
|
||||
for kind in [DamageKind::Arrow, DamageKind::Thrown, DamageKind::Fireball] {
|
||||
let mut free = enderman();
|
||||
assert!(!on_damage(&mut free, &Damage::of(kind)), "{kind:?}");
|
||||
assert_eq!(free.extra.counter2, 64);
|
||||
|
||||
let mut seated = enderman();
|
||||
seated.extra.passenger = true;
|
||||
assert!(on_damage(&mut seated, &Damage::of(kind)), "{kind:?}");
|
||||
assert_eq!(seated.extra.counter2, 0, "no teleport while seated");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Horses, donkeys, mules and the undead horses (`AbstractHorse`): random
|
||||
//! stats, coat colours and markings, eating, rearing, taming by riding,
|
||||
//! the ridden movement and jump, offspring stats and `AbstractEquineModel`.
|
||||
use super::finalize::{Attr, set_base};
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species,
|
||||
anim::{Poses, State},
|
||||
@@ -125,20 +126,22 @@ pub fn register(m: &mut Mob) {
|
||||
}
|
||||
/// `randomizeAttributes` of each class: horses roll health, speed and jump,
|
||||
/// donkeys and mules only health (their jump stays 0.5), skeleton horses only
|
||||
/// the jump, zombie horses a smaller jump and their own speed.
|
||||
/// the jump, zombie horses a smaller jump and their own speed. The health and
|
||||
/// speed are attribute bases (`setBaseValue`), so the server syncs them to
|
||||
/// the clients that draw the mount's hearts.
|
||||
pub fn finalize(m: &mut Mob) {
|
||||
let species = m.species();
|
||||
let r = &mut m.random;
|
||||
if matches!(species, Species::Horse | Species::Donkey | Species::Mule) {
|
||||
m.attrs.max_health = 15.0
|
||||
+ r.next_int_bound(8).unwrap_or(0) as f32
|
||||
+ r.next_int_bound(9).unwrap_or(0) as f32;
|
||||
}
|
||||
let health = matches!(species, Species::Horse | Species::Donkey | Species::Mule).then(|| {
|
||||
15.0 + r.next_int_bound(8).unwrap_or(0) as f32 + r.next_int_bound(9).unwrap_or(0) as f32
|
||||
});
|
||||
let mut speed = None;
|
||||
match species {
|
||||
Species::Horse => {
|
||||
m.attrs.speed =
|
||||
speed = Some(
|
||||
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3)
|
||||
* 0.25;
|
||||
* 0.25,
|
||||
);
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
|
||||
m.extra.variant = r.next_int_bound(7).unwrap_or(0) as u8;
|
||||
@@ -150,14 +153,21 @@ pub fn finalize(m: &mut Mob) {
|
||||
let d = 0.066_666_666_666_666_67;
|
||||
m.extra.vector[0] =
|
||||
0.5 + r.next_double() * d + r.next_double() * d + r.next_double() * d;
|
||||
m.attrs.speed =
|
||||
(9.0 + r.next_double() + r.next_double() + r.next_double()) / f64::from(42.16f32);
|
||||
speed = Some(
|
||||
(9.0 + r.next_double() + r.next_double() + r.next_double()) / f64::from(42.16f32),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2
|
||||
}
|
||||
}
|
||||
if let Some(health) = health {
|
||||
set_base(m, Attr::MaxHealth, f64::from(health));
|
||||
}
|
||||
if let Some(speed) = speed {
|
||||
set_base(m, Attr::MovementSpeed, speed);
|
||||
}
|
||||
m.health = m.attrs.max_health;
|
||||
}
|
||||
/// `AbstractHorse.createOffspringAttribute`.
|
||||
@@ -177,16 +187,18 @@ pub fn offspring(a: f64, b: f64, min: f64, max: f64, r: &mut impl RandomSource)
|
||||
}
|
||||
/// `AbstractHorse.setOffspringAttributes` from two parents.
|
||||
pub fn inherit(child: &mut Mob, a: &Mob, b: &Mob, r: &mut impl RandomSource) {
|
||||
child.attrs.max_health = offspring(
|
||||
let health = offspring(
|
||||
f64::from(a.attrs.max_health),
|
||||
f64::from(b.attrs.max_health),
|
||||
15.0,
|
||||
30.0,
|
||||
r,
|
||||
) as f32;
|
||||
);
|
||||
set_base(child, Attr::MaxHealth, f64::from(health as f32));
|
||||
child.health = child.attrs.max_health;
|
||||
child.extra.vector[0] = offspring(a.extra.vector[0], b.extra.vector[0], 0.4, 1.0, r);
|
||||
child.attrs.speed = offspring(a.attrs.speed, b.attrs.speed, 0.1125, 0.3375, r);
|
||||
let speed = offspring(a.attrs.speed, b.attrs.speed, 0.1125, 0.3375, r);
|
||||
set_base(child, Attr::MovementSpeed, speed);
|
||||
}
|
||||
/// The rider controls the mount: saddled, with a player on it.
|
||||
pub fn controlled(m: &Mob) -> bool {
|
||||
@@ -844,3 +856,37 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::finalize::base_value;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn horse(species: Species) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.species = species;
|
||||
k.max_health = 53.0;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// The rolled health is the attribute's base, which the server syncs: a
|
||||
/// rider's client draws the horse's hearts from it, not the type's 53.
|
||||
#[test]
|
||||
fn rolled_health_and_speed_are_attribute_bases() {
|
||||
for species in [Species::Horse, Species::Donkey, Species::Mule] {
|
||||
let mut m = horse(species);
|
||||
finalize(&mut m);
|
||||
assert!((15.0..=30.0).contains(&m.attrs.max_health));
|
||||
assert_eq!(
|
||||
base_value(&m, Attr::MaxHealth),
|
||||
f64::from(m.attrs.max_health)
|
||||
);
|
||||
assert_eq!(m.health, m.attrs.max_health);
|
||||
}
|
||||
let mut m = horse(Species::Horse);
|
||||
finalize(&mut m);
|
||||
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
|
||||
}
|
||||
}
|
||||
@@ -173,16 +173,21 @@ pub fn protection(m: &Mob, kind: DamageKind) -> f32 {
|
||||
protection_points(g, kind).min(20.0)
|
||||
}
|
||||
pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
|
||||
if kind == DamageKind::Generic {
|
||||
// Protection's `damage_protection` skips #bypasses_invulnerability.
|
||||
if kind == DamageKind::Generic || kind.bypasses_invulnerability() {
|
||||
return 0.0;
|
||||
}
|
||||
let fire = matches!(
|
||||
kind,
|
||||
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball
|
||||
DamageKind::OnFire
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Fireball
|
||||
| DamageKind::SulfurCubeHot
|
||||
);
|
||||
let projectile = matches!(
|
||||
kind,
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball
|
||||
DamageKind::Arrow | DamageKind::Thrown | DamageKind::Fireball | DamageKind::WindCharge
|
||||
);
|
||||
let mut total = 0.0;
|
||||
for slot in ARMOR {
|
||||
@@ -191,9 +196,17 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 {
|
||||
total += match &**id {
|
||||
"minecraft:protection" => l,
|
||||
"minecraft:fire_protection" if fire => 2.0 * l,
|
||||
"minecraft:blast_protection" if kind == DamageKind::Explosion => 2.0 * l,
|
||||
"minecraft:blast_protection"
|
||||
if matches!(kind, DamageKind::Explosion | DamageKind::Fireworks) =>
|
||||
{
|
||||
2.0 * l
|
||||
}
|
||||
"minecraft:projectile_protection" if projectile => 2.0 * l,
|
||||
"minecraft:feather_falling" if kind == DamageKind::Fall => 3.0 * l,
|
||||
"minecraft:feather_falling"
|
||||
if matches!(kind, DamageKind::Fall | DamageKind::Stalagmite) =>
|
||||
{
|
||||
3.0 * l
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! strength, chests and carpets, taming by riding (maximum temper 30), the
|
||||
//! spit ranged attack, hunting wolves (and, for traders, zombies and
|
||||
//! illagers) and `LlamaModel`.
|
||||
use super::finalize::{Attr, set_base};
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Seen, Species, Who, equine,
|
||||
goals::{
|
||||
@@ -103,7 +104,7 @@ pub fn finalize(m: &mut Mob) {
|
||||
let max = if r.next_float() < 0.04 { 5 } else { 3 };
|
||||
let strength = 1 + r.next_int_bound(max).unwrap_or(0);
|
||||
let variant = r.next_int_bound(4).unwrap_or(0);
|
||||
m.attrs.max_health = health;
|
||||
set_base(m, Attr::MaxHealth, f64::from(health));
|
||||
m.health = health;
|
||||
m.extra.variant2 = strength as u8;
|
||||
m.extra.variant = variant as u8;
|
||||
@@ -170,10 +171,10 @@ pub fn breed_offspring(m: &mut Mob, partner: &Seen) -> Offspring {
|
||||
}
|
||||
/// The game makes the baby drawn by `breed_offspring`.
|
||||
pub fn apply_offspring(child: &mut Mob, o: &Offspring) {
|
||||
child.attrs.max_health = o.max_health;
|
||||
set_base(child, Attr::MaxHealth, f64::from(o.max_health));
|
||||
child.health = o.max_health;
|
||||
child.extra.vector[0] = o.jump;
|
||||
child.attrs.speed = o.speed;
|
||||
set_base(child, Attr::MovementSpeed, o.speed);
|
||||
child.extra.variant2 = o.strength;
|
||||
child.extra.variant = o.variant;
|
||||
if is_trader(child) {
|
||||
|
||||
@@ -691,10 +691,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
white,
|
||||
true,
|
||||
),
|
||||
// The copper golem's eyes glow (LivingEntityEmissiveLayer, eyes render type).
|
||||
// The copper golem's eyes glow: CopperGolemRenderer's
|
||||
// LivingEntityEmissiveLayer (eyes render type) draws on a model baked
|
||||
// from ModelLayers.COPPER_GOLEM, the body's, not COPPER_GOLEM_EYES.
|
||||
S::CopperGolem => {
|
||||
let eyes = super::copper_golem::eye_texture(m);
|
||||
push(&mut v, "copper_golem#eyes", &eyes, white, true);
|
||||
push(&mut v, &m.kind.model_key(), &eyes, white, true);
|
||||
}
|
||||
S::Spider | S::CaveSpider => push(
|
||||
&mut v,
|
||||
@@ -1007,7 +1009,7 @@ fn shaking(m: &Mob) -> bool {
|
||||
match m.species() {
|
||||
Species::Strider => super::strider::suffocating(m),
|
||||
Species::ZombieVillager => super::zombie_villager::is_converting(m),
|
||||
Species::Zombie | Species::Husk => m.extra.counter2 > 0,
|
||||
Species::Zombie | Species::Husk | Species::Skeleton => m.extra.counter2 > 0,
|
||||
Species::Piglin | Species::PiglinBrute | Species::Hoglin => {
|
||||
!m.extra.flag2 && m.extra.counter2 > 0
|
||||
}
|
||||
@@ -1568,6 +1570,43 @@ pub fn part_transform(
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
part_matrix(&model, base, &poses, part, 0.0)
|
||||
}
|
||||
/// `FoxHeldItemLayer`: the item sits at the head's position under the render
|
||||
/// state's head roll, yaw and pitch (not the posed head's, which differ while
|
||||
/// it sleeps), pushed out to the mouth.
|
||||
fn fox_item_transform(m: &Mob, alpha: f32, camera: glam::DVec3, data: &MobData) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
let s = anim::state(m, alpha);
|
||||
let (model_key, _) = body(m);
|
||||
let model = m
|
||||
.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone());
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
let head = *poses.get("head");
|
||||
let fox = super::fox::pose(m, alpha);
|
||||
let baby = m.is_baby();
|
||||
let mut t = base_transform(m, alpha, camera)
|
||||
* Mat4::from_translation(Vec3::new(head[0], head[1], head[2]) / 16.0);
|
||||
if baby {
|
||||
t *= Mat4::from_scale(Vec3::splat(0.75));
|
||||
}
|
||||
t *= Mat4::from_rotation_z(fox.head_roll_angle)
|
||||
* Mat4::from_rotation_y(s.y_rot.to_radians())
|
||||
* Mat4::from_rotation_x(s.x_rot.to_radians());
|
||||
let (x, y, z) = match (baby, fox.sleeping) {
|
||||
(true, true) => (0.4, 0.26, 0.15),
|
||||
(true, false) => (0.06, 0.26, -0.5),
|
||||
(false, true) => (0.46, 0.26, 0.22),
|
||||
(false, false) => (0.06, 0.27, -0.5),
|
||||
};
|
||||
t *= Mat4::from_translation(Vec3::new(x, y, z)) * Mat4::from_rotation_x(90.0f32.to_radians());
|
||||
if fox.sleeping {
|
||||
t *= Mat4::from_rotation_z(90.0f32.to_radians());
|
||||
}
|
||||
t
|
||||
}
|
||||
/// The pose a mob's held item is submitted under (`ItemInHandLayer`:
|
||||
/// `translateToHand`, then the hand offset; the item model adds its own
|
||||
/// display transform), or `None` when the mob shows no held items.
|
||||
@@ -1584,6 +1623,10 @@ pub fn held_item_transform(
|
||||
.filter(|_| !left)
|
||||
.map(|offset| base_transform(m, alpha, camera) * offset);
|
||||
}
|
||||
// FoxHeldItemLayer: the main hand's item in its mouth.
|
||||
if m.species() == Species::Fox {
|
||||
return (!left).then(|| fox_item_transform(m, alpha, camera, data));
|
||||
}
|
||||
if !super::illager::shows_item(m) {
|
||||
return None;
|
||||
}
|
||||
@@ -1598,7 +1641,20 @@ pub fn held_item_transform(
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
let mut t = hand_base(m, &model, base, &poses, left)?;
|
||||
// ItemInHandLayer: a spear in use (ArmPose.SPEAR's animateUseItem).
|
||||
// ItemInHandLayer: a STAB swing of this arm thrusts its item out
|
||||
// (SpearAnimations.thirdPersonAttackItem, the main hand's forward
|
||||
// movement), and a spear in use moves with it (ArmPose.SPEAR's
|
||||
// animateUseItem).
|
||||
let swing_left = m.extra.swing_right_arm.map_or(m.main_arm_left(), |r| !r);
|
||||
if m.extra.swing_stab && swing_left == left {
|
||||
let forward = m
|
||||
.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.and_then(crate::spear::Kinetic::of)
|
||||
.map_or(0.0, |k| k.forward_movement);
|
||||
t *= crate::spear::third_person_attack_item(s.swing, forward);
|
||||
}
|
||||
let off_hand = left != m.main_arm_left();
|
||||
if m.extra.using_item > 0
|
||||
&& m.extra.using_off_hand == off_hand
|
||||
@@ -1742,6 +1798,58 @@ mod tests {
|
||||
assert!(vertices.iter().all(|v| v.uv[0] >= 0.5));
|
||||
}
|
||||
}
|
||||
/// CopperGolemRenderer: the emissive eye layer draws the eye texture on
|
||||
/// the body's model, every polygon of it, at each oxidation level. The
|
||||
/// prepared `copper_golem#eyes` model has no polygons, so the eyes
|
||||
/// showed the body texture's black sockets.
|
||||
#[test]
|
||||
fn copper_golem_eyes_are_drawn_on_the_body_model() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:copper_golem"].clone());
|
||||
let data = MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
pois: Default::default(),
|
||||
};
|
||||
let atlas = |name: &str| {
|
||||
Some(if name.contains("copper_golem_eyes") {
|
||||
[0.5, 0.5, 0.75, 0.75]
|
||||
} else {
|
||||
[0.0, 0.0, 0.25, 0.25]
|
||||
})
|
||||
};
|
||||
for variant in 0..4 {
|
||||
let mut mob = Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
mob.extra.variant = variant;
|
||||
let mut vertices = Vec::new();
|
||||
render(
|
||||
&mob,
|
||||
0.5,
|
||||
glam::DVec3::ZERO,
|
||||
&atlas,
|
||||
0xf0f0,
|
||||
&data,
|
||||
&mut vertices,
|
||||
);
|
||||
let body = vertices.iter().filter(|v| v.uv[0] <= 0.25).count();
|
||||
let eyes = vertices.iter().filter(|v| v.uv[0] >= 0.5).count();
|
||||
assert!(body > 0, "variant {variant}: no body drawn");
|
||||
assert_eq!(
|
||||
eyes, body,
|
||||
"variant {variant}: the eye layer covers the body"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `VexModel.translateToHand`: the held item is drawn at 0.55 scale and
|
||||
/// moved by the vex's own offset, so the vex's sword is the tiny one
|
||||
/// vanilla shows. It was drawn at the full size, like a zombie's.
|
||||
|
||||
@@ -1470,15 +1470,17 @@ impl Def {
|
||||
| S::Zoglin
|
||||
| S::Wither
|
||||
),
|
||||
// The `burn_in_daylight` entity type tag.
|
||||
burns_in_day: matches!(
|
||||
s,
|
||||
S::Zombie
|
||||
| S::ZombieHorse
|
||||
| S::Drowned
|
||||
| S::ZombieVillager
|
||||
| S::Skeleton
|
||||
| S::Stray
|
||||
| S::Bogged
|
||||
| S::Parched
|
||||
| S::WitherSkeleton
|
||||
| S::Phantom
|
||||
| S::ZombieNautilus
|
||||
),
|
||||
|
||||
@@ -564,9 +564,6 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) {
|
||||
}
|
||||
}
|
||||
pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
|
||||
if m.species() == Species::ZombieVillager {
|
||||
super::zombie_villager::step(m, cx);
|
||||
}
|
||||
if m.species() == Species::Skeleton && m.alive() && !m.no_ai {
|
||||
skeleton_freezing(m, cx);
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ pub fn anger_level(m: &Mob) -> AngerLevel {
|
||||
|
||||
/// `Warden.isInvulnerableTo`: while digging or emerging.
|
||||
pub fn accepts_damage(m: &Mob, source: &Damage) -> bool {
|
||||
!(digging_or_emerging(m) && source.kind != DamageKind::Generic)
|
||||
!(digging_or_emerging(m) && !source.kind.bypasses_invulnerability())
|
||||
}
|
||||
/// `Warden.hurtServer`'s reaction, handled at the warden's next tick.
|
||||
pub fn hurt(m: &mut Mob, source: &Damage) {
|
||||
|
||||
@@ -401,7 +401,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
|
||||
/// `WitherBoss.hurtServer` filter: not while charging, no arrows once
|
||||
/// powered, nothing from the undead.
|
||||
pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option<Who>, others_undead: bool) -> bool {
|
||||
if m.extra.counter > 0 && kind != DamageKind::Generic {
|
||||
if m.extra.counter > 0 && !kind.bypasses_invulnerability() {
|
||||
return false;
|
||||
}
|
||||
if powered(m) && kind == DamageKind::Arrow {
|
||||
|
||||
@@ -162,6 +162,93 @@ mod tests {
|
||||
put_cure(&mut map, Some(cure));
|
||||
assert_eq!(parse_cure(&Tag::Compound(map)), Some(cure));
|
||||
}
|
||||
/// `ZombieVillager.tick` counts the cure down before `Zombie.tick`,
|
||||
/// whatever the AI: a `NoAI` zombie villager finishes curing too (#3429).
|
||||
#[test]
|
||||
fn a_no_ai_zombie_villager_still_cures() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists()
|
||||
|| !root.join("movement-properties.nbt").exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let data = super::super::MobData::load(&root, &facts);
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let items = crate::items::Items::load(&root, registry, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, registry).unwrap();
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let world = crate::world::World::new(air, 0);
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
let w = MobWorld {
|
||||
teams: Default::default(),
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
day_time: 18000,
|
||||
raining: false,
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: super::super::spawning::LocalDifficulty::new(2, 18000, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
sea_level: 63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
};
|
||||
for no_ai in [false, true] {
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:zombie_villager"].clone());
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
kind,
|
||||
horizon_core::geometry::Vec3::new(0.5, 0.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
m.persistent = true;
|
||||
m.no_ai = no_ai;
|
||||
start_converting(&mut m, None, 3, &mut Vec::new());
|
||||
let mut level_random = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut ticks = 0;
|
||||
while ticks < 10 {
|
||||
let mut events = Vec::new();
|
||||
m.tick(&w, &mut level_random, &mut events);
|
||||
ticks += 1;
|
||||
if events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::CureZombieVillager { mob: 1 }))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
ticks <= 3,
|
||||
"no_ai={no_ai}: the cure must finish within its 3 ticks, took {ticks}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn no_cure_saves_minus_one() {
|
||||
let mut map = Compound::new();
|
||||
|
||||
@@ -132,6 +132,22 @@ pub enum Outcome {
|
||||
/// A button: x, y, width, label, enabled, id.
|
||||
pub type ButtonSpec = (f32, f32, f32, String, bool, u8);
|
||||
|
||||
/// `this.width / 2` (or `height / 2`): `Screen`'s size is an int, so the
|
||||
/// division drops the half and widgets stay on whole GUI pixels. A half
|
||||
/// pixel lands between device pixels and draws thinner borders and uneven
|
||||
/// glyphs at most window heights.
|
||||
fn half(size: f32) -> f32 {
|
||||
(size.ceil() / 2.0).floor()
|
||||
}
|
||||
/// `this.height / 4`, as an int division.
|
||||
fn quarter(size: f32) -> f32 {
|
||||
(size.ceil() / 4.0).floor()
|
||||
}
|
||||
/// `centeredText` around `width / 2`: `x - font.width(text) / 2`.
|
||||
fn centered_x(width: f32, text_width: f32) -> f32 {
|
||||
half(width) - half(text_width)
|
||||
}
|
||||
|
||||
pub fn servers_file(saves: &Path) -> PathBuf {
|
||||
saves
|
||||
.parent()
|
||||
@@ -548,24 +564,24 @@ impl Screen {
|
||||
Self::Edit { address, packs, .. } => vec![
|
||||
// ManageServerScreen's CycleButton, above Done.
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 72.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 72.0,
|
||||
200.0,
|
||||
&pack_setting_label(*packs),
|
||||
true,
|
||||
60,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 18.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 96.0 + 18.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("gui.done", "Done"),
|
||||
servers::parse_address(address).is_some(),
|
||||
50,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 120.0 + 18.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 120.0 + 18.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("gui.cancel", "Cancel"),
|
||||
true,
|
||||
@@ -597,16 +613,16 @@ impl Screen {
|
||||
}
|
||||
Self::Direct { address } => vec![
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 12.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 96.0 + 12.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("selectServer.select", "Join Server"),
|
||||
servers::parse_address(address).is_some(),
|
||||
52,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 120.0 + 12.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 120.0 + 12.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("gui.cancel", "Cancel"),
|
||||
true,
|
||||
@@ -615,16 +631,16 @@ impl Screen {
|
||||
],
|
||||
Self::Friend { code, .. } => vec![
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 18.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 96.0 + 18.0,
|
||||
200.0,
|
||||
"Join",
|
||||
horizon_friends::invite::parse(code).is_some(),
|
||||
56,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 120.0 + 18.0,
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 120.0 + 18.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("gui.cancel", "Cancel"),
|
||||
true,
|
||||
@@ -633,8 +649,8 @@ impl Screen {
|
||||
],
|
||||
Self::Disconnected { retry, .. } => {
|
||||
let mut v = vec![b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 2.0 + 30.0,
|
||||
half(w) - 100.0,
|
||||
half(h) + 30.0,
|
||||
200.0,
|
||||
&crate::lang::get_or("gui.toMenu", "Back to Server List"),
|
||||
true,
|
||||
@@ -642,8 +658,8 @@ impl Screen {
|
||||
)];
|
||||
if retry.is_some() {
|
||||
v.push(b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 2.0 + 54.0,
|
||||
half(w) - 100.0,
|
||||
half(h) + 54.0,
|
||||
200.0,
|
||||
"Cancel Reconnecting",
|
||||
true,
|
||||
@@ -698,9 +714,10 @@ impl Screen {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let max = self.max_length();
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
// GuiGraphicsExtractor.centeredText at width / 2: x - width(text) / 2.
|
||||
let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
|
||||
let tw = HudBatch::text_width(atlas, text);
|
||||
batch.text(atlas, text, (w - tw) / 2.0, y, color, true);
|
||||
batch.text(atlas, text, centered_x(w, tw), y, color, true);
|
||||
};
|
||||
match self {
|
||||
Self::List {
|
||||
@@ -1042,15 +1059,15 @@ impl Screen {
|
||||
centered(
|
||||
batch,
|
||||
&crate::lang::get_or("disconnect.lost", "Connection Lost"),
|
||||
h / 2.0 - 40.0,
|
||||
half(h) - 40.0,
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
for (i, line) in wrap(reason, 300.0, atlas).iter().enumerate() {
|
||||
centered(batch, line, h / 2.0 - 16.0 + i as f32 * 9.0, u32::MAX);
|
||||
centered(batch, line, half(h) - 16.0 + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
if let Some(r) = retry {
|
||||
let text = r.text(std::time::Instant::now());
|
||||
centered(batch, &text, h / 2.0 + 16.0, rgba(170, 170, 170, 255));
|
||||
centered(batch, &text, half(h) + 16.0, rgba(170, 170, 170, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1281,7 +1298,7 @@ impl Screen {
|
||||
unreachable!()
|
||||
};
|
||||
for (i, fy) in [(0u8, boxes[0]), (1, boxes[1])] {
|
||||
if y >= fy && y < fy + 20.0 && (x - w / 2.0).abs() < 100.0 {
|
||||
if y >= fy && y < fy + 20.0 && (half(w) - 100.0..half(w) + 100.0).contains(&x) {
|
||||
*focus = i;
|
||||
}
|
||||
}
|
||||
@@ -1539,6 +1556,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screens_lay_out_on_whole_gui_pixels_at_any_window_size() {
|
||||
// A 1000-pixel-high window at GUI scale 3 is 334 GUI pixels high and
|
||||
// a 1281-wide one 427 wide: vanilla's `height / 4` is 83 and
|
||||
// `width / 2` 213, never 83.5 and 213.5.
|
||||
let direct = Screen::Direct {
|
||||
address: "localhost".into(),
|
||||
};
|
||||
let at = |screen: &Screen, w, h| -> Vec<(f32, f32)> {
|
||||
screen.buttons(w, h).iter().map(|b| (b.0, b.1)).collect()
|
||||
};
|
||||
assert_eq!(at(&direct, 427.0, 334.0), [(113.0, 191.0), (113.0, 215.0)]);
|
||||
assert_eq!(at(&direct, 320.0, 240.0), [(60.0, 168.0), (60.0, 192.0)]);
|
||||
let friend = Screen::Friend {
|
||||
code: String::new(),
|
||||
relay: String::new(),
|
||||
focus: 0,
|
||||
};
|
||||
assert_eq!(at(&friend, 427.0, 253.0), [(113.0, 177.0), (113.0, 201.0)]);
|
||||
let lost = Screen::Disconnected {
|
||||
reason: String::new(),
|
||||
retry: None,
|
||||
};
|
||||
assert_eq!(at(&lost, 427.0, 253.0), [(113.0, 156.0)]);
|
||||
// centeredText: `width / 2 - font.width(text) / 2`.
|
||||
assert_eq!(centered_x(427.0, 85.0), 171.0);
|
||||
assert_eq!(centered_x(320.0, 84.0), 118.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_list_styles_sample_names_and_counts_the_rest() {
|
||||
let players = serde_json::json!({
|
||||
@@ -1820,11 +1866,12 @@ mod tests {
|
||||
.find(|b| b.5 == 60)
|
||||
.map(|b| (b.0, b.1, b.2, b.3))
|
||||
};
|
||||
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
|
||||
// ManageServerScreen: 200 wide at width / 2 - 100 (ints: 427 / 2 is
|
||||
// 213) and height / 4 + 72, the saved value first.
|
||||
assert_eq!(
|
||||
cycle(&screen),
|
||||
Some((
|
||||
113.5,
|
||||
113.0,
|
||||
132.0,
|
||||
200.0,
|
||||
"Server Resource Packs: Disabled".into()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `misc/shadow.png` projected onto the tops of full-collision blocks under
|
||||
//! an entity, faded by camera distance, depth below the entity and the light
|
||||
//! at each block.
|
||||
use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, mobs::Mob, world::World};
|
||||
use glam::DVec3;
|
||||
|
||||
/// Vanilla renderer shadow radius and strength for an entity type id.
|
||||
@@ -16,7 +16,7 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
|
||||
"chicken" | "cod" | "parrot" | "rabbit" | "frog" | "silverfish" | "endermite" | "vex" => {
|
||||
0.3
|
||||
}
|
||||
"cat" | "ocelot" | "fox" | "bee" | "allay" | "salmon" => 0.4,
|
||||
"cat" | "ocelot" | "fox" | "bee" | "allay" | "salmon" | "armadillo" => 0.4,
|
||||
"zombie" | "husk" | "drowned" | "zombie_villager" | "zombified_piglin" | "skeleton"
|
||||
| "stray" | "wither_skeleton" | "bogged" | "creeper" | "enderman" | "villager"
|
||||
| "wandering_trader" | "witch" | "pillager" | "vindicator" | "evoker" | "illusioner"
|
||||
@@ -30,6 +30,12 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
|
||||
"horse" | "donkey" | "mule" | "skeleton_horse" | "zombie_horse" | "phantom" => 0.75,
|
||||
"spider" | "boat" => 0.8,
|
||||
"polar_bear" | "panda" | "warden" => 0.9,
|
||||
// GiantMobRenderer: 0.5 times its scale of 6.
|
||||
"giant" => 3.0,
|
||||
"elder_guardian" => 1.2,
|
||||
"happy_ghast" => 2.0,
|
||||
// ShulkerRenderer: none.
|
||||
"shulker" => 0.0,
|
||||
"wither" => 1.0,
|
||||
"sniffer" | "ravager" => 1.1,
|
||||
"ghast" => 1.5,
|
||||
@@ -43,6 +49,21 @@ pub fn shadow_params(entity: &str) -> (f32, f32) {
|
||||
(radius, strength)
|
||||
}
|
||||
|
||||
/// The mobs that cast a shadow, at their feet position interpolated by
|
||||
/// `alpha`, with their type id. `EntityRenderer.extractShadow` skips an entity
|
||||
/// whose `EntityRenderState.isInvisible` is set (`Entity.isInvisible`).
|
||||
pub fn mob_casters(mobs: &[Mob], alpha: f64) -> impl Iterator<Item = (DVec3, &str)> {
|
||||
mobs.iter().filter(|m| !m.is_invisible()).map(move |m| {
|
||||
let (p, q) = (m.previous, m.position);
|
||||
let pos = DVec3::new(
|
||||
p.x + (q.x - p.x) * alpha,
|
||||
p.y + (q.y - p.y) * alpha,
|
||||
p.z + (q.z - p.z) * alpha,
|
||||
);
|
||||
(pos, m.kind.id.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// Shadow quads for an entity at `pos` (interpolated feet position).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn shadow(
|
||||
@@ -178,6 +199,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn invisible_mobs_cast_no_shadow() {
|
||||
use horizon_core::geometry::Vec3;
|
||||
let kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let mut seen = Mob::new(1, kind.clone(), Vec3::new(1.0, 64.0, 1.0), 0.0);
|
||||
seen.previous = Vec3::new(0.0, 64.0, 1.0);
|
||||
// A server's invisible mob (shared flag 0x20) and one ticked here
|
||||
// with the Invisibility effect.
|
||||
let mut flagged = Mob::new(2, kind.clone(), Vec3::ZERO, 0.0);
|
||||
flagged.invisible = true;
|
||||
let mut potion = Mob::new(3, kind, Vec3::ZERO, 0.0);
|
||||
potion.add_effect("minecraft:invisibility", 600, 0);
|
||||
let mobs = [seen, flagged, potion];
|
||||
let casters: Vec<_> = mob_casters(&mobs, 0.5).collect();
|
||||
assert_eq!(
|
||||
casters,
|
||||
vec![(DVec3::new(0.5, 64.0, 1.0), "minecraft:cow")],
|
||||
"only the visible mob casts a shadow"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn vanilla_radii() {
|
||||
assert_eq!(shadow_params("minecraft:zombie"), (0.5, 1.0));
|
||||
assert_eq!(shadow_params("minecraft:item"), (0.15, 0.75));
|
||||
|
||||
@@ -370,3 +370,65 @@ pub fn third_person_use_item(
|
||||
Vec3::new(0.0, 0.0, 0.125),
|
||||
)
|
||||
}
|
||||
|
||||
/// `SpearAnimations.firstPersonAttack`, after `applyItemArmTransform`: the
|
||||
/// jab (a STAB swing), `attack` into the swing.
|
||||
pub fn first_person_attack(attack: f32, right: bool) -> Mat4 {
|
||||
let invert = if right { 1.0 } else { -1.0 };
|
||||
let start = in_out_sine(progress(attack, 0.0, 0.05));
|
||||
let middle = out_back(progress(attack, 0.05, 0.2));
|
||||
let end = in_out_expo(progress(attack, 0.4, 1.0));
|
||||
Mat4::from_translation(Vec3::new(
|
||||
invert * 0.1 * (start - middle),
|
||||
-0.075 * (start - end),
|
||||
0.65 * (start - middle),
|
||||
)) * Mat4::from_rotation_x((-70.0 * (start - end)).to_radians())
|
||||
* Mat4::from_translation(Vec3::new(0.0, 0.0, -0.25 * (end - middle)))
|
||||
}
|
||||
|
||||
/// `SpearAnimations.thirdPersonAttackHand`: what a STAB swing adds to the
|
||||
/// swinging arm's `xRot`, `swing` into the swing.
|
||||
pub fn third_person_attack_arm(swing: f32) -> f32 {
|
||||
let prepare = in_out_sine(progress(swing, 0.0, 0.05));
|
||||
let attack = in_quad(progress(swing, 0.05, 0.2));
|
||||
let retract = in_out_expo(progress(swing, 0.4, 1.0));
|
||||
(90.0 * prepare - 120.0 * attack + 30.0 * retract).to_radians()
|
||||
}
|
||||
|
||||
/// `SpearAnimations.thirdPersonAttackItem`: the item in the swinging hand
|
||||
/// thrust out by a STAB swing, `forward` the main hand's `forward_movement`
|
||||
/// (0 without a kinetic weapon there).
|
||||
pub fn third_person_attack_item(swing: f32, forward: f32) -> Mat4 {
|
||||
if swing <= 0.0 {
|
||||
return Mat4::IDENTITY;
|
||||
}
|
||||
let attack = in_quad(progress(swing, 0.05, 0.2));
|
||||
let retract = in_out_expo(progress(swing, 0.4, 1.0));
|
||||
rotate_around(
|
||||
Mat4::from_rotation_x((-70.0 * (attack - retract)).to_radians()),
|
||||
Vec3::new(0.0, -0.125, 0.125),
|
||||
) * Mat4::from_translation(Vec3::new(0.0, forward * (attack - retract), 0.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_jab_starts_and_ends_at_rest() {
|
||||
assert_eq!(first_person_attack(0.0, true), Mat4::IDENTITY);
|
||||
assert!(first_person_attack(1.0, true).abs_diff_eq(Mat4::IDENTITY, 1e-5));
|
||||
// Thrust out and turned mid-jab, mirrored in the left hand.
|
||||
let right = first_person_attack(0.2, true);
|
||||
assert!(!right.abs_diff_eq(Mat4::IDENTITY, 1e-3));
|
||||
let left = first_person_attack(0.2, false);
|
||||
assert!((left.w_axis.x + right.w_axis.x).abs() < 1e-6);
|
||||
assert_eq!(third_person_attack_item(0.0, 0.38), Mat4::IDENTITY);
|
||||
assert!(third_person_attack_item(1.0, 0.38).abs_diff_eq(Mat4::IDENTITY, 1e-5));
|
||||
// The arm draws back, thrusts forward, then returns.
|
||||
assert_eq!(third_person_attack_arm(0.0), 0.0);
|
||||
assert!((third_person_attack_arm(0.05) - 90f32.to_radians()).abs() < 1e-5);
|
||||
assert!(third_person_attack_arm(0.2) < 0.0);
|
||||
assert!(third_person_attack_arm(1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,9 @@ impl WorldToast {
|
||||
pub struct WorldEntry {
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
pub creative: bool,
|
||||
/// `GameType`: 0 survival, 1 creative, 2 adventure, 3 spectator.
|
||||
pub game_mode: i32,
|
||||
pub hardcore: bool,
|
||||
pub modified: std::time::SystemTime,
|
||||
pub commands: bool,
|
||||
pub version: String,
|
||||
@@ -221,7 +223,8 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
Some(WorldEntry {
|
||||
folder: e.file_name().to_string_lossy().into_owned(),
|
||||
name: level.name,
|
||||
creative: level.creative,
|
||||
game_mode: level.game_mode,
|
||||
hardcore: level.hardcore,
|
||||
modified,
|
||||
commands: matches!(data.get("allowCommands"), Some(horizon_world::nbt::Tag::Byte(v)) if *v != 0),
|
||||
version: data.get("Version").and_then(|v| v.get("Name"))
|
||||
@@ -233,6 +236,31 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
worlds.sort_by_key(|w| std::cmp::Reverse(w.modified));
|
||||
worlds
|
||||
}
|
||||
/// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED),
|
||||
/// else `gameMode.` + its GameType name, then commands and the version.
|
||||
fn world_info(world: &WorldEntry) -> crate::text::Text {
|
||||
let mode = if world.hardcore {
|
||||
crate::text::Text::colored(lang::get_or("gameMode.hardcore", "Hardcore Mode"), 0xFF0000)
|
||||
} else {
|
||||
let (key, fallback) = match world.game_mode {
|
||||
1 => ("gameMode.creative", "Creative Mode"),
|
||||
2 => ("gameMode.adventure", "Adventure Mode"),
|
||||
3 => ("gameMode.spectator", "Spectator Mode"),
|
||||
_ => ("gameMode.survival", "Survival Mode"),
|
||||
};
|
||||
crate::text::Text::literal(lang::get_or(key, fallback))
|
||||
};
|
||||
let commands = if world.commands {
|
||||
format!(", {}", lang::get_or("selectWorld.commands", "Commands"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
mode.append(crate::text::Text::literal(format!(
|
||||
"{commands}, {} {}",
|
||||
lang::get_or("selectWorld.version", "Version:"),
|
||||
world.version
|
||||
)))
|
||||
}
|
||||
fn choose_splash(text: &str, index: usize, date: (u32, u32)) -> Option<String> {
|
||||
// SplashManager checks SpecialDates before the resource list (local date).
|
||||
let special = match date {
|
||||
@@ -1145,22 +1173,8 @@ impl Title {
|
||||
rgba(128, 128, 128, 255),
|
||||
true,
|
||||
);
|
||||
let mode = if world.creative {
|
||||
lang::get_or("gameMode.creative", "Creative Mode")
|
||||
} else {
|
||||
lang::get_or("gameMode.survival", "Survival Mode")
|
||||
};
|
||||
let commands = if world.commands {
|
||||
format!(", {}", lang::get_or("selectWorld.commands", "Commands"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let details = format!(
|
||||
"{mode}{commands}, {} {}",
|
||||
lang::get_or("selectWorld.version", "Version:"),
|
||||
world.version
|
||||
);
|
||||
batch.text(
|
||||
let details = world_info(world);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&details,
|
||||
x + 35.0,
|
||||
|
||||
@@ -954,3 +954,48 @@ fn server_fields_edit_at_their_cursor() {
|
||||
};
|
||||
assert_eq!(address, "one ");
|
||||
}
|
||||
#[test]
|
||||
fn world_list_names_every_game_mode_and_hardcore_in_red() {
|
||||
let f = Fixture::new();
|
||||
f.world("Creative", "Creative");
|
||||
let level = |folder: &str, game_type: i32, hardcore: bool| {
|
||||
crate::save::write_dat(
|
||||
&f.title.saves.join(folder).join("level.dat"),
|
||||
Tag::compound([(
|
||||
"Data",
|
||||
Tag::compound([
|
||||
("LevelName", Tag::String(folder.into())),
|
||||
("GameType", Tag::Int(game_type)),
|
||||
(
|
||||
"difficulty_settings",
|
||||
Tag::compound([
|
||||
("difficulty", Tag::String("hard".into())),
|
||||
("hardcore", Tag::Byte(i8::from(hardcore))),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"Version",
|
||||
Tag::compound([("Name", Tag::String("26.3".into()))]),
|
||||
),
|
||||
]),
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
level("Hardcore", 0, true);
|
||||
level("Adventure", 2, false);
|
||||
level("Spectator", 3, false);
|
||||
let worlds = list_worlds(&f.title.saves);
|
||||
let info = |folder: &str| world_info(worlds.iter().find(|w| w.folder == folder).unwrap());
|
||||
|
||||
let hardcore = info("Hardcore");
|
||||
assert_eq!(hardcore.plain(), "Hardcore Mode, Version: 26.3");
|
||||
assert_eq!(hardcore.spans[0].text, "Hardcore Mode");
|
||||
assert_eq!(hardcore.spans[0].style.color, Some(0xFF0000));
|
||||
assert!(hardcore.spans[1..].iter().all(|s| s.style.color.is_none()));
|
||||
assert_eq!(info("Adventure").plain(), "Adventure Mode, Version: 26.3");
|
||||
assert_eq!(info("Spectator").plain(), "Spectator Mode, Version: 26.3");
|
||||
let creative = info("Creative");
|
||||
assert_eq!(creative.plain(), "Creative Mode, Commands, Version: 26.3");
|
||||
assert!(creative.spans.iter().all(|s| s.style.color.is_none()));
|
||||
}
|
||||
@@ -798,7 +798,9 @@ fn java_split(s: &str) -> Vec<&str> {
|
||||
parts
|
||||
}
|
||||
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
|
||||
/// candidates `ty` parses completely.
|
||||
/// candidates `ty` parses completely. `relevant` arguments (block and column
|
||||
/// positions) offer the targeted block (`getRelevantCoordinates`), the others
|
||||
/// (`Vec3`, `Vec2`) the exact hit point (`getAbsoluteCoordinates`).
|
||||
fn suggest_coordinates(
|
||||
builder: &mut SuggestionsBuilder,
|
||||
reg: &Registries,
|
||||
@@ -811,6 +813,8 @@ fn suggest_coordinates(
|
||||
["^".into(), "^".into(), "^".into()]
|
||||
} else if let (true, Some(t)) = (relevant, reg.target_block) {
|
||||
[t[0].to_string(), t[1].to_string(), t[2].to_string()]
|
||||
} else if let (false, Some(p)) = (relevant, ®.target_point) {
|
||||
p.clone()
|
||||
} else {
|
||||
["~".into(), "~".into(), "~".into()]
|
||||
};
|
||||
|
||||
@@ -256,6 +256,45 @@ fn suggestions_like_vanilla() {
|
||||
assert_eq!(suggestions("time set 5"), ["d", "s", "t"]);
|
||||
assert!(suggestions("time set ").contains(&"minecraft:day".to_owned()));
|
||||
}
|
||||
/// ClientSuggestionProvider: block and column positions offer the targeted
|
||||
/// block (`getRelevantCoordinates`), `Vec3` and `Vec2` the exact hit point
|
||||
/// with two decimals (`getAbsoluteCoordinates`); with no block hit, `~`.
|
||||
#[test]
|
||||
fn coordinates_suggest_the_crosshair_block_or_hit_point() {
|
||||
let d = vanilla::dispatcher();
|
||||
let suggest = |reg: &Registries, input: &str| -> Vec<String> {
|
||||
let parse = d.parse(input, reg);
|
||||
d.suggestions(&parse, input.len(), reg)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|s| s.text)
|
||||
.collect()
|
||||
};
|
||||
let mut hit = reg();
|
||||
hit.target_block = Some([-29, 73, 8]);
|
||||
hit.target_point = Some(["-28.77".into(), "73.28".into(), "8.00".into()]);
|
||||
assert_eq!(suggest(&hit, "setblock "), ["-29", "-29 73", "-29 73 8"]);
|
||||
assert_eq!(
|
||||
suggest(&hit, "summon minecraft:pig "),
|
||||
["-28.77", "-28.77 73.28", "-28.77 73.28 8.00"]
|
||||
);
|
||||
assert_eq!(
|
||||
suggest(&hit, "summon minecraft:pig -28.77 "),
|
||||
["-28.77 73.28", "-28.77 73.28 8.00"]
|
||||
);
|
||||
// Vec2Argument: x and z.
|
||||
assert_eq!(
|
||||
suggest(&hit, "worldborder center "),
|
||||
["-28.77", "-28.77 8.00"]
|
||||
);
|
||||
// An entity in front of the block (or no hit): the defaults.
|
||||
let none = reg();
|
||||
assert_eq!(suggest(&none, "setblock "), ["~", "~ ~", "~ ~ ~"]);
|
||||
assert_eq!(
|
||||
suggest(&none, "summon minecraft:pig "),
|
||||
["~", "~ ~", "~ ~ ~"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn smart_usage_matches_brigadier() {
|
||||
let d = vanilla::dispatcher();
|
||||
|
||||
@@ -92,7 +92,10 @@ impl<B: TerrainBlockStates> MaterialColumn for Column<'_, '_, B> {
|
||||
}
|
||||
fn set_block(&mut self, y: i32, state: BlockStateId) -> Result<(), SurfaceError> {
|
||||
if y >= self.min && y <= self.max {
|
||||
self.chunk.set_block_worldgen(self.x, y, self.z, state)?;
|
||||
// Most of the Nether's rule writes netherrack over netherrack.
|
||||
if self.chunk.block(self.x, y, self.z) != state {
|
||||
self.chunk.set_block_worldgen(self.x, y, self.z, state)?;
|
||||
}
|
||||
if self.chunk.block_properties(state).has_fluid() {
|
||||
self.chunk.mark_postprocessing(self.x, y, self.z);
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Unknown areas are sampled directly from the dimension's seed-bound density and s
|
||||
|
||||
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
|
||||
|
||||
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. The control status reports target and sampled target tile counts separately. Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
|
||||
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. Far sampling runs behind the game's frame and tick threads; on machines with 12 or more threads it uses up to 6 sampling threads once the nearby chunks have loaded (a quarter of the threads, at most 3, elsewhere). The control status reports target and sampled target tile counts separately. Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
|
||||
|
||||
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ A Java instance's Java: its own Java home or the automatic pick, memory and JVM
|
||||
- `--auto`: Go back to the Java the version needs, picked or downloaded by the launcher
|
||||
- `--memory`: The most memory the game may use, in MiB
|
||||
- `--args`: Extra JVM arguments (one string; "" clears them)
|
||||
- `--window`: The game window's size, as 1280x720; "default" goes back to the game's own
|
||||
|
||||
MCP: `instance_java`.
|
||||
|
||||
@@ -456,13 +457,14 @@ MCP: `instance_export`.
|
||||
|
||||
## `horizon-launcher instance launch`
|
||||
|
||||
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
|
||||
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), environment variables ("NAME=value NAME2=value") and a shell command after it exits. An empty value clears one. The game and the commands also get INST_NAME, INST_ID, INST_DIR, INST_MC_DIR and, for a Java instance, INST_JAVA. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
|
||||
|
||||
horizon-launcher instance launch <INSTANCE>
|
||||
|
||||
- `INSTANCE`
|
||||
- `--pre-launch`
|
||||
- `--wrapper`
|
||||
- `--post-exit`
|
||||
- `--env`
|
||||
- `--gamemode`: Linux: run it through GameMode (gamemoderun), on or off
|
||||
- `--mangohud`: Linux: MangoHud's overlay, on or off
|
||||
@@ -494,7 +496,7 @@ MCP: `instance_import`.
|
||||
|
||||
## `horizon-launcher instance others`
|
||||
|
||||
List the instances other launchers keep (Prism Launcher, MultiMC, the official launcher), read without changing anything there.
|
||||
List the instances other launchers keep (Prism Launcher, MultiMC, the official launcher, the CurseForge app, the Modrinth App), read without changing anything there.
|
||||
|
||||
horizon-launcher instance others
|
||||
|
||||
@@ -511,6 +513,7 @@ Copy another launcher's instance into a new Java instance: its Minecraft version
|
||||
- `NAME`: Its name, as `instance others` lists it
|
||||
- `--folder`: Look in this folder instead: a launcher's folder, its instances/ or one instance
|
||||
- `--as`: The new instance's name (default: the same)
|
||||
- `--minecraft`: The Minecraft version it plays, when its launcher doesn't say (the CurseForge app, the Modrinth App)
|
||||
|
||||
MCP: `instance_copy_from`.
|
||||
|
||||
@@ -846,7 +849,7 @@ MCP: `mods_remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher mods enable`
|
||||
|
||||
Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does).
|
||||
Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does; in a Java instance, its file loses `.disabled`).
|
||||
|
||||
horizon-launcher mods enable <PROJECT>
|
||||
|
||||
@@ -857,7 +860,7 @@ MCP: `mods_enable`.
|
||||
|
||||
## `horizon-launcher mods disable`
|
||||
|
||||
Turn off a mod the browser installed; it stays installed.
|
||||
Turn off a mod the browser installed; it stays installed (in a Java instance, as `<file>.disabled`).
|
||||
|
||||
horizon-launcher mods disable <PROJECT>
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"Home": "Inicio",
|
||||
"Mods": "Mods",
|
||||
"Servers": "Servidores",
|
||||
"Versions": "Versiones",
|
||||
"Patch notes": "Notas de la versión",
|
||||
"Settings": "Ajustes",
|
||||
"Accounts, updates, and the launcher itself.": "Cuentas, actualizaciones y el propio lanzador.",
|
||||
"Account": "Cuenta",
|
||||
"Updates": "Actualizaciones",
|
||||
"Launcher": "Lanzador",
|
||||
"Network": "Red",
|
||||
"Game": "Juego",
|
||||
"AI": "IA",
|
||||
"About": "Acerca de",
|
||||
"Install new versions automatically": "Instalar nuevas versiones automáticamente",
|
||||
"Your selected version only changes if you were playing the newest one.": "Tu versión seleccionada solo cambia si estabas jugando a la más reciente.",
|
||||
"Appearance": "Apariencia",
|
||||
"Dark": "Oscuro",
|
||||
"Light": "Claro",
|
||||
"System": "Sistema",
|
||||
"Dark by default. System follows your computer's light or dark.": "Oscuro por defecto. Sistema sigue el modo claro u oscuro de tu equipo.",
|
||||
"Language": "Idioma",
|
||||
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "Sistema sigue el idioma de tu equipo: {language} aquí. Un texto aún no traducido se muestra en inglés.",
|
||||
"While playing": "Al jugar",
|
||||
"Stay": "Mantener",
|
||||
"Minimise": "Minimizar",
|
||||
"Hide": "Ocultar",
|
||||
"The launcher stays open while you play.": "El lanzador permanece abierto mientras juegas.",
|
||||
"The launcher goes down when the game starts, and comes back when it closes.": "El lanzador se minimiza cuando el juego inicia y vuelve cuando se cierra.",
|
||||
"The launcher hides when the game starts, and comes back when it closes.": "El lanzador se oculta cuando el juego inicia y vuelve cuando se cierra.",
|
||||
"Show the opening": "Mostrar animación de inicio",
|
||||
"The wordmark, while the release list loads.": "El logotipo mientras carga la lista de versiones.",
|
||||
"{version} available": "{version} disponible",
|
||||
"Up to date": "Actualizado",
|
||||
"up to date": "actualizado",
|
||||
"Checking…": "Comprobando…",
|
||||
"downloading {version}, {percent}%": "descargando {version}, {percent}%",
|
||||
"Updated to {version}. Restart the launcher to use it.": "Actualizado a {version}. Reinicia el lanzador para usarlo.",
|
||||
"the update failed: {error}": "la actualización falló: {error}",
|
||||
"Install": "Instalar",
|
||||
"Restart": "Reiniciar",
|
||||
"Check now": "Comprobar ahora",
|
||||
"Open": "Abrir",
|
||||
"Data folder": "Carpeta de datos",
|
||||
"Logs": "Registros",
|
||||
"crash reports beside it": "informes de fallos al lado",
|
||||
"Applications menu": "Menú de aplicaciones",
|
||||
"Horizon and its icon are in your desktop's menu.": "Horizon y su icono están en el menú de tu escritorio.",
|
||||
"Not in your desktop's menu.": "No está en el menú de tu escritorio.",
|
||||
"Microsoft account": "Cuenta de Microsoft",
|
||||
"Another account": "Otra cuenta",
|
||||
"Microsoft account · plays": "Cuenta de Microsoft · jugando",
|
||||
"Microsoft account · signed in": "Cuenta de Microsoft · sesión iniciada",
|
||||
"Asking Microsoft…": "Consultando a Microsoft…",
|
||||
"Add another account.": "Añadir otra cuenta.",
|
||||
"Copy code": "Copiar código",
|
||||
"Open link": "Abrir enlace",
|
||||
"Play as": "Jugar como",
|
||||
"Play offline": "Jugar sin conexión",
|
||||
"Sign out": "Cerrar sesión",
|
||||
"Offline": "Sin conexión",
|
||||
"Microsoft": "Microsoft",
|
||||
"Offline, the game's name": "Sin conexión, el nombre en el juego",
|
||||
"Manage accounts": "Gestionar cuentas",
|
||||
"Offline name": "Nombre sin conexión",
|
||||
"Offline name · plays": "Nombre sin conexión · jugando",
|
||||
"A name, 3 to 16 letters": "Un nombre, de 3 a 16 letras",
|
||||
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Juega sin conexión con otro nombre y cámbialo desde la barra superior. Los servidores en línea no aceptan nombres sin conexión.",
|
||||
"Add": "Añadir",
|
||||
"Skin…": "Aspecto…",
|
||||
"Forget": "Olvidar",
|
||||
"{name}'s skin": "Aspecto de {name}",
|
||||
"{name}'s skin is kept.": "Se conserva el aspecto de {name}.",
|
||||
"{name}'s cape is kept.": "Se conserva la capa de {name}.",
|
||||
"Choose a skin…": "Elegir un aspecto…",
|
||||
"Default skin": "Aspecto por defecto",
|
||||
"Classic arms": "Brazos clásicos",
|
||||
"Slim arms": "Brazos delgados",
|
||||
"Choose a cape…": "Elegir una capa…",
|
||||
"No cape": "Sin capa",
|
||||
"Default": "Por defecto",
|
||||
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de aspecto de 64×64 (o 64×32) y un PNG de capa de 64×32, guardados por el lanzador y proporcionados al juego cuando juega con este nombre. Por ahora el juego muestra los aspectos por defecto; usará estos en cuanto sea posible. El aspecto y capa de una cuenta de Microsoft estarán disponibles pronto.",
|
||||
"Accent colour": "Color de acento",
|
||||
"Horizon's violet by default. Every colour keeps text as easy to read.": "Violeta de Horizon por defecto. Cada color mantiene el texto igual de legible.",
|
||||
"Choose": "Elegir",
|
||||
"Choose a picture…": "Elegir una imagen…",
|
||||
"Landscape": "Paisaje",
|
||||
"Your picture, behind Home, cropped to fill it.": "Tu imagen, detrás de Inicio, recortada para llenarlo.",
|
||||
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "El paisaje de Horizon. Elige tu propia imagen en PNG, JPEG o WebP.",
|
||||
"Home background": "Fondo de Inicio",
|
||||
"Play": "Jugar",
|
||||
"Running": "En ejecución",
|
||||
"Stopped": "Detenido",
|
||||
"Start the game, or run a server for others to join.": "Inicia el juego o ejecuta un servidor para que otros se unan.",
|
||||
"No version installed yet.": "Aún no hay ninguna versión instalada.",
|
||||
"Choose a version": "Elegir una versión",
|
||||
"Host a server": "Alojar un servidor",
|
||||
"Preparing…": "Preparando…",
|
||||
"Prepare": "Preparar",
|
||||
"Prepare again": "Preparar de nuevo",
|
||||
"None needed: Horizon downloads Minecraft from Mojang for your account.": "No hace falta: Horizon descarga Minecraft de Mojang para tu cuenta.",
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.": "No encontrado. Elige la carpeta donde tu lanzador guarda Minecraft.",
|
||||
"Ready to play": "Listo para jugar",
|
||||
"Resources": "Recursos",
|
||||
"Minecraft folder": "Carpeta de Minecraft",
|
||||
"Choose…": "Elegir…",
|
||||
"Playing offline": "Jugando sin conexión",
|
||||
"Online servers need a Microsoft account.": "Los servidores en línea requieren una cuenta de Microsoft.",
|
||||
"Accounts…": "Cuentas…",
|
||||
"The first preparation takes a few minutes.": "La primera preparación toma unos minutos.",
|
||||
"Horizon crashed": "Horizon se cerró inesperadamente",
|
||||
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Al reportarlo en Discord, adjunta el archivo del informe de fallos (o pégalo con Copiar informe) y describe qué estabas haciendo.",
|
||||
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "El juego terminó sin un informe de fallos. Al reportarlo en Discord, adjunta latest.log de la carpeta de registros y describe qué estabas haciendo.",
|
||||
"Open report": "Abrir informe",
|
||||
"Copied": "Copiado",
|
||||
"Copy report": "Copiar informe",
|
||||
"Open logs folder": "Abrir carpeta de registros",
|
||||
"Report on Discord": "Reportar en Discord",
|
||||
"Dismiss": "Descartar",
|
||||
"INSTANCES": "INSTANCIAS",
|
||||
"SERVERS": "SERVIDORES",
|
||||
"HIDE": "OCULTAR",
|
||||
"Sponsor": "Patrocinar",
|
||||
"Website": "Sitio web",
|
||||
"Ready": "Listo",
|
||||
"Needs preparing": "Requiere preparación",
|
||||
"{version} is not installed": "{version} no está instalada",
|
||||
"No version installed": "Ninguna versión instalada",
|
||||
"Modpack": "Paquete de mods",
|
||||
"From Horizon's registry": "Del registro de Horizon",
|
||||
"Instance": "Instancia",
|
||||
"Edit…": "Editar…",
|
||||
"Copy…": "Copiar…",
|
||||
"Export…": "Exportar…",
|
||||
"Close export": "Cerrar exportación",
|
||||
"Launch…": "Lanzar…",
|
||||
"Close launch": "Cerrar lanzamiento",
|
||||
"Open folder": "Abrir carpeta",
|
||||
"Join a friend": "Unirse a un amigo",
|
||||
"Paste an invite code": "Pega un código de invitación",
|
||||
"Paste the code a friend shows when they invite you.": "Pega el código que un amigo muestra cuando te invita.",
|
||||
"Join": "Unirse",
|
||||
"Shortcut": "Acceso directo",
|
||||
"Remove shortcut": "Eliminar acceso directo",
|
||||
"today": "hoy",
|
||||
"yesterday": "ayer",
|
||||
"{days} days ago": "hace {days} días",
|
||||
"{time} in total · last played {when}": "{time} en total · última vez jugado {when}",
|
||||
"Not played yet": "Aún no jugado",
|
||||
"under a minute": "menos de un minuto",
|
||||
"PARTNER": "COLABORADOR",
|
||||
"Your Minecraft server, {online}.": "Tu servidor de Minecraft, {online}.",
|
||||
"24/7 online": "en línea 24/7",
|
||||
"Ready in under a minute": "Listo en menos de un minuto",
|
||||
"Get my server": "Obtener mi servidor",
|
||||
"15% off": "15% de descuento",
|
||||
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "No preparado. Horizon necesita los archivos de tu propia instalación de Minecraft {version}.",
|
||||
"Prepared for Minecraft {version}": "Preparado para Minecraft {version}",
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Preparado para Minecraft {version}, sin sonidos: el juego estará en silencio. Prepara de nuevo para copiarlos desde tu instalación de Minecraft.",
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Esta compilación del lanzador no tiene inicio de sesión con Microsoft (HORIZON_MSA_CLIENT_ID), por lo que no puede iniciar el juego.",
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Inicia sesión con una cuenta de Microsoft que posea Minecraft: Java Edition para jugar",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon necesita una cuenta de Microsoft que posea Minecraft: Java Edition, como otros lanzadores. También permite jugar en servidores en línea con tu nombre y aspecto.",
|
||||
"Sign in": "Iniciar sesión",
|
||||
"Sign in with Microsoft": "Iniciar sesión con Microsoft",
|
||||
"Not now": "Ahora no",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.": "Horizon necesita una cuenta de Microsoft que posea Minecraft: Java Edition, como otros lanzadores.",
|
||||
"Your worlds, offline names and settings stay where they are.": "Tus mundos, nombres sin conexión y ajustes se mantienen en su lugar.",
|
||||
"Open {link} in your browser and enter this code:": "Abre {link} en tu navegador e introduce este código:",
|
||||
"From my install": "Desde mi instalación",
|
||||
"Download and prepare": "Descargar y preparar",
|
||||
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Sin preparar. Jugar descarga Minecraft {version} de Mojang para tu cuenta y lo prepara.",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "No preparado. Horizon puede descargar Minecraft {version} desde Mojang para tu cuenta, o usar tu propia instalación.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "No preparado. Inicia sesión primero con la cuenta de Microsoft que posea Minecraft {version}.",
|
||||
"Cancel": "Cancelar",
|
||||
"Native mods (experimental)": "Mods nativos (experimental)",
|
||||
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Los mods nativos de la página Mods se compilan en el juego con el compilador Rust del propio lanzador. No están aislados: pueden hacer cualquier cosa que tú puedas hacer en este ordenador.",
|
||||
"{version} plays in this instance.": "{version} se ejecuta en esta instancia.",
|
||||
"Install Horizon first.": "Instala Horizon primero.",
|
||||
"Starting the build": "Iniciando compilación",
|
||||
"These mods will run with full access to your computer": "Estos mods se ejecutarán con acceso completo a tu ordenador",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Pueden leer, modificar o eliminar tus archivos y usar tus cuentas y red, como cualquier programa que ejecutes. Compílalos únicamente si confías en sus autores: la página Mods los enumera.",
|
||||
"Mods: {mods}": "Mods: {mods}",
|
||||
"Delete old versions automatically": "Eliminar automáticamente las versiones antiguas",
|
||||
"Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Conserva las {count} más recientes, la que está en uso y las que usan tus instancias. Las versiones eliminadas se vuelven a instalar desde la página Versiones.",
|
||||
"Installed versions use {size}.": "Las versiones instaladas ocupan {size}.",
|
||||
"{size} on disk": "{size} en disco",
|
||||
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilación construye el juego: de 20 a 30 minutos y unos pocos GB de disco la primera vez (descarga Rust), y unos minutos después.",
|
||||
"Delete…": "Eliminar…",
|
||||
"Keep": "Conservar",
|
||||
"Move to Trash": "Mover a la papelera",
|
||||
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.",
|
||||
"Install and turn on native mods": "Instalar y activar los mods nativos",
|
||||
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.",
|
||||
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas."
|
||||
}
|
||||
+10
-1
@@ -1320,8 +1320,10 @@ pub fn download_version_for_owner(
|
||||
/// version downloaded from Mojang the first time (then it starts offline too), the Java it needs
|
||||
/// (the instance's own, else one found here, else Mojang's, downloaded), and the game in the
|
||||
/// instance's folder. The caller wraps it (`launch::wrap`) and never logs it: it holds the token.
|
||||
/// A Java instance's game, ready to start; `server` (`host[:port]`) is joined once it has.
|
||||
pub fn java_game(
|
||||
instance: &crate::instances::Instance,
|
||||
server: Option<&str>,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<Command, String> {
|
||||
let game = instance.java.as_ref().ok_or_else(|| {
|
||||
@@ -1391,6 +1393,8 @@ pub fn java_game(
|
||||
jvm_args: &jvm_args,
|
||||
player: &player,
|
||||
os: crate::java_launch::os_name(),
|
||||
window: game.window,
|
||||
server,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2767,7 +2771,12 @@ pub enum Msg {
|
||||
Native(Result<String, String>),
|
||||
/// A Java instance's game is ready to start (`java_game`, wrapped): the instance's id and
|
||||
/// game folder, and the command or why not.
|
||||
JavaGame(String, PathBuf, Result<Command, String>),
|
||||
/// A Java instance's game ready to start, and its variables for the post-exit command.
|
||||
JavaGame(
|
||||
String,
|
||||
PathBuf,
|
||||
Result<(Command, crate::launch::Vars), String>,
|
||||
),
|
||||
}
|
||||
|
||||
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
|
||||
|
||||
@@ -164,7 +164,51 @@ pub fn install(
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let steps = plan(source, target, project)?;
|
||||
let folder = |kind| target.folder(kind).expect("plan checks every kind");
|
||||
installed::put(source, steps, home, &folder, progress)
|
||||
let was = Installed::load(home).entries;
|
||||
let done = installed::put(source, steps, home, &folder, progress)?;
|
||||
// A mod the player turned off stays off through an update, and its old file goes.
|
||||
for e in &done {
|
||||
let Some(old) = was
|
||||
.iter()
|
||||
.find(|o| o.source == e.source && o.project == e.project && !is_on(home, o))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if old.file != e.file {
|
||||
let _ = fs::remove_file(off(home, old));
|
||||
}
|
||||
set_on(home, e, false)?;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// A mod turned off keeps its file under this suffix, which the loaders don't read: they load
|
||||
/// the `.jar` files in `mods/`.
|
||||
const OFF: &str = ".disabled";
|
||||
|
||||
fn off(home: &Path, entry: &Entry) -> std::path::PathBuf {
|
||||
home.join(format!("{}{OFF}", entry.file))
|
||||
}
|
||||
|
||||
/// Whether the game loads `entry`: everything but a mod turned off.
|
||||
pub fn is_on(home: &Path, entry: &Entry) -> bool {
|
||||
entry.kind != Kind::Mod || !off(home, entry).is_file()
|
||||
}
|
||||
|
||||
/// Turns the mod `entry` on or off by renaming its file; it stays installed.
|
||||
pub fn set_on(home: &Path, entry: &Entry, on: bool) -> Result<(), String> {
|
||||
if entry.kind != Kind::Mod {
|
||||
return Err(format!(
|
||||
"{} isn't a mod: only mods turn on and off.",
|
||||
entry.title
|
||||
));
|
||||
}
|
||||
let (jar, off) = (home.join(&entry.file), off(home, entry));
|
||||
let (from, to) = if on { (off, jar) } else { (jar, off) };
|
||||
if !from.exists() && to.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::rename(&from, &to).map_err(|e| format!("{}: {e}", from.display()))
|
||||
}
|
||||
|
||||
/// The newer version `entry` can be updated to, if any.
|
||||
@@ -188,10 +232,12 @@ pub fn remove(home: &Path, name: &str) -> Result<Entry, String> {
|
||||
.lookup(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{name} wasn't installed by the browser."))?;
|
||||
match fs::remove_file(home.join(&entry.file)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("{}: {e}", entry.file)),
|
||||
for path in [home.join(&entry.file), off(home, &entry)] {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
installed
|
||||
.entries
|
||||
|
||||
@@ -232,14 +232,34 @@ fn installs_jars_with_dependencies_then_updates_and_removes() {
|
||||
fake.release("map", Kind::Mod, "2", &["1.21.1"], &["fabric"], &["lib"]);
|
||||
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
|
||||
assert_eq!(update_for(&fake, &t, &entry).unwrap().unwrap().number, "2");
|
||||
// Turned off, the mod is kept as `.jar.disabled`, which the loaders skip.
|
||||
set_on(&home, &entry, false).unwrap();
|
||||
assert!(!is_on(&home, &entry));
|
||||
assert!(
|
||||
home.join("mods/map-1.jar.disabled").is_file() && !home.join("mods/map-1.jar").exists()
|
||||
);
|
||||
set_on(&home, &entry, false).unwrap();
|
||||
// It stays off through an update, and the old file goes.
|
||||
install(&fake, &t, &map, &home, NONE).unwrap();
|
||||
assert!(!home.join("mods/map-1.jar").exists());
|
||||
assert!(home.join("mods/map-2.jar").is_file());
|
||||
assert!(
|
||||
!home.join("mods/map-1.jar").exists() && !home.join("mods/map-1.jar.disabled").exists()
|
||||
);
|
||||
assert!(
|
||||
home.join("mods/map-2.jar.disabled").is_file() && !home.join("mods/map-2.jar").exists()
|
||||
);
|
||||
let entry = Installed::load(&home).lookup("map").cloned().unwrap();
|
||||
assert!(!is_on(&home, &entry));
|
||||
assert!(update_for(&fake, &t, &entry).unwrap().is_none());
|
||||
install(&fake, &t, &map, &home, NONE).unwrap();
|
||||
assert!(!is_on(&home, &entry));
|
||||
set_on(&home, &entry, true).unwrap();
|
||||
assert!(is_on(&home, &entry) && home.join("mods/map-2.jar").is_file());
|
||||
set_on(&home, &entry, false).unwrap();
|
||||
|
||||
remove(&home, "map-slug").unwrap();
|
||||
assert!(!home.join("mods/map-2.jar").exists());
|
||||
assert!(
|
||||
!home.join("mods/map-2.jar").exists() && !home.join("mods/map-2.jar.disabled").exists()
|
||||
);
|
||||
assert!(Installed::load(&home).lookup("map").is_none());
|
||||
assert!(home.join("mods/lib-1.jar").is_file());
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
|
||||
+107
-25
@@ -249,6 +249,9 @@ enum InstanceCmd {
|
||||
/// Extra JVM arguments (one string; "" clears them)
|
||||
#[arg(long, allow_hyphen_values = true)]
|
||||
args: Option<String>,
|
||||
/// The game window's size, as 1280x720; "default" goes back to the game's own
|
||||
#[arg(long)]
|
||||
window: Option<String>,
|
||||
},
|
||||
/// A Java instance's mod loader: fabric or quilt, or none. --list shows its versions
|
||||
Loader {
|
||||
@@ -294,9 +297,11 @@ enum InstanceCmd {
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Show or set what runs around an instance's game: a shell command before it (the game
|
||||
/// starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment
|
||||
/// variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode,
|
||||
/// MangoHud and the dedicated graphics card, when the computer has them
|
||||
/// starts only if it succeeds), a wrapper it runs through (`nice -n 5`), environment
|
||||
/// variables ("NAME=value NAME2=value") and a shell command after it exits. An empty value
|
||||
/// clears one. The game and the commands also get INST_NAME, INST_ID, INST_DIR,
|
||||
/// INST_MC_DIR and, for a Java instance, INST_JAVA. On Linux, also GameMode, MangoHud and
|
||||
/// the dedicated graphics card, when the computer has them
|
||||
Launch {
|
||||
instance: String,
|
||||
#[arg(long)]
|
||||
@@ -304,6 +309,8 @@ enum InstanceCmd {
|
||||
#[arg(long)]
|
||||
wrapper: Option<String>,
|
||||
#[arg(long)]
|
||||
post_exit: Option<String>,
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Linux: run it through GameMode (gamemoderun), on or off
|
||||
#[arg(long)]
|
||||
@@ -335,7 +342,7 @@ enum InstanceCmd {
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// List the instances other launchers keep (Prism Launcher, MultiMC, the official
|
||||
/// launcher), read without changing anything there
|
||||
/// launcher, the CurseForge app, the Modrinth App), read without changing anything there
|
||||
Others {
|
||||
/// Look in this folder instead: a launcher's folder, its instances/ or one instance
|
||||
#[arg(long)]
|
||||
@@ -352,6 +359,10 @@ enum InstanceCmd {
|
||||
/// The new instance's name (default: the same)
|
||||
#[arg(long = "as")]
|
||||
new_name: Option<String>,
|
||||
/// The Minecraft version it plays, when its launcher doesn't say (the CurseForge app,
|
||||
/// the Modrinth App)
|
||||
#[arg(long)]
|
||||
minecraft: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -583,14 +594,16 @@ enum ModsCmd {
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does)
|
||||
/// Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does;
|
||||
/// in a Java instance, its file loses `.disabled`)
|
||||
Enable {
|
||||
project: String,
|
||||
/// The instance (default: the selected one)
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Turn off a mod the browser installed; it stays installed
|
||||
/// Turn off a mod the browser installed; it stays installed (in a Java instance, as
|
||||
/// `<file>.disabled`)
|
||||
Disable {
|
||||
project: String,
|
||||
/// The instance (default: the selected one)
|
||||
@@ -1189,25 +1202,28 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
None => instances::selected(),
|
||||
};
|
||||
if instance.java.is_some() {
|
||||
if version.is_some()
|
||||
|| world.is_some()
|
||||
|| join.is_some()
|
||||
|| server.is_some()
|
||||
|| creative
|
||||
{
|
||||
if version.is_some() || world.is_some() || join.is_some() || creative {
|
||||
return Err(format!(
|
||||
"{} plays Minecraft: Java Edition: --version, --world, --creative, --join \
|
||||
and --server are for Horizon instances.",
|
||||
"{} plays Minecraft: Java Edition: --version, --world, --creative and \
|
||||
--join are for Horizon instances.",
|
||||
instance.name
|
||||
));
|
||||
}
|
||||
let mut cmd = backend::java_game(&instance, &|line| eprintln!("{line}"))?;
|
||||
let server = server.map(|server| {
|
||||
let list = server_list::List::load(&instance.home);
|
||||
server_list::find(&list, &server).map_or(server, |(_, s)| s.ip)
|
||||
});
|
||||
let mut cmd =
|
||||
backend::java_game(&instance, server.as_deref(), &|line| eprintln!("{line}"))?;
|
||||
cmd.args(arg);
|
||||
let vars = instance.vars(cmd.get_program());
|
||||
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
|
||||
let started = std::time::Instant::now();
|
||||
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
|
||||
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
|
||||
eprintln!("Play time not saved: {e}");
|
||||
}
|
||||
after_exit(&instance, &vars);
|
||||
return played;
|
||||
}
|
||||
let mut cmd = backend::client(&instance.resolve_version(version.as_deref())?)?;
|
||||
@@ -1233,10 +1249,13 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
cmd.arg("--name").arg(name);
|
||||
}
|
||||
cmd.envs(accounts.look_env());
|
||||
let vars = instance.vars(cmd.get_program());
|
||||
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
|
||||
let played = attached(horizon_launcher::launch::wrap(cmd, &instance.launch));
|
||||
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
|
||||
eprintln!("Play time not saved: {e}");
|
||||
}
|
||||
after_exit(&instance, &vars);
|
||||
played?
|
||||
}
|
||||
Cmd::Server {
|
||||
@@ -1989,9 +2008,11 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
for e in list.iter().filter(|_| !as_json) {
|
||||
let off = java.is_some() && !java_instance::is_on(&home, e);
|
||||
println!(
|
||||
"{} {} ({}, {})\n {} {}",
|
||||
"{}{} {} ({}, {})\n {} {}",
|
||||
e.title,
|
||||
if off { " (off)" } else { "" },
|
||||
e.number,
|
||||
e.source.label(),
|
||||
e.slug,
|
||||
@@ -2273,11 +2294,13 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
|
||||
};
|
||||
println!("Removed {} ({}).", e.title, e.file);
|
||||
}
|
||||
ModsCmd::Enable { .. } | ModsCmd::Disable { .. } if java.is_some() => {
|
||||
return Err(
|
||||
"A Java instance loads every mod in its mods/ folder: remove one to turn it off."
|
||||
.into(),
|
||||
);
|
||||
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } if java.is_some() => {
|
||||
let e = installed::Installed::load(&home)
|
||||
.lookup(&project)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{project} wasn't installed by the browser."))?;
|
||||
java_instance::set_on(&home, &e, on)?;
|
||||
println!("{} is {}.", e.title, if on { "on" } else { "off" });
|
||||
}
|
||||
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } => {
|
||||
let e = installed::Installed::load(&home)
|
||||
@@ -2320,9 +2343,10 @@ fn other_instances(folder: Option<&Path>) -> Result<Vec<other_launchers::Found>,
|
||||
}
|
||||
|
||||
fn other_plays(f: &other_launchers::Found) -> String {
|
||||
match &f.loader {
|
||||
Some(l) => format!("Minecraft {} with {} {}", f.minecraft, l.name, l.version),
|
||||
None => format!("Minecraft {}", f.minecraft),
|
||||
match (&f.minecraft, &f.loader) {
|
||||
(None, _) => "Minecraft version not recorded: copy-from --minecraft <version>".into(),
|
||||
(Some(v), Some(l)) => format!("Minecraft {v} with {} {}", l.name, l.version),
|
||||
(Some(v), None) => format!("Minecraft {v}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2335,6 +2359,38 @@ fn other_json(f: &other_launchers::Found) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the instance's post-exit command in this terminal, if it has one, and waits for it.
|
||||
fn after_exit(instance: &instances::Instance, vars: &[(&'static str, std::ffi::OsString)]) {
|
||||
let Some(mut cmd) =
|
||||
horizon_launcher::launch::after_exit(&instance.launch, &instance.home, vars)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match cmd.status() {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => eprintln!("The post-exit command failed ({s})."),
|
||||
Err(e) => eprintln!("The post-exit command didn't run: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1280x720` (or `1280×720`), or `default` for none.
|
||||
fn parse_window(text: &str) -> Result<Option<(u32, u32)>, String> {
|
||||
let text = text.trim();
|
||||
if text.eq_ignore_ascii_case("default") {
|
||||
return Ok(None);
|
||||
}
|
||||
let size = text
|
||||
.split_once(['x', 'X', '×'])
|
||||
.and_then(|(w, h)| Some((w.trim().parse().ok()?, h.trim().parse().ok()?)))
|
||||
.filter(|&(w, h): &(u32, u32)| (1..=16384).contains(&w) && (1..=16384).contains(&h));
|
||||
match size {
|
||||
Some(size) => Ok(Some(size)),
|
||||
None => Err(format!(
|
||||
"{text:?} isn't a window size: 1280x720, or default."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
use instances::Icon;
|
||||
let value = |i: &instances::Instance, selected: bool| {
|
||||
@@ -2469,7 +2525,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
auto,
|
||||
memory,
|
||||
args,
|
||||
window,
|
||||
} => {
|
||||
let window = window.as_deref().map(parse_window).transpose()?;
|
||||
if instances::find(&instance)?.java.is_none() {
|
||||
return Err(format!(
|
||||
"{instance} is a Horizon instance: Horizon is not a Java program."
|
||||
@@ -2485,6 +2543,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
if let Some(args) = args {
|
||||
java.jvm_args = args.trim().to_string();
|
||||
}
|
||||
if let Some(window) = window {
|
||||
java.window = window;
|
||||
}
|
||||
})?)
|
||||
}
|
||||
InstanceCmd::Delete { instance } => {
|
||||
@@ -2520,6 +2581,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
gamemode,
|
||||
mangohud,
|
||||
dedicated_gpu,
|
||||
post_exit,
|
||||
} => {
|
||||
use horizon_launcher::launch;
|
||||
let env = env.as_deref().map(launch::parse_env).transpose()?;
|
||||
@@ -2544,6 +2606,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
let changing = pre_launch.is_some()
|
||||
|| wrapper.is_some()
|
||||
|| post_exit.is_some()
|
||||
|| env.is_some()
|
||||
|| gamemode.is_some()
|
||||
|| mangohud.is_some()
|
||||
@@ -2557,6 +2620,9 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
if let Some(w) = wrapper {
|
||||
m.launch.wrapper = w.trim().to_string();
|
||||
}
|
||||
if let Some(p) = post_exit {
|
||||
m.launch.post_exit = p.trim().to_string();
|
||||
}
|
||||
if let Some(e) = env {
|
||||
m.launch.env = e;
|
||||
}
|
||||
@@ -2575,6 +2641,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
if as_json {
|
||||
json(serde_json::json!({
|
||||
"instance": i.id, "pre_launch": l.pre_launch, "wrapper": l.wrapper, "env": l.env,
|
||||
"post_exit": l.post_exit,
|
||||
"gamemode": l.gamemode, "mangohud": l.mangohud, "dedicated_gpu": l.dedicated_gpu,
|
||||
"found": {
|
||||
"gamemode": found.gamemode, "mangohud": found.mangohud,
|
||||
@@ -2590,6 +2657,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
println!(" pre-launch: {}", l.pre_launch);
|
||||
println!(" wrapper: {}", l.wrapper);
|
||||
println!(" env: {}", launch::env_line(&l.env));
|
||||
println!(" post-exit: {}", l.post_exit);
|
||||
let yes = |b: bool| if b { "on" } else { "off" };
|
||||
for (name, on, has) in [
|
||||
("gamemode:", l.gamemode, found.gamemode),
|
||||
@@ -2643,6 +2711,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
name,
|
||||
folder,
|
||||
new_name,
|
||||
minecraft,
|
||||
} => {
|
||||
let found = other_instances(folder.as_deref())?;
|
||||
let matching: Vec<_> = found.iter().filter(|f| f.name == name).collect();
|
||||
@@ -2661,7 +2730,11 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
};
|
||||
let made = other_launchers::import(f, new_name.as_deref().unwrap_or(&f.name))?;
|
||||
let made = other_launchers::import(
|
||||
f,
|
||||
new_name.as_deref().unwrap_or(&f.name),
|
||||
minecraft.as_deref(),
|
||||
)?;
|
||||
let unsupported = other_launchers::loader(f).is_none();
|
||||
if let Some(l) = f.loader.as_ref().filter(|_| unsupported && !as_json) {
|
||||
println!(
|
||||
@@ -3182,6 +3255,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_sizes() {
|
||||
assert_eq!(parse_window("1280x720"), Ok(Some((1280, 720))));
|
||||
assert_eq!(parse_window(" 1920 × 1080 "), Ok(Some((1920, 1080))));
|
||||
assert_eq!(parse_window("Default"), Ok(None));
|
||||
assert!(parse_window("0x720").is_err());
|
||||
assert!(parse_window("big").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_commands_name_their_instance() {
|
||||
let parse = |args: &[&str]| {
|
||||
|
||||
+16
-5
@@ -1,7 +1,7 @@
|
||||
//! The launcher's languages: English (the strings in the code), French, Russian, and
|
||||
//! German, the languages Horizon's own game menus have. A table per language
|
||||
//! (`launcher/lang/<code>.json`) maps each English string to its translation; a string
|
||||
//! the table lacks shows in English.
|
||||
//! The launcher's languages: English (the strings in the code), French, Russian, German and
|
||||
//! Spanish, the languages Horizon's own game menus have. A table per language
|
||||
//! (`launcher/lang/<code>.json`) maps each English string to its translation; a string the
|
||||
//! table lacks shows in English.
|
||||
//!
|
||||
//! The language follows the system's unless the player picks one in Settings
|
||||
//! (`Settings::language`). Translations are Horizon's own, written for this launcher.
|
||||
@@ -21,10 +21,18 @@ pub enum Language {
|
||||
Russian,
|
||||
#[serde(rename = "de")]
|
||||
German,
|
||||
#[serde(rename = "es")]
|
||||
Spanish,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Language; 4] = [Self::English, Self::French, Self::Russian, Self::German];
|
||||
pub const ALL: [Language; 5] = [
|
||||
Self::English,
|
||||
Self::French,
|
||||
Self::Russian,
|
||||
Self::German,
|
||||
Self::Spanish,
|
||||
];
|
||||
|
||||
/// Its code in `settings.json` and on the command line.
|
||||
pub fn code(self) -> &'static str {
|
||||
@@ -33,6 +41,7 @@ impl Language {
|
||||
Self::French => "fr",
|
||||
Self::Russian => "ru",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +52,7 @@ impl Language {
|
||||
Self::French => "Français",
|
||||
Self::Russian => "Русский",
|
||||
Self::German => "Deutsch",
|
||||
Self::Spanish => "Español",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +84,7 @@ impl Language {
|
||||
Self::French => include_str!("../lang/fr.json"),
|
||||
Self::Russian => include_str!("../lang/ru.json"),
|
||||
Self::German => include_str!("../lang/de.json"),
|
||||
Self::Spanish => include_str!("../lang/es.json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,12 @@ fn holes(s: &str) -> BTreeSet<&str> {
|
||||
fn every_language_has_every_string_the_code_asks_for() {
|
||||
let (asked, all) = strings();
|
||||
assert!(asked.contains("Language"), "the scan finds tr(\"…\")");
|
||||
for language in [Language::French, Language::Russian, Language::German] {
|
||||
for language in [
|
||||
Language::French,
|
||||
Language::Russian,
|
||||
Language::German,
|
||||
Language::Spanish,
|
||||
] {
|
||||
let table: HashMap<String, String> = serde_json::from_str(language.source())
|
||||
.unwrap_or_else(|e| panic!("lang/{}.json: {e}", language.code()));
|
||||
let missing: Vec<_> = asked.iter().filter(|s| !table.contains_key(*s)).collect();
|
||||
@@ -122,11 +127,15 @@ fn the_system_language_is_the_first_one_the_launcher_has() {
|
||||
assert_eq!(Language::pick(["fr-CH", "en-US"]), Language::French);
|
||||
assert_eq!(Language::pick(["de-CH", "ru_RU.UTF-8"]), Language::German);
|
||||
assert_eq!(Language::pick(["de-DE", "ja"]), Language::German);
|
||||
assert_eq!(Language::pick(["es-ES", "en-US"]), Language::Spanish);
|
||||
assert_eq!(Language::pick(["ja", "ko"]), Language::English);
|
||||
assert_eq!(Language::pick([]), Language::English);
|
||||
assert_eq!(Language::parse("FR"), Some(Language::French));
|
||||
assert_eq!(Language::parse("DE"), Some(Language::German));
|
||||
assert_eq!(Language::parse("ES"), Some(Language::Spanish));
|
||||
assert_eq!(Language::parse("german"), None);
|
||||
assert_eq!(serde_json::to_string(&Language::German).unwrap(), "\"de\"");
|
||||
assert_eq!(serde_json::to_string(&Language::Spanish).unwrap(), "\"es\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -134,6 +143,7 @@ fn a_missing_string_shows_in_english() {
|
||||
assert_eq!(tr_in(Language::French, "Settings"), "Paramètres");
|
||||
assert_eq!(tr_in(Language::Russian, "Settings"), "Настройки");
|
||||
assert_eq!(tr_in(Language::German, "Settings"), "Einstellungen");
|
||||
assert_eq!(tr_in(Language::Spanish, "Settings"), "Ajustes");
|
||||
assert_eq!(tr_in(Language::English, "Settings"), "Settings");
|
||||
assert_eq!(tr_in(Language::French, "No such string"), "No such string");
|
||||
}
|
||||
@@ -124,6 +124,18 @@ pub struct JavaGame {
|
||||
/// The mod loader it plays with, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub loader: Option<crate::loaders::Loader>,
|
||||
/// The game window's size, width then height; none: the game's own 854×480.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub window: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Its variables for the game started as `program` and the commands around it
|
||||
/// ([`crate::launch::instance_vars`]).
|
||||
pub fn vars(&self, program: &std::ffi::OsStr) -> crate::launch::Vars {
|
||||
let java = self.java.as_ref().map(|_| program);
|
||||
crate::launch::instance_vars(&self.name, &self.id, &self.home, java)
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaGame {
|
||||
|
||||
+85
-11
@@ -44,6 +44,48 @@ pub struct Launch<'a> {
|
||||
pub player: &'a Player,
|
||||
/// `osx`, `linux` or `windows`, as version files name systems.
|
||||
pub os: &'a str,
|
||||
/// The window's size (`--width`, `--height`); none: the game's own 854×480.
|
||||
pub window: Option<(u32, u32)>,
|
||||
/// A server to join once the game has started: `host` or `host:port`.
|
||||
pub server: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Launch<'_> {
|
||||
/// The optional features of a version's argument rules this launch turns on
|
||||
/// (<https://minecraft.wiki/w/Client.json>).
|
||||
fn features(&self) -> Vec<&'static str> {
|
||||
let mut on = Vec::new();
|
||||
if self.window.is_some() {
|
||||
on.push("has_custom_resolution");
|
||||
}
|
||||
if self.server.is_some() {
|
||||
on.push("is_quick_play_multiplayer");
|
||||
}
|
||||
on
|
||||
}
|
||||
}
|
||||
|
||||
/// `host:port` as the game's arguments before 1.20 take it: `--server host --port port`
|
||||
/// (25565 when none is given; an IPv6 address keeps its own colons).
|
||||
fn host_and_port(address: &str) -> (&str, u16) {
|
||||
match address.rsplit_once(':') {
|
||||
Some((host, port)) if !host.contains(':') || host.ends_with(']') => match port.parse() {
|
||||
Ok(port) => (host.trim_matches(['[', ']']), port),
|
||||
Err(_) => (address, 25565),
|
||||
},
|
||||
_ => (address, 25565),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether one of `args`' rules asks for `feature`.
|
||||
fn mentions(args: &Value, feature: &str) -> bool {
|
||||
args.as_array().into_iter().flatten().any(|a| {
|
||||
a["rules"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|r| r["features"].get(feature).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
/// This system's name in version files.
|
||||
@@ -56,8 +98,8 @@ pub fn os_name() -> &'static str {
|
||||
}
|
||||
|
||||
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
|
||||
/// Every optional feature (demo, custom resolution, quick play) is off.
|
||||
fn allowed(rules: &Value, os: &str) -> bool {
|
||||
/// The optional features (demo, custom resolution, quick play) are on only when named in `on`.
|
||||
fn allowed(rules: &Value, os: &str, on: &[&str]) -> bool {
|
||||
let Some(rules) = rules.as_array() else {
|
||||
return true;
|
||||
};
|
||||
@@ -72,9 +114,10 @@ fn allowed(rules: &Value, os: &str) -> bool {
|
||||
let system = &rule["os"];
|
||||
let matches = system["name"].as_str().is_none_or(|n| n == os)
|
||||
&& system["arch"].as_str().is_none_or(arch_matches)
|
||||
&& rule["features"]
|
||||
.as_object()
|
||||
.is_none_or(|f| f.values().all(|v| v == false));
|
||||
&& rule["features"].as_object().is_none_or(|f| {
|
||||
f.iter()
|
||||
.all(|(name, v)| v.as_bool() == Some(on.contains(&name.as_str())))
|
||||
});
|
||||
if matches {
|
||||
allow = rule["action"] == "allow";
|
||||
}
|
||||
@@ -174,7 +217,7 @@ pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<Path
|
||||
let jar = version["jar"].as_str().unwrap_or(id);
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
if !allowed(&library["rules"], os, &[]) {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
|
||||
@@ -203,7 +246,7 @@ pub fn unpack_natives(
|
||||
) -> Result<(), String> {
|
||||
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
if !allowed(&library["rules"], os, &[]) {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = mojang::native_classifier(library, os) else {
|
||||
@@ -275,12 +318,12 @@ pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result
|
||||
}
|
||||
|
||||
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
|
||||
fn list(args: &Value, os: &str) -> Vec<String> {
|
||||
fn list(args: &Value, os: &str, on: &[&str]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for arg in args.as_array().into_iter().flatten() {
|
||||
match arg {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
|
||||
Value::Object(o) if allowed(&o["rules"], os, on) => match &o["value"] {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Array(values) => {
|
||||
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
|
||||
@@ -366,6 +409,18 @@ pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
|
||||
.join(separator),
|
||||
),
|
||||
("launcher_name", "horizon-launcher".into()),
|
||||
(
|
||||
"resolution_width",
|
||||
launch.window.map(|w| w.0.to_string()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"resolution_height",
|
||||
launch.window.map(|w| w.1.to_string()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"quickPlayMultiplayer",
|
||||
launch.server.unwrap_or_default().to_string(),
|
||||
),
|
||||
("launcher_version", env!("CARGO_PKG_VERSION").into()),
|
||||
]);
|
||||
let mut jvm = vec![
|
||||
@@ -398,8 +453,27 @@ pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
|
||||
);
|
||||
game.extend(line.split_whitespace().map(String::from));
|
||||
}
|
||||
jvm_list.extend(list(&arguments["jvm"], launch.os));
|
||||
game.extend(list(&arguments["game"], launch.os));
|
||||
let on = launch.features();
|
||||
jvm_list.extend(list(&arguments["jvm"], launch.os, &on));
|
||||
game.extend(list(&arguments["game"], launch.os, &on));
|
||||
// Versions whose rules don't ask for them still take the arguments (since 1.6.1): the
|
||||
// window's size, and before 1.20 (23w14a) a server as `--server` and `--port`.
|
||||
// <https://minecraft.wiki/w/Java_Edition_client_command_line_arguments>
|
||||
if let Some((width, height)) = launch
|
||||
.window
|
||||
.filter(|_| !mentions(&arguments["game"], "has_custom_resolution"))
|
||||
{
|
||||
game.extend(["--width".into(), width.to_string()]);
|
||||
game.extend(["--height".into(), height.to_string()]);
|
||||
}
|
||||
if let Some(server) = launch
|
||||
.server
|
||||
.filter(|_| !mentions(&arguments["game"], "is_quick_play_multiplayer"))
|
||||
{
|
||||
let (host, port) = host_and_port(server);
|
||||
game.extend(["--server".into(), host.to_string()]);
|
||||
game.extend(["--port".into(), port.to_string()]);
|
||||
}
|
||||
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
|
||||
jvm.extend(launch.jvm_args.iter().cloned());
|
||||
let mut cmd = Command::new(launch.java);
|
||||
|
||||
@@ -44,7 +44,9 @@ fn modern() -> Value {
|
||||
"--userType", "${user_type}",
|
||||
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
|
||||
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
|
||||
"value": ["--width", "${resolution_width}"]}],
|
||||
"value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
|
||||
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}],
|
||||
"value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
|
||||
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
|
||||
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
|
||||
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
|
||||
@@ -79,6 +81,8 @@ fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
|
||||
jvm_args: &extra,
|
||||
player: &p,
|
||||
os,
|
||||
window: None,
|
||||
server: None,
|
||||
};
|
||||
let cmd = command(&modern(), &launch("osx")).unwrap();
|
||||
let a = args(&cmd);
|
||||
@@ -179,6 +183,8 @@ fn an_old_version_gets_its_natives_and_assets_by_name() {
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "osx",
|
||||
window: None,
|
||||
server: None,
|
||||
};
|
||||
let a = args(&command(&version, &launch).unwrap());
|
||||
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
|
||||
@@ -262,6 +268,8 @@ fn a_loader_profile_plays_on_its_game_version() {
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "linux",
|
||||
window: None,
|
||||
server: None,
|
||||
};
|
||||
let a = args(&command(&version, &launch).unwrap());
|
||||
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
|
||||
@@ -317,3 +325,89 @@ fn a_version_that_inherits_from_itself_is_refused() {
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_size_and_a_server_to_join() {
|
||||
let dir = scratch("join");
|
||||
let (mc, game, natives, java) = (
|
||||
dir.join("mc"),
|
||||
dir.join("game"),
|
||||
dir.join("natives"),
|
||||
dir.join("java"),
|
||||
);
|
||||
let p = player();
|
||||
let launch = |window, server| Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 4096,
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "linux",
|
||||
window,
|
||||
server,
|
||||
};
|
||||
let after = |a: &[String], flag: &str| {
|
||||
a.iter()
|
||||
.position(|x| x == flag)
|
||||
.map(|i| a[i + 1..].iter().take(3).cloned().collect::<Vec<_>>())
|
||||
};
|
||||
// A modern version asks for both through its rules.
|
||||
let a = args(
|
||||
&command(
|
||||
&modern(),
|
||||
&launch(Some((1280, 720)), Some("play.example.net:25570")),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
after(&a, "--width").unwrap()[..3],
|
||||
["1280", "--height", "720"]
|
||||
);
|
||||
assert_eq!(
|
||||
after(&a, "--quickPlayMultiplayer").unwrap()[0],
|
||||
"play.example.net:25570"
|
||||
);
|
||||
assert!(!a.contains(&"--server".into()) && !a.contains(&"--demo".into()));
|
||||
// Off unless asked.
|
||||
let a = args(&command(&modern(), &launch(None, None)).unwrap());
|
||||
assert!(!a.contains(&"--width".into()) && !a.contains(&"--quickPlayMultiplayer".into()));
|
||||
|
||||
// Before 1.13: no rules, the arguments themselves; before 1.20 a server is --server/--port.
|
||||
let old = json!({
|
||||
"id": "1.12.2", "mainClass": "net.minecraft.client.main.Main", "assetIndex": {"id": "1.12"},
|
||||
"minecraftArguments": "--username ${auth_player_name}", "libraries": []
|
||||
});
|
||||
let a = args(&command(&old, &launch(Some((1280, 720)), Some("mc.example.org"))).unwrap());
|
||||
let tail: Vec<&str> = a[a.len() - 8..].iter().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
tail,
|
||||
[
|
||||
"--width",
|
||||
"1280",
|
||||
"--height",
|
||||
"720",
|
||||
"--server",
|
||||
"mc.example.org",
|
||||
"--port",
|
||||
"25565"
|
||||
]
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_addresses_split_into_host_and_port() {
|
||||
assert_eq!(host_and_port("mc.example.org"), ("mc.example.org", 25565));
|
||||
assert_eq!(
|
||||
host_and_port("mc.example.org:25570"),
|
||||
("mc.example.org", 25570)
|
||||
);
|
||||
assert_eq!(host_and_port("[2001:db8::1]:25570"), ("2001:db8::1", 25570));
|
||||
assert_eq!(host_and_port("2001:db8::1"), ("2001:db8::1", 25565));
|
||||
assert_eq!(
|
||||
host_and_port("mc.example.org:x"),
|
||||
("mc.example.org:x", 25565)
|
||||
);
|
||||
}
|
||||
+78
-1
@@ -1,5 +1,7 @@
|
||||
//! What runs around the game for one instance (`instances::Meta::launch`): a command run before
|
||||
//! it, a wrapper it runs through, and environment variables. All empty by default.
|
||||
//! it, a wrapper it runs through, environment variables, and a command run after it exits
|
||||
//! ([`after_exit`], started by the launcher once it sees the game end). All empty by default.
|
||||
//! The game and the commands also get the instance's own variables ([`instance_vars`]).
|
||||
//!
|
||||
//! [`wrap`] turns the game's command into one process that does all three, so the launcher still
|
||||
//! follows one program (its output, its exit, Stop):
|
||||
@@ -26,6 +28,9 @@ pub struct Launch {
|
||||
/// A command line the game runs through, such as `nice -n 5` (quotes group a word).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub wrapper: String,
|
||||
/// A shell command run in the instance's folder after the game exits, whatever its exit.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub post_exit: String,
|
||||
/// Environment variables for the game (and the two commands above).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub env: BTreeMap<String, String>,
|
||||
@@ -157,6 +162,9 @@ impl Launch {
|
||||
if !self.wrapper.trim().is_empty() {
|
||||
parts.push(format!("through {}", self.wrapper.trim()));
|
||||
}
|
||||
if !self.post_exit.trim().is_empty() {
|
||||
parts.push(format!("runs {} after", self.post_exit.trim()));
|
||||
}
|
||||
if self.gamemode {
|
||||
parts.push("with GameMode".into());
|
||||
}
|
||||
@@ -213,6 +221,75 @@ pub fn env_line(env: &BTreeMap<String, String>) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// The instance's variables for the game and the commands around it, named as Prism Launcher
|
||||
/// names them so its scripts carry over (<https://prismlauncher.org/wiki/help-pages/custom-commands/>):
|
||||
/// `INST_NAME`, `INST_ID`, `INST_DIR` and `INST_MC_DIR` (both the instance's game folder), and
|
||||
/// for a Java instance `INST_JAVA`, the `java` it runs on.
|
||||
pub fn instance_vars(name: &str, id: &str, dir: &std::path::Path, java: Option<&OsStr>) -> Vars {
|
||||
let mut vars = vec![
|
||||
("INST_NAME", OsString::from(name)),
|
||||
("INST_ID", OsString::from(id)),
|
||||
("INST_DIR", dir.as_os_str().to_owned()),
|
||||
("INST_MC_DIR", dir.as_os_str().to_owned()),
|
||||
];
|
||||
if let Some(java) = java {
|
||||
vars.push(("INST_JAVA", java.to_owned()));
|
||||
}
|
||||
vars
|
||||
}
|
||||
|
||||
/// An instance's variables ([`instance_vars`]).
|
||||
pub type Vars = Vec<(&'static str, OsString)>;
|
||||
|
||||
/// The post-exit command, ready to run in `dir` with the instance's `vars` and variables; none
|
||||
/// when there is none. `/bin/sh -c`, or `cmd /D /S /C` on Windows.
|
||||
pub fn after_exit(
|
||||
launch: &Launch,
|
||||
dir: &std::path::Path,
|
||||
vars: &[(&'static str, OsString)],
|
||||
) -> Option<Command> {
|
||||
let line = launch.post_exit.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.raw_arg(format!("/D /S /C \"{line}\""));
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
c.args(["/D", "/S", "/C", line]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.arg("-c").arg(line);
|
||||
c
|
||||
};
|
||||
cmd.current_dir(dir)
|
||||
.envs(vars.iter().map(|(k, v)| (k, v)))
|
||||
.envs(&launch.env);
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// Runs the post-exit command, if any, on its own thread: nothing waits for it, its output is
|
||||
/// dropped.
|
||||
pub fn run_after_exit(launch: &Launch, dir: &std::path::Path, vars: &[(&'static str, OsString)]) {
|
||||
let Some(mut cmd) = after_exit(launch, dir, vars) else {
|
||||
return;
|
||||
};
|
||||
crate::process::hide_console(&mut cmd);
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = cmd.status() {
|
||||
eprintln!("The post-exit command didn't run: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Unix script: the pre-launch command (in a subshell, so its `exit` doesn't end the script
|
||||
/// before the message), then the rest of the arguments as the program.
|
||||
fn script(pre_launch: &str) -> String {
|
||||
|
||||
@@ -193,3 +193,49 @@ fn the_linux_switches_act_only_where_their_tools_are() {
|
||||
let text = serde_json::to_string(&Launch::default()).unwrap();
|
||||
assert_eq!(text, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_post_exit_command_runs_with_the_instance_variables() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-post-exit-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(after_exit(&Launch::default(), &dir, &[]).is_none());
|
||||
let launch = Launch {
|
||||
post_exit: if cfg!(windows) {
|
||||
// The redirection first: `1> after.txt` would redirect stdout and drop the 1.
|
||||
"> after.txt echo %INST_NAME% %INST_ID% %INST_JAVA% %MINE%".into()
|
||||
} else {
|
||||
"echo \"$INST_NAME $INST_ID $INST_JAVA $MINE\" > after.txt".into()
|
||||
},
|
||||
env: parse_env("MINE=1").unwrap(),
|
||||
..Launch::default()
|
||||
};
|
||||
assert_eq!(
|
||||
launch.summary().unwrap(),
|
||||
format!("runs {} after, with 1 variable", launch.post_exit)
|
||||
);
|
||||
let vars = instance_vars("My Pack", "my-pack", &dir, Some(OsStr::new("java")));
|
||||
let names: Vec<&str> = vars.iter().map(|(k, _)| *k).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"INST_NAME",
|
||||
"INST_ID",
|
||||
"INST_DIR",
|
||||
"INST_MC_DIR",
|
||||
"INST_JAVA"
|
||||
]
|
||||
);
|
||||
assert!(after_exit(&launch, &dir, &vars)
|
||||
.unwrap()
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
let written = std::fs::read_to_string(dir.join("after.txt")).unwrap();
|
||||
assert_eq!(written.trim(), "My Pack my-pack java 1");
|
||||
// A Horizon instance has no Java.
|
||||
assert!(!instance_vars("H", "horizon", &dir, None)
|
||||
.iter()
|
||||
.any(|(k, _)| *k == "INST_JAVA"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ impl Launcher {
|
||||
*self.servers.text(Field::PreLaunch) = i.launch.pre_launch.clone();
|
||||
*self.servers.text(Field::Wrapper) = i.launch.wrapper.clone();
|
||||
*self.servers.text(Field::LaunchEnv) = launch::env_line(&i.launch.env);
|
||||
*self.servers.text(Field::PostExit) = i.launch.post_exit.clone();
|
||||
self.launch = Some(LaunchPanel {
|
||||
instance: i.id,
|
||||
saved: None,
|
||||
@@ -45,12 +46,14 @@ impl Launcher {
|
||||
};
|
||||
let pre = self.servers.value(Field::PreLaunch).trim().to_string();
|
||||
let wrapper = self.servers.value(Field::Wrapper).trim().to_string();
|
||||
let post_exit = self.servers.value(Field::PostExit).trim().to_string();
|
||||
let saved = launch::parse_env(self.servers.value(Field::LaunchEnv)).and_then(|env| {
|
||||
instances::edit(&id, |m| {
|
||||
m.launch = launch::Launch {
|
||||
pre_launch: pre,
|
||||
wrapper,
|
||||
env,
|
||||
post_exit,
|
||||
..m.launch.clone()
|
||||
}
|
||||
})
|
||||
@@ -199,7 +202,15 @@ impl Launcher {
|
||||
"Environment variables",
|
||||
Field::LaunchEnv,
|
||||
"For example NAME=value OTHER=\"two words\"",
|
||||
"For the game and both commands above.",
|
||||
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.",
|
||||
);
|
||||
let post = row(
|
||||
self,
|
||||
cx,
|
||||
"After the game",
|
||||
Field::PostExit,
|
||||
"For example ./backup.sh",
|
||||
"A shell command, run in the instance's folder once the game has exited.",
|
||||
);
|
||||
let save = button("launch-save", "Save", Kind::Secondary, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
@@ -223,6 +234,7 @@ impl Launcher {
|
||||
.child(pre)
|
||||
.child(wrapper)
|
||||
.child(env)
|
||||
.child(post)
|
||||
.children(self.linux_switches(cx))
|
||||
.child(
|
||||
div()
|
||||
|
||||
+27
-6
@@ -236,6 +236,8 @@ struct Launcher {
|
||||
quick_play: Option<(&'static str, String)>,
|
||||
/// The instance the running game plays and when it started: its play time grows on exit.
|
||||
client_run: Option<(String, Instant)>,
|
||||
/// The running game's instance variables, for its post-exit command.
|
||||
client_vars: horizon_launcher::launch::Vars,
|
||||
/// Settings' Game arguments field: the line, whether it has focus, and its focus handle.
|
||||
args_input: line_edit::LineEdit,
|
||||
args_focused: bool,
|
||||
@@ -442,6 +444,7 @@ impl Launcher {
|
||||
joining: None,
|
||||
quick_play: None,
|
||||
client_run: None,
|
||||
client_vars: Vec::new(),
|
||||
collection: collection::Collection::load(),
|
||||
agents: agents_page::AgentsUi::default(),
|
||||
playing: None,
|
||||
@@ -680,7 +683,11 @@ impl Launcher {
|
||||
Msg::Playing(playing) => self.playing = playing,
|
||||
Msg::JavaGame(id, home, cmd) => {
|
||||
self.starting_client = None;
|
||||
match cmd.and_then(|cmd| backend::spawn(Job::Client, cmd, self.tx.clone())) {
|
||||
let spawned = cmd.and_then(|(cmd, vars)| {
|
||||
self.client_vars = vars;
|
||||
backend::spawn(Job::Client, cmd, self.tx.clone())
|
||||
});
|
||||
match spawned {
|
||||
Ok(r) => {
|
||||
self.client = Some(r);
|
||||
self.client_home = Some(home);
|
||||
@@ -759,11 +766,15 @@ impl Launcher {
|
||||
backend::append_game_args(&mut cmd);
|
||||
// Last, around every argument: the instance's pre-launch command,
|
||||
// wrapper and variables.
|
||||
let launch = run
|
||||
let instance = run
|
||||
.as_ref()
|
||||
.and_then(|id| instances::list().into_iter().find(|i| &i.id == id))
|
||||
.map(|i| i.launch)
|
||||
.and_then(|id| instances::list().into_iter().find(|i| &i.id == id));
|
||||
self.client_vars = instance
|
||||
.as_ref()
|
||||
.map(|i| i.vars(cmd.get_program()))
|
||||
.unwrap_or_default();
|
||||
cmd.envs(self.client_vars.iter().map(|(k, v)| (k, v)));
|
||||
let launch = instance.map(|i| i.launch).unwrap_or_default();
|
||||
let cmd = horizon_launcher::launch::wrap(cmd, &launch);
|
||||
self.client_home = cmd.get_current_dir().map(|d| d.to_path_buf());
|
||||
match backend::spawn(Job::Client, cmd, self.tx.clone()) {
|
||||
@@ -923,10 +934,17 @@ impl Launcher {
|
||||
self.multiplayer.stale();
|
||||
self.screenshots.stale();
|
||||
self.game_packs.stale();
|
||||
let vars = std::mem::take(&mut self.client_vars);
|
||||
if let Some((id, started)) = self.client_run.take() {
|
||||
if let Err(e) = instances::record_play(&id, started.elapsed()) {
|
||||
eprintln!("Play time not saved: {e}");
|
||||
}
|
||||
if let Some(i) = instances::list().into_iter().find(|i| i.id == id)
|
||||
{
|
||||
horizon_launcher::launch::run_after_exit(
|
||||
&i.launch, &i.home, &vars,
|
||||
);
|
||||
}
|
||||
self.reload_instances();
|
||||
}
|
||||
// The run's own game folder: an instance's or a pack's keeps its reports.
|
||||
@@ -1168,8 +1186,11 @@ impl Launcher {
|
||||
let log = |line: String| {
|
||||
let _ = tx.send(Msg::Line(Job::Client, line));
|
||||
};
|
||||
let cmd = backend::java_game(&instance, &log)
|
||||
.map(|cmd| horizon_launcher::launch::wrap(cmd, &instance.launch));
|
||||
let cmd = backend::java_game(&instance, None, &log).map(|mut cmd| {
|
||||
let vars = instance.vars(cmd.get_program());
|
||||
cmd.envs(vars.iter().map(|(k, v)| (k, v)));
|
||||
(horizon_launcher::launch::wrap(cmd, &instance.launch), vars)
|
||||
});
|
||||
let _ = tx.send(Msg::JavaGame(instance.id, instance.home, cmd));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,9 +463,21 @@ impl Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does.
|
||||
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does;
|
||||
/// in a Java instance, renames its file.
|
||||
fn mods_switch(&mut self, e: &installed::Entry) {
|
||||
let home = self.mods.home();
|
||||
if self.mods.java.is_some() {
|
||||
let on = !java_instance::is_on(&home, e);
|
||||
self.mods.message = Some(java_instance::set_on(&home, e, on).map(|()| {
|
||||
format!(
|
||||
"{} is {}. The game reads this when it starts.",
|
||||
e.title,
|
||||
if on { "on" } else { "off" }
|
||||
)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
let Some(id) = horizon::mod_id(e) else {
|
||||
return;
|
||||
};
|
||||
@@ -1184,9 +1196,15 @@ impl Launcher {
|
||||
}
|
||||
cx.notify();
|
||||
}));
|
||||
if e.source == SourceId::Horizon && e.kind == PackKind::Mod {
|
||||
let on =
|
||||
horizon::mod_id(e).is_some_and(|id| java::is_enabled(&self.mods.home(), id));
|
||||
let on = match &self.mods.java {
|
||||
_ if e.kind != PackKind::Mod => None,
|
||||
Some(_) => Some(java_instance::is_on(&self.mods.home(), e)),
|
||||
None if e.source == SourceId::Horizon => Some(
|
||||
horizon::mod_id(e).is_some_and(|id| java::is_enabled(&self.mods.home(), id)),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
if let Some(on) = on {
|
||||
let entry = e.clone();
|
||||
right = right.child(
|
||||
div()
|
||||
|
||||
+108
-12
@@ -12,6 +12,20 @@
|
||||
//! (`custom`, `latest-release`, `latest-snapshot`), the version it plays (`lastVersionId`, an
|
||||
//! id of `versions/<id>/<id>.json`) and its game folder (`gameDir`, else the launcher's own).
|
||||
//! <https://minecraft.wiki/w/Launcher_profiles.json>
|
||||
//! - The CurseForge app: its modding folder (`~/curseforge/minecraft` on Windows,
|
||||
//! `~/Documents/curseforge/minecraft` on macOS and Linux) keeps each modpack in
|
||||
//! `Instances/<name>/`, the game folder itself beside the app's `minecraftinstance.json` and
|
||||
//! `.curseclient`.
|
||||
//! <https://support.curseforge.com/support/solutions/articles/9000218572-minecraft-getting-started>,
|
||||
//! <https://support.curseforge.com/support/solutions/articles/9000238895-how-to-move-your-minecraft-modding-folder>,
|
||||
//! <https://help.akliz.net/docs/make-a-curseforge-launcher-modpack-and-server-pack>
|
||||
//! - The Modrinth App: its folder (`ModrinthApp`, `com.modrinth.theseus` before 0.8.0) keeps each
|
||||
//! instance's game folder in `profiles/<name>/`.
|
||||
//! <https://support.modrinth.com/en/articles/8797641-modrinth-app-storage-folder-location>,
|
||||
//! <https://github.com/modrinth/code/issues/5825>
|
||||
//!
|
||||
//! Neither app describes where it records an instance's Minecraft version, so the player names
|
||||
//! it when copying one.
|
||||
use crate::instances::{self, Instance};
|
||||
use crate::loaders;
|
||||
use crate::sources::{self, Folders, Os};
|
||||
@@ -34,8 +48,9 @@ pub struct Loader {
|
||||
pub struct Found {
|
||||
pub launcher: &'static str,
|
||||
pub name: String,
|
||||
/// The Minecraft version: an id (`1.21.1`), `latest` or `latest-snapshot`.
|
||||
pub minecraft: String,
|
||||
/// The Minecraft version: an id (`1.21.1`), `latest` or `latest-snapshot`; none when the
|
||||
/// launcher keeps it where Horizon doesn't read.
|
||||
pub minecraft: Option<String>,
|
||||
pub loader: Option<Loader>,
|
||||
/// Its game folder: saves, mods, packs, options.
|
||||
pub game: PathBuf,
|
||||
@@ -56,8 +71,16 @@ const SHARED: [&str; 9] = [
|
||||
"servers.dat",
|
||||
"optionsshaders.txt",
|
||||
];
|
||||
/// What is never copied from an instance's own game folder.
|
||||
const SKIPPED: [&str; 2] = ["logs", "crash-reports"];
|
||||
/// What is never copied from an instance's own game folder: logs, and the CurseForge app's own
|
||||
/// files.
|
||||
const SKIPPED: [&str; 4] = [
|
||||
"logs",
|
||||
"crash-reports",
|
||||
"minecraftinstance.json",
|
||||
".curseclient",
|
||||
];
|
||||
const CURSEFORGE: &str = "the CurseForge app";
|
||||
const MODRINTH_APP: &str = "the Modrinth App";
|
||||
|
||||
fn loader_of_uid(uid: &str) -> Option<&'static str> {
|
||||
match uid {
|
||||
@@ -122,7 +145,7 @@ pub fn prism_instance(dir: &Path, launcher: &'static str) -> Option<Found> {
|
||||
Some(Found {
|
||||
launcher,
|
||||
name,
|
||||
minecraft,
|
||||
minecraft: Some(minecraft),
|
||||
loader,
|
||||
game,
|
||||
shared: false,
|
||||
@@ -191,7 +214,7 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
|
||||
.filter_map(|(_, p)| {
|
||||
let kind = p["type"].as_str().unwrap_or("custom");
|
||||
let id = p["lastVersionId"].as_str().unwrap_or_default();
|
||||
let (minecraft, loader) = match kind {
|
||||
let (minecraft, loader): (String, _) = match kind {
|
||||
"latest-release" => ("latest".to_string(), None),
|
||||
"latest-snapshot" => ("latest-snapshot".to_string(), None),
|
||||
_ if id.is_empty() => return None,
|
||||
@@ -211,7 +234,7 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
|
||||
Some(Found {
|
||||
launcher: OFFICIAL,
|
||||
name,
|
||||
minecraft,
|
||||
minecraft: Some(minecraft),
|
||||
loader,
|
||||
shared: game.as_ref().is_none_or(|g| g == root),
|
||||
game: game.unwrap_or_else(|| root.to_path_buf()),
|
||||
@@ -222,12 +245,64 @@ pub fn official_profiles(root: &Path) -> Vec<Found> {
|
||||
found
|
||||
}
|
||||
|
||||
/// The game folders in `dir` (each folder in it that holds one), as `launcher`'s instances.
|
||||
fn game_folders(dir: &Path, launcher: &'static str) -> Vec<Found> {
|
||||
let mut found: Vec<Found> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
["saves", "mods", "options.txt", "resourcepacks", "config"]
|
||||
.iter()
|
||||
.any(|f| p.join(f).exists())
|
||||
})
|
||||
.filter_map(|game| {
|
||||
Some(Found {
|
||||
launcher,
|
||||
name: game.file_name()?.to_string_lossy().into_owned(),
|
||||
minecraft: None,
|
||||
loader: None,
|
||||
game,
|
||||
shared: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
found.sort_by_key(|f| f.name.to_lowercase());
|
||||
found
|
||||
}
|
||||
|
||||
/// The CurseForge app's instances: in its modding folder's `Instances/`.
|
||||
pub fn curseforge_instances(root: &Path) -> Vec<Found> {
|
||||
game_folders(&root.join("Instances"), CURSEFORGE)
|
||||
}
|
||||
|
||||
/// The Modrinth App's instances: in its folder's `profiles/`.
|
||||
pub fn modrinth_app_profiles(root: &Path) -> Vec<Found> {
|
||||
game_folders(&root.join("profiles"), MODRINTH_APP)
|
||||
}
|
||||
|
||||
/// What one folder holds: an official launcher's profiles, a Prism or MultiMC root (or its
|
||||
/// `instances/`), or a single instance folder.
|
||||
/// `instances/`), the CurseForge app's modding folder, the Modrinth App's folder, or a single
|
||||
/// instance folder.
|
||||
pub fn in_folder(dir: &Path, launcher: Option<&'static str>) -> Vec<Found> {
|
||||
if dir.join("launcher_profiles.json").is_file() {
|
||||
return official_profiles(dir);
|
||||
}
|
||||
if dir.join("minecraftinstance.json").is_file() {
|
||||
let parent = dir.parent().unwrap_or(dir);
|
||||
let name = dir.file_name();
|
||||
return game_folders(parent, CURSEFORGE)
|
||||
.into_iter()
|
||||
.filter(|f| f.game.file_name() == name)
|
||||
.collect();
|
||||
}
|
||||
if dir.join("Instances").is_dir() {
|
||||
return curseforge_instances(dir);
|
||||
}
|
||||
if dir.join("profiles").is_dir() {
|
||||
return modrinth_app_profiles(dir);
|
||||
}
|
||||
let named = |names: &[&str]| {
|
||||
dir.ancestors()
|
||||
.filter_map(Path::file_name)
|
||||
@@ -258,12 +333,26 @@ pub fn scan(os: Os, folders: &Folders) -> Vec<Found> {
|
||||
match launcher {
|
||||
OFFICIAL => found.extend(official_profiles(&dir)),
|
||||
"Prism Launcher" | "MultiMC" => found.extend(prism_instances(&dir, launcher)),
|
||||
"Modrinth App" => found.extend(modrinth_app_profiles(&dir)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
found.extend(
|
||||
curseforge_folders(os, folders)
|
||||
.iter()
|
||||
.flat_map(|dir| curseforge_instances(dir)),
|
||||
);
|
||||
found
|
||||
}
|
||||
|
||||
/// The CurseForge app's default modding folder on `os`.
|
||||
pub fn curseforge_folders(os: Os, folders: &Folders) -> Vec<PathBuf> {
|
||||
match os {
|
||||
Os::Windows => vec![folders.home.join("curseforge/minecraft")],
|
||||
Os::MacOs | Os::Linux => vec![folders.home.join("Documents/curseforge/minecraft")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `found`'s game folder into `to`: all of it from an instance's own folder but its
|
||||
/// logs, and only what an instance holds from a folder shared with the launcher. Nothing in
|
||||
/// the other launcher's folder changes.
|
||||
@@ -302,11 +391,18 @@ pub fn loader(found: &Found) -> Option<loaders::Loader> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A new Java instance named `name` playing what `found` plays, with its loader when Horizon
|
||||
/// installs it ([`loader`]) and a copy of its game folder.
|
||||
pub fn import(found: &Found, name: &str) -> Result<Instance, String> {
|
||||
/// A new Java instance named `name` playing what `found` plays (or `minecraft`, the version the
|
||||
/// player names), with its loader when Horizon installs it ([`loader`]) and a copy of its game
|
||||
/// folder.
|
||||
pub fn import(found: &Found, name: &str, minecraft: Option<&str>) -> Result<Instance, String> {
|
||||
let Some(minecraft) = minecraft.or(found.minecraft.as_deref()) else {
|
||||
return Err(format!(
|
||||
"{} doesn't say which Minecraft version {} plays here: name it with --minecraft.",
|
||||
found.launcher, found.name
|
||||
));
|
||||
};
|
||||
let versions = crate::minecraft_versions::load()?;
|
||||
let version = versions.find(&found.minecraft)?.id.clone();
|
||||
let version = versions.find(minecraft)?.id.clone();
|
||||
let instance = instances::create_java(name, &version)?;
|
||||
if let Err(e) = copy_game(found, &instance.home) {
|
||||
let _ = instances::delete(&instance.id);
|
||||
|
||||
@@ -48,7 +48,7 @@ fn prism_and_multimc_instances() {
|
||||
Found {
|
||||
launcher: "Prism Launcher",
|
||||
name: "My Fabric".into(),
|
||||
minecraft: "1.21.1".into(),
|
||||
minecraft: Some("1.21.1".into()),
|
||||
loader: Some(Loader {
|
||||
name: "fabric",
|
||||
version: "0.16.9".into()
|
||||
@@ -59,7 +59,7 @@ fn prism_and_multimc_instances() {
|
||||
Found {
|
||||
launcher: "Prism Launcher",
|
||||
name: "old".into(),
|
||||
minecraft: "1.12.2".into(),
|
||||
minecraft: Some("1.12.2".into()),
|
||||
loader: Some(Loader {
|
||||
name: "forge",
|
||||
version: "14.23.5.2860".into()
|
||||
@@ -116,7 +116,7 @@ fn official_launcher_profiles() {
|
||||
.map(|f| {
|
||||
(
|
||||
f.name.as_str(),
|
||||
f.minecraft.as_str(),
|
||||
f.minecraft.as_deref().unwrap(),
|
||||
f.loader.as_ref().map(|l| (l.name, l.version.as_str())),
|
||||
f.shared,
|
||||
)
|
||||
@@ -154,7 +154,7 @@ fn copies_an_instance_but_only_what_a_shared_folder_holds_for_one() {
|
||||
let mut found = Found {
|
||||
launcher: "Prism Launcher",
|
||||
name: "x".into(),
|
||||
minecraft: "1.21.1".into(),
|
||||
minecraft: Some("1.21.1".into()),
|
||||
loader: None,
|
||||
game: game.clone(),
|
||||
shared: false,
|
||||
@@ -193,3 +193,57 @@ fn copies_an_instance_but_only_what_a_shared_folder_holds_for_one() {
|
||||
assert!(game.join("logs/latest.log").is_file());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_and_modrinth_app_instances_are_their_game_folders() {
|
||||
let root = scratch("apps");
|
||||
let curseforge = root.join("Documents/curseforge/minecraft");
|
||||
write(&curseforge.join("Instances/All the Mods/mods/a.jar"), "jar");
|
||||
write(
|
||||
&curseforge.join("Instances/All the Mods/minecraftinstance.json"),
|
||||
"{}",
|
||||
);
|
||||
write(&curseforge.join("Instances/All the Mods/.curseclient"), "");
|
||||
fs::create_dir_all(curseforge.join("Instances/empty")).unwrap();
|
||||
let modrinth = root.join("data/ModrinthApp");
|
||||
write(
|
||||
&modrinth.join("profiles/Fabulously Optimized/options.txt"),
|
||||
"",
|
||||
);
|
||||
|
||||
let folders = Folders {
|
||||
home: root.clone(),
|
||||
data: root.join("data"),
|
||||
config: root.join("config"),
|
||||
};
|
||||
let found = scan(Os::MacOs, &folders);
|
||||
let short: Vec<_> = found
|
||||
.iter()
|
||||
.map(|f| (f.launcher, f.name.as_str(), f.minecraft.is_none(), f.shared))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
short,
|
||||
[
|
||||
(MODRINTH_APP, "Fabulously Optimized", true, false),
|
||||
(CURSEFORGE, "All the Mods", true, false),
|
||||
]
|
||||
);
|
||||
let pack = curseforge.join("Instances/All the Mods");
|
||||
assert_eq!(in_folder(&curseforge, None), [found[1].clone()]);
|
||||
assert_eq!(in_folder(&pack, None), [found[1].clone()]);
|
||||
assert_eq!(in_folder(&modrinth, None), [found[0].clone()]);
|
||||
|
||||
// The app's own files stay behind.
|
||||
let to = root.join("copy");
|
||||
copy_game(&found[1], &to).unwrap();
|
||||
assert!(to.join("mods/a.jar").is_file());
|
||||
assert!(!to.join("minecraftinstance.json").exists() && !to.join(".curseclient").exists());
|
||||
|
||||
let err = import(&found[1], "x", None).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"the CurseForge app doesn't say which Minecraft version All the Mods plays here: name \
|
||||
it with --minecraft."
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -44,13 +44,14 @@ pub(super) enum Field {
|
||||
PreLaunch,
|
||||
Wrapper,
|
||||
LaunchEnv,
|
||||
PostExit,
|
||||
/// Settings' new offline name (`switch_page.rs`).
|
||||
OfflineName,
|
||||
DeleteServer,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
const ALL: [Field; 19] = [
|
||||
const ALL: [Field; 20] = [
|
||||
Field::Name,
|
||||
Field::Seed,
|
||||
Field::Players,
|
||||
@@ -68,6 +69,7 @@ impl Field {
|
||||
Field::PreLaunch,
|
||||
Field::Wrapper,
|
||||
Field::LaunchEnv,
|
||||
Field::PostExit,
|
||||
Field::OfflineName,
|
||||
Field::DeleteServer,
|
||||
];
|
||||
@@ -1081,7 +1083,9 @@ impl Launcher {
|
||||
| Field::InstanceGroup
|
||||
| Field::ConsoleSearch
|
||||
| Field::DeleteServer => {}
|
||||
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv => self.save_launch(),
|
||||
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv | Field::PostExit => {
|
||||
self.save_launch()
|
||||
}
|
||||
Field::OfflineName => self.offline_name_enter(),
|
||||
Field::ServerName | Field::ServerAddress => self.add_server(),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -146,6 +147,14 @@ class ParityRunnerTests(unittest.TestCase):
|
||||
with mock.patch.object(parity.sys, "platform", "darwin"):
|
||||
self.assertEqual(parity.platform_skips(config), {"options": "libm"})
|
||||
|
||||
def test_termination_signals_are_caught_where_they_exist(self):
|
||||
for numbers in ({"SIGTERM": 15, "SIGHUP": 1}, {"SIGTERM": 15}): # Windows has no SIGHUP
|
||||
handled = []
|
||||
fake = SimpleNamespace(**numbers, signal=lambda number, _handler: handled.append(number))
|
||||
with self.subTest(numbers=numbers), mock.patch.object(parity, "signal", fake):
|
||||
parity.exit_on_termination()
|
||||
self.assertEqual(handled, list(numbers.values()))
|
||||
|
||||
def test_a_hosts_excludes_are_skipped_when_it_runs_parity_itself(self):
|
||||
config = {"hosts": {"playhorizon": {"exclude": {"remote_fireworks": "starved"}}, "macmini": {"exclude": {}}}}
|
||||
with mock.patch.object(parity.sys, "platform", "linux"):
|
||||
@@ -180,7 +189,7 @@ class ParityRunnerTests(unittest.TestCase):
|
||||
|
||||
class DependencyPathTests(unittest.TestCase):
|
||||
def test_traced_dependencies_use_repository_posix_paths(self):
|
||||
with tempfile.TemporaryDirectory() as temp, mock.patch.object(parity, "ROOT", Path(temp)):
|
||||
with tempfile.TemporaryDirectory() as temp, mock.patch.object(parity, "ROOT", Path(temp).resolve()):
|
||||
source = Path(temp, "crates/w/src/lib.rs")
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("")
|
||||
|
||||
+4
-3
@@ -1143,9 +1143,10 @@ def run(selected, state, hasher, environment_digest, executors, reuse_oracles, r
|
||||
def exit_on_termination():
|
||||
"""SIGTERM and SIGHUP (a cancelled CI job, a dropped ssh) end this process through its finally blocks, which stop
|
||||
the running harnesses. Each runs in its own session, so without that they, and the vanilla servers they started,
|
||||
outlive it (orphans of days were found on the Mac mini's runners, 2026-10-26)."""
|
||||
for number in (signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(number, lambda received, _frame: sys.exit(128 + received))
|
||||
outlive it (orphans of days were found on the Mac mini's runners, 2026-10-26). Windows has no SIGHUP."""
|
||||
for name in ("SIGTERM", "SIGHUP"):
|
||||
if hasattr(signal, name):
|
||||
signal.signal(getattr(signal, name), lambda received, _frame: sys.exit(128 + received))
|
||||
|
||||
|
||||
def worker_main(shared_local):
|
||||
|
||||
@@ -66,6 +66,8 @@ def scenarios():
|
||||
# Night: undead do not burn, monsters seek darkness.
|
||||
add('zombie_night', 200, ['player 10.5 -60 0.5', 'mob minecraft:zombie 0.5 -60 0.5 0 31 persistent'], 'time=18000')
|
||||
add('skeleton_day_burn', 120, ['mob minecraft:skeleton 0.5 -60 0.5 0 32 persistent'])
|
||||
# NoAI only skips serverAiStep: Mob.aiStep's burnUndead still sets a NoAI zombie alight (#3430).
|
||||
add('zombie_noai_day_burn', 120, ['mob minecraft:zombie 0.5 -60 0.5 0 33 persistent noai'])
|
||||
# The wither (summoned, not built: no invulnerable spawn phase).
|
||||
add('wither_player', 200, ['player 10.5 -60 0.5', 'mob minecraft:wither 0.5 -58 0.5 0 41 persistent'])
|
||||
# A villager with a job site, a bed and a bell.
|
||||
|
||||
Reference in new issue
Block a user