From 65cb6a85aabbd6c892125f6bf199bce6456621b3 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 18:05:16 +0700 Subject: [PATCH 01/20] fix(godmode): handle case where no provider is available Set provider to null when neither JS nor internal providers are available to prevent potential null pointer exceptions. --- src/mindustrytool/features/godmode/GodModeFeature.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mindustrytool/features/godmode/GodModeFeature.java b/src/mindustrytool/features/godmode/GodModeFeature.java index 29aeef17..38f59e7f 100644 --- a/src/mindustrytool/features/godmode/GodModeFeature.java +++ b/src/mindustrytool/features/godmode/GodModeFeature.java @@ -50,6 +50,8 @@ private void switchProvider() { provider = js; } else if (internal.isAvailable()) { provider = internal; + } else { + provider = null; } rebuild(); From ead4fc5fb72ba14ec22c47d2b8d3619aef6c3115 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 18:07:53 +0700 Subject: [PATCH 02/20] refactor(chat-translation): simplify translated message formatting Remove unused import and simplify the formatted string when showing original message. Instead of displaying locale name on a separate line, show translated text inline with original in parentheses. --- .../features/chat/translation/ChatTranslationFeature.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java b/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java index e87d6a20..7994e0db 100644 --- a/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java +++ b/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java @@ -19,7 +19,6 @@ import mindustry.gen.SendMessageCallPacket2; import mindustry.ui.Styles; import mindustry.ui.dialogs.BaseDialog; -import mindustry.ui.dialogs.LanguageDialog; import mindustrytool.Main; import mindustrytool.features.Feature; import mindustrytool.features.FeatureManager; @@ -94,8 +93,7 @@ public void handleMessage(String message, Cons cons) { currentProvider.translate(Strings.stripColors(message)) .thenApply(translated -> { if (ChatTranslationConfig.isShowOriginal()) { - String locale = LanguageDialog.getDisplayName(Core.bundle.getLocale()); - String formated = Strings.format("[white]@[]\n\n[gold]@[]: @\n\n", message, locale, translated); + String formated = Strings.format("[white]@ [gray](@)", message, translated); return formated; } From 95bbfbae0b5b18b44e3f2116d189d8bb921a4763 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 18:34:04 +0700 Subject: [PATCH 03/20] refactor(autoplay): update moveTo call with additional parameters - Remove unused NoopTranslationProvider class - Simplify provider selection logic by eliminating no-op provider - Update test button disabling condition to only check testing state --- .../features/autoplay/tasks/FleeTask.java | 5 ++-- .../translation/ChatTranslationFeature.java | 12 ++------ .../translation/NoopTranslationProvider.java | 28 ------------------- 3 files changed, 5 insertions(+), 40 deletions(-) delete mode 100644 src/mindustrytool/features/chat/translation/NoopTranslationProvider.java diff --git a/src/mindustrytool/features/autoplay/tasks/FleeTask.java b/src/mindustrytool/features/autoplay/tasks/FleeTask.java index bd12e723..78f88355 100644 --- a/src/mindustrytool/features/autoplay/tasks/FleeTask.java +++ b/src/mindustrytool/features/autoplay/tasks/FleeTask.java @@ -35,7 +35,8 @@ public void setEnabled(boolean enabled) { @Override public boolean update(Unit unit) { Unit enemy = Units.closestEnemy(unit.team, unit.x, unit.y, 300f, u -> !u.dead()); - if (enemy != null && enemy.inRange(enemy)) { + + if (enemy != null && enemy.inRange(unit)) { ai.fleeFrom = enemy; status = Core.bundle.get("autoplay.status.fleeing"); return true; @@ -62,7 +63,7 @@ public static class FleeAI extends BaseAutoplayAI { @Override public void updateMovement() { if (fleeFrom != null) { - moveTo(fleeFrom, fleeFrom.range() * 1.5f); + moveTo(fleeFrom, fleeFrom.range() * 1.5f, 0f, true, null); } } } diff --git a/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java b/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java index 7994e0db..cd3a855c 100644 --- a/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java +++ b/src/mindustrytool/features/chat/translation/ChatTranslationFeature.java @@ -21,7 +21,6 @@ import mindustry.ui.dialogs.BaseDialog; import mindustrytool.Main; import mindustrytool.features.Feature; -import mindustrytool.features.FeatureManager; import mindustrytool.features.FeatureMetadata; import arc.struct.Seq; @@ -30,7 +29,6 @@ public class ChatTranslationFeature implements Feature { private final Seq providers = new Seq<>(); private final TranslationProvider defaultTranslationProvider = new MindustryToolTranslationProvider(); - private final NoopTranslationProvider noopTranslationProvider = new NoopTranslationProvider(); private String lastError = null; private TranslationProvider currentProvider = defaultTranslationProvider; @@ -73,7 +71,6 @@ public void init() { Main.registerPacketPlacement(SendMessageCallPacket.class, SendTranslatedMessageCallPacket::new); Main.registerPacketPlacement(SendMessageCallPacket2.class, SendTranslatedMessageCallPacket2::new); - providers.add(noopTranslationProvider); providers.add(defaultTranslationProvider); providers.add(new GeminiTranslationProvider()); providers.add(new DeepLTranslationProvider()); @@ -161,9 +158,7 @@ public Optional setting() { card.clicked(() -> { if (currentProvider != prov) { currentProvider = prov; - var isNoop = prov.getId().equals(noopTranslationProvider.getId()); ChatTranslationConfig.setProviderId(prov.getId()); - FeatureManager.getInstance().setEnabled(this, !isNoop); } }); @@ -188,9 +183,6 @@ public Optional setting() { TextButton testButton = new TextButton(Core.bundle.get("chat-translation.settings.test-button"), Styles.defaultt); testButton.clicked(() -> { - if (currentProvider == noopTranslationProvider) - return; - testButton.setDisabled(true); testButton.setText(Core.bundle.get("chat-translation.settings.testing")); resultLabel.setText(Core.bundle.get("chat-translation.settings.testing-connection")); @@ -213,8 +205,8 @@ public Optional setting() { root.add(testInput).growX().pad(10).row(); root.add(testButton).size(250, 50).pad(10) - .disabled(b -> currentProvider == noopTranslationProvider - || testButton.getText().toString().equals(Core.bundle.get("chat-translation.settings.testing"))) + .disabled(b -> testButton.getText().toString() + .equals(Core.bundle.get("chat-translation.settings.testing"))) .row(); root.add(resultLabel).growX().pad(10).row(); diff --git a/src/mindustrytool/features/chat/translation/NoopTranslationProvider.java b/src/mindustrytool/features/chat/translation/NoopTranslationProvider.java deleted file mode 100644 index 1040cb24..00000000 --- a/src/mindustrytool/features/chat/translation/NoopTranslationProvider.java +++ /dev/null @@ -1,28 +0,0 @@ -package mindustrytool.features.chat.translation; - -import arc.Core; -import arc.scene.ui.layout.Table; - -import java.util.concurrent.CompletableFuture; - -public class NoopTranslationProvider implements TranslationProvider { - @Override - public CompletableFuture translate(String message) { - return CompletableFuture.completedFuture(message); - } - - @Override - public Table settings() { - return new Table(); - } - - @Override - public String getName() { - return Core.bundle.get("chat-translation.provider.none"); - } - - @Override - public String getId() { - return "noop"; - } -} From e23beae9c43dd6d1c0569db614b8b020356cf4b2 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 18:39:22 +0700 Subject: [PATCH 04/20] fix(autoplay): increase flee task minimum distance from 0 to 40 This prevents units from moving too close to the enemy when fleeing, which could cause them to get stuck or take unnecessary damage. The previous value of 0 allowed units to approach the target's flee range boundary too closely. --- src/mindustrytool/features/autoplay/tasks/FleeTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mindustrytool/features/autoplay/tasks/FleeTask.java b/src/mindustrytool/features/autoplay/tasks/FleeTask.java index 78f88355..a8fa6d51 100644 --- a/src/mindustrytool/features/autoplay/tasks/FleeTask.java +++ b/src/mindustrytool/features/autoplay/tasks/FleeTask.java @@ -63,7 +63,7 @@ public static class FleeAI extends BaseAutoplayAI { @Override public void updateMovement() { if (fleeFrom != null) { - moveTo(fleeFrom, fleeFrom.range() * 1.5f, 0f, true, null); + moveTo(fleeFrom, fleeFrom.range() * 1.5f, 40f, true, null); } } } From fd1992acc445fe5d2bae721fa47778f80ab1341c Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 18:43:26 +0700 Subject: [PATCH 05/20] fix(smartdrill): prevent action on tile with null build Adds a null check for `e.tile.build` to avoid a potential NullPointerException when handling double-tap detection on a tile that has no building. --- src/mindustrytool/features/smartdrill/SmartDrillFeature.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java index 4b471a87..d8101455 100644 --- a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java +++ b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java @@ -66,6 +66,10 @@ public void init() { return; } + if (e.tile.build != null){ + return; + } + if (e.tile == lastTapTile && Time.timeSinceMillis(lastTapTime) < 500) { // Double tap detected handleDoubleTap(e.tile); From c4628a8a23c3d4de2be1323a0c615fad57ca3fa3 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 19:01:52 +0700 Subject: [PATCH 06/20] refactor: replace double-tap detection with unified hold listener - Extract hold detection logic into TapListener for reuse across features - SmartDrillFeature now uses hold gesture instead of double-tap for drill menu - SmartUpgradeFeature uses hold gesture for upgrade menu instead of complex click tracking - Remove duplicate tile tracking logic from both features - Centralize hold duration and tile validation in TapListener --- .../features/godmode/TapListener.java | 84 +++++++++++++++++++ .../smartdrill/SmartDrillFeature.java | 30 +++---- .../smartupgrade/SmartUpgradeFeature.java | 31 +++---- 3 files changed, 105 insertions(+), 40 deletions(-) diff --git a/src/mindustrytool/features/godmode/TapListener.java b/src/mindustrytool/features/godmode/TapListener.java index ecd323e7..e520acdf 100644 --- a/src/mindustrytool/features/godmode/TapListener.java +++ b/src/mindustrytool/features/godmode/TapListener.java @@ -1,7 +1,15 @@ package mindustrytool.features.godmode; +import arc.Core; import arc.Events; +import arc.math.geom.Vec2; +import arc.struct.ObjectSet; +import arc.struct.Seq; +import arc.util.Time; +import mindustry.Vars; import mindustry.game.EventType.TapEvent; +import mindustry.game.EventType.Trigger; +import mindustry.world.Tile; import java.util.function.BiConsumer; @@ -14,6 +22,36 @@ public static TapListener getInstance() { private BiConsumer currentListener; + private Tile touchTile; + private long touchTime; + private boolean wasTouched; + private final ObjectSet> triggeredListeners = new ObjectSet<>(); + private final Seq> holdListeners = new Seq<>(); + + public static class HoldRegistration implements Comparable> { + public long duration; + public int order; + public T data; + public BiConsumer callback; + + public HoldRegistration(long duration, int order, T data, BiConsumer callback) { + this.duration = duration; + this.order = order; + this.data = data; + this.callback = callback; + } + + @Override + public int compareTo(HoldRegistration o) { + return Integer.compare(this.order, o.order); + } + } + + public void registerHoldListener(long duration, int order, T data, BiConsumer callback) { + holdListeners.add(new HoldRegistration<>(duration, order, data, callback)); + holdListeners.sort(); + } + public void init() { Events.on(TapEvent.class, e -> { if (currentListener != null) { @@ -27,6 +65,52 @@ public void init() { listener.accept(worldX, worldY); } }); + + Events.run(Trigger.update, () -> { + if (Vars.state == null || Vars.state.isMenu() || Core.scene.hasMouse()) { + resetHold(); + return; + } + + if (Core.input.isTouched()) { + Vec2 pos = Core.input.mouseWorld(); + Tile currentTile = Vars.world.tileWorld(pos.x, pos.y); + + if (!wasTouched) { + wasTouched = true; + touchTime = Time.millis(); + touchTile = currentTile; + triggeredListeners.clear(); + } else if (currentTile != touchTile) { + // Reset if dragged to a different tile + touchTile = currentTile; + touchTime = Time.millis(); + triggeredListeners.clear(); + } + + if (touchTile != null) { + long holdDuration = Time.timeSinceMillis(touchTime); + for (HoldRegistration listener : holdListeners) { + if (holdDuration >= listener.duration && !triggeredListeners.contains(listener)) { + triggeredListeners.add(listener); + invokeCallback(listener, touchTile); + } + } + } + } else { + resetHold(); + } + }); + } + + private void resetHold() { + wasTouched = false; + touchTile = null; + triggeredListeners.clear(); + } + + private void invokeCallback(HoldRegistration listener, Tile tile) { + listener.callback.accept(tile, listener.data); } public void select(BiConsumer onSelect) { diff --git a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java index d8101455..7e87ee0a 100644 --- a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java +++ b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java @@ -9,7 +9,6 @@ import arc.struct.Seq; import arc.util.Align; import arc.util.Scaling; -import arc.util.Time; import arc.util.Timer; import mindustry.Vars; import mindustry.content.Blocks; @@ -26,13 +25,11 @@ import mindustry.world.blocks.production.Drill; import mindustrytool.features.Feature; import mindustrytool.features.FeatureMetadata; +import mindustrytool.features.godmode.TapListener; import arc.scene.ui.Dialog; import java.util.Optional; public class SmartDrillFeature implements Feature { - private Tile lastTapTile; - private long lastTapTime; - private Table currentMenu; private Tile selectedTile; @@ -57,28 +54,23 @@ public static int getMaxTiles(Block drill) { @Override public void init() { - Events.on(TapEvent.class, e -> { - if (!isEnabled()) { + TapListener.getInstance().registerHoldListener(300, 10, null, (tile, data) -> { + if (!isEnabled() || tile == null || tile.build != null || tile.drop() == null) { return; } - - if (e.tile == null) { - return; + if (currentMenu == null) { + handleHold(tile); } + }); - if (e.tile.build != null){ + Events.on(TapEvent.class, e -> { + if (!isEnabled()) { return; } - if (e.tile == lastTapTile && Time.timeSinceMillis(lastTapTime) < 500) { - // Double tap detected - handleDoubleTap(e.tile); - } else if (currentMenu != null && e.tile != selectedTile) { + if (currentMenu != null && e.tile != selectedTile) { closeMenu(); } - - lastTapTile = e.tile; - lastTapTime = Time.millis(); }); Events.on(StateChangeEvent.class, e -> { @@ -93,7 +85,7 @@ public void onDisable() { closeMenu(); } - private void handleDoubleTap(Tile tile) { + private void handleHold(Tile tile) { Item drop = tile.drop(); if (drop != null) { showDirectionMenu(tile); @@ -275,7 +267,7 @@ private void place2x2Drill(Tile tile, int direction, Block drill, Item drop) { bridgeTiles.sort(t -> t.dst2(outMostTile)); var output = bridgeTiles.first().nearby(dir.mul(3)); - if (output == null){ + if (output == null) { output = bridgeTiles.first(); } var outputBridge = output; diff --git a/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java b/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java index abf5cf9e..5ff36025 100644 --- a/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java +++ b/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java @@ -43,11 +43,11 @@ import mindustry.world.blocks.production.BeamDrill; import mindustrytool.features.Feature; import mindustrytool.features.FeatureMetadata; +import mindustrytool.features.godmode.TapListener; public class SmartUpgradeFeature implements Feature { private Table currentMenu; private Tile selectedTile; - private Tile lastClick; @Override public FeatureMetadata getMetadata() { @@ -61,40 +61,29 @@ public FeatureMetadata getMetadata() { @Override public void init() { - Events.on(TapEvent.class, e -> { - if (!isEnabled()) { + TapListener.getInstance().registerHoldListener(300, 10, null, (tile, data) -> { + if (!isEnabled() || tile == null) { return; } - - if (e.tile == null) { - return; + if (currentMenu == null) { + if (getGroup(tile.block()) != BlockGroup.NONE) { + showMenu(tile); + } } + }); - if (e.tile != lastClick && lastClick != null) { - lastClick = e.tile; - closeMenu(); + Events.on(TapEvent.class, e -> { + if (!isEnabled()) { return; } - lastClick = e.tile; - if (currentMenu != null) { - if (e.tile == selectedTile) { closeMenu(); return; } closeMenu(); - - if (getGroup(e.tile.block()) != BlockGroup.NONE) { - showMenu(e.tile); - } - return; - } - - if (getGroup(e.tile.block()) != BlockGroup.NONE) { - showMenu(e.tile); } }); From 41991b577d0b2cdae985637e20ac769600e2eb40 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 19:42:30 +0700 Subject: [PATCH 07/20] fix: catch Throwable instead of Exception for schematic copy error Using Throwable ensures that both Exception and Error types are caught, preventing unhandled errors from crashing the UI when copying schematics. --- .../features/browser/schematic/SchematicDialog.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mindustrytool/features/browser/schematic/SchematicDialog.java b/src/mindustrytool/features/browser/schematic/SchematicDialog.java index ae05157c..dea2fd68 100644 --- a/src/mindustrytool/features/browser/schematic/SchematicDialog.java +++ b/src/mindustrytool/features/browser/schematic/SchematicDialog.java @@ -402,8 +402,8 @@ public static void handleCopySchematic(String id) { Schematic s = Utils.readSchematic(data); Core.app.setClipboardText(Vars.schematics.writeBase64(s)); ui.showInfoFade("@copied"); - } catch (Exception e) { - ui.showInfoFade(e.getMessage()); + } catch (Throwable e) { + ui.showException(e); } }); }); From d57b303df6f4203094503ffc0f0f34e86bafdf09 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:02:40 +0700 Subject: [PATCH 08/20] feat(godmode): schedule periodic provider switching Add Timer.schedule to call switchProvider every 60 seconds, ensuring consistent god mode state during long play sessions beyond PlayEvent and StateChangeEvent triggers. --- src/mindustrytool/features/godmode/GodModeFeature.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mindustrytool/features/godmode/GodModeFeature.java b/src/mindustrytool/features/godmode/GodModeFeature.java index 38f59e7f..ce6bfa53 100644 --- a/src/mindustrytool/features/godmode/GodModeFeature.java +++ b/src/mindustrytool/features/godmode/GodModeFeature.java @@ -7,6 +7,7 @@ import arc.scene.ui.layout.Stack; import arc.scene.ui.layout.Table; import arc.util.Log; +import arc.util.Timer; import mindustry.Vars; import mindustry.game.EventType.PlayEvent; import mindustry.game.EventType.StateChangeEvent; @@ -43,6 +44,7 @@ public void init() { Events.run(PlayEvent.class, this::switchProvider); Events.run(StateChangeEvent.class, this::switchProvider); + Timer.schedule(this::switchProvider, 60, 60); } private void switchProvider() { From ebd4119e586c33dde50e50fb89e70d426bda5168 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:03:28 +0700 Subject: [PATCH 09/20] fix(godmode): prevent unnecessary provider switching Add early return to avoid switching providers when current provider is still available. This prevents potential issues when both providers are available simultaneously. --- src/mindustrytool/features/godmode/GodModeFeature.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mindustrytool/features/godmode/GodModeFeature.java b/src/mindustrytool/features/godmode/GodModeFeature.java index ce6bfa53..e234c3b8 100644 --- a/src/mindustrytool/features/godmode/GodModeFeature.java +++ b/src/mindustrytool/features/godmode/GodModeFeature.java @@ -48,6 +48,10 @@ public void init() { } private void switchProvider() { + if (provider != null && provider.isAvailable()) { + return; + } + if (js.isAvailable()) { provider = js; } else if (internal.isAvailable()) { From 9a7a95a055740238c9eba54fe9417552eca2fe13 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:04:13 +0700 Subject: [PATCH 10/20] refactor: move TapListener to services package for shared usage The TapListener class was previously located in the godmode feature package but is used by multiple features (godmode, smartdrill, smartupgrade). Moving it to a common services package improves code organization and reusability across the codebase. --- src/mindustrytool/Main.java | 2 +- src/mindustrytool/features/godmode/GodModeDialogs.java | 1 + src/mindustrytool/features/smartdrill/SmartDrillFeature.java | 2 +- .../features/smartupgrade/SmartUpgradeFeature.java | 2 +- .../{features/godmode => services}/TapListener.java | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) rename src/mindustrytool/{features/godmode => services}/TapListener.java (98%) diff --git a/src/mindustrytool/Main.java b/src/mindustrytool/Main.java index b3dc2fdc..35ecc4ba 100644 --- a/src/mindustrytool/Main.java +++ b/src/mindustrytool/Main.java @@ -29,11 +29,11 @@ import mindustrytool.features.smartupgrade.SmartUpgradeFeature; import mindustrytool.features.smartdrill.SmartDrillFeature; import mindustrytool.services.ServerService; +import mindustrytool.services.TapListener; import mindustrytool.services.CrashReportService; import mindustrytool.services.UpdateService; import mindustrytool.features.chat.global.ChatFeature; import mindustrytool.features.godmode.GodModeFeature; -import mindustrytool.features.godmode.TapListener; import mindustrytool.features.autoplay.AutoplayFeature; import mindustrytool.features.background.BackgroundFeature; import mindustrytool.features.music.MusicFeature; diff --git a/src/mindustrytool/features/godmode/GodModeDialogs.java b/src/mindustrytool/features/godmode/GodModeDialogs.java index 715c0abe..58bb4085 100644 --- a/src/mindustrytool/features/godmode/GodModeDialogs.java +++ b/src/mindustrytool/features/godmode/GodModeDialogs.java @@ -16,6 +16,7 @@ import mindustry.ui.dialogs.BaseDialog; import mindustry.world.Block; import mindustry.world.blocks.storage.CoreBlock; +import mindustrytool.services.TapListener; import java.util.function.BiConsumer; import java.util.function.Consumer; diff --git a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java index 7e87ee0a..ca15618a 100644 --- a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java +++ b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java @@ -25,7 +25,7 @@ import mindustry.world.blocks.production.Drill; import mindustrytool.features.Feature; import mindustrytool.features.FeatureMetadata; -import mindustrytool.features.godmode.TapListener; +import mindustrytool.services.TapListener; import arc.scene.ui.Dialog; import java.util.Optional; diff --git a/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java b/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java index 5ff36025..b7fc1c29 100644 --- a/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java +++ b/src/mindustrytool/features/smartupgrade/SmartUpgradeFeature.java @@ -43,7 +43,7 @@ import mindustry.world.blocks.production.BeamDrill; import mindustrytool.features.Feature; import mindustrytool.features.FeatureMetadata; -import mindustrytool.features.godmode.TapListener; +import mindustrytool.services.TapListener; public class SmartUpgradeFeature implements Feature { private Table currentMenu; diff --git a/src/mindustrytool/features/godmode/TapListener.java b/src/mindustrytool/services/TapListener.java similarity index 98% rename from src/mindustrytool/features/godmode/TapListener.java rename to src/mindustrytool/services/TapListener.java index e520acdf..61cc3e5c 100644 --- a/src/mindustrytool/features/godmode/TapListener.java +++ b/src/mindustrytool/services/TapListener.java @@ -1,4 +1,4 @@ -package mindustrytool.features.godmode; +package mindustrytool.services; import arc.Core; import arc.Events; From 29057ca1d1dc8bb796438f526f87107e08ecbc43 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:06:08 +0700 Subject: [PATCH 11/20] chore: update mod version to v4.51.1-v8 --- mod.hjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod.hjson b/mod.hjson index eb9d79ea..b3695cad 100644 --- a/mod.hjson +++ b/mod.hjson @@ -53,7 +53,7 @@ description: [#FAA31B[]]  Reddit: [white[]]r/MindustryTool[[]] [#EF4444[]]  YouTube: [white[]]Mindustry Tool[[]] ''' -version: v4.51.0-v8 +version: v4.51.1-v8 minGameVersion: 154 From 8e2a36a21eea73fe35d341bac3cc5d602b75af2c Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:43:25 +0700 Subject: [PATCH 12/20] feat(chat): condense consecutive messages from same user Only display user avatar and name for the first message in a consecutive series from the same user. Adjust padding between messages to visually group messages from the same user together. --- .../features/chat/global/ui/MessageList.java | 105 ++++++++++-------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/src/mindustrytool/features/chat/global/ui/MessageList.java b/src/mindustrytool/features/chat/global/ui/MessageList.java index 3572a109..ed4f2bfe 100644 --- a/src/mindustrytool/features/chat/global/ui/MessageList.java +++ b/src/mindustrytool/features/chat/global/ui/MessageList.java @@ -44,6 +44,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -149,61 +150,71 @@ public void rebuild() { float scale = ChatConfig.scale(); - for (ChatMessage msg : channelMsgs) { + for (int i = 0; i < channelMsgs.size; i++) { + ChatMessage msg = channelMsgs.get(i); + boolean isSameUser = i > 0 && Objects.equals(channelMsgs.get(i - 1).createdBy, msg.createdBy); + boolean isNextSameUser = i < channelMsgs.size - 1 + && Objects.equals(channelMsgs.get(i + 1).createdBy, msg.createdBy); + Table entry = new Table(); entry.setBackground(null); entry.table(avatar -> { avatar.top(); - UserService.findUserById(msg.createdBy).thenAccept(data -> { - Core.app.post(() -> { - avatar.clear(); - if (data.getImageUrl() != null && !data.getImageUrl().isEmpty()) { - avatar.add(new NetworkImage(data.getImageUrl())).size(40 * scale); - } else { - avatar.add(new Image(Icon.players)).size(40 * scale); - } + if (!isSameUser) { + UserService.findUserById(msg.createdBy).thenAccept(data -> { + Core.app.post(() -> { + avatar.clear(); + if (data.getImageUrl() != null && !data.getImageUrl().isEmpty()) { + avatar.add(new NetworkImage(data.getImageUrl())).size(40 * scale); + } else { + avatar.add(new Image(Icon.players)).size(40 * scale); + } + }); }); - }); - }).size(48 * scale).top().pad(8 * scale); + } + }).width(48 * scale).top().padLeft(8 * scale).padRight(8 * scale).padTop(isSameUser ? 0 : 8 * scale) + .padBottom(isNextSameUser ? 0 : 8 * scale); entry.table(card -> { card.top().left(); - Label label = new Label("..."); - label.setStyle(Styles.defaultLabel); - label.setFontScale(scale); - - UserService.findUserById(msg.createdBy).thenAccept(data -> { - Core.app.post(() -> { - String timeStr = msg.createdAt; - if (msg.createdAt != null) { - try { - Instant instant = Instant.parse(msg.createdAt); - timeStr = DateTimeFormatter.ofPattern("HH:mm") - .withZone(ZoneId.systemDefault()) - .format(instant); - } catch (Throwable err) { - Log.err(err); + if (!isSameUser) { + Label label = new Label("..."); + label.setStyle(Styles.defaultLabel); + label.setFontScale(scale); + + UserService.findUserById(msg.createdBy).thenAccept(data -> { + Core.app.post(() -> { + String timeStr = msg.createdAt; + if (msg.createdAt != null) { + try { + Instant instant = Instant.parse(msg.createdAt); + timeStr = DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneId.systemDefault()) + .format(instant); + } catch (Throwable err) { + Log.err(err); + } } - } - - Color color = data.getHighestRole() - .map(r -> { - try { - return Color.valueOf(r.getColor()); - } catch (Exception err) { - return Color.white; - } - }) - .orElse(Color.white); - - label.setText("[#" + color.toString() + "]" + data.getName() + "[white]" - + (timeStr.isEmpty() ? "" : " [gray]" + timeStr)); + + Color color = data.getHighestRole() + .map(r -> { + try { + return Color.valueOf(r.getColor()); + } catch (Exception err) { + return Color.white; + } + }) + .orElse(Color.white); + + label.setText("[#" + color.toString() + "]" + data.getName() + "[white]" + + (timeStr.isEmpty() ? "" : " [gray]" + timeStr)); + }); }); - }); - card.add(label).left().row(); + card.add(label).left().row(); + } if (msg.replyTo != null && !msg.replyTo.isEmpty()) { ChatMessage repliedMsg = channelMsgs.find(m -> m.id.equals(msg.replyTo)); @@ -217,11 +228,12 @@ public void rebuild() { replyContent.setColor(Color.gray); replyContent.setEllipsis(true); replyTable.add(replyContent).minWidth(0).maxWidth(200 * scale); - }).growX().padBottom(2 * scale).row(); + }).growX().padTop(isSameUser ? 2 * scale : 0).padBottom(0).row(); } } - card.table(c -> renderContent(c, msg.content, scale)).top().left().growX().padTop(6 * scale); + card.table(c -> renderContent(c, msg.content, scale)).top().left().growX() + .padTop(isSameUser && (msg.replyTo == null || msg.replyTo.isEmpty()) ? 2 * scale : 0); card.clicked(() -> { if (expandedMessageId != null && expandedMessageId.equals(msg.id)) { @@ -262,9 +274,10 @@ public void rebuild() { }).growX().padTop(4 * scale); } - }).growX().pad(8 * scale).top(); + }).growX().padLeft(8 * scale).padRight(8 * scale).padTop(isSameUser ? 0 : 8 * scale) + .padBottom(isNextSameUser ? 0 : 8 * scale).top(); - messageTable.add(entry).growX().padBottom(4 * scale).row(); + messageTable.add(entry).growX().padBottom(isNextSameUser ? 0 : 4 * scale).row(); } } From 247b9ec8c68f844ccb99d4cc32b2249226fe54ef Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 20:50:33 +0700 Subject: [PATCH 13/20] style(ui): add padding to chat settings and simplify drill menu styling - Add left/right padding to labels in chat settings for better visual spacing - Remove explicit table style and button styles from smart drill menu to use defaults --- .../features/chat/global/ChatFeature.java | 8 ++++---- .../features/smartdrill/SmartDrillFeature.java | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/mindustrytool/features/chat/global/ChatFeature.java b/src/mindustrytool/features/chat/global/ChatFeature.java index 127c5090..fbb514f7 100644 --- a/src/mindustrytool/features/chat/global/ChatFeature.java +++ b/src/mindustrytool/features/chat/global/ChatFeature.java @@ -145,7 +145,7 @@ private void rebuildSettings() { Table opacityContent = new Table(); opacityContent.touchable = Touchable.disabled; - opacityContent.add("@opacity").left().growX(); + opacityContent.add("@opacity").left().growX().padLeft(10).padRight(10); opacityContent.add(opacityValue).padLeft(10f).right(); opacitySlider.changed(() -> { @@ -164,7 +164,7 @@ private void rebuildSettings() { Table scaleContent = new Table(); scaleContent.touchable = Touchable.disabled; - scaleContent.add("@scale").left().growX(); + scaleContent.add("@scale").left().growX().padLeft(10).padRight(10); scaleContent.add(scaleValue).padLeft(10f).right(); scaleSlider.changed(() -> { @@ -183,7 +183,7 @@ private void rebuildSettings() { Table widthContent = new Table(); widthContent.touchable = Touchable.disabled; - widthContent.add("@width").left().growX(); + widthContent.add("@width").left().growX().padLeft(10).padRight(10); widthContent.add(widthValue).padLeft(10f).right(); widthSlider.changed(() -> { @@ -202,7 +202,7 @@ private void rebuildSettings() { Table heightContent = new Table(); heightContent.touchable = Touchable.disabled; - heightContent.add("@height").left().growX(); + heightContent.add("@height").left().growX().padLeft(10).padRight(10); heightContent.add(heightValue).padLeft(10f).right(); heightSlider.changed(() -> { diff --git a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java index ca15618a..0573c820 100644 --- a/src/mindustrytool/features/smartdrill/SmartDrillFeature.java +++ b/src/mindustrytool/features/smartdrill/SmartDrillFeature.java @@ -106,7 +106,7 @@ private void showDirectionMenu(Tile tile) { closeMenu(); selectedTile = tile; - currentMenu = new Table(Styles.black6); + currentMenu = new Table(); currentMenu.visible(() -> Vars.ui.hudfrag != null && Vars.ui.hudfrag.shown); currentMenu.touchable = arc.scene.event.Touchable.enabled; @@ -124,17 +124,17 @@ private void showDirectionMenu(Tile tile) { // Up directionTable.add().size(48f); - directionTable.button(Icon.up, Styles.clearNonei, () -> showDrillMenu(tile, 1)).size(48f).pad(4); + directionTable.button(Icon.up, () -> showDrillMenu(tile, 1)).size(48f).pad(4); directionTable.add().size(48f).row(); // Left, Cancel, Right - directionTable.button(Icon.left, Styles.clearNonei, () -> showDrillMenu(tile, 2)).size(48f).pad(4); - directionTable.button(Icon.cancel, Styles.clearNonei, this::closeMenu).size(48f).pad(4); - directionTable.button(Icon.right, Styles.clearNonei, () -> showDrillMenu(tile, 0)).size(48f).pad(4).row(); + directionTable.button(Icon.left, () -> showDrillMenu(tile, 2)).size(48f).pad(4); + directionTable.button(Icon.cancel, this::closeMenu).size(48f).pad(4); + directionTable.button(Icon.right, () -> showDrillMenu(tile, 0)).size(48f).pad(4).row(); // Down directionTable.add().size(48f); - directionTable.button(Icon.down, Styles.clearNonei, () -> showDrillMenu(tile, 3)).size(48f).pad(4); + directionTable.button(Icon.down, () -> showDrillMenu(tile, 3)).size(48f).pad(4); directionTable.add().size(48f); currentMenu.add(directionTable); From def6d38b2c442152576fcb6dbdac14366556ad0f Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 22:10:04 +0700 Subject: [PATCH 14/20] refactor(features): improve icon handling and fix spacing - Change icon parameter type to TextureRegionDrawable for consistency - Add scalable icon utility to prevent scaling issues in UI - Fix inconsistent spacing in icon method calls - Update progress display icon from Tex.bar to Icon.chartBar - Remove explicit size setting for icons in quick access buttons --- src/mindustrytool/Utils.java | 7 ++++++- src/mindustrytool/features/FeatureMetadata.java | 6 ++++-- .../features/display/progress/ProgressDisplay.java | 3 +-- .../display/quickaccess/QuickAccessHud.java | 13 +++++-------- .../display/wavepreview/WavePreviewFeature.java | 2 +- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/mindustrytool/Utils.java b/src/mindustrytool/Utils.java index 199be17e..3ee2fe3b 100644 --- a/src/mindustrytool/Utils.java +++ b/src/mindustrytool/Utils.java @@ -42,6 +42,9 @@ public class Utils { public static LoadedMod mod; public static ObjectMap schematicData = new ObjectMap<>(); + private static ConcurrentHashMap iconCache = new ConcurrentHashMap<>(); + private static ConcurrentHashMap scalableIconCache = new ConcurrentHashMap<>(); + private static final byte[] header = { 'm', 's', 'c', 'h' }; private static final ObjectMapper mapper = new ObjectMapper() @@ -208,7 +211,9 @@ public static String getString(String text) { return text; } - private static ConcurrentHashMap iconCache = new ConcurrentHashMap<>(); + public static TextureRegionDrawable scalable(TextureRegionDrawable original) { + return scalableIconCache.computeIfAbsent(original, _key -> new TextureRegionDrawable(original.getRegion())); + } public static TextureRegionDrawable icons(String name) { if (iconCache.containsKey(name)) { diff --git a/src/mindustrytool/features/FeatureMetadata.java b/src/mindustrytool/features/FeatureMetadata.java index 9294088c..655e5de7 100644 --- a/src/mindustrytool/features/FeatureMetadata.java +++ b/src/mindustrytool/features/FeatureMetadata.java @@ -1,6 +1,8 @@ package mindustrytool.features; import arc.scene.style.Drawable; +import arc.scene.style.TextureRegionDrawable; +import mindustrytool.Utils; public class FeatureMetadata { String name; @@ -66,8 +68,8 @@ public Builder description(String description) { return this; } - public Builder icon(Drawable icon) { - this.icon = icon; + public Builder icon(TextureRegionDrawable icon) { + this.icon = Utils.scalable(icon); return this; } diff --git a/src/mindustrytool/features/display/progress/ProgressDisplay.java b/src/mindustrytool/features/display/progress/ProgressDisplay.java index 2deeb7ea..148fa65d 100644 --- a/src/mindustrytool/features/display/progress/ProgressDisplay.java +++ b/src/mindustrytool/features/display/progress/ProgressDisplay.java @@ -23,7 +23,6 @@ import arc.scene.ui.Slider; import arc.scene.ui.layout.Table; import mindustry.gen.Icon; -import mindustry.gen.Tex; import mindustry.ui.Styles; import mindustry.ui.dialogs.BaseDialog; import mindustrytool.features.Feature; @@ -47,7 +46,7 @@ public FeatureMetadata getMetadata() { return FeatureMetadata.builder() .name("@feature.progress-display.name") .description("@feature.progress-display.description") - .icon(Tex.bar) + .icon(Icon.chartBar) .order(10) .enabledByDefault(true) .build(); diff --git a/src/mindustrytool/features/display/quickaccess/QuickAccessHud.java b/src/mindustrytool/features/display/quickaccess/QuickAccessHud.java index 549a66af..8bf1a310 100644 --- a/src/mindustrytool/features/display/quickaccess/QuickAccessHud.java +++ b/src/mindustrytool/features/display/quickaccess/QuickAccessHud.java @@ -28,6 +28,7 @@ import mindustry.ui.Styles; import mindustry.ui.dialogs.BaseDialog; import mindustrytool.Main; +import mindustrytool.Utils; import mindustrytool.features.Feature; import mindustrytool.features.FeatureManager; import mindustrytool.features.FeatureMetadata; @@ -161,7 +162,6 @@ private void populateContent(Table t) { btnRef[0] = t.button(b -> { b.image(meta.icon()) - .size(buttonSize * 0.7f) .scaling(Scaling.fit) .update(l -> l.setColor(f.isEnabled() ? Color.white : Pal.gray)); }, Styles.clearNonei, () -> { @@ -193,13 +193,10 @@ private void populateContent(Table t) { } Button[] btnRef = new Button[1]; - btnRef[0] = t.button(b -> { - b.image(Icon.settings) - .size(buttonSize * 0.7f) - .scaling(Scaling.fit); - }, Styles.clearNonei, () -> { - Main.featureSettingDialog.show(); - }) + btnRef[0] = t + .button(b -> b.image(Utils.scalable(Icon.settings)).scaling(Scaling.fit), Styles.clearNonei, () -> { + Main.featureSettingDialog.show(); + }) .size(buttonSize) .margin(margin) .get(); diff --git a/src/mindustrytool/features/display/wavepreview/WavePreviewFeature.java b/src/mindustrytool/features/display/wavepreview/WavePreviewFeature.java index 37b03ce6..a7a6b318 100644 --- a/src/mindustrytool/features/display/wavepreview/WavePreviewFeature.java +++ b/src/mindustrytool/features/display/wavepreview/WavePreviewFeature.java @@ -34,7 +34,7 @@ public FeatureMetadata getMetadata() { return FeatureMetadata.builder() .name("@feature.wave-preview.name") .description("@feature.wave-preview.description") - .icon(Icon.units) + .icon( Icon.units) .order(1) .quickAccess(true) .enabledByDefault(true) From 1a366fffd7fab87e834a517064c0f287b40fc944 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 23:01:10 +0700 Subject: [PATCH 15/20] fix(display): adjust toggle rendering icon and fix feature card layout - Use consistent Icon.eye for toggle rendering feature metadata - Move enabled/disabled icon before settings button in feature card header to improve visual hierarchy --- .../togglerendering/ToggleRenderingFeature.java | 2 +- src/mindustrytool/features/settings/FeatureCard.java | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/mindustrytool/features/display/togglerendering/ToggleRenderingFeature.java b/src/mindustrytool/features/display/togglerendering/ToggleRenderingFeature.java index 0729c549..a01026c2 100644 --- a/src/mindustrytool/features/display/togglerendering/ToggleRenderingFeature.java +++ b/src/mindustrytool/features/display/togglerendering/ToggleRenderingFeature.java @@ -33,7 +33,7 @@ public FeatureMetadata getMetadata() { return FeatureMetadata.builder() .name("@feature.toggle-rendering.name") .description("@feature.toggle-rendering.description") - .icon(Icon.eyeSmall) + .icon(Icon.eye) .order(5) .enabledByDefault(false) .quickAccess(true) diff --git a/src/mindustrytool/features/settings/FeatureCard.java b/src/mindustrytool/features/settings/FeatureCard.java index fb751be8..032da2f9 100644 --- a/src/mindustrytool/features/settings/FeatureCard.java +++ b/src/mindustrytool/features/settings/FeatureCard.java @@ -51,10 +51,15 @@ public void clicked(InputEvent event, float x, float y) { .ellipsis(true) .left(); + header.image(Utils.scalable(enabled ? Icon.eye : Icon.eyeOff)).height(24).width(32).padRight(8) + .color(enabled ? Color.white : Color.gray); + if (feature.setting().isPresent()) { - header.button(Icon.settings, Styles.clearNonei, + header.button(Utils.scalable(Icon.settings), Styles.clearNonei, () -> feature.setting().ifPresent(dialog -> Core.app.post(dialog::show))) - .size(32).padLeft(8).get().addListener(new ClickListener() { + .size(32) + .get() + .addListener(new ClickListener() { @Override public void clicked(InputEvent event, float x, float y) { event.stop(); @@ -62,8 +67,6 @@ public void clicked(InputEvent event, float x, float y) { }); } - header.image(enabled ? Icon.eyeSmall : Icon.eyeOffSmall).size(24).padLeft(4) - .color(enabled ? Color.white : Color.gray); }).growX().row(); c.add(Utils.getString(metadata.description())).color(Color.lightGray).fontScale(0.9f).wrap().growX() From 3c224ecaf3293ea3abd0f2a298ab26b2ff83b1c2 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Mon, 30 Mar 2026 23:44:39 +0700 Subject: [PATCH 16/20] a --- mod.hjson | 2 +- .../display/pathfinding/PathfindingCache.java | 3 + .../pathfinding/PathfindingCacheManager.java | 35 +++ .../pathfinding/PathfindingDisplay.java | 266 +++++++----------- .../pathfinding/PathfindingSettingsUI.java | 109 +++++++ 5 files changed, 242 insertions(+), 173 deletions(-) create mode 100644 src/mindustrytool/features/display/pathfinding/PathfindingCacheManager.java create mode 100644 src/mindustrytool/features/display/pathfinding/PathfindingSettingsUI.java diff --git a/mod.hjson b/mod.hjson index b3695cad..46e2de02 100644 --- a/mod.hjson +++ b/mod.hjson @@ -53,7 +53,7 @@ description: [#FAA31B[]]  Reddit: [white[]]r/MindustryTool[[]] [#EF4444[]]  YouTube: [white[]]Mindustry Tool[[]] ''' -version: v4.51.1-v8 +version: v4.51.2-v8 minGameVersion: 154 diff --git a/src/mindustrytool/features/display/pathfinding/PathfindingCache.java b/src/mindustrytool/features/display/pathfinding/PathfindingCache.java index 07eca8fc..74119d18 100644 --- a/src/mindustrytool/features/display/pathfinding/PathfindingCache.java +++ b/src/mindustrytool/features/display/pathfinding/PathfindingCache.java @@ -1,5 +1,8 @@ package mindustrytool.features.display.pathfinding; +import lombok.Data; + +@Data public class PathfindingCache { public float[] data; public int size; diff --git a/src/mindustrytool/features/display/pathfinding/PathfindingCacheManager.java b/src/mindustrytool/features/display/pathfinding/PathfindingCacheManager.java new file mode 100644 index 00000000..932636b6 --- /dev/null +++ b/src/mindustrytool/features/display/pathfinding/PathfindingCacheManager.java @@ -0,0 +1,35 @@ +package mindustrytool.features.display.pathfinding; + +import arc.struct.LongMap; +import arc.struct.LongSeq; + +public class PathfindingCacheManager { + private final LongMap cache = new LongMap<>(); + private final LongSeq keysToRemove = new LongSeq(); + + public PathfindingCache get(long key) { + return cache.get(key); + } + + public void put(long key, PathfindingCache value) { + cache.put(key, value); + } + + public void clear() { + cache.clear(); + } + + public void cleanup(float currentTime, float maxAge) { + keysToRemove.clear(); + + for (LongMap.Entry entry : cache.entries()) { + if ((currentTime - entry.value.lastUsedTime) > maxAge) { + keysToRemove.add(entry.key); + } + } + + for (int i = 0; i < keysToRemove.size; i++) { + cache.remove(keysToRemove.get(i)); + } + } +} \ No newline at end of file diff --git a/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java b/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java index 2c324414..80ad2a71 100644 --- a/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java +++ b/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java @@ -8,48 +8,63 @@ import arc.math.Mathf; import arc.math.geom.Point2; import arc.math.geom.Rect; -import arc.scene.event.Touchable; import arc.scene.ui.Dialog; -import arc.scene.ui.Label; -import arc.scene.ui.Slider; -import arc.scene.ui.layout.Table; import arc.struct.IntSet; -import arc.struct.LongMap; -import arc.struct.LongSeq; -import arc.struct.Seq; +import arc.struct.IntSet.IntSetIterator; +import arc.util.Interval; +import arc.util.Log; import arc.util.Time; import arc.util.Tmp; import mindustry.Vars; import mindustry.ai.Pathfinder; -import mindustry.content.Blocks; import mindustry.game.Team; -import mindustry.game.EventType.TileOverlayChangeEvent; import mindustry.game.EventType.Trigger; import mindustry.game.EventType.WorldLoadEvent; import mindustry.gen.Groups; -import mindustry.gen.Icon; import mindustry.gen.Unit; import mindustry.graphics.Layer; -import mindustry.ui.Styles; -import mindustry.ui.dialogs.BaseDialog; import mindustry.world.Tile; import mindustrytool.Utils; import mindustrytool.features.Feature; import mindustrytool.features.FeatureMetadata; +import java.util.Arrays; import java.util.Optional; import static mindustry.Vars.*; public class PathfindingDisplay implements Feature { - private final LongMap pathCache = new LongMap<>(); - private final LongMap spawnPathCache = new LongMap<>(); - private final LongSeq keysToRemove = new LongSeq(); + private static final int MAX_STEPS_VERY_HIGH = 50; + private static final int MAX_STEPS_HIGH = 100; + private static final int MAX_STEPS_MEDIUM = 150; + private static final int MAX_STEPS_LOW = 250; + + private static final int MAX_UPDATES_PER_FRAME = 3; + private static final float CACHE_UPDATE_INTERVAL_UNIT = 15f; + private static final float CACHE_CLEANUP_AGE_UNIT = 60f; + private static final float CACHE_CLEANUP_AGE_SPAWN = 60f; + private static final float CACHE_UPDATE_INTERVAL_SPAWN = 60f; + + private static final int CULLING_THRESHOLD = 300; + private static final float CULLING_GROW = 500f; + + private static final int MAX_SPAWN_PATH_STEPS = 1000; + + private static final int TIMER_CLEANUP = 0; + private static final float CLEANUP_SCHEDULE_FRAMES = 60f; + + private final PathfindingCacheManager pathCache = new PathfindingCacheManager(); + private final PathfindingCacheManager spawnPathCache = new PathfindingCacheManager(); + private final IntSet activeTeams = new IntSet(); + private final Interval timer = new Interval(1); - private Seq spawns = new Seq<>(false); private boolean isEnabled; - private BaseDialog settingsDialog; + private final PathfindingSettingsUI settingsUI = new PathfindingSettingsUI(); + + private int currentFrameUpdates; + private int currentMaxSteps; + private float currentOpacity; @Override public FeatureMetadata getMetadata() { @@ -69,26 +84,10 @@ public void init() { Events.run(Trigger.draw, this::draw); Events.on(WorldLoadEvent.class, e -> reset()); - Events.on(TileOverlayChangeEvent.class, e -> { - if (e.previous == Blocks.spawn) { - spawns.remove(e.tile); - } - - if (e.overlay == Blocks.spawn) { - spawns.add(e.tile); - } - }); } public void reset() { - spawns.clear(); spawnPathCache.clear(); - - for (Tile tile : world.tiles) { - if (tile.overlay() == Blocks.spawn) { - spawns.add(tile); - } - } } @Override @@ -105,97 +104,7 @@ public void onDisable() { @Override public Optional setting() { - if (settingsDialog == null) { - settingsDialog = new BaseDialog("@pathfinding.settings.title"); - settingsDialog.name = "pathfindingSettingDialog"; - settingsDialog.addCloseButton(); - settingsDialog.shown(this::rebuildSettings); - settingsDialog.buttons.button("@reset", Icon.refresh, () -> { - PathfindingConfig.setZoomThreshold(0.5f); - rebuildSettings(); - }).size(250, 64); - } - return Optional.of(settingsDialog); - } - - private void rebuildSettings() { - Table settingsContainer = settingsDialog.cont; - settingsContainer.clear(); - settingsContainer.defaults().pad(6).left(); - - float width = Math.min(Core.graphics.getWidth() / 1.2f, 460f); - float currentZoom = PathfindingConfig.getZoomThreshold(); - - Slider zoomSlider = new Slider(0f, 5f, 0.1f, false); - zoomSlider.setValue(currentZoom); - - Label zoomValueLabel = new Label( - currentZoom <= 0.01f ? "@off" : String.format("%.1fx", currentZoom), - Styles.outlineLabel); - zoomValueLabel.setColor(currentZoom <= 0.01f ? Color.gray : Color.lightGray); - - Table zoomContent = new Table(); - zoomContent.touchable = Touchable.disabled; - zoomContent.margin(3f, 33f, 3f, 33f); - zoomContent.add("@health-bar.min-zoom", Styles.outlineLabel).left().growX(); - zoomContent.add(zoomValueLabel).padLeft(10f).right(); - - zoomSlider.changed(() -> { - float newZoomValue = zoomSlider.getValue(); - PathfindingConfig.setZoomThreshold(newZoomValue); - zoomValueLabel.setText(newZoomValue <= 0.01f ? "@off" : String.format("%.1fx", newZoomValue)); - zoomValueLabel.setColor(newZoomValue <= 0.01f ? Color.gray : Color.lightGray); - }); - - settingsContainer.stack(zoomSlider, zoomContent).width(width).left().padTop(4f).row(); - - Slider opacitySlider = new Slider(0f, 1f, 0.05f, false); - opacitySlider.setValue(PathfindingConfig.getOpacity()); - - Label opacityValue = new Label( - String.format("%.0f%%", PathfindingConfig.getOpacity() * 100), - Styles.outlineLabel); - opacityValue.setColor(Color.lightGray); - - Table opacityContent = new Table(); - opacityContent.touchable = Touchable.disabled; - opacityContent.margin(3f, 33f, 3f, 33f); - opacityContent.add("@opacity", Styles.outlineLabel).left().growX(); - opacityContent.add(opacityValue).padLeft(10f).right(); - - opacitySlider.changed(() -> { - PathfindingConfig.setOpacity(opacitySlider.getValue()); - opacityValue.setText(String.format("%.0f%%", PathfindingConfig.getOpacity() * 100)); - }); - - settingsContainer.stack(opacitySlider, opacityContent).width(width).left().padTop(4f).row(); - - settingsContainer.check("@pathfinding.draw-unit-path", PathfindingConfig.isDrawUnitPath(), (checked) -> { - PathfindingConfig.setDrawUnitPath(checked); - }).left().row(); - - settingsContainer - .check("@pathfinding.draw-spawn-point-path", PathfindingConfig.isDrawSpawnPointPath(), (checked) -> { - PathfindingConfig.setDrawSpawnPointPath(checked); - rebuildSettings(); - }).left().row(); - - if (PathfindingConfig.isDrawSpawnPointPath()) { - Table costTable = new Table(); - costTable.left().defaults().left().padLeft(16); - - String[] costNames = { "@pathfinding.cost.ground", "@pathfinding.cost.legs", "@pathfinding.cost.water", - "@pathfinding.cost.neoplasm", "@pathfinding.cost.flat", "@pathfinding.cost.hover" }; - - for (int i = 0; i < costNames.length; i++) { - int index = i; - costTable.check(costNames[i], PathfindingConfig.isCostTypeEnabled(index), c -> { - PathfindingConfig.setCostTypeEnabled(index, c); - }).padBottom(4).row(); - } - - settingsContainer.add(costTable).left().row(); - } + return Optional.of(settingsUI.getDialog()); } private void draw() { @@ -206,10 +115,12 @@ private void draw() { float zoomThreshold = PathfindingConfig.getZoomThreshold(); float currentZoom = renderer.getScale(); - if (-currentZoom > -zoomThreshold) { + if (currentZoom < zoomThreshold) { return; } + currentOpacity = PathfindingConfig.getOpacity(); + if (PathfindingConfig.isDrawSpawnPointPath()) { drawSpawnPointPath(); } @@ -217,44 +128,38 @@ private void draw() { if (PathfindingConfig.isDrawUnitPath()) { drawUnitPath(); } + + if (timer.get(TIMER_CLEANUP, CLEANUP_SCHEDULE_FRAMES)) { + float time = Time.time; + pathCache.cleanup(time, CACHE_CLEANUP_AGE_UNIT); + spawnPathCache.cleanup(time, CACHE_CLEANUP_AGE_SPAWN); + } } private void drawUnitPath() { Draw.z(Layer.overlayUI); int totalUnits = Groups.unit.size(); - int maxSteps = (totalUnits > 2000) ? 50 : (totalUnits > 1000) ? 100 : (totalUnits > 500) ? 150 : 250; - boolean useCulling = totalUnits > 300; - Rect cullBounds = useCulling ? Core.camera.bounds(Tmp.r1).grow(500f) : null; + currentMaxSteps = (totalUnits > 2000) ? MAX_STEPS_VERY_HIGH + : (totalUnits > 1000) ? MAX_STEPS_HIGH : (totalUnits > 500) ? MAX_STEPS_MEDIUM : MAX_STEPS_LOW; + + boolean useCulling = totalUnits > CULLING_THRESHOLD; + Rect cullBounds = useCulling ? Core.camera.bounds(Tmp.r1).grow(CULLING_GROW) : null; float currentTime = Time.time; - int[] updatesThisFrame = { 0 }; + currentFrameUpdates = 0; if (useCulling) { Groups.unit.intersect(cullBounds.x, cullBounds.y, cullBounds.width, cullBounds.height, unit -> { - processUnitPath(unit, maxSteps, currentTime, updatesThisFrame); + processUnitPath(unit, currentTime); }); } else { for (Unit unit : Groups.unit) { - processUnitPath(unit, maxSteps, currentTime, updatesThisFrame); - } - } - - if (Core.graphics.getFrameId() % 60 == 0) { - keysToRemove.clear(); - - for (LongMap.Entry entry : pathCache.entries()) { - if ((currentTime - entry.value.lastUsedTime) > 60f) { - keysToRemove.add(entry.key); - } - } - - for (int i = 0; i < keysToRemove.size; i++) { - pathCache.remove(keysToRemove.get(i)); + processUnitPath(unit, currentTime); } } } - private void processUnitPath(Unit unit, int maxSteps, float currentTime, int[] updatesThisFrame) { + private void processUnitPath(Unit unit, float currentTime) { if (unit.team == player.team()) { return; } @@ -265,18 +170,18 @@ private void processUnitPath(Unit unit, int maxSteps, float currentTime, int[] u PathfindingCache cacheEntry = pathCache.get(cacheKey); - if (cacheEntry == null || (currentTime - cacheEntry.lastUpdateTime) > 15f) { + if (cacheEntry == null || (currentTime - cacheEntry.lastUpdateTime) > CACHE_UPDATE_INTERVAL_UNIT) { if (cacheEntry == null) { cacheEntry = new PathfindingCache(); - cacheEntry.data = new float[250 * 2]; + cacheEntry.data = new float[MAX_STEPS_LOW * 2]; pathCache.put(cacheKey, cacheEntry); } - if (updatesThisFrame[0] < 3) { + if (currentFrameUpdates < MAX_UPDATES_PER_FRAME) { cacheEntry.size = 0; - recalculatePath(unit, cacheEntry, maxSteps); + recalculatePath(unit, cacheEntry, currentMaxSteps); cacheEntry.lastUpdateTime = currentTime + Mathf.random(3f, 8f); - updatesThisFrame[0]++; + currentFrameUpdates++; } } @@ -291,7 +196,7 @@ private void processUnitPath(Unit unit, int maxSteps, float currentTime, int[] u cacheEntry.data[1] = unit.y; } - drawFromCache(cacheEntry, unit.team.color, maxSteps); + drawFromCache(cacheEntry, unit.team.color, currentMaxSteps); } private void recalculatePath(Unit unit, PathfindingCache cacheEntry, int maxSteps) { @@ -320,10 +225,13 @@ private void recalculatePath(Unit unit, PathfindingCache cacheEntry, int maxStep for (int i = 0; i < maxSteps; i++) { Tile nextTile = pathfinder.getTargetTile(currentTile, field); - if (nextTile == null || nextTile == currentTile) + if (nextTile == null || nextTile == currentTile) { break; - if (dataIndex >= cacheEntry.data.length - 2) + } + + if (dataIndex >= cacheEntry.data.length - 2) { break; + } cacheEntry.data[dataIndex++] = nextTile.worldx(); cacheEntry.data[dataIndex++] = nextTile.worldy(); @@ -351,7 +259,7 @@ private void drawFromCache(PathfindingCache cacheEntry, Color pathColor, int max float nextX = cacheEntry.data[(i + 1) * 2]; float nextY = cacheEntry.data[(i + 1) * 2 + 1]; - Draw.color(pathColor, (1f - ((float) i / maxSteps)) * PathfindingConfig.getOpacity()); + Draw.color(pathColor, (1f - ((float) i / maxSteps)) * currentOpacity); Lines.line(currentX, currentY, nextX, nextY); currentX = nextX; @@ -369,7 +277,6 @@ private void drawSpawnPointPath() { float currentTime = Time.time; - // Collect unique enemy teams activeTeams.clear(); for (var spawnPoint : Vars.state.rules.spawns) { var team = spawnPoint.team == null ? Vars.state.rules.waveTeam : spawnPoint.team; @@ -378,7 +285,7 @@ private void drawSpawnPointPath() { } } - for (arc.struct.IntSet.IntSetIterator it = activeTeams.iterator(); it.hasNext;) { + for (IntSetIterator it = activeTeams.iterator(); it.hasNext;) { int teamId = it.next(); Team team = Team.get(teamId); @@ -387,20 +294,23 @@ private void drawSpawnPointPath() { continue; } - for (var spawnTile : spawns) { + for (var spawnTile : Vars.spawner.getSpawns()) { long key = ((long) spawnTile.pos() << 32) | ((long) costType << 16) | (long) team.id; PathfindingCache cache = spawnPathCache.get(key); - if (cache == null || (currentTime - cache.lastUpdateTime) > 60f) { - if (cache == null) { - cache = new PathfindingCache(); - cache.data = new float[2048]; - spawnPathCache.put(key, cache); - } + if (cache == null) { + cache = new PathfindingCache(); + cache.data = new float[2048]; + spawnPathCache.put(key, cache); + } + + if ((currentTime - cache.lastUpdateTime) > CACHE_UPDATE_INTERVAL_SPAWN) { updateSpawnPathCache(cache, spawnTile, team, costType); cache.lastUpdateTime = currentTime + Mathf.random(0f, 20f); } + cache.lastUsedTime = currentTime; + if (cache.size > 0) { drawSpawnPathFromCache(cache, team.color); } @@ -414,6 +324,7 @@ private void drawSpawnPointPath() { private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team team, int costType) { int fieldType = Pathfinder.fieldCore; Pathfinder.Flowfield field = pathfinder.getField(team, costType, fieldType); + if (field == null) { cache.size = 0; return; @@ -422,18 +333,27 @@ private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team t Tile currentTile = startTile; float segmentStartX = startTile.worldx(); float segmentStartY = startTile.worldy(); + int lastDx = -2, lastDy = -2; int dataIndex = 0; - if (dataIndex + 2 > cache.data.length) - cache.data = java.util.Arrays.copyOf(cache.data, cache.data.length * 2); + if (dataIndex + 2 > cache.data.length) { + cache.data = Arrays.copyOf(cache.data, cache.data.length * 2); + } + cache.data[dataIndex++] = segmentStartX; cache.data[dataIndex++] = segmentStartY; - for (int i = 0; i < 1000; i++) { + for (int i = 0; i < MAX_SPAWN_PATH_STEPS; i++) { Tile nextTile = pathfinder.getTargetTile(currentTile, field); - if (nextTile == null || nextTile == currentTile) + if (nextTile == null) { + break; + } + + if (nextTile == currentTile){ + Log.err("Pathfinder loop detected"); break; + } int dx = nextTile.x - currentTile.x; int dy = nextTile.y - currentTile.y; @@ -441,7 +361,7 @@ private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team t if (dx != lastDx || dy != lastDy) { if (i > 0) { if (dataIndex + 2 > cache.data.length) - cache.data = java.util.Arrays.copyOf(cache.data, cache.data.length * 2); + cache.data = Arrays.copyOf(cache.data, cache.data.length * 2); cache.data[dataIndex++] = currentTile.worldx(); cache.data[dataIndex++] = currentTile.worldy(); } @@ -452,7 +372,7 @@ private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team t } if (dataIndex + 2 > cache.data.length) - cache.data = java.util.Arrays.copyOf(cache.data, cache.data.length * 2); + cache.data = Arrays.copyOf(cache.data, cache.data.length * 2); cache.data[dataIndex++] = currentTile.worldx(); cache.data[dataIndex++] = currentTile.worldy(); @@ -460,9 +380,11 @@ private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team t } private void drawSpawnPathFromCache(PathfindingCache cache, Color color) { - if (cache.size < 4) + if (cache.size < 4) { return; - Draw.color(color, PathfindingConfig.getOpacity()); + } + + Draw.color(color, currentOpacity); Lines.stroke(1f); for (int i = 0; i < cache.size - 2; i += 2) { diff --git a/src/mindustrytool/features/display/pathfinding/PathfindingSettingsUI.java b/src/mindustrytool/features/display/pathfinding/PathfindingSettingsUI.java new file mode 100644 index 00000000..8e9f4d18 --- /dev/null +++ b/src/mindustrytool/features/display/pathfinding/PathfindingSettingsUI.java @@ -0,0 +1,109 @@ +package mindustrytool.features.display.pathfinding; + +import arc.Core; +import arc.graphics.Color; +import arc.scene.event.Touchable; +import arc.scene.ui.Label; +import arc.scene.ui.Slider; +import arc.scene.ui.layout.Table; +import mindustry.gen.Icon; +import mindustry.ui.Styles; +import mindustry.ui.dialogs.BaseDialog; + +public class PathfindingSettingsUI { + private BaseDialog settingsDialog; + + public BaseDialog getDialog() { + if (settingsDialog == null) { + settingsDialog = new BaseDialog("@pathfinding.settings.title"); + settingsDialog.name = "pathfindingSettingDialog"; + settingsDialog.addCloseButton(); + settingsDialog.shown(this::rebuildSettings); + settingsDialog.buttons.button("@reset", Icon.refresh, () -> { + PathfindingConfig.setZoomThreshold(0.5f); + rebuildSettings(); + }).size(250, 64); + } + return settingsDialog; + } + + private void rebuildSettings() { + Table settingsContainer = settingsDialog.cont; + settingsContainer.clear(); + settingsContainer.defaults().pad(6).left(); + + float width = Math.min(Core.graphics.getWidth() / 1.2f, 460f); + float currentZoom = PathfindingConfig.getZoomThreshold(); + + Slider zoomSlider = new Slider(0f, 5f, 0.1f, false); + zoomSlider.setValue(currentZoom); + + Label zoomValueLabel = new Label( + currentZoom <= 0.01f ? "@off" : String.format("%.1fx", currentZoom), + Styles.outlineLabel); + zoomValueLabel.setColor(currentZoom <= 0.01f ? Color.gray : Color.lightGray); + + Table zoomContent = new Table(); + zoomContent.touchable = Touchable.disabled; + zoomContent.margin(3f, 33f, 3f, 33f); + zoomContent.add("@health-bar.min-zoom", Styles.outlineLabel).left().growX(); + zoomContent.add(zoomValueLabel).padLeft(10f).right(); + + zoomSlider.changed(() -> { + float newZoomValue = zoomSlider.getValue(); + PathfindingConfig.setZoomThreshold(newZoomValue); + zoomValueLabel.setText(newZoomValue <= 0.01f ? "@off" : String.format("%.1fx", newZoomValue)); + zoomValueLabel.setColor(newZoomValue <= 0.01f ? Color.gray : Color.lightGray); + }); + + settingsContainer.stack(zoomSlider, zoomContent).width(width).left().padTop(4f).row(); + + Slider opacitySlider = new Slider(0f, 1f, 0.05f, false); + opacitySlider.setValue(PathfindingConfig.getOpacity()); + + Label opacityValue = new Label( + String.format("%.0f%%", PathfindingConfig.getOpacity() * 100), + Styles.outlineLabel); + opacityValue.setColor(Color.lightGray); + + Table opacityContent = new Table(); + opacityContent.touchable = Touchable.disabled; + opacityContent.margin(3f, 33f, 3f, 33f); + opacityContent.add("@opacity", Styles.outlineLabel).left().growX(); + opacityContent.add(opacityValue).padLeft(10f).right(); + + opacitySlider.changed(() -> { + PathfindingConfig.setOpacity(opacitySlider.getValue()); + opacityValue.setText(String.format("%.0f%%", PathfindingConfig.getOpacity() * 100)); + }); + + settingsContainer.stack(opacitySlider, opacityContent).width(width).left().padTop(4f).row(); + + settingsContainer.check("@pathfinding.draw-unit-path", PathfindingConfig.isDrawUnitPath(), (checked) -> { + PathfindingConfig.setDrawUnitPath(checked); + }).left().row(); + + settingsContainer + .check("@pathfinding.draw-spawn-point-path", PathfindingConfig.isDrawSpawnPointPath(), (checked) -> { + PathfindingConfig.setDrawSpawnPointPath(checked); + rebuildSettings(); + }).left().row(); + + if (PathfindingConfig.isDrawSpawnPointPath()) { + Table costTable = new Table(); + costTable.left().defaults().left().padLeft(16); + + String[] costNames = { "@pathfinding.cost.ground", "@pathfinding.cost.legs", "@pathfinding.cost.water", + "@pathfinding.cost.neoplasm", "@pathfinding.cost.flat", "@pathfinding.cost.hover" }; + + for (int i = 0; i < costNames.length; i++) { + int index = i; + costTable.check(costNames[i], PathfindingConfig.isCostTypeEnabled(index), c -> { + PathfindingConfig.setCostTypeEnabled(index, c); + }).padBottom(4).row(); + } + + settingsContainer.add(costTable).left().row(); + } + } +} \ No newline at end of file From 9e8ec9e71750bb1ab589a2a898e75ba802fc145e Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Tue, 31 Mar 2026 00:28:26 +0700 Subject: [PATCH 17/20] refactor(pathfinding): separate update and draw logic for performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move path calculation and cache updates from draw method to new update method triggered by update event. This reduces per-frame workload in the render thread and improves performance by processing path updates independently of rendering. Add separate team tracking sets for update and draw phases to prevent concurrent modification issues. Remove unused currentMaxSteps field and rename methods to clarify their purpose (drawUnitPath → drawUnitPaths, etc.). --- .../pathfinding/PathfindingDisplay.java | 143 ++++++++++++++---- 1 file changed, 114 insertions(+), 29 deletions(-) diff --git a/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java b/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java index 80ad2a71..da8fb49b 100644 --- a/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java +++ b/src/mindustrytool/features/display/pathfinding/PathfindingDisplay.java @@ -56,14 +56,14 @@ public class PathfindingDisplay implements Feature { private final PathfindingCacheManager pathCache = new PathfindingCacheManager(); private final PathfindingCacheManager spawnPathCache = new PathfindingCacheManager(); - private final IntSet activeTeams = new IntSet(); + private final IntSet updateActiveTeams = new IntSet(); + private final IntSet drawActiveTeams = new IntSet(); private final Interval timer = new Interval(1); private boolean isEnabled; private final PathfindingSettingsUI settingsUI = new PathfindingSettingsUI(); private int currentFrameUpdates; - private int currentMaxSteps; private float currentOpacity; @Override @@ -82,6 +82,7 @@ public FeatureMetadata getMetadata() { public void init() { PathfindingConfig.load(); Events.run(Trigger.draw, this::draw); + Events.run(Trigger.update, this::update); Events.on(WorldLoadEvent.class, e -> reset()); } @@ -107,6 +108,26 @@ public Optional setting() { return Optional.of(settingsUI.getDialog()); } + private void update() { + if (!isEnabled || !state.isGame()) { + return; + } + + if (timer.get(TIMER_CLEANUP, CLEANUP_SCHEDULE_FRAMES)) { + float time = Time.time; + pathCache.cleanup(time, CACHE_CLEANUP_AGE_UNIT); + spawnPathCache.cleanup(time, CACHE_CLEANUP_AGE_SPAWN); + } + + if (PathfindingConfig.isDrawSpawnPointPath()) { + updateSpawnPointPaths(); + } + + if (PathfindingConfig.isDrawUnitPath()) { + updateUnitPaths(); + } + } + private void draw() { if (!isEnabled || !state.isGame() || Vars.ui.hudfrag == null || !Vars.ui.hudfrag.shown) { return; @@ -122,25 +143,17 @@ private void draw() { currentOpacity = PathfindingConfig.getOpacity(); if (PathfindingConfig.isDrawSpawnPointPath()) { - drawSpawnPointPath(); + drawSpawnPointPaths(); } if (PathfindingConfig.isDrawUnitPath()) { - drawUnitPath(); - } - - if (timer.get(TIMER_CLEANUP, CLEANUP_SCHEDULE_FRAMES)) { - float time = Time.time; - pathCache.cleanup(time, CACHE_CLEANUP_AGE_UNIT); - spawnPathCache.cleanup(time, CACHE_CLEANUP_AGE_SPAWN); + drawUnitPaths(); } } - private void drawUnitPath() { - Draw.z(Layer.overlayUI); - + private void updateUnitPaths() { int totalUnits = Groups.unit.size(); - currentMaxSteps = (totalUnits > 2000) ? MAX_STEPS_VERY_HIGH + int maxSteps = (totalUnits > 2000) ? MAX_STEPS_VERY_HIGH : (totalUnits > 1000) ? MAX_STEPS_HIGH : (totalUnits > 500) ? MAX_STEPS_MEDIUM : MAX_STEPS_LOW; boolean useCulling = totalUnits > CULLING_THRESHOLD; @@ -150,16 +163,16 @@ private void drawUnitPath() { if (useCulling) { Groups.unit.intersect(cullBounds.x, cullBounds.y, cullBounds.width, cullBounds.height, unit -> { - processUnitPath(unit, currentTime); + updateProcessUnitPath(unit, currentTime, maxSteps); }); } else { for (Unit unit : Groups.unit) { - processUnitPath(unit, currentTime); + updateProcessUnitPath(unit, currentTime, maxSteps); } } } - private void processUnitPath(Unit unit, float currentTime) { + private void updateProcessUnitPath(Unit unit, float currentTime, int maxSteps) { if (unit.team == player.team()) { return; } @@ -179,11 +192,49 @@ private void processUnitPath(Unit unit, float currentTime) { if (currentFrameUpdates < MAX_UPDATES_PER_FRAME) { cacheEntry.size = 0; - recalculatePath(unit, cacheEntry, currentMaxSteps); + recalculatePath(unit, cacheEntry, maxSteps); cacheEntry.lastUpdateTime = currentTime + Mathf.random(3f, 8f); currentFrameUpdates++; } } + } + + private void drawUnitPaths() { + Draw.z(Layer.overlayUI); + + int totalUnits = Groups.unit.size(); + int maxSteps = (totalUnits > 2000) ? MAX_STEPS_VERY_HIGH + : (totalUnits > 1000) ? MAX_STEPS_HIGH : (totalUnits > 500) ? MAX_STEPS_MEDIUM : MAX_STEPS_LOW; + + boolean useCulling = totalUnits > CULLING_THRESHOLD; + Rect cullBounds = useCulling ? Core.camera.bounds(Tmp.r1).grow(CULLING_GROW) : null; + float currentTime = Time.time; + + if (useCulling) { + Groups.unit.intersect(cullBounds.x, cullBounds.y, cullBounds.width, cullBounds.height, unit -> { + drawProcessUnitPath(unit, currentTime, maxSteps); + }); + } else { + for (Unit unit : Groups.unit) { + drawProcessUnitPath(unit, currentTime, maxSteps); + } + } + } + + private void drawProcessUnitPath(Unit unit, float currentTime, int maxSteps) { + if (unit.team == player.team()) { + return; + } + + long packedPosition = Point2.pack(unit.tileX(), unit.tileY()); + long cacheKey = (((long) packedPosition) << 32) | ((long) unit.type.flowfieldPathType << 8) + | (long) unit.team.id; + + PathfindingCache cacheEntry = pathCache.get(cacheKey); + + if (cacheEntry == null) { + return; + } if (cacheEntry.lastUsedTime == currentTime) { return; @@ -196,7 +247,7 @@ private void processUnitPath(Unit unit, float currentTime) { cacheEntry.data[1] = unit.y; } - drawFromCache(cacheEntry, unit.team.color, currentMaxSteps); + drawFromCache(cacheEntry, unit.team.color, maxSteps); } private void recalculatePath(Unit unit, PathfindingCache cacheEntry, int maxSteps) { @@ -268,24 +319,22 @@ private void drawFromCache(PathfindingCache cacheEntry, Color pathColor, int max Draw.reset(); } - private void drawSpawnPointPath() { + private void updateSpawnPointPaths() { if (pathfinder == null) { return; } - Draw.z(Layer.overlayUI); - float currentTime = Time.time; - activeTeams.clear(); + updateActiveTeams.clear(); for (var spawnPoint : Vars.state.rules.spawns) { var team = spawnPoint.team == null ? Vars.state.rules.waveTeam : spawnPoint.team; if (team != player.team()) { - activeTeams.add(team.id); + updateActiveTeams.add(team.id); } } - for (IntSetIterator it = activeTeams.iterator(); it.hasNext;) { + for (IntSetIterator it = updateActiveTeams.iterator(); it.hasNext;) { int teamId = it.next(); Team team = Team.get(teamId); @@ -308,11 +357,47 @@ private void drawSpawnPointPath() { updateSpawnPathCache(cache, spawnTile, team, costType); cache.lastUpdateTime = currentTime + Mathf.random(0f, 20f); } + } + } + } + } + + private void drawSpawnPointPaths() { + if (pathfinder == null) { + return; + } + + Draw.z(Layer.overlayUI); + + float currentTime = Time.time; + + drawActiveTeams.clear(); + for (var spawnPoint : Vars.state.rules.spawns) { + var team = spawnPoint.team == null ? Vars.state.rules.waveTeam : spawnPoint.team; + if (team != player.team()) { + drawActiveTeams.add(team.id); + } + } + + for (IntSetIterator it = drawActiveTeams.iterator(); it.hasNext;) { + int teamId = it.next(); + Team team = Team.get(teamId); + + for (var costType = 0; costType < Pathfinder.costTypes.size; costType++) { + if (!PathfindingConfig.isCostTypeEnabled(costType)) { + continue; + } + + for (var spawnTile : Vars.spawner.getSpawns()) { + long key = ((long) spawnTile.pos() << 32) | ((long) costType << 16) | (long) team.id; + PathfindingCache cache = spawnPathCache.get(key); - cache.lastUsedTime = currentTime; + if (cache != null) { + cache.lastUsedTime = currentTime; - if (cache.size > 0) { - drawSpawnPathFromCache(cache, team.color); + if (cache.size > 0) { + drawSpawnPathFromCache(cache, team.color); + } } } } @@ -350,7 +435,7 @@ private void updateSpawnPathCache(PathfindingCache cache, Tile startTile, Team t break; } - if (nextTile == currentTile){ + if (nextTile == currentTile) { Log.err("Pathfinder loop detected"); break; } From 972363515a2e873437086e1b7c8c39852325754e Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Tue, 31 Mar 2026 00:42:44 +0700 Subject: [PATCH 18/20] feat(playerconnect): auto close hosted room when joining another server Add a scheduled task that checks every second if the user is hosting a server and simultaneously joining another one as a client. When both conditions are met, a notification is shown and the local room is automatically closed to prevent conflicts. --- .../features/playerconnect/PlayerConnect.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mindustrytool/features/playerconnect/PlayerConnect.java b/src/mindustrytool/features/playerconnect/PlayerConnect.java index 19bd5957..398f2414 100644 --- a/src/mindustrytool/features/playerconnect/PlayerConnect.java +++ b/src/mindustrytool/features/playerconnect/PlayerConnect.java @@ -60,6 +60,13 @@ public class PlayerConnect { updateStats(); }); + Timer.schedule(() -> { + if (isHosting() && Vars.net.client()) { + close(); + Vars.ui.showInfoFade("Auto close room when join another server"); + } + }, 1, 1); + Timer.schedule(() -> { updateStats(); }, 60f, 60f); From defeb8b775fbd0ff89d615fff80881b982cf562a Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Tue, 31 Mar 2026 01:03:34 +0700 Subject: [PATCH 19/20] fix(IconBrowserDialog): correct icon filtering logic for string types Previously, the condition incorrectly filtered out string icons by checking `icon instanceof String`. This was causing valid string-based icons to be excluded from the browser. The fix removes the string check, allowing both Character and String icons to be displayed correctly based on the name filter. --- src/mindustrytool/IconUtils.java | 33 ++++++++++++ .../features/settings/IconBrowserDialog.java | 54 +++++++------------ 2 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 src/mindustrytool/IconUtils.java diff --git a/src/mindustrytool/IconUtils.java b/src/mindustrytool/IconUtils.java new file mode 100644 index 00000000..e146e7d0 --- /dev/null +++ b/src/mindustrytool/IconUtils.java @@ -0,0 +1,33 @@ +package mindustrytool; + +import arc.struct.Seq; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import mindustry.gen.Iconc; + +public class IconUtils { + + public static final Seq iconcs = getIconc(); + + @Data + @RequiredArgsConstructor + public static class IconC { + private final String name; + private final Character value; + } + + private static Seq getIconc() { + return Seq.with(Iconc.class.getDeclaredFields()) + .map(f -> { + try { + Object value = f.get(null); + if (value instanceof Character) { + return new IconC(f.getName(), (Character) value); + } + return null; + } catch (Exception e) { + return null; + } + }).select(f -> f != null); + } +} diff --git a/src/mindustrytool/features/settings/IconBrowserDialog.java b/src/mindustrytool/features/settings/IconBrowserDialog.java index 52fd2cff..a7df257b 100644 --- a/src/mindustrytool/features/settings/IconBrowserDialog.java +++ b/src/mindustrytool/features/settings/IconBrowserDialog.java @@ -3,11 +3,10 @@ import arc.Core; import arc.scene.ui.layout.Table; import arc.util.Align; -import arc.util.Log; import arc.util.Scaling; -import mindustry.gen.Iconc; import mindustry.ui.Styles; import mindustry.ui.dialogs.BaseDialog; +import mindustrytool.IconUtils; public class IconBrowserDialog extends BaseDialog { @@ -27,43 +26,30 @@ private void setup() { Runnable build = () -> { containers.clear(); - var declaredFields = Iconc.class.getDeclaredFields(); int col = 0; - for (var field : declaredFields) { - try { - field.setAccessible(true); - var icon = field.get(null); - - if (icon.equals(Iconc.all)) { - continue; - } - - if (icon instanceof String || icon instanceof Character) { - if (!field.getName().toLowerCase().contains(filter[0].toLowerCase())) { - continue; - } - - containers.button(String.valueOf(icon) + " " + field.getName(), () -> { - Core.app.setClipboardText(String.valueOf(icon)); - }) - .width(width) - .scaling(Scaling.fill) - .growX() - .padRight(8) - .padBottom(8) - .labelAlign(Align.left) - .top() - .left(); + for (var icon : IconUtils.iconcs) { + if (!icon.getName().toLowerCase().contains(filter[0].toLowerCase())) { + continue; + } - if (++col % cols == 0) { - containers.row(); - } - } - } catch (Exception e) { - Log.err(e); + containers.button(String.valueOf(icon.getValue()) + " " + icon.getName(), () -> { + Core.app.setClipboardText(String.valueOf(icon.getValue())); + }) + .width(width) + .scaling(Scaling.fill) + .growX() + .padRight(8) + .padBottom(8) + .labelAlign(Align.left) + .top() + .left(); + + if (++col % cols == 0) { + containers.row(); } } + }; cont.field(filter[0], Styles.defaultField, (t) -> { From d972c0fcb271eaa24f9fcd29da88ba4cc75ffba8 Mon Sep 17 00:00:00 2001 From: Sharlotte Date: Tue, 31 Mar 2026 01:38:21 +0700 Subject: [PATCH 20/20] fix: prevent NPE when accessing static character icon fields Add null check before instanceof to handle cases where static fields may be null during reflection access. --- src/mindustrytool/IconUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mindustrytool/IconUtils.java b/src/mindustrytool/IconUtils.java index e146e7d0..4ea098f1 100644 --- a/src/mindustrytool/IconUtils.java +++ b/src/mindustrytool/IconUtils.java @@ -21,7 +21,7 @@ private static Seq getIconc() { .map(f -> { try { Object value = f.get(null); - if (value instanceof Character) { + if (value != null && value instanceof Character) { return new IconC(f.getName(), (Character) value); } return null;