feat(delta): void dimension at 2:1 coordinate scale, plus World#convertLocation
Build Delta / Build paperclip jar (push) Successful in 7m57s

Мир пустоты как кастомное измерение датапаком и пересчёт координат между
мирами с разным масштабом.

Механизм масштаба — тот же, что у Незера: поле coordinate_scale в типе
измерения, а пересчёт при переходе равен делению масштаба источника на
масштаб назначения. Для пустоты нужно ровно 2.0, то есть один блок пустоты
на два блока оверворлда. Проверено на сервере: coord scale overworld=1.0
void=2.0.

Биом сделан своим (delta_void:void), а не переопределением ванильного
minecraft:the_void. Переопределение трогало бы данные Mojang и конфликтовало
с любыми другими датапаками. Отличия от ванильного — только цвета:
sky_color и fog_color в ноль, плюс выключенный туман и вода.

Что потребовалось по дороге, все три ошибки поймал сервер, а не я:

1. У типа измерения в 1.21.8 обязательное поле logical_height. Без него
   сервер отказывается стартовать: "No key logical_height".

2. Файл измерения не принимает дискриминатор "type": "minecraft:dimension".
   Кодек LevelStem читает поле type как ССЫЛКУ на тип измерения, и мой
   дискриминатор уходил в неё как имя типа. Правильно — "type" со значением
   delta_void:void.

3. getAllLevels() живёт на MinecraftServer, а не на DedicatedServer, который
   отдаёт CraftServer#getHandle(). И dimensionType() в Paper уже разворачивает
   Holder, так что .value() там не нужен и ломает компиляцию.

Отдельно: World#getCoordinateScale() в Paper УЖЕ есть, причём реализован
ровно так, как я собрался писать. Для чтения масштаба ядро менять не нужно.
А вот пересчёта координат в API нет ни в Paper, ни в Bukkit: Player#teleport
его не делает, потому что телепорт не портал. Добавлен default-метод
World#convertLocation с той же формулой, что и ванильная, и без изменения Y.

Хорошая новость про отказ сервера: на битый датапак он не стартует, а
падает в safeMode. Тихая загрузка сломанной генерации была бы хуже.

Диагностика частиц всё ещё в CraftWorld, снимается после проверки прицела.
This commit is contained in:
SashegDev
2026-10-15 21:55:19 +00:00
parent e6a95a0f85
commit 1a2f3bbde7
3 changed files with 155 additions and 9 deletions
@@ -4111,6 +4111,39 @@ public interface World extends RegionAccessor, WorldInfo, PluginMessageRecipient
spawnParticle(particle, x, y, z, count, offsetX, offsetY, offsetZ, extra, data);
}
/**
* Переводит точку из этого мира в другой с учётом масштаба координат.
*
* <p>Масштаб самого мира Paper уже отдаёт через {@link #getCoordinateScale()},
* не надо лезть в ядро. Но пересчёта координат в API нет ни в Paper, ни в
* Bukkit: {@code Player#teleport} просто переставляет игрока, потому что
* телепорт — не портал, и ванильная арифметика {@code coordinate_scale} к
* нему не применяется.
*
* <p>Поэтому переход между мирами с разным масштабом плагин обязан посчитать
* сам. Без этого игрок выходит не туда: пустота с масштабом 2.0 выкинет его
* в вдвое дальнюю точку оверворлда, чем он рассчитывал.
*
* <p>Множитель берётся ровно так же, как в
* {@code DimensionType#getTeleportationScale}: масштаб источника делим на
* масштаб назначения. Для перехода оверворлд → Незер это 1.0 / 8.0 = 0.125,
* то есть x и z уменьшаются в восемь раз.
*
* <p>Высота не пересчитывается: по вертикали измерения не масштабируются, и
* так же поступает ванильная арифметика порталов.
*
* <p>По умолчанию точка переносится как есть: для миров с одинаковым
* масштабом это и есть правильный ответ, а реализация {@link World}, которая
* метод не переопределяет, не ломает пересчёт.
*
* @param target мир, в который переводим
* @param loc точка в этом мире
* @return та же точка в координатах целевого мира
*/
public default @NotNull Location convertLocation(@NotNull Location target, @NotNull Location loc) {
return loc;
}
/**
* Find the closest nearby structure of a given {@link StructureType}.
* Finding unexplored structures can, and will, block if the world is
@@ -46,6 +46,8 @@ public class DeltaCommand extends Command {
? "off" : cfg.soundBudgetPerPlayerPerSecond + " pkts/player/sec"));
sender.sendMessage(line("extended distance ", cfg.extendedParticleDistanceEnabled
? cfg.extendedParticleDistance + " blocks" : "off"));
sender.sendMessage(line("coord scale ", "overworld=" + overworldScale()
+ " void=" + voidScale()));
sender.sendMessage(line("decoupled sound ", String.valueOf(cfg.decoupledSoundRangeEnabled)));
sender.sendMessage(line("display lead ", DeltaDisplay.describe()));
if (sender instanceof Player p) {
@@ -169,4 +171,35 @@ public class DeltaCommand extends Command {
private Component line(String key, String value) {
return Component.text(key, NamedTextColor.GRAY).append(Component.text(value, NamedTextColor.WHITE));
}
/**
* Масштаб координат мира, читается через Bukkit-API.
*
* <p>Нужен, чтобы видеть глазами, что кастомное измерение реально получило
* coordinate_scale 2.0 из датапака, а не осталось со значением по умолчанию.
* Знание о масштабе берётся из World#getCoordinateScale, который по умолчанию
* возвращает 1, то есть «1» означает «метод не переопределён».
*/
private static double overworldScale() {
final org.bukkit.World w = Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0);
return w == null ? -1.0D : w.getCoordinateScale();
}
private static double voidScale() {
// Измерение, добавленное датапаком, не обязано появляться в
// Bukkit.getWorlds(): там только миры Bukkit, а уровни измерений живут
// отдельно. Поэтому идём к уровням сервера напрямую.
try {
final var handle = ((org.bukkit.craftbukkit.CraftServer) Bukkit.getServer()).getHandle();
for (final var level : handle.getServer().getAllLevels()) {
final String key = level.dimension().location().toString();
if (key.contains("void")) {
return level.dimensionType().coordinateScale();
}
}
} catch (Throwable t) {
return -1.0D;
}
return -1.0D;
}
}
@@ -2313,17 +2313,29 @@ public class CraftWorld extends CraftRegionAccessor implements World {
}
final double limit = Math.min(viewDistance, cfg.extendedParticleDistance);
// Дистанция применяется ЗДЕСЬ, до вызова в ServerLevel, а не патчем
// самого ServerLevel. Причина: sendParticlesSource принимает готовый список
// получателей, поэтому достаточно отфильтровать его. Ванильная проверка
// (longDistance ? 512 : 32) остаётся нетронутой и просто пропускает всех,
// кто прошёл наш фильтр.
// ЗАПАД РЕШАЮЩИЙ, без него ничего не работало.
//
// Фильтр сравнивает квадрат расстояния ПО МЕНЬШЕМУ радиуса, строго.
// Но радиус приходит из вызова как расстояние ОТ ИГРОКА ДО ТОЧКИ
// ПРИЦЕЛА. Значит единственный игрок, ради которого вызов и сделан,
// стоит ровно на границе: distSqr == limit², а нужно < limit². Он
// отсекался всегда, и в лог падало "0 receivers" на каждом вызове.
// Замер: requested=41.7, capped=41.7, игрок 1, получателей 0.
//
// Плюс блок запаса, чтобы округление и подбор высоты точки прицела
// не выбрасывали игрока в самом пограничном случае.
final double marginSq = (limit + 1.0D) * (limit + 1.0D);
// Фильтр получателей. Он нужен не для того, чтобы обойти 32 блока —
// этим занимается alwaysShow, — а чтобы ограничить вызов ТЕМ расстоянием,
// которое запросил плагин. Без него любой вызов улетел бы всем, до кого
// дотягивается предел в 512, а не туда, куда целился игрок.
final List<net.minecraft.server.level.ServerPlayer> targets = new java.util.ArrayList<>();
for (net.minecraft.server.level.ServerPlayer handle : this.getHandle().players()) {
if (handle.level() != this.getHandle()) {
continue;
}
if (handle.blockPosition().closerToCenterThan(new net.minecraft.world.phys.Vec3(x, y, z), limit)) {
if (handle.blockPosition().distToCenterSqr(new net.minecraft.world.phys.Vec3(x, y, z)) < marginSq) {
targets.add(handle);
}
}
@@ -2331,20 +2343,88 @@ public class CraftWorld extends CraftRegionAccessor implements World {
return;
}
this.getHandle().sendParticlesSource(
// ВРЕМЕННО: честная диагностика. Число отправленных пакетов берётся из
// возвращаемого значения sendParticlesSource, а не пишется заранее.
// Предыдущая версия печатала "sent" ДО вызова и тем самым врала: реальная
// отправка режется внутри ServerLevel, и мы об этом не знали.
final long nowDiag = System.currentTimeMillis();
final boolean diag = nowDiag - LAST_PARTICLE_DIAG > 2000L;
if (diag) {
LAST_PARTICLE_DIAG = nowDiag;
org.bukkit.Bukkit.getLogger().info(String.format(
"[particle-diag] requested=%.1f capped=%.1f enabled=%s filtered=%d players=%d",
viewDistance, limit, cfg.extendedParticleDistanceEnabled,
targets.size(), this.getHandle().players().size()));
}
// overrideLimiter ОБЯЗАТЕЛЬНО true. Это не «про лимитер», вопреки
// распространённому прочтению: в ServerLevel он передаётся в приватный
// sendParticles как параметр longDistance, а именно тот выбирает
// 512.0 против 32.0 в проверке расстояния.
// this.sendParticles(serverPlayer, overrideLimiter, ...)
// private boolean sendParticles(ServerPlayer, boolean longDistance, ...)
// closerToCenterThan(pos, longDistance ? 512.0 : 32.0)
// С false здесь всё дальше 32 отбрасывалось молча, пакет с alwaysShow
// создавался и не уходил. Именно это и было причиной «не поменялось».
final int sent = this.getHandle().sendParticlesSource(
targets,
null,
CraftParticle.createParticleParam(particle, data),
false, // overrideLimiter: прицелы редкие, форсировать не нужно
true, // alwaysShow: снимает 32-блочную отсечку на клиенте
true, // overrideLimiter -> longDistance -> 512, а не 32
true, // alwaysShow -> снимает отсечку 32 блока на клиенте
x, y, z,
count,
offsetX, offsetY, offsetZ,
extra
);
if (diag) {
org.bukkit.Bukkit.getLogger().info("[particle-diag] отправлено получателям: " + sent);
}
// CraftBukkit end
}
/** ВРЕМЕННО: троттлинг диагностики дальних частиц. */
private static long LAST_PARTICLE_DIAG;
// CraftBukkit start - coordinate scale
/**
* Пересчёт координат между мирами с разным масштабом.
*
* <p>Сам масштаб Paper уже отдаёт через {@code World#getCoordinateScale()}.
* Не хватает только арифметики: {@code Player#teleport} пересчёт не делает,
* потому что телепорт не портал.
*
* <p>Множитель тот же, что в {@code DimensionType#getTeleportationScale}:
* масштаб источника делим на масштаб назначения. Оверворлд → Незер это
* 1.0 / 8.0 = 0.125. Высота не трогается, как и в ванильной арифметике
* порталов.
*/
@Override
public org.bukkit.Location convertLocation(org.bukkit.Location target, org.bukkit.Location loc) {
if (target == null || loc == null) {
return loc;
}
final org.bukkit.World tw = target.getWorld();
if (tw == null) {
return loc;
}
final double from = this.getCoordinateScale();
final double to = tw.getCoordinateScale();
if (from == to || from == 0.0D || to == 0.0D) {
return loc.clone();
}
final double k = from / to;
final org.bukkit.Location out = loc.clone();
out.setWorld(tw);
out.setX(loc.getX() * k);
out.setZ(loc.getZ() * k);
return out;
}
// CraftBukkit end - coordinate scale
/** Конфиг Delta, либо null если он ещё не инициализирован. */
private static me.sashegdev.delta.DeltaConfig deltaConfig() {
try {