Bugfix portal visual systems

This commit is contained in:
Darto KLoning
2026-09-06 23:03:00 +07:00
parent c4f4bd971a
commit b24e031b05
6 changed files with 322 additions and 28 deletions
@@ -40,6 +40,7 @@ public final class LobbySystemsMod implements ModInitializer {
private static final SessionTracker SESSIONS = new SessionTracker();
private static final PositionManager POSITIONS = new PositionManager();
private static final PortalManager PORTALS = new PortalManager();
private static final PortalVisualManager PORTAL_VISUALS = new PortalVisualManager();
private static final TeleportCoordinator TELEPORTS = new TeleportCoordinator();
private static final DimensionGameModeManager DIMENSION_GAMEMODES = new DimensionGameModeManager();
private static final DimensionGameRuleManager DIMENSION_GAMERULES = new DimensionGameRuleManager();
@@ -98,6 +99,16 @@ public final class LobbySystemsMod implements ModInitializer {
"Rules applied: " + summary.applied(),
"Rules skipped: " + summary.skipped()
);
int visualBlocks = PORTAL_VISUALS.repairAll(server, PORTALS, false);
if (visualBlocks > 0) {
LobbyLog.state(
"PORTAL VISUALS RESTORED",
"Visual blocks restored: " + visualBlocks,
"Visual type: vanilla nether_portal",
"Client mod required: no"
);
}
});
ServerLevelEvents.LOAD.register((server, world) -> {
@@ -110,6 +121,12 @@ public final class LobbySystemsMod implements ModInitializer {
"Rules skipped: " + summary.skipped()
);
}
int visualBlocks = PORTAL_VISUALS.applyToLevel(world, PORTALS);
if (visualBlocks > 0) {
LOGGER.info("Restored {} Lobby Systems portal visual block(s) in {}.",
visualBlocks, world.dimension().identifier());
}
});
ServerPlayConnectionEvents.JOIN.register((listener, sender, server) -> {
@@ -161,6 +178,7 @@ public final class LobbySystemsMod implements ModInitializer {
LobbyConfig cfg = CONFIG.get();
PORTALS.tick(server, cfg.portalCheckIntervalTicks, cfg.portalCooldownTicks,
(player, portalName, portal) -> activatePortal(player, server, portalName, portal));
PORTAL_VISUALS.tick(server, PORTALS);
if (++saveTicker >= 1200) {
saveTicker = 0;
@@ -351,6 +369,7 @@ public final class LobbySystemsMod implements ModInitializer {
LobbyLog.state("RELOAD FAILED", "Reason: portals.json gagal dibaca");
return 0;
}
int repairedPortalVisuals = PORTAL_VISUALS.repairAll(server, PORTALS, false);
boolean gameModesOk = DIMENSION_GAMEMODES.load();
if (!gameModesOk) {
@@ -385,6 +404,7 @@ public final class LobbySystemsMod implements ModInitializer {
"Lobby: " + CONFIG.get().lobbyDimension,
"Minimum play: " + CONFIG.get().minimumPlaySeconds + "s",
"Portals: " + PORTALS.size(),
"Portal visual blocks restored: " + repairedPortalVisuals,
"Dimension gamemodes: " + DIMENSION_GAMEMODES.all().size()
+ " (management " + (CONFIG.get().manageDimensionGamemode ? "enabled" : "disabled") + ")",
"Dimension gamerules: " + DIMENSION_GAMERULES.all().size() + " dimensions / " + gameRuleSummary.applied() + " rules applied",
@@ -697,42 +717,66 @@ public final class LobbySystemsMod implements ModInitializer {
try {
WorldEditSelectionBridge.Selection s = WorldEditSelectionBridge.readSelection(player);
String visualAxis = PORTAL_VISUALS.determineAxis(s, player);
PortalDefinition portal = new PortalDefinition(
player.level().dimension().identifier().toString(),
new PortalPoint(s.minX(), s.minY(), s.minZ()),
new PortalPoint(s.maxX(), s.maxY(), s.maxZ()),
targetId
targetId,
"nether_portal",
visualAxis
);
// Apply the visual before persisting the portal so a bad selection can
// fail safely without leaving a half-created portal definition.
int visualBlocks = PORTAL_VISUALS.applyInitial((ServerLevel) player.level(), portal);
if (!PORTALS.create(name, portal)) {
PORTAL_VISUALS.clear((ServerLevel) player.level(), portal);
player.sendSystemMessage(Component.literal("§cPortal '" + name + "' sudah ada."));
return 0;
}
PORTALS.suppress(player.getUUID(), Math.max(CONFIG.get().portalCooldownTicks, 100));
player.sendSystemMessage(Component.literal(
"§aPortal §f" + name + "§a dibuat. §7" + portal.sourceDimension + " §8→ §f" + targetId));
"§aPortal §f" + name + "§a dibuat. §7" + portal.sourceDimension + " §8→ §f" + targetId
+ "\n§dVisual portal aktif §7(" + visualBlocks + " blok, axis=" + visualAxis + ")."
+ "\n§7Frame boleh block apa saja; selection harus hanya area dalam portal."));
LobbyLog.state(
"PORTAL CREATED",
"Name: " + name,
"Source: " + portal.sourceDimension,
"Target: " + targetId
"Target: " + targetId,
"Visual: vanilla nether_portal",
"Axis: " + visualAxis,
"Visual blocks: " + visualBlocks
);
return 1;
} catch (Exception e) {
LOGGER.warn("Failed to read WorldEdit selection for {}", player.getUUID(), e);
player.sendSystemMessage(Component.literal("§cGagal membaca selection WorldEdit: " + rootMessage(e)));
LOGGER.warn("Failed to create portal for {}", player.getUUID(), e);
player.sendSystemMessage(Component.literal("§cGagal membuat portal: " + rootMessage(e)));
return 0;
}
}
private static int deletePortal(CommandSourceStack source, String name) {
String key = PortalManager.normalizeName(name);
if (!PORTALS.delete(key)) {
PortalDefinition existing = PORTALS.get(key);
if (existing == null || !PORTALS.delete(key)) {
source.sendFailure(Component.literal("§cPortal '" + key + "' tidak ditemukan."));
return 0;
}
source.sendSuccess(() -> Component.literal("§aPortal §f" + key + "§a dihapus."), false);
LobbyLog.state("PORTAL DELETED", "Name: " + key);
int cleared = 0;
ServerLevel sourceLevel = findLevel(source.getServer(), existing.sourceDimension);
if (sourceLevel != null) {
cleared = PORTAL_VISUALS.clear(sourceLevel, existing);
}
int finalCleared = cleared;
source.sendSuccess(() -> Component.literal(
"§aPortal §f" + key + "§a dihapus."
+ (finalCleared > 0 ? " §7Visual dibersihkan: §f" + finalCleared + " blok." : "")), false);
LobbyLog.state("PORTAL DELETED", "Name: " + key, "Visual blocks cleared: " + cleared);
return 1;
}
@@ -744,7 +788,8 @@ public final class LobbySystemsMod implements ModInitializer {
source.sendSuccess(() -> Component.literal("§6Lobby Portals §7(" + PORTALS.size() + ")"), false);
for (String name : PORTALS.names()) {
PortalDefinition p = PORTALS.get(name);
source.sendSuccess(() -> Component.literal("§e- §f" + name + " §7: " + p.sourceDimension + " §8→ §f" + p.targetDimension), false);
source.sendSuccess(() -> Component.literal("§e- §f" + name + " §7: " + p.sourceDimension + " §8→ §f" + p.targetDimension
+ (p.hasNetherPortalVisual() ? " §d[visual]" : " §8[legacy/no visual]")), false);
}
return PORTALS.size();
}
@@ -760,7 +805,8 @@ public final class LobbySystemsMod implements ModInitializer {
"§6Portal §f" + name
+ "\n§7Source: §f" + p.sourceDimension
+ "\n§7Region: §f(" + p.min.x + ", " + p.min.y + ", " + p.min.z + ") §7→ §f(" + p.max.x + ", " + p.max.y + ", " + p.max.z + ")"
+ "\n§7Target: §f" + p.targetDimension), false);
+ "\n§7Target: §f" + p.targetDimension
+ "\n§7Visual: §f" + (p.hasNetherPortalVisual() ? "nether_portal (axis=" + p.visualAxis + ")" : "none / legacy")), false);
return 1;
}
@@ -6,13 +6,28 @@ public final class PortalDefinition {
public PortalPoint max;
public String targetDimension;
// Added in v1.5.0. Legacy portals without these fields stay visual-free.
public String visualMode = "none";
public String visualAxis = null;
public PortalDefinition() {}
public PortalDefinition(String sourceDimension, PortalPoint min, PortalPoint max, String targetDimension) {
this(sourceDimension, min, max, targetDimension, "none", null);
}
public PortalDefinition(String sourceDimension, PortalPoint min, PortalPoint max, String targetDimension,
String visualMode, String visualAxis) {
this.sourceDimension = sourceDimension;
this.min = min;
this.max = max;
this.targetDimension = targetDimension;
this.visualMode = visualMode;
this.visualAxis = visualAxis;
}
public boolean hasNetherPortalVisual() {
return "nether_portal".equalsIgnoreCase(visualMode);
}
public boolean contains(double x, double y, double z) {
@@ -88,6 +88,24 @@ public final class PortalManager {
public void tick(MinecraftServer server, int intervalTicks, int cooldownTicks, PortalTrigger trigger) {
tick++;
cooldownUntil.entrySet().removeIf(e -> e.getValue() <= tick);
// v1.5.0 uses vanilla nether_portal blocks only as a visual layer.
// Keep Minecraft's own Nether portal processor on cooldown while a player
// is inside a managed visual region, so Lobby Systems remains the sole
// owner of the destination (important for Creative players as well).
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
String dimension = player.level().dimension().identifier().toString();
List<Map.Entry<String, PortalDefinition>> candidates = byDimension.get(dimension);
if (candidates == null) continue;
for (Map.Entry<String, PortalDefinition> entry : candidates) {
PortalDefinition portal = entry.getValue();
if (portal.hasNetherPortalVisual() && portal.contains(player.getX(), player.getY(), player.getZ())) {
player.setPortalCooldown(20);
break;
}
}
}
if (tick % Math.max(1, intervalTicks) != 0) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
@@ -0,0 +1,188 @@
package id.dartokloning.lobbysystems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.NetherPortalBlock;
import net.minecraft.world.level.block.state.BlockState;
/**
* Server-only visual layer for Lobby Systems portals.
*
* It deliberately uses Minecraft's existing nether_portal block state, so clients
* need no Lobby Systems mod/resource pack. Teleport routing remains owned by
* PortalManager; the vanilla block is only used as the animated purple surface.
*/
public final class PortalVisualManager {
private static final int REPAIR_INTERVAL_TICKS = 20;
private int repairTicker;
public String determineAxis(WorldEditSelectionBridge.Selection selection, ServerPlayer player) {
int sizeX = Math.abs(selection.maxX() - selection.minX()) + 1;
int sizeY = Math.abs(selection.maxY() - selection.minY()) + 1;
int sizeZ = Math.abs(selection.maxZ() - selection.minZ()) + 1;
if (sizeY < 1) {
throw new IllegalArgumentException("Selection portal tidak valid.");
}
// A vertical portal surface must be one block thick on exactly one
// horizontal axis. This supports both X-facing and Z-facing portals.
if (sizeX == 1 && sizeZ > 1) return "z";
if (sizeZ == 1 && sizeX > 1) return "x";
// A one-block-wide column has no geometric width axis. Pick the axis
// from the player's facing so the vanilla portal texture still renders.
if (sizeX == 1 && sizeZ == 1) {
return player.getDirection().getAxis() == Direction.Axis.Z ? "x" : "z";
}
throw new IllegalArgumentException(
"Selection visual portal harus berupa bidang vertikal setebal 1 blok. "
+ "Select hanya area DALAM portal, contoh 1x5x3 atau 3x5x1; bukan frame/volume penuh.");
}
public int applyInitial(ServerLevel level, PortalDefinition portal) {
if (!portal.hasNetherPortalVisual()) return 0;
ensureReplaceable(level, portal);
return apply(level, portal);
}
public int apply(ServerLevel level, PortalDefinition portal) {
if (!portal.hasNetherPortalVisual()) return 0;
Direction.Axis axis = parseAxis(portal.visualAxis);
BlockState visual = Blocks.NETHER_PORTAL.defaultBlockState()
.setValue(NetherPortalBlock.AXIS, axis);
int changed = 0;
int minX = Math.min(portal.min.x, portal.max.x);
int minY = Math.min(portal.min.y, portal.max.y);
int minZ = Math.min(portal.min.z, portal.max.z);
int maxX = Math.max(portal.min.x, portal.max.x);
int maxY = Math.max(portal.min.y, portal.max.y);
int maxZ = Math.max(portal.min.z, portal.max.z);
// UPDATE_KNOWN_SHAPE is intentional: visual portals are allowed to use
// arbitrary decorative frames and must not require vanilla obsidian shape
// validation when the block state is placed.
int flags = Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE;
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
BlockPos pos = new BlockPos(x, y, z);
BlockState current = level.getBlockState(pos);
if (!current.equals(visual)) {
level.setBlock(pos, visual, flags);
changed++;
}
}
}
}
return changed;
}
public int clear(ServerLevel level, PortalDefinition portal) {
if (!portal.hasNetherPortalVisual()) return 0;
int changed = 0;
int flags = Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE;
int minX = Math.min(portal.min.x, portal.max.x);
int minY = Math.min(portal.min.y, portal.max.y);
int minZ = Math.min(portal.min.z, portal.max.z);
int maxX = Math.max(portal.min.x, portal.max.x);
int maxY = Math.max(portal.min.y, portal.max.y);
int maxZ = Math.max(portal.min.z, portal.max.z);
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
BlockPos pos = new BlockPos(x, y, z);
if (level.getBlockState(pos).is(Blocks.NETHER_PORTAL)) {
level.setBlock(pos, Blocks.AIR.defaultBlockState(), flags);
changed++;
}
}
}
}
return changed;
}
public void tick(MinecraftServer server, PortalManager portals) {
if (++repairTicker < REPAIR_INTERVAL_TICKS) return;
repairTicker = 0;
repairAll(server, portals, false);
}
public int repairAll(MinecraftServer server, PortalManager portals, boolean log) {
int changed = 0;
for (String name : portals.names()) {
PortalDefinition portal = portals.get(name);
if (portal == null || !portal.hasNetherPortalVisual()) continue;
ServerLevel level = findLevel(server, portal.sourceDimension);
if (level == null) continue;
int repaired = apply(level, portal);
changed += repaired;
if (log && repaired > 0) {
LobbySystemsMod.LOGGER.info("Re-applied {} portal visual block(s) for '{}'.", repaired, name);
}
}
return changed;
}
public int applyToLevel(ServerLevel level, PortalManager portals) {
int changed = 0;
String dimension = level.dimension().identifier().toString();
for (String name : portals.names()) {
PortalDefinition portal = portals.get(name);
if (portal != null && portal.hasNetherPortalVisual() && dimension.equals(portal.sourceDimension)) {
changed += apply(level, portal);
}
}
return changed;
}
private static void ensureReplaceable(ServerLevel level, PortalDefinition portal) {
int blocked = 0;
int minX = Math.min(portal.min.x, portal.max.x);
int minY = Math.min(portal.min.y, portal.max.y);
int minZ = Math.min(portal.min.z, portal.max.z);
int maxX = Math.max(portal.min.x, portal.max.x);
int maxY = Math.max(portal.min.y, portal.max.y);
int maxZ = Math.max(portal.min.z, portal.max.z);
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
BlockState state = level.getBlockState(new BlockPos(x, y, z));
if (!(state.isAir() || state.is(Blocks.FIRE) || state.is(Blocks.SOUL_FIRE)
|| state.is(Blocks.NETHER_PORTAL))) {
blocked++;
}
}
}
}
if (blocked > 0) {
throw new IllegalArgumentException(
"Selection menyentuh " + blocked + " blok non-kosong. "
+ "Select hanya area DALAM portal; frame/dekorasi jangan ikut terseleksi.");
}
}
private static Direction.Axis parseAxis(String axis) {
return "z".equalsIgnoreCase(axis) ? Direction.Axis.Z : Direction.Axis.X;
}
private static ServerLevel findLevel(MinecraftServer server, String dimensionId) {
for (ServerLevel level : server.getAllLevels()) {
if (level.dimension().identifier().toString().equals(dimensionId)) return level;
}
return null;
}
}