Multiplayer: allow punching End crystals
Include End crystals in attack-only object hit-testing using their network entity IDs. Add a real-mouse LAN regression for survival and creative, reach, block occlusion, passing item use, and explosion damage. Co-Authored-By: OpenCode GPT-6.1 <noreply@openai.com>
This commit is contained in:
@@ -3883,7 +3883,7 @@ impl Game<'_> {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// A boat, minecart, item frame, armor stand or painting nearer than
|
||||
// any mob: the server uses it (AbstractBoat.interact boards it).
|
||||
if let Some(hit) = self.remote_object_hit()
|
||||
if let Some(hit) = self.remote_object_hit(false)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
@@ -4360,12 +4360,12 @@ impl Game<'_> {
|
||||
id: self.creatures.projectiles[i].id as i32,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some(hit) = self.remote_object_hit()
|
||||
} else if let Some(hit) = self.remote_object_hit(true)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
|
||||
// a painting broken: the server decides.
|
||||
// a painting broken or an EndCrystal hurtServer: the server decides.
|
||||
self.send(S::Attack(play::Attack { id: hit.id }));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, view_vector};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{Game, Keys, ray_box, view_vector};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// An object under the crosshair: its entity id, how far along the view it
|
||||
@@ -24,8 +24,8 @@ pub(super) struct ObjectHit {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The nearest boat, minecart, item frame, armor stand or painting the
|
||||
/// view hits within reach.
|
||||
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
|
||||
/// view hits within reach; also End crystals when attacking.
|
||||
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let at = |d: f64| {
|
||||
@@ -64,6 +64,22 @@ impl Game<'_> {
|
||||
let p = self.carts[i].position;
|
||||
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if attacking {
|
||||
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
|
||||
// id, not special_targets' local projectile target id.
|
||||
// Entity.interact passes on a crystal, so right clicks use the item.
|
||||
let reach = if self.creative { 5.0 } else { 3.0 };
|
||||
for c in self.crystals.iter().filter(|c| c.id != 0) {
|
||||
let p = c.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
|
||||
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
|
||||
);
|
||||
if let Some(d) = ray_box(eye, dir, reach, bounds) {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
Some(ObjectHit {
|
||||
|
||||
+91
-1
@@ -109,7 +109,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the
|
||||
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
|
||||
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
|
||||
the host has them; a falling block lands on both sides.
|
||||
the host has them; a falling block lands on both sides.
|
||||
- crystals: the host and the guest punch placed End crystals in survival and creative; the blast
|
||||
kills the survival player, and attacks respect reach and blocks in front.
|
||||
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
|
||||
sides (#1363, #1391);
|
||||
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
|
||||
@@ -5539,5 +5541,93 @@ CHECKS += ['falling']
|
||||
CHECK_FNS.update(falling=check_falling)
|
||||
|
||||
|
||||
def check_crystals(host, guest, out):
|
||||
"""#1532: a real left click detonates a placed End crystal in survival and creative,
|
||||
on the host and the guest; reach, block occlusion and passing right clicks still apply."""
|
||||
spots = apart(host, guest)
|
||||
seen = {}
|
||||
|
||||
def crystals(game, at):
|
||||
return [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal'
|
||||
and math.dist(p['position'], at) < 0.01]
|
||||
|
||||
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
||||
x, y, z = spots[label]
|
||||
y += 32 # Above the terrain and its entities: EndCrystalItem requires an empty box.
|
||||
at = [x + 2.5, y, z + 0.5]
|
||||
other('command', command='gamemode creative')
|
||||
for mode in ('creative', 'survival'):
|
||||
case = f'{label}-{mode}'
|
||||
try:
|
||||
host('command', command=f'fill {x - 5} {y - 1} {z - 3} {x + 5} {y - 1} {z + 3} minecraft:obsidian')
|
||||
host('command', command=f'fill {x - 5} {y} {z - 3} {x + 5} {y + 3} {z + 3} minecraft:air')
|
||||
player('command', command=f'gamemode {mode}')
|
||||
player('command', command='clear @s')
|
||||
player('command', command='effect clear @s')
|
||||
player('command', command='effect give @s minecraft:instant_health 1 5 true')
|
||||
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
player('command', command='give @s minecraft:end_crystal 1')
|
||||
player.raw('select', 1)
|
||||
player.wait(10)
|
||||
player.raw('look-at', at[0], y - 0.01, at[2])
|
||||
player.raw('mouse', 'right', 'click')
|
||||
player.wait(10)
|
||||
placed = crystals(player, at)
|
||||
assert len(placed) == 1, f'{case}: placing the crystal: {placed}'
|
||||
crystal_id = placed[0]['id']
|
||||
tracked = crystals(other, at)
|
||||
assert crystal_id != 0 and [p['id'] for p in tracked] == [crystal_id], f'{case}: crystal not tracked on both sides'
|
||||
|
||||
# The nearest face is six blocks away, beyond both modes' attack reach.
|
||||
player('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
|
||||
player.wait(5)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(10)
|
||||
assert crystals(player, at), f'{case}: attacked a crystal out of reach'
|
||||
|
||||
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:obsidian')
|
||||
player.wait(5)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(10)
|
||||
assert crystals(player, at), f'{case}: attacked a crystal through a block'
|
||||
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:air')
|
||||
player('command', command='item replace entity @s weapon.mainhand with minecraft:shield')
|
||||
player.wait(10)
|
||||
player.raw('look-at', at[0], y + 1, at[2])
|
||||
player.raw('mouse', 'right', 'down')
|
||||
player.wait(10)
|
||||
assert player('state').get('using') and server_using(host, player), f'{case}: crystal consumed right click'
|
||||
player.raw('mouse', 'right', 'up')
|
||||
player('command', command='clear @s')
|
||||
player.wait(5)
|
||||
player.shot(out, f'crystals-{mode}-before')
|
||||
|
||||
player.raw('mouse', 'left', 'click')
|
||||
player.wait(40)
|
||||
remaining = {g.name: len(crystals(g, at)) for g in (player, other)}
|
||||
health = server_player(host, player)['health']
|
||||
screen = player('state').get('screen')
|
||||
seen[case] = {'id': crystal_id, 'remaining': remaining, 'health': health, 'screen': screen}
|
||||
player.shot(out, f'crystals-{mode}-after')
|
||||
assert not any(remaining.values()), f'{case}: left click left the crystal intact: {seen[case]}'
|
||||
assert health == (20 if mode == 'creative' else 0), f'{case}: explosion damage: {seen[case]}'
|
||||
assert screen == ('playing' if mode == 'creative' else 'dead'), f'{case}: explosion death: {seen[case]}'
|
||||
finally:
|
||||
if player('state').get('screen') == 'dead':
|
||||
respawn_when_ready(player)
|
||||
player.wait(30)
|
||||
else:
|
||||
player.raw('mouse', 'right', 'up')
|
||||
player('command', command='gamemode creative')
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['crystals']
|
||||
CHECK_FNS.update(crystals=check_crystals)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user