diff --git a/src/main/java/com/duckblade/osrs/sailing/SailingConfig.java b/src/main/java/com/duckblade/osrs/sailing/SailingConfig.java index 25968c9f..a4b5a13a 100644 --- a/src/main/java/com/duckblade/osrs/sailing/SailingConfig.java +++ b/src/main/java/com/duckblade/osrs/sailing/SailingConfig.java @@ -587,6 +587,31 @@ default boolean chartingMermaidSolver() return true; } + @ConfigItem( + keyName = "showChartingPath", + name = "Show Charting Path", + description = "Display an optimized path through uncompleted charting locations.", + section = SECTION_SEA_CHARTING, + position = 9 + ) + default boolean showChartingPath() + { + return false; + } + + @ConfigItem( + keyName = "chartingPathColor", + name = "Path Colour", + description = "Colour of the charting path line.", + section = SECTION_SEA_CHARTING, + position = 10 + ) + @Alpha + default Color chartingPathColor() + { + return Color.CYAN; + } + @ConfigItem( keyName = "barracudaHighlightLostCrates", name = "Highlight Crates", diff --git a/src/main/java/com/duckblade/osrs/sailing/SailingPlugin.java b/src/main/java/com/duckblade/osrs/sailing/SailingPlugin.java index 4ed83b69..5bdc654c 100644 --- a/src/main/java/com/duckblade/osrs/sailing/SailingPlugin.java +++ b/src/main/java/com/duckblade/osrs/sailing/SailingPlugin.java @@ -32,7 +32,7 @@ public void configure(Binder binder) protected void startUp() throws Exception { componentManager.onPluginStart(); - } + } @Override protected void shutDown() throws Exception diff --git a/src/main/java/com/duckblade/osrs/sailing/features/charting/ChartingPathOverlay.java b/src/main/java/com/duckblade/osrs/sailing/features/charting/ChartingPathOverlay.java new file mode 100644 index 00000000..78228a82 --- /dev/null +++ b/src/main/java/com/duckblade/osrs/sailing/features/charting/ChartingPathOverlay.java @@ -0,0 +1,268 @@ +package com.duckblade.osrs.sailing.features.charting; + +import com.duckblade.osrs.sailing.SailingConfig; +import com.duckblade.osrs.sailing.features.util.BoatTracker; +import com.duckblade.osrs.sailing.features.util.SailingUtil; +import com.duckblade.osrs.sailing.module.PluginLifecycleComponent; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.VarbitChanged; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; + +@Singleton +public class ChartingPathOverlay extends Overlay implements PluginLifecycleComponent +{ + private static final int RECALC_DISTANCE_THRESHOLD = 50; + + private final Client client; + private final SailingConfig config; + private final BoatTracker boatTracker; + + private List cachedPath = new ArrayList<>(); + private WorldPoint lastCalcPosition = null; + private Color pathColor; + + @Inject + public ChartingPathOverlay(Client client, SailingConfig config, BoatTracker boatTracker) + { + this.client = client; + this.config = config; + this.boatTracker = boatTracker; + + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public boolean isEnabled(SailingConfig config) + { + pathColor = config.chartingPathColor(); + return config.showChartingPath(); + } + + @Override + public void shutDown() + { + cachedPath.clear(); + lastCalcPosition = null; + } + + @Override + public Dimension render(Graphics2D g) + { + // Only show when on a boat + if (boatTracker.getBoat() == null) + { + return null; + } + + // Use top-level world point to get real world coordinates when on a boat + WorldPoint playerPos = SailingUtil.getTopLevelWorldPoint(client); + if (playerPos == null) + { + return null; + } + + // Recalculate path if needed + boolean needsRecalc = cachedPath.isEmpty() + || lastCalcPosition == null + || playerPos.distanceTo(lastCalcPosition) > RECALC_DISTANCE_THRESHOLD + || cachedPath.get(0).isComplete(client); + + if (needsRecalc) + { + recalculatePath(playerPos); + } + + if (cachedPath.isEmpty()) + { + return null; + } + + // Draw line to next task + SeaChartTask nextTask = cachedPath.get(0); + WorldPoint targetWorld = nextTask.getLocation(); + + // Use top-level local point for consistent coordinate system + LocalPoint playerLocal = SailingUtil.getTopLevelLocalPoint(client); + if (playerLocal == null) + { + return null; + } + + Point playerScreen = Perspective.localToCanvas(client, playerLocal, 0); + if (playerScreen == null) + { + return null; + } + + // Try to get target on screen, otherwise calculate a point in that direction + LocalPoint targetLocal = LocalPoint.fromWorld(client.getTopLevelWorldView(), targetWorld); + + if (targetLocal == null) + { + // Target is off-screen, calculate a LocalPoint in the direction of the target + int dx = targetWorld.getX() - playerPos.getX(); + int dy = targetWorld.getY() - playerPos.getY(); + + // Normalize and extend to edge of scene (use ~50 tiles as max distance) + double length = Math.sqrt(dx * dx + dy * dy); + if (length == 0) + { + return null; + } + + int extendDist = 50 * Perspective.LOCAL_TILE_SIZE; + int targetLocalX = playerLocal.getX() + (int) (dx / length * extendDist); + int targetLocalY = playerLocal.getY() + (int) (dy / length * extendDist); + + targetLocal = new LocalPoint(targetLocalX, targetLocalY, client.getTopLevelWorldView()); + } + + Point targetScreen = Perspective.localToCanvas(client, targetLocal, 0); + + if (playerScreen != null && targetScreen != null) + { + g.setColor(pathColor); + g.drawLine(playerScreen.getX(), playerScreen.getY(), + targetScreen.getX(), targetScreen.getY()); + } + + return null; + } + + @Subscribe + public void onVarbitChanged(VarbitChanged ev) + { + if (!cachedPath.isEmpty()) + { + SeaChartTask firstTask = cachedPath.get(0); + if (ev.getVarbitId() == firstTask.getCompletionVarb()) + { + cachedPath.clear(); + } + } + } + + private void recalculatePath(WorldPoint startPos) + { + List uncompleted = Arrays.stream(SeaChartTask.values()) + .filter(task -> !task.isComplete(client)) + .collect(Collectors.toList()); + + if (uncompleted.isEmpty()) + { + cachedPath.clear(); + } + else + { + cachedPath = twoOpt(nearestNeighbor(uncompleted, startPos)); + } + lastCalcPosition = startPos; + } + + private List nearestNeighbor(List tasks, WorldPoint start) + { + List remaining = new ArrayList<>(tasks); + List path = new ArrayList<>(); + WorldPoint current = start; + + while (!remaining.isEmpty()) + { + SeaChartTask nearest = null; + int minDist = Integer.MAX_VALUE; + + for (SeaChartTask task : remaining) + { + int dist = distance(current, task.getLocation()); + if (dist < minDist) + { + minDist = dist; + nearest = task; + } + } + + path.add(nearest); + current = nearest.getLocation(); + remaining.remove(nearest); + } + + return path; + } + + private List twoOpt(List path) + { + if (path.size() < 4) + { + return path; + } + + List best = new ArrayList<>(path); + boolean improved = true; + + while (improved) + { + improved = false; + for (int i = 0; i < best.size() - 2; i++) + { + for (int j = i + 2; j < best.size(); j++) + { + if (twoOptImproves(best, i, j)) + { + reverse(best, i + 1, j); + improved = true; + } + } + } + } + + return best; + } + + private boolean twoOptImproves(List path, int i, int j) + { + WorldPoint a = path.get(i).getLocation(); + WorldPoint b = path.get(i + 1).getLocation(); + WorldPoint c = path.get(j).getLocation(); + WorldPoint d = (j + 1 < path.size()) ? path.get(j + 1).getLocation() : null; + + int oldDist = distance(a, b); + int newDist = distance(a, c); + + if (d != null) + { + oldDist += distance(c, d); + newDist += distance(b, d); + } + + return newDist < oldDist; + } + + private void reverse(List path, int start, int end) + { + Collections.reverse(path.subList(start, end + 1)); + } + + private int distance(WorldPoint a, WorldPoint b) + { + int dx = a.getX() - b.getX(); + int dy = a.getY() - b.getY(); + return dx * dx + dy * dy; + } +} diff --git a/src/main/java/com/duckblade/osrs/sailing/module/SailingModule.java b/src/main/java/com/duckblade/osrs/sailing/module/SailingModule.java index f60bbf5b..12a1fc6d 100644 --- a/src/main/java/com/duckblade/osrs/sailing/module/SailingModule.java +++ b/src/main/java/com/duckblade/osrs/sailing/module/SailingModule.java @@ -9,6 +9,7 @@ import com.duckblade.osrs.sailing.features.barracudatrials.splits.BarracudaSplitsFileWriter; import com.duckblade.osrs.sailing.features.barracudatrials.splits.BarracudaSplitsOverlayPanel; import com.duckblade.osrs.sailing.features.barracudatrials.splits.BarracudaSplitsTracker; +import com.duckblade.osrs.sailing.features.charting.ChartingPathOverlay; import com.duckblade.osrs.sailing.features.charting.CurrentDuckTaskTracker; import com.duckblade.osrs.sailing.features.charting.MermaidTaskSolver; import com.duckblade.osrs.sailing.features.charting.SeaChartMapPointManager; @@ -72,6 +73,7 @@ Set lifecycleComponents( BoatTracker boatTracker, CargoHoldTracker cargoHoldTracker, Castaway castaway, + ChartingPathOverlay chartingPathOverlay, ClueCasket clueCasket, ClueTurtle clueTurtle, CourierTaskLedgerOverlay courierTaskLedgerOverlay, @@ -116,6 +118,7 @@ Set lifecycleComponents( .add(boatTracker) .add(cargoHoldTracker) .add(castaway) + .add(chartingPathOverlay) .add(clueCasket) .add(clueTurtle) .add(courierTaskLedgerOverlay)