REntityServer: use per-entity view range instead of chunk view distance

Entities now decide per player whether they are visible. REntity defaults
to the player view radius, RDisplay derives it from its viewRange (1 = 64
blocks) capped at the client view distance, so values like Float.MAX_VALUE
stay cheap. Changing the viewRange re-evaluates the viewers. Fixes #488
This commit is contained in:
2026-10-19 10:40:03 +02:00
parent 9d5fcc07e8
commit 3fe2e006b9
3 changed files with 101 additions and 117 deletions
@@ -26,6 +26,7 @@ import net.minecraft.network.syncher.EntityDataSerializers;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.entity.Display;
import org.bukkit.entity.Player;
import org.bukkit.entity.EntityType;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
@@ -135,6 +136,15 @@ public abstract class RDisplay extends REntity {
public void setViewRange(float viewRange) {
this.viewRange = viewRange;
sendPacket(updatePacketSink, this::getViewRange);
server.updateViewers(this);
}
/**
* A view range of 1 equals 64 blocks. This is only a comparison, so huge values like {@link Float#MAX_VALUE} are cheap.
*/
@Override
protected boolean canView(Player player, int chunkDistance) {
return chunkDistance <= Math.ceil(viewRange * 64.0 / 16.0);
}
private static final EntityDataAccessor<Float> viewRangeWatcher = new EntityDataAccessor<>(17, EntityDataSerializers.FLOAT);
@@ -39,6 +39,7 @@ import org.bukkit.Location;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.*;
@@ -56,6 +57,10 @@ public class REntity {
private static final Random random = new Random();
protected final REntityServer server;
/**
* Players this entity is currently spawned for, maintained by the {@link REntityServer}.
*/
final Set<Player> viewers = new HashSet<>();
private final EntityType entityType;
@Getter
protected final int entityId;
@@ -120,6 +125,16 @@ public class REntity {
this.objectData = objectData;
}
/**
* Whether the given player should see this entity.
* Defaults to the view distance of the player.
*
* @param chunkDistance the larger of the chunk distances between the player and the entity on the x and z axis
*/
protected boolean canView(Player player, int chunkDistance) {
return chunkDistance <= server.viewRadius(player);
}
public void hide(boolean hide) {
if (hidden == hide) return;
@@ -42,14 +42,8 @@ import java.util.stream.Collectors;
public class REntityServer implements Listener {
private static final HashSet<REntity> emptyEntities = new HashSet<>(0);
private static final HashSet<Player> emptyPlayers = new HashSet<>(0);
private final ConcurrentHashMap<Integer, REntity> entityMap = new ConcurrentHashMap<>();
private final HashMap<Long, HashSet<REntity>> entities = new HashMap<>();
private final HashMap<Long, Set<Player>> players = new HashMap<>();
private final HashMap<Player, Location> lastLocation = new HashMap<>();
private final HashMap<Player, Integer> viewDistance = new HashMap<>();
private final Set<Player> playersThatClicked = Collections.synchronizedSet(new HashSet<>());
@@ -78,20 +72,22 @@ public class REntityServer implements Listener {
public void addPlayer(Player player) {
Location location = player.getLocation();
lastLocation.put(player, location);
viewDistance.put(player, viewRadius(player));
forChunkInView(player, location, (x, z) -> addPlayerToChunk(player, x, z));
updateViewer(player, posToChunk(location.getX()), posToChunk(location.getZ()));
}
public void removePlayer(Player player) {
if (!viewDistance.containsKey(player)) {
if (lastLocation.remove(player) == null) {
return;
}
forChunkInView(player, lastLocation.remove(player), (x, z) -> removePlayerFromChunk(player, x, z));
viewDistance.remove(player);
for (REntity entity : entityMap.values()) {
if (entity.viewers.remove(player)) {
hideFrom(entity, player);
}
}
}
public List<Player> getPlayers() {
return new ArrayList<>(viewDistance.keySet());
return new ArrayList<>(lastLocation.keySet());
}
public void close() {
@@ -104,43 +100,53 @@ public class REntityServer implements Listener {
void addEntity(REntity entity) {
entityMap.put(entity.entityId, entity);
addEntityToChunk(entity);
updateViewers(entity);
entity.list(packet -> updateEntity(entity, packet));
entity.spawn(packet -> updateEntity(entity, packet));
}
void preEntityMove(REntity entity, double toX, double toZ) {
long fromId = entityToId(entity);
long toId = posToId(toX, toZ);
if (fromId == toId) return;
if (posToChunk(entity.x()) == posToChunk(toX) && posToChunk(entity.z()) == posToChunk(toZ)) return;
if (!entity.isHidden()) {
onMissing(players.get(fromId), players.get(toId), entity::despawn);
}
onMissing(players.get(fromId), players.get(toId), entity::delist);
removeEntityFromChunk(entity);
int chunkX = posToChunk(toX);
int chunkZ = posToChunk(toZ);
entity.viewers.removeIf(player -> {
if (inView(entity, player, chunkX, chunkZ)) return false;
hideFrom(entity, player);
return true;
});
}
void postEntityMove(REntity entity, double fromX, double fromZ) {
long fromId = posToId(fromX, fromZ);
long toId = entityToId(entity);
if (fromId == toId) return;
if (posToChunk(fromX) == posToChunk(entity.x()) && posToChunk(fromZ) == posToChunk(entity.z())) return;
addEntityToChunk(entity);
onMissing(players.get(toId), players.get(fromId), entity::list);
if (!entity.isHidden()) {
onMissing(players.get(toId), players.get(fromId), entity::spawn);
}
addMissingViewers(entity);
}
void updateEntity(REntity entity, Object packet) {
if (entity.isHidden()) return;
for (Player player : players.getOrDefault(entityToId(entity), emptyPlayers)) {
for (Player player : entity.viewers) {
TinyProtocol.instance.sendPacket(player, packet);
}
}
/**
* Re-evaluates which players see the entity, e.g. after its view range changed.
*/
void updateViewers(REntity entity) {
if (!entityMap.containsKey(entity.entityId)) return;
int chunkX = posToChunk(entity.x());
int chunkZ = posToChunk(entity.z());
entity.viewers.removeIf(player -> {
if (inView(entity, player, chunkX, chunkZ)) return false;
hideFrom(entity, player);
return true;
});
addMissingViewers(entity);
}
/**
* Re-sends list and spawn packets of an already added entity to all current viewers,
* e.g. when information that is part of the tab list entry changed after the entity was added.
@@ -148,7 +154,7 @@ public class REntityServer implements Listener {
void refreshEntity(REntity entity) {
if (!entityMap.containsKey(entity.entityId)) return;
for (Player player : players.getOrDefault(entityToId(entity), emptyPlayers)) {
for (Player player : entity.viewers) {
Consumer<Object> sink = packet -> TinyProtocol.instance.sendPacket(player, packet);
if (!entity.isHidden()) entity.despawn(sink);
entity.delist(sink);
@@ -159,11 +165,12 @@ public class REntityServer implements Listener {
void removeEntity(REntity entity) {
entity.despawn(packet -> updateEntity(entity, packet));
removeEntityFromChunk(entity);
entity.delist(packet -> updateEntity(entity, packet));
entity.viewers.clear();
entityMap.remove(entity.entityId);
}
public List<REntity> getEntities() {
return new ArrayList<>(entityMap.values());
}
@@ -172,18 +179,6 @@ public class REntityServer implements Listener {
return entityMap.values().stream().filter(clazz::isInstance).map(clazz::cast).collect(Collectors.toList());
}
private void addEntityToChunk(REntity entity) {
entities.computeIfAbsent(entityToId(entity), i -> new HashSet<>()).add(entity);
}
private void removeEntityFromChunk(REntity entity) {
long id = entityToId(entity);
HashSet<REntity> entitiesInChunk = entities.get(id);
if (entitiesInChunk == null) return;
entitiesInChunk.remove(entity);
if (entitiesInChunk.isEmpty()) entities.remove(id);
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onMove(PlayerMoveEvent e) {
Player player = e.getPlayer();
@@ -191,91 +186,64 @@ public class REntityServer implements Listener {
Location to = e.getTo();
if (from == null || to == null) return;
int fromX = posToChunk(from.getX());
int fromZ = posToChunk(from.getZ());
int toX = posToChunk(to.getX());
int toZ = posToChunk(to.getZ());
if (fromX == toX && fromZ == toZ) return;
if (posToChunk(from.getX()) == toX && posToChunk(from.getZ()) == toZ) return;
lastLocation.put(player, to);
int toViewDistance = viewRadius(player);
forChunkInView(player, from, (x, z) -> {
if (Math.abs(x - toX) > toViewDistance || Math.abs(z - toZ) > toViewDistance) {
removePlayerFromChunk(player, x, z);
}
});
int fromViewDistance = this.viewDistance.put(player, toViewDistance);
forChunkInView(player, to, (x, z) -> {
if (Math.abs(x - fromX) > fromViewDistance || Math.abs(z - fromZ) > fromViewDistance) {
addPlayerToChunk(player, x, z);
}
});
updateViewer(player, toX, toZ);
}
@EventHandler
public void onQuit(PlayerQuitEvent e) {
Player player = e.getPlayer();
Location location = lastLocation.remove(player);
if (location == null) return;
if (lastLocation.remove(player) == null) return;
forChunkInView(player, location, (x, z) -> {
long id = chunkToId(x, z);
Set<Player> playersInChunk = players.get(id);
playersInChunk.remove(player);
if (playersInChunk.isEmpty()) players.remove(id);
});
viewDistance.remove(player);
for (REntity entity : entityMap.values()) {
entity.viewers.remove(player);
}
}
private void onMissing(Set<Player> of, Set<Player> in, Consumer<Consumer<Object>> packetProvider) {
if (of == null) return;
for (Player player : of) {
if (in == null || !in.contains(player)) {
packetProvider.accept(packet -> TinyProtocol.instance.sendPacket(player, packet));
private void updateViewer(Player player, int chunkX, int chunkZ) {
for (REntity entity : entityMap.values()) {
boolean inView = inView(entity, player, chunkX, chunkZ);
if (inView) {
if (entity.viewers.add(player)) showTo(entity, player);
} else if (entity.viewers.remove(player)) {
hideFrom(entity, player);
}
}
}
private void forChunkInView(Player player, Location location, BiConsumer<Integer, Integer> func) {
int chunkX = posToChunk(location.getX());
int chunkZ = posToChunk(location.getZ());
int viewDistance = this.viewDistance.get(player);
for (int x = chunkX - viewDistance; x <= chunkX + viewDistance; x++) {
for (int z = chunkZ - viewDistance; z <= chunkZ + viewDistance; z++) {
func.accept(x, z);
private void addMissingViewers(REntity entity) {
int chunkX = posToChunk(entity.x());
int chunkZ = posToChunk(entity.z());
for (Player player : lastLocation.keySet()) {
if (inView(entity, player, chunkX, chunkZ) && entity.viewers.add(player)) {
showTo(entity, player);
}
}
}
private void addPlayerToChunk(Player player, int x, int z) {
long id = chunkToId(x, z);
players.computeIfAbsent(id, i -> new HashSet<>()).add(player);
for (REntity entity : entities.getOrDefault(id, emptyEntities)) {
private boolean inView(REntity entity, Player player, int entityChunkX, int entityChunkZ) {
Location location = lastLocation.get(player);
int distance = Math.max(Math.abs(posToChunk(location.getX()) - entityChunkX), Math.abs(posToChunk(location.getZ()) - entityChunkZ));
return entity.canView(player, distance);
}
private void showTo(REntity entity, Player player) {
entity.list(packet -> TinyProtocol.instance.sendPacket(player, packet));
if (!entity.isHidden()) {
entity.spawn(packet -> TinyProtocol.instance.sendPacket(player, packet));
}
}
}
private void removePlayerFromChunk(Player player, int x, int z) {
long id = chunkToId(x, z);
Set<Player> playersInChunk = players.get(id);
playersInChunk.remove(player);
if (playersInChunk.isEmpty()) players.remove(id);
for (REntity entity : entities.getOrDefault(id, emptyEntities)) {
private void hideFrom(REntity entity, Player player) {
if (!entity.isHidden()) {
entity.despawn(packet -> TinyProtocol.instance.sendPacket(player, packet));
}
entity.delist(packet -> TinyProtocol.instance.sendPacket(player, packet));
}
}
public void tick() {
for (REntity entity : entityMap.values()) {
@@ -287,19 +255,10 @@ public class REntityServer implements Listener {
return (int) (coord / 16) - (coord < 0 ? 1 : 0);
}
private int viewRadius(Player player) {
/**
* Default view radius in chunks for entities without a view range of their own.
*/
int viewRadius(Player player) {
return player.getClientViewDistance() / 2;
}
private long entityToId(REntity entity) {
return posToId(entity.x(), entity.z());
}
private long posToId(double x, double z) {
return chunkToId(posToChunk(x), posToChunk(z));
}
private long chunkToId(int x, int z) {
return ((long) x << 32) + z;
}
}