From 5dd1aa692c2c6e1237be50d516d0dfc5eb991cf3 Mon Sep 17 00:00:00 2001 From: debajit gayen Date: Thu, 21 May 2026 18:37:45 +0100 Subject: [PATCH 1/2] fix: Mat to BufferedImage conversions, MouseOver reliability --- build.gradle.kts | 78 +- src/main/java/com/chromascape/api/Dax.java | 138 ++-- .../chromascape/api/DiscordNotification.java | 156 ++-- .../java/com/chromascape/base/BaseScript.java | 320 ++++---- .../chromascape/controller/Controller.java | 310 ++++---- .../scripts/DemoAgilityScript.java | 440 +++++------ .../scripts/DemoFishingScript.java | 400 +++++----- .../chromascape/scripts/DemoMiningScript.java | 158 ++-- .../chromascape/scripts/DemoWineScript.java | 384 ++++----- .../chromascape/scripts/Screenshotter.java | 98 +-- .../com/chromascape/utils/actions/Idler.java | 122 +-- .../utils/actions/ItemDropper.java | 252 +++--- .../chromascape/utils/actions/Minimap.java | 182 ++--- .../chromascape/utils/actions/MouseOver.java | 297 +++++-- .../utils/actions/MovingObject.java | 350 ++++----- .../utils/actions/PointSelector.java | 506 ++++++------ .../input/distribution/ClickDistribution.java | 306 ++++---- .../input/keyboard/VirtualKeyboardUtils.java | 238 +++--- .../core/input/mouse/VirtualMouseUtils.java | 560 +++++++------- .../utils/core/input/mouse/WindMouse.java | 616 +++++++-------- .../core/input/remoteinput/ControlKey.java | 48 +- .../core/input/remoteinput/MouseButton.java | 26 +- .../core/input/remoteinput/RemoteInput.java | 666 ++++++++-------- .../remoteinput/RemoteInputInterface.java | 452 +++++------ .../runtime/exception/DaxAuthException.java | 26 +- .../core/runtime/exception/DaxException.java | 28 +- .../exception/DaxRateLimitException.java | 30 +- .../exception/ScriptStoppedException.java | 38 +- .../utils/core/runtime/profile/Profile.java | 46 +- .../runtime/profile/ProfileContainer.java | 30 +- .../core/runtime/profile/ProfileManager.java | 372 ++++----- .../utils/core/screen/DisplayImage.java | 87 ++- .../core/screen/colour/ColourInstances.java | 168 ++-- .../utils/core/screen/colour/ColourObj.java | 98 +-- .../utils/core/screen/topology/ChromaObj.java | 42 +- .../core/screen/topology/ColourContours.java | 401 +++++----- .../core/screen/topology/MatchResult.java | 36 +- .../screen/topology/TemplateMatching.java | 487 +++++++----- .../utils/core/screen/viewport/Viewport.java | 38 +- .../core/screen/viewport/ViewportManager.java | 116 +-- .../screen/window/LinuxProcessManager.java | 112 +-- .../core/screen/window/MacProcessManager.java | 38 +- .../core/screen/window/ProcessManager.java | 30 +- .../screen/window/ProcessManagerFactory.java | 54 +- .../core/screen/window/ScreenManager.java | 225 +++--- .../screen/window/WindowsProcessManager.java | 182 ++--- .../utils/core/state/BotState.java | 76 +- .../utils/core/state/BotStateListener.java | 24 +- .../utils/core/state/StateManager.java | 94 +-- .../core/statistics/StatisticsManager.java | 210 ++--- .../utils/domain/ocr/CharMatch.java | 24 +- .../com/chromascape/utils/domain/ocr/Ocr.java | 730 +++++++++--------- .../utils/domain/walker/Compass.java | 576 +++++++------- .../utils/domain/walker/DaxPath.java | 32 +- .../chromascape/utils/domain/walker/Tile.java | 24 +- .../utils/domain/walker/Walker.java | 632 +++++++-------- .../utils/domain/zones/MaskZones.java | 170 ++-- .../utils/domain/zones/SubZoneMapper.java | 388 +++++----- .../utils/domain/zones/ZoneManager.java | 432 +++++------ .../web/ChromaScapeApplication.java | 146 ++-- .../java/com/chromascape/web/ServePages.java | 66 +- .../web/config/StartupConfiguration.java | 108 +-- .../web/config/WebSocketConfig.java | 178 ++--- .../com/chromascape/web/image/AddColour.java | 86 +-- .../com/chromascape/web/image/ColourData.java | 164 ++-- .../web/image/ImageController.java | 142 ++-- .../com/chromascape/web/image/MaskImage.java | 130 ++-- .../chromascape/web/image/ModifyImage.java | 282 +++---- .../chromascape/web/image/SubmitColour.java | 144 ++-- .../chromascape/web/instance/RunConfig.java | 54 +- .../web/instance/ScriptControl.java | 190 ++--- .../web/instance/ScriptInstance.java | 190 ++--- .../web/instance/ScriptInstanceManager.java | 94 +-- .../chromascape/web/instance/SendScripts.java | 92 +-- .../web/instance/WebSocketStateHandler.java | 196 ++--- .../web/logs/LogWebSocketHandler.java | 242 +++--- .../web/logs/WebSocketLogAppender.java | 214 ++--- .../web/slider/CurrentSliderState.java | 168 ++-- .../chromascape/web/slider/SliderConfig.java | 100 +-- .../web/slider/SliderController.java | 136 ++-- .../web/state/SemanticWebSocketHandler.java | 176 ++--- .../web/state/WebsocketBotStateListener.java | 102 +-- .../web/stats/StatisticsBroadcaster.java | 148 ++-- .../web/stats/StatisticsWebSocketHandler.java | 182 ++--- .../viewport/ViewportWebSocketHandler.java | 228 +++--- .../web/viewport/WebsocketViewport.java | 266 +++---- .../ChromaScapeApplicationTests.java | 22 +- 87 files changed, 8793 insertions(+), 8652 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d393c7f..13ee80b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,10 +7,7 @@ plugins { } group = "com.chromascape" -version = "0.4.0-SNAPSHOT" - -// Customize build directories - put DLLs in build/dist -layout.buildDirectory.set(file("build")) +version = "0.4.0" java { toolchain { @@ -83,76 +80,3 @@ spotless { tasks.named("check") { dependsOn("spotlessApply", "spotlessCheck", "checkstyleMain") } - -// Windows-only native build configuration -val isWindows = org.gradle.internal.os.OperatingSystem.current().isWindows - -// Copy prebuilt DLLs to build/dist folder -val copyNativeLibraries by tasks.registering(Copy::class) { - group = "native" - description = "Copy prebuilt native libraries to build/dist" - - // Only run on Windows - onlyIf { - isWindows - } - - // Check if we have prebuilt libraries - onlyIf { - val kInputExists = file("third_party/KInput/KInput/KInput/bin/Release/KInput.dll").exists() - val kInputCtrlExists = file("third_party/KInput/KInput/KInputCtrl/bin/Release/KInputCtrl.dll").exists() - - kInputExists && kInputCtrlExists - } - - doFirst { - // Ensure build/dist directory exists - file("build/dist").mkdirs() - } - - // Copy from prebuilt libraries - from("third_party/KInput/KInput/KInput/bin/Release") - from("third_party/KInput/KInput/KInputCtrl/bin/Release") - into("build/dist") - - include("*.dll") -} - -// Note: Removed copyNativeToResources task as the application loads DLLs directly from build/dist -// and doesn't use classpath fallback mechanism - -// Make build depend on native library copying and quality checks -tasks.named("processResources") { - dependsOn(copyNativeLibraries) -} - -tasks.named("build") { - dependsOn(copyNativeLibraries, "check") -} - -tasks.named("jar") { - dependsOn(copyNativeLibraries) -} - -// Custom task to clean .chromascape directory -tasks.register("cleanChromascape") { - group = "cleanup" - description = "Remove the .chromascape directory" - - doLast { - val chromascapeDir = file(".chromascape") - if (chromascapeDir.exists()) { - delete(chromascapeDir) - println("Removed .chromascape directory") - } else { - println(".chromascape directory does not exist") - } - } -} - -// Task to clean everything including .chromascape -tasks.register("cleanAll") { - group = "cleanup" - description = "Clean build artifacts and .chromascape directory" - dependsOn("clean", "cleanChromascape") -} diff --git a/src/main/java/com/chromascape/api/Dax.java b/src/main/java/com/chromascape/api/Dax.java index 6357371..57773e8 100644 --- a/src/main/java/com/chromascape/api/Dax.java +++ b/src/main/java/com/chromascape/api/Dax.java @@ -1,69 +1,69 @@ -package com.chromascape.api; - -import com.chromascape.utils.core.runtime.exception.DaxAuthException; -import com.chromascape.utils.core.runtime.exception.DaxException; -import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; -import java.awt.Point; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -/** - * Client wrapper for the DAX Walker REST API. Sends pathfinding requests and returns the raw JSON - * response representing the calculated path. - */ -public class Dax { - - private static final String WALKER_ENDPOINT = "https://walker.dax.cloud/walker/generatePath"; - - private final HttpClient client = - HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); - - /** - * Sends a pathfinding request to the DAX Walker API. - * - * @param start The starting tile coordinates. - * @param end The destination tile coordinates. - * @param members True if the player is a member; false otherwise. - * @return Raw JSON string representing the generated path. - * @throws IOException If an IO error occurs during the request. - * @throws InterruptedException If the thread is interrupted. - * @throws DaxRateLimitException If HTTP 429 is returned. - * @throws DaxAuthException If credentials or endpoint are invalid (400, 401, 404). - */ - public String generatePath(Point start, Point end, boolean members) - throws IOException, InterruptedException { - - String payload = - String.format( - """ - { - "start": {"x": %d, "y": %d, "z": 0}, - "end": {"x": %d, "y": %d, "z": 0}, - "player": {"members": %b} - } - """, - start.x, start.y, end.x, end.y, members); - - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(WALKER_ENDPOINT)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("key", "sub_DPjXXzL5DeSiPf") - .header("secret", "PUBLIC-KEY") - .POST(HttpRequest.BodyPublishers.ofString(payload)) - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - return switch (response.statusCode()) { - case 200 -> response.body(); - case 429 -> throw new DaxRateLimitException(); - case 400, 401, 404 -> throw new DaxAuthException(); - default -> throw new DaxException("Unexpected API error: " + response.statusCode()); - }; - } -} +package com.chromascape.api; + +import com.chromascape.utils.core.runtime.exception.DaxAuthException; +import com.chromascape.utils.core.runtime.exception.DaxException; +import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; +import java.awt.Point; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * Client wrapper for the DAX Walker REST API. Sends pathfinding requests and returns the raw JSON + * response representing the calculated path. + */ +public class Dax { + + private static final String WALKER_ENDPOINT = "https://walker.dax.cloud/walker/generatePath"; + + private final HttpClient client = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); + + /** + * Sends a pathfinding request to the DAX Walker API. + * + * @param start The starting tile coordinates. + * @param end The destination tile coordinates. + * @param members True if the player is a member; false otherwise. + * @return Raw JSON string representing the generated path. + * @throws IOException If an IO error occurs during the request. + * @throws InterruptedException If the thread is interrupted. + * @throws DaxRateLimitException If HTTP 429 is returned. + * @throws DaxAuthException If credentials or endpoint are invalid (400, 401, 404). + */ + public String generatePath(Point start, Point end, boolean members) + throws IOException, InterruptedException { + + String payload = + String.format( + """ + { + "start": {"x": %d, "y": %d, "z": 0}, + "end": {"x": %d, "y": %d, "z": 0}, + "player": {"members": %b} + } + """, + start.x, start.y, end.x, end.y, members); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(WALKER_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("key", "sub_DPjXXzL5DeSiPf") + .header("secret", "PUBLIC-KEY") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + return switch (response.statusCode()) { + case 200 -> response.body(); + case 429 -> throw new DaxRateLimitException(); + case 400, 401, 404 -> throw new DaxAuthException(); + default -> throw new DaxException("Unexpected API error: " + response.statusCode()); + }; + } +} diff --git a/src/main/java/com/chromascape/api/DiscordNotification.java b/src/main/java/com/chromascape/api/DiscordNotification.java index 49d0d43..b6227e9 100644 --- a/src/main/java/com/chromascape/api/DiscordNotification.java +++ b/src/main/java/com/chromascape/api/DiscordNotification.java @@ -1,78 +1,78 @@ -package com.chromascape.api; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Properties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Provides functionality to send logs as notifications to yourself via Discord. - * - *
    - *
  • Loads the {@code secrets.properties} file in the root directory. - *
  • Saves the specified WebHook URL. - *
  • Sends a POST req to the endpoint with the user's desired notification. - *
- * - *

This is extremely useful if you aren't actively babysitting your bot or in the case of - * reaching a specified XP/GP goal. It's my personal advice to the reader for you to inform yourself - * upon catastrophic failure, promptly. - */ -public class DiscordNotification { - - private static final Logger logger = LoggerFactory.getLogger(DiscordNotification.class); - private static String webhookUrl; - - static { - try (InputStream input = new FileInputStream("secrets.properties")) { - Properties prop = new Properties(); - prop.load(input); - webhookUrl = prop.getProperty("discord.webhook.url"); - } catch (IOException ex) { - logger.info("Could not find secrets.properties in the project root."); - } - } - - /** - * Sends a user specified message to a Discord WebHook endpoint. Sets up a post request and - * expects a 204 response code for success. - * - * @param message User specified String to send to the endpoint. - */ - public static void send(String message) { - if (webhookUrl == null || webhookUrl.isEmpty()) { - return; - } - - String sanitizedMessage = message.replace("\"", "\\\"").replace("\n", "\\n"); - String jsonPayload = "{\"content\": \"" + sanitizedMessage + "\"}"; - - try { - URL url = new URL(webhookUrl); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setDoOutput(true); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json"); - conn.setRequestProperty("User-Agent", "Java-Discord-Webhook"); - - try (OutputStream os = conn.getOutputStream()) { - os.write(jsonPayload.getBytes(StandardCharsets.UTF_8)); - } - - // 204 means Success (No Content) - if (conn.getResponseCode() != 204) { - logger.error("Failed to send. Response code: {}", conn.getResponseCode()); - } - - conn.disconnect(); - } catch (Exception e) { - logger.error("Error sending Discord notification: {}", e.getMessage()); - } - } -} +package com.chromascape.api; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides functionality to send logs as notifications to yourself via Discord. + * + *

    + *
  • Loads the {@code secrets.properties} file in the root directory. + *
  • Saves the specified WebHook URL. + *
  • Sends a POST req to the endpoint with the user's desired notification. + *
+ * + *

This is extremely useful if you aren't actively babysitting your bot or in the case of + * reaching a specified XP/GP goal. It's my personal advice to the reader for you to inform yourself + * upon catastrophic failure, promptly. + */ +public class DiscordNotification { + + private static final Logger logger = LoggerFactory.getLogger(DiscordNotification.class); + private static String webhookUrl; + + static { + try (InputStream input = new FileInputStream("secrets.properties")) { + Properties prop = new Properties(); + prop.load(input); + webhookUrl = prop.getProperty("discord.webhook.url"); + } catch (IOException ex) { + logger.info("Could not find secrets.properties in the project root."); + } + } + + /** + * Sends a user specified message to a Discord WebHook endpoint. Sets up a post request and + * expects a 204 response code for success. + * + * @param message User specified String to send to the endpoint. + */ + public static void send(String message) { + if (webhookUrl == null || webhookUrl.isEmpty()) { + return; + } + + String sanitizedMessage = message.replace("\"", "\\\"").replace("\n", "\\n"); + String jsonPayload = "{\"content\": \"" + sanitizedMessage + "\"}"; + + try { + URL url = new URL(webhookUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "Java-Discord-Webhook"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(jsonPayload.getBytes(StandardCharsets.UTF_8)); + } + + // 204 means Success (No Content) + if (conn.getResponseCode() != 204) { + logger.error("Failed to send. Response code: {}", conn.getResponseCode()); + } + + conn.disconnect(); + } catch (Exception e) { + logger.error("Error sending Discord notification: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/chromascape/base/BaseScript.java b/src/main/java/com/chromascape/base/BaseScript.java index a7c26b5..a3c75b1 100644 --- a/src/main/java/com/chromascape/base/BaseScript.java +++ b/src/main/java/com/chromascape/base/BaseScript.java @@ -1,160 +1,160 @@ -package com.chromascape.base; - -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.runtime.exception.ScriptStoppedException; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.util.concurrent.ThreadLocalRandom; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Abstract base class representing a generic automation script with lifecycle management. - * - *

Provides a timed execution framework where the script runs cycles until the script is stopped - * externally. - * - *

Manages the underlying Controller instance. Subclasses should override {@link #cycle()} to - * define the script's main logic. - */ -public abstract class BaseScript { - private final Controller controller; - private static final Logger logger = LogManager.getLogger(BaseScript.class); - private volatile boolean running = true; - private Thread scriptThread; - - /** Constructs a BaseScript. */ - public BaseScript() { - controller = new Controller(); - } - - /** - * Runs the script lifecycle. - * - *

Initializes the controller, logs start and stop events, then continuously invokes the {@link - * #cycle()} method until the script is stopped. Checks for thread interruption and stops - * gracefully if detected. - * - *

This method blocks until completion. - */ - public final void run() { - scriptThread = Thread.currentThread(); - controller.init(); - StatisticsManager.reset(); - - try { - while (running) { - StatisticsManager.incrementCycles(); - if (Thread.currentThread().isInterrupted()) { - logger.info("Thread interrupted, exiting."); - break; - } - try { - cycle(); - } catch (ScriptStoppedException e) { - logger.error("Cycle interrupted: {}", e.getMessage()); - break; - } catch (Exception e) { - StateManager.setState(BotState.ERROR); - logger.error("Exception in cycle: {}, {}", e.getMessage(), e.getStackTrace()); - break; - } - } - } finally { - logger.info("Stopping and cleaning up."); - controller.shutdown(); - } - logger.info("Finished running script."); - } - - /** - * Stops the script execution by interrupting the script thread. - * - *

Can be called externally (e.g., via UI controls or programmatically) to request an immediate - * stop of the running script. If the script is already stopped, this method does nothing. - */ - public void stop() { - if (!running) { - return; - } - logger.info("Stop requested"); - running = false; - - // Interrupt the script thread instead of throwing exception - if (scriptThread != null) { - scriptThread.interrupt(); - } - } - - /** - * Pauses the current thread for the specified number of milliseconds. - * - *

If the sleep is interrupted, this method throws ScriptStoppedException to enable immediate - * stopping. - * - * @param ms the duration to sleep in milliseconds - * @throws ScriptStoppedException if the thread is interrupted during sleep - */ - public static void waitMillis(long ms) { - StateManager.setState(BotState.WAITING); - try { - Thread.sleep(ms); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // Restore interrupt status - throw new ScriptStoppedException(); - } - } - - /** - * Pauses the current thread for a random duration between {@code min} and {@code max} - * milliseconds (inclusive). - * - *

This method internally calls {@link #waitMillis(long)} with a randomly generated delay. - * - * @param min the minimum number of milliseconds to sleep (inclusive) - * @param max the maximum number of milliseconds to sleep (inclusive) - * @throws IllegalArgumentException if {@code min} is greater than {@code max} - * @throws ScriptStoppedException if the thread is interrupted during sleep - */ - public static void waitRandomMillis(long min, long max) throws ScriptStoppedException { - if (min > max) { - throw new IllegalArgumentException("min must be less than or equal to max"); - } - waitMillis(ThreadLocalRandom.current().nextLong(min, max + 1)); - } - - /** - * Checks if the current thread has been interrupted and throws ScriptStoppedException if so. Call - * this method frequently in your cycle implementation, especially in loops. - * - * @throws ScriptStoppedException if the thread has been interrupted - */ - public static void checkInterrupted() throws ScriptStoppedException { - if (Thread.currentThread().isInterrupted()) { - throw new ScriptStoppedException(); - } - } - - /** - * The core logic of the script. - * - *

This method is called repeatedly in a loop by {@link #run()} for the specified duration. - * Subclasses must override this method to implement their specific bot behavior. - * - *

Note: This method is called synchronously on the running thread. Use the provided sleep - * methods and call {@link #checkInterrupted()} frequently to enable immediate stopping. - */ - protected void cycle() { - // override this - } - - /** - * Exposes the local controller to children of this class. - * - * @return The controller object. - */ - public Controller controller() { - return controller; - } -} +package com.chromascape.base; + +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.runtime.exception.ScriptStoppedException; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Abstract base class representing a generic automation script with lifecycle management. + * + *

Provides a timed execution framework where the script runs cycles until the script is stopped + * externally. + * + *

Manages the underlying Controller instance. Subclasses should override {@link #cycle()} to + * define the script's main logic. + */ +public abstract class BaseScript { + private final Controller controller; + private static final Logger logger = LogManager.getLogger(BaseScript.class); + private volatile boolean running = true; + private Thread scriptThread; + + /** Constructs a BaseScript. */ + public BaseScript() { + controller = new Controller(); + } + + /** + * Runs the script lifecycle. + * + *

Initializes the controller, logs start and stop events, then continuously invokes the {@link + * #cycle()} method until the script is stopped. Checks for thread interruption and stops + * gracefully if detected. + * + *

This method blocks until completion. + */ + public final void run() { + scriptThread = Thread.currentThread(); + controller.init(); + StatisticsManager.reset(); + + try { + while (running) { + StatisticsManager.incrementCycles(); + if (Thread.currentThread().isInterrupted()) { + logger.info("Thread interrupted, exiting."); + break; + } + try { + cycle(); + } catch (ScriptStoppedException e) { + logger.error("Cycle interrupted: {}", e.getMessage()); + break; + } catch (Exception e) { + StateManager.setState(BotState.ERROR); + logger.error("Exception in cycle: {}, {}", e.getMessage(), e.getStackTrace()); + break; + } + } + } finally { + logger.info("Stopping and cleaning up."); + controller.shutdown(); + } + logger.info("Finished running script."); + } + + /** + * Stops the script execution by interrupting the script thread. + * + *

Can be called externally (e.g., via UI controls or programmatically) to request an immediate + * stop of the running script. If the script is already stopped, this method does nothing. + */ + public void stop() { + if (!running) { + return; + } + logger.info("Stop requested"); + running = false; + + // Interrupt the script thread instead of throwing exception + if (scriptThread != null) { + scriptThread.interrupt(); + } + } + + /** + * Pauses the current thread for the specified number of milliseconds. + * + *

If the sleep is interrupted, this method throws ScriptStoppedException to enable immediate + * stopping. + * + * @param ms the duration to sleep in milliseconds + * @throws ScriptStoppedException if the thread is interrupted during sleep + */ + public static void waitMillis(long ms) { + StateManager.setState(BotState.WAITING); + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore interrupt status + throw new ScriptStoppedException(); + } + } + + /** + * Pauses the current thread for a random duration between {@code min} and {@code max} + * milliseconds (inclusive). + * + *

This method internally calls {@link #waitMillis(long)} with a randomly generated delay. + * + * @param min the minimum number of milliseconds to sleep (inclusive) + * @param max the maximum number of milliseconds to sleep (inclusive) + * @throws IllegalArgumentException if {@code min} is greater than {@code max} + * @throws ScriptStoppedException if the thread is interrupted during sleep + */ + public static void waitRandomMillis(long min, long max) throws ScriptStoppedException { + if (min > max) { + throw new IllegalArgumentException("min must be less than or equal to max"); + } + waitMillis(ThreadLocalRandom.current().nextLong(min, max + 1)); + } + + /** + * Checks if the current thread has been interrupted and throws ScriptStoppedException if so. Call + * this method frequently in your cycle implementation, especially in loops. + * + * @throws ScriptStoppedException if the thread has been interrupted + */ + public static void checkInterrupted() throws ScriptStoppedException { + if (Thread.currentThread().isInterrupted()) { + throw new ScriptStoppedException(); + } + } + + /** + * The core logic of the script. + * + *

This method is called repeatedly in a loop by {@link #run()} for the specified duration. + * Subclasses must override this method to implement their specific bot behavior. + * + *

Note: This method is called synchronously on the running thread. Use the provided sleep + * methods and call {@link #checkInterrupted()} frequently to enable immediate stopping. + */ + protected void cycle() { + // override this + } + + /** + * Exposes the local controller to children of this class. + * + * @return The controller object. + */ + public Controller controller() { + return controller; + } +} diff --git a/src/main/java/com/chromascape/controller/Controller.java b/src/main/java/com/chromascape/controller/Controller.java index cc0deb2..3a3e24e 100644 --- a/src/main/java/com/chromascape/controller/Controller.java +++ b/src/main/java/com/chromascape/controller/Controller.java @@ -1,155 +1,155 @@ -package com.chromascape.controller; - -import com.chromascape.utils.core.input.keyboard.VirtualKeyboardUtils; -import com.chromascape.utils.core.input.mouse.VirtualMouseUtils; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.screen.window.ProcessManagerFactory; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import com.chromascape.utils.domain.walker.Walker; -import com.chromascape.utils.domain.zones.ZoneManager; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * The central controller managing the lifecycle and access to core stateful utilities for input - * simulation, screen capture, zone management, and hotkey listening. - * - *

Responsible for initializing and shutting down resources and enforcing runtime state checks to - * prevent access to utilities when inactive. - * - *

This class abstracts and coordinates lower-level modules required for automation scripts. - */ -public class Controller { - - /** Represents the current running state of the controller. */ - private enum ControllerState { - STOPPED, - RUNNING - } - - private ControllerState state; - - private RemoteInput remoteInput; - private VirtualMouseUtils virtualMouseUtils; - private VirtualKeyboardUtils virtualKeyboardUtils; - private ZoneManager zoneManager; - private Walker walker; - private static final Logger logger = LogManager.getLogger(Controller.class); - - /** Constructs a new Controller instance. */ - public Controller() { - this.state = ControllerState.STOPPED; - } - - /** - * Initializes and starts the controller, setting up all core utilities needed for the bot to - * operate, including input devices, hotkey listener, screen capture, and zone management. - * - *

This method queries the target client window, configures input hooks, and prepares the - * internal state for running. - */ - public void init() { - logger.info("Setting up Font masks..."); - Ocr.loadFont("Plain 11"); - Ocr.loadFont("Plain 12"); - Ocr.loadFont("Bold 12"); - - logger.info("Setting up Remote Input Library..."); - // Obtain process ID of the target window to initialize input injection - remoteInput = new RemoteInput(ProcessManagerFactory.getProcessManager().getPid()); - // Give screen manager access to Remote Input to grab screen buffer - ScreenManager.setRemoteInput(remoteInput); - - // Initialize virtual input utilities with current window bounds and fullscreen status - logger.info("Initialising mouse and keyboard utils..."); - virtualMouseUtils = new VirtualMouseUtils(remoteInput); - virtualKeyboardUtils = new VirtualKeyboardUtils(remoteInput); - - logger.info("Pre-loading and instantiating zones..."); - // Initialize zone management with fixed mode option - zoneManager = new ZoneManager(); - // Initialise gameView instead of LazyLoading, to improve startup overhead - zoneManager.getGameView(); - - state = ControllerState.RUNNING; - - // Initialises a walker to provide the script with Walking functionality through the DAX API - walker = new Walker(this); - logger.info("Controller State: {}", state); - } - - /** - * Shuts down the controller and releases all resources. - * - *

This stops input injection, stops hotkey listening, and prevents further access to stateful - * utilities until re-initialized. - */ - public void shutdown() { - remoteInput.close(); - state = ControllerState.STOPPED; - logger.info("Shutting down"); - } - - /** - * Provides access to the virtual mouse utility. - * - * @return The virtual mouse utility for simulated mouse actions. - * @throws IllegalStateException if called while the controller is not running. - */ - public VirtualMouseUtils mouse() { - assertRunning("VirtualMouseUtils"); - return virtualMouseUtils; - } - - /** - * Provides access to the virtual keyboard utility. - * - * @return The virtual keyboard utility for simulated keyboard actions. - * @throws IllegalStateException if called while the controller is not running. - */ - public VirtualKeyboardUtils keyboard() { - assertRunning("VirtualKeyboardUtils"); - return virtualKeyboardUtils; - } - - /** - * Provides access to the zone manager utility. - * - *

The ZoneManager maintains mappings of UI sub-zones to support interaction with different - * client interface areas. - * - * @return The ZoneManager instance. - * @throws IllegalStateException if called while the controller is not running. - */ - public ZoneManager zones() { - assertRunning("ZoneManager"); - return zoneManager; - } - - /** - * Provides access to the walker domain utility. - * - * @return The walker utility, to be able to pathfind in-game. - */ - public Walker walker() { - assertRunning("Walker"); - return walker; - } - - /** - * Checks that the controller is currently running before allowing access to any stateful utility, - * logging and throwing an exception if not. - * - * @param component The name of the utility being accessed. - * @throws IllegalStateException if the controller is not running. - */ - private void assertRunning(String component) { - if (state != ControllerState.RUNNING) { - if (logger != null) { - logger.info("{} accessed while bot is not running.", component); - } - throw new IllegalStateException(component + " accessed while bot is not running."); - } - } -} +package com.chromascape.controller; + +import com.chromascape.utils.core.input.keyboard.VirtualKeyboardUtils; +import com.chromascape.utils.core.input.mouse.VirtualMouseUtils; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.screen.window.ProcessManagerFactory; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import com.chromascape.utils.domain.walker.Walker; +import com.chromascape.utils.domain.zones.ZoneManager; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The central controller managing the lifecycle and access to core stateful utilities for input + * simulation, screen capture, zone management, and hotkey listening. + * + *

Responsible for initializing and shutting down resources and enforcing runtime state checks to + * prevent access to utilities when inactive. + * + *

This class abstracts and coordinates lower-level modules required for automation scripts. + */ +public class Controller { + + /** Represents the current running state of the controller. */ + private enum ControllerState { + STOPPED, + RUNNING + } + + private ControllerState state; + + private RemoteInput remoteInput; + private VirtualMouseUtils virtualMouseUtils; + private VirtualKeyboardUtils virtualKeyboardUtils; + private ZoneManager zoneManager; + private Walker walker; + private static final Logger logger = LogManager.getLogger(Controller.class); + + /** Constructs a new Controller instance. */ + public Controller() { + this.state = ControllerState.STOPPED; + } + + /** + * Initializes and starts the controller, setting up all core utilities needed for the bot to + * operate, including input devices, hotkey listener, screen capture, and zone management. + * + *

This method queries the target client window, configures input hooks, and prepares the + * internal state for running. + */ + public void init() { + logger.info("Setting up Font masks..."); + Ocr.loadFont("Plain 11"); + Ocr.loadFont("Plain 12"); + Ocr.loadFont("Bold 12"); + + logger.info("Setting up Remote Input Library..."); + // Obtain process ID of the target window to initialize input injection + remoteInput = new RemoteInput(ProcessManagerFactory.getProcessManager().getPid()); + // Give screen manager access to Remote Input to grab screen buffer + ScreenManager.setRemoteInput(remoteInput); + + // Initialize virtual input utilities with current window bounds and fullscreen status + logger.info("Initialising mouse and keyboard utils..."); + virtualMouseUtils = new VirtualMouseUtils(remoteInput); + virtualKeyboardUtils = new VirtualKeyboardUtils(remoteInput); + + logger.info("Pre-loading and instantiating zones..."); + // Initialize zone management with fixed mode option + zoneManager = new ZoneManager(); + // Initialise gameView instead of LazyLoading, to improve startup overhead + zoneManager.getGameView(); + + state = ControllerState.RUNNING; + + // Initialises a walker to provide the script with Walking functionality through the DAX API + walker = new Walker(this); + logger.info("Controller State: {}", state); + } + + /** + * Shuts down the controller and releases all resources. + * + *

This stops input injection, stops hotkey listening, and prevents further access to stateful + * utilities until re-initialized. + */ + public void shutdown() { + remoteInput.close(); + state = ControllerState.STOPPED; + logger.info("Shutting down"); + } + + /** + * Provides access to the virtual mouse utility. + * + * @return The virtual mouse utility for simulated mouse actions. + * @throws IllegalStateException if called while the controller is not running. + */ + public VirtualMouseUtils mouse() { + assertRunning("VirtualMouseUtils"); + return virtualMouseUtils; + } + + /** + * Provides access to the virtual keyboard utility. + * + * @return The virtual keyboard utility for simulated keyboard actions. + * @throws IllegalStateException if called while the controller is not running. + */ + public VirtualKeyboardUtils keyboard() { + assertRunning("VirtualKeyboardUtils"); + return virtualKeyboardUtils; + } + + /** + * Provides access to the zone manager utility. + * + *

The ZoneManager maintains mappings of UI sub-zones to support interaction with different + * client interface areas. + * + * @return The ZoneManager instance. + * @throws IllegalStateException if called while the controller is not running. + */ + public ZoneManager zones() { + assertRunning("ZoneManager"); + return zoneManager; + } + + /** + * Provides access to the walker domain utility. + * + * @return The walker utility, to be able to pathfind in-game. + */ + public Walker walker() { + assertRunning("Walker"); + return walker; + } + + /** + * Checks that the controller is currently running before allowing access to any stateful utility, + * logging and throwing an exception if not. + * + * @param component The name of the utility being accessed. + * @throws IllegalStateException if the controller is not running. + */ + private void assertRunning(String component) { + if (state != ControllerState.RUNNING) { + if (logger != null) { + logger.info("{} accessed while bot is not running.", component); + } + throw new IllegalStateException(component + " accessed while bot is not running."); + } + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoAgilityScript.java b/src/main/java/com/chromascape/scripts/DemoAgilityScript.java index a4b1621..837b1bc 100644 --- a/src/main/java/com/chromascape/scripts/DemoAgilityScript.java +++ b/src/main/java/com/chromascape/scripts/DemoAgilityScript.java @@ -1,220 +1,220 @@ -package com.chromascape.scripts; - -import com.chromascape.api.DiscordNotification; -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Minimap; -import com.chromascape.utils.actions.MovingObject; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import java.awt.Point; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; -import java.util.Random; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * An Agility script designed to be used with the ChromaScape RuneLite plugin configuration. - * - *

>>>TO USE THIS SCRIPT YOU MUST ENABLE THE PERMANENT XP BAR<<< - * - *

See instructions here: Instructions - * - *

The script relies on the improved agility plugin to show only the next visible obstacle or - * mark. - * - *

    - *
  • Green Highlight indicates the next obstacle is safe to click - *
  • Red Highlight (or absence of Green) indicates a Mark of Grace (next obstacle isn't - * highlighted green because of the plugin) - *
  • This script uses concurrency (multiple threads) to click the obstacle until a red click - * occurs. - *
- * - *

This implementation prioritizes Mark of Grace collection over course progression and includes - * fail-safe logic to prevent getting confused during the delay between looting and the plugin - * updating the obstacle highlights. - */ -public class DemoAgilityScript extends BaseScript { - - // Logger that appends to the Web UI - private static final Logger logger = LogManager.getLogger(DemoAgilityScript.class); - - // Preset tiles for specific rooftop courses - private static final Map ROOFTOP_RESET_TILES = - new HashMap<>() { - { - put("Draynor", new Point(3103, 3278)); - put("Varrock", new Point(3223, 3414)); - put("Canifis", new Point(3507, 3487)); - } - }; - - // Configuration Constants - - /** - * This is where you pick the reset tile for your script. e.g., if using Varrock, set the String - * below to "Varrock" - */ - private static final Point RESET_TILE = ROOFTOP_RESET_TILES.get("Canifis"); - - private static final int TIMEOUT_XP_CHANGE = 15; - private static final int TIMEOUT_OBSTACLE_APPEAR = 10; - - // Colour Definitions - // These are instantiated as final fields to prevent unnecessary memory allocation during cycles - private static final ColourObj OBSTACLE_COLOUR = - new ColourObj("green", new Scalar(59, 254, 254, 0), new Scalar(60, 255, 255, 0)); - private static final ColourObj MARK_COLOUR = - new ColourObj("red", new Scalar(0, 254, 254, 0), new Scalar(1, 255, 255, 0)); - - // Random used in randomising break times between obstacles - private final Random random = new Random(); - - /** - * The main execution loop of the script. - * - *

The cycle follows a priority order: - * - *

    - *
  • Check for the next obstacle highlight and mark of grace as a fallback - *
  • If present, click it and wait for xp or pickup - *
  • If neither is present, verify state and potentially walk to reset - *
  • 1% chance of taking a break after each obstacle click - *
- */ - @Override - protected void cycle() { - // Log the current XP before clicking obstacle for comparison later - // The idea is to click the obstacle then wait for XP change then loop - int previousXp = Minimap.getXp(this); - - // Make sure it's read properly - if (previousXp == -1) { - stop(); - DiscordNotification.send("Xp could not be read."); - } - - // Check the state of the course - if (!isObstacleVisible()) { - if (clickMarkOfGraceIfPresent()) { - waitForObstacleToAppear(); - } else { - recoverToResetTile(); - } - return; - } - - // Interact with the detected obstacle - // Clicking continuously until the Red X animation is detected - MovingObject.clickMovingObjectByColourObjUntilRedClick(OBSTACLE_COLOUR, this); - - // Wait for the action to complete via XP update - waitUntilXpChange(previousXp); - - // Humanizing sleep to mimic natural player behavior - // And to prevent overloading moving object logic - waitRandomMillis(650, 800); - - // 1% chance to take a break between 2 and 5 minutes after clicking an obstacle - if (random.nextInt(100) < 1) { - logger.info("Taking a break..."); - waitRandomMillis(120000, 300000); - } - } - - /** - * Manages the scenario when nothing is visible. Firstly, confirms that it's really lost, if so -> - * uses the walker to path back to the reset tile. Finally, waits for the player's animation to - * settle after reaching the true tile. - */ - private void recoverToResetTile() { - // Double check we are actually lost to protect against lag or rendering delays - waitRandomMillis(600, 800); - - if (!isObstacleVisible()) { - - int attempts = 0; - int allowedAttempts = 5; - - while (attempts < allowedAttempts) { - try { - logger.info("We are lost. Walking to reset tile."); - controller().walker().pathTo(RESET_TILE, true); - // wait for camera to stabilise and walking animation to finish at true tile. - waitRandomMillis(4000, 6000); - break; - - } catch (IOException e) { - // This exception refers to Timeout or transport error - logger.error("Walker error {}", e.getMessage()); - attempts++; - - } catch (InterruptedException e) { - // This error means that the thread was interrupted while calling Dax - DiscordNotification.send("Walker thread interrupted, catastrophic failure."); - logger.error("Walker thread interrupted, catastrophic failure."); - stop(); - } - } - } - } - - /** - * Scans the game view for the Red colour associated with a Mark of Grace and attempts to click - * it. - * - * @return true if the mouse action was taken, false if no mark was found - */ - private boolean clickMarkOfGraceIfPresent() { - BufferedImage gameView = controller().zones().getGameView(); - // You'll see that there's an extra parameter on the point selector - // This is "tightness", how closely grouped the click should be - // 15.0 or more works best for ground items, best to look from a higher camera angle - Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, MARK_COLOUR, 15, 15.0); - - if (clickLocation != null) { - controller().mouse().moveTo(clickLocation, "medium"); - controller().mouse().leftClick(); - return true; - } - return false; - } - - /** - * Blocks execution until the Total XP value changes or the timeout is reached. - * - * @param previousXp the XP value captured before the action started - */ - private void waitUntilXpChange(int previousXp) { - LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_XP_CHANGE); - // Ensure we do not hang if the initial OCR read failed and returned an empty string - while (previousXp == Minimap.getXp(this) && LocalDateTime.now().isBefore(endTime)) { - waitMillis(300); - } - } - - /** Blocks execution until the obstacle highlight appears or the timeout is reached. */ - private void waitForObstacleToAppear() { - LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_OBSTACLE_APPEAR); - while (!isObstacleVisible() && LocalDateTime.now().isBefore(endTime)) { - waitMillis(300); - } - } - - /** - * Checks if the obstacle highlight is currently present in the game view. - * - * @return true if the colour contours are detected, false otherwise - */ - private boolean isObstacleVisible() { - BufferedImage gameView = controller().zones().getGameView(); - return !ColourContours.getChromaObjsInColour(gameView, OBSTACLE_COLOUR).isEmpty(); - } -} +package com.chromascape.scripts; + +import com.chromascape.api.DiscordNotification; +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Minimap; +import com.chromascape.utils.actions.MovingObject; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import java.awt.Point; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * An Agility script designed to be used with the ChromaScape RuneLite plugin configuration. + * + *

>>>TO USE THIS SCRIPT YOU MUST ENABLE THE PERMANENT XP BAR<<< + * + *

See instructions here: Instructions + * + *

The script relies on the improved agility plugin to show only the next visible obstacle or + * mark. + * + *

    + *
  • Green Highlight indicates the next obstacle is safe to click + *
  • Red Highlight (or absence of Green) indicates a Mark of Grace (next obstacle isn't + * highlighted green because of the plugin) + *
  • This script uses concurrency (multiple threads) to click the obstacle until a red click + * occurs. + *
+ * + *

This implementation prioritizes Mark of Grace collection over course progression and includes + * fail-safe logic to prevent getting confused during the delay between looting and the plugin + * updating the obstacle highlights. + */ +public class DemoAgilityScript extends BaseScript { + + // Logger that appends to the Web UI + private static final Logger logger = LogManager.getLogger(DemoAgilityScript.class); + + // Preset tiles for specific rooftop courses + private static final Map ROOFTOP_RESET_TILES = + new HashMap<>() { + { + put("Draynor", new Point(3103, 3278)); + put("Varrock", new Point(3223, 3414)); + put("Canifis", new Point(3507, 3487)); + } + }; + + // Configuration Constants + + /** + * This is where you pick the reset tile for your script. e.g., if using Varrock, set the String + * below to "Varrock" + */ + private static final Point RESET_TILE = ROOFTOP_RESET_TILES.get("Canifis"); + + private static final int TIMEOUT_XP_CHANGE = 15; + private static final int TIMEOUT_OBSTACLE_APPEAR = 10; + + // Colour Definitions + // These are instantiated as final fields to prevent unnecessary memory allocation during cycles + private static final ColourObj OBSTACLE_COLOUR = + new ColourObj("green", new Scalar(59, 254, 254, 0), new Scalar(60, 255, 255, 0)); + private static final ColourObj MARK_COLOUR = + new ColourObj("red", new Scalar(0, 254, 254, 0), new Scalar(1, 255, 255, 0)); + + // Random used in randomising break times between obstacles + private final Random random = new Random(); + + /** + * The main execution loop of the script. + * + *

The cycle follows a priority order: + * + *

    + *
  • Check for the next obstacle highlight and mark of grace as a fallback + *
  • If present, click it and wait for xp or pickup + *
  • If neither is present, verify state and potentially walk to reset + *
  • 1% chance of taking a break after each obstacle click + *
+ */ + @Override + protected void cycle() { + // Log the current XP before clicking obstacle for comparison later + // The idea is to click the obstacle then wait for XP change then loop + int previousXp = Minimap.getXp(this); + + // Make sure it's read properly + if (previousXp == -1) { + stop(); + DiscordNotification.send("Xp could not be read."); + } + + // Check the state of the course + if (!isObstacleVisible()) { + if (clickMarkOfGraceIfPresent()) { + waitForObstacleToAppear(); + } else { + recoverToResetTile(); + } + return; + } + + // Interact with the detected obstacle + // Clicking continuously until the Red X animation is detected + MovingObject.clickMovingObjectByColourObjUntilRedClick(OBSTACLE_COLOUR, this); + + // Wait for the action to complete via XP update + waitUntilXpChange(previousXp); + + // Humanizing sleep to mimic natural player behavior + // And to prevent overloading moving object logic + waitRandomMillis(650, 800); + + // 1% chance to take a break between 2 and 5 minutes after clicking an obstacle + if (random.nextInt(100) < 1) { + logger.info("Taking a break..."); + waitRandomMillis(120000, 300000); + } + } + + /** + * Manages the scenario when nothing is visible. Firstly, confirms that it's really lost, if so -> + * uses the walker to path back to the reset tile. Finally, waits for the player's animation to + * settle after reaching the true tile. + */ + private void recoverToResetTile() { + // Double check we are actually lost to protect against lag or rendering delays + waitRandomMillis(600, 800); + + if (!isObstacleVisible()) { + + int attempts = 0; + int allowedAttempts = 5; + + while (attempts < allowedAttempts) { + try { + logger.info("We are lost. Walking to reset tile."); + controller().walker().pathTo(RESET_TILE, true); + // wait for camera to stabilise and walking animation to finish at true tile. + waitRandomMillis(4000, 6000); + break; + + } catch (IOException e) { + // This exception refers to Timeout or transport error + logger.error("Walker error {}", e.getMessage()); + attempts++; + + } catch (InterruptedException e) { + // This error means that the thread was interrupted while calling Dax + DiscordNotification.send("Walker thread interrupted, catastrophic failure."); + logger.error("Walker thread interrupted, catastrophic failure."); + stop(); + } + } + } + } + + /** + * Scans the game view for the Red colour associated with a Mark of Grace and attempts to click + * it. + * + * @return true if the mouse action was taken, false if no mark was found + */ + private boolean clickMarkOfGraceIfPresent() { + BufferedImage gameView = controller().zones().getGameView(); + // You'll see that there's an extra parameter on the point selector + // This is "tightness", how closely grouped the click should be + // 15.0 or more works best for ground items, best to look from a higher camera angle + Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, MARK_COLOUR, 15, 15.0); + + if (clickLocation != null) { + controller().mouse().moveTo(clickLocation, "medium"); + controller().mouse().leftClick(); + return true; + } + return false; + } + + /** + * Blocks execution until the Total XP value changes or the timeout is reached. + * + * @param previousXp the XP value captured before the action started + */ + private void waitUntilXpChange(int previousXp) { + LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_XP_CHANGE); + // Ensure we do not hang if the initial OCR read failed and returned an empty string + while (previousXp == Minimap.getXp(this) && LocalDateTime.now().isBefore(endTime)) { + waitMillis(300); + } + } + + /** Blocks execution until the obstacle highlight appears or the timeout is reached. */ + private void waitForObstacleToAppear() { + LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_OBSTACLE_APPEAR); + while (!isObstacleVisible() && LocalDateTime.now().isBefore(endTime)) { + waitMillis(300); + } + } + + /** + * Checks if the obstacle highlight is currently present in the game view. + * + * @return true if the colour contours are detected, false otherwise + */ + private boolean isObstacleVisible() { + BufferedImage gameView = controller().zones().getGameView(); + return !ColourContours.getChromaObjsInColour(gameView, OBSTACLE_COLOUR).isEmpty(); + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoFishingScript.java b/src/main/java/com/chromascape/scripts/DemoFishingScript.java index d8cfd34..0f9636d 100644 --- a/src/main/java/com/chromascape/scripts/DemoFishingScript.java +++ b/src/main/java/com/chromascape/scripts/DemoFishingScript.java @@ -1,200 +1,200 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Idler; -import com.chromascape.utils.actions.ItemDropper; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.time.LocalDateTime; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * A demo script. Created to show off how to use certain utilities, namely: - * - *
    - *
  • Ocr and how to read text in screen regions. - *
  • How to use the Idler - *
  • Dropping items in a human like manner using the mouse - *
  • Template matching, how to use the MatchResult, and how to search for images on-screen - *
  • Colour detection within the gameView - *
  • The use of the {@link PointSelector} actions utility - *
- * - *

This is a DEMO script. It's not intended to be used, but rather as a reference. ChromaScape is - * not liable for any damages incurred whilst using DEMO scripts. Images not included. - */ -public class DemoFishingScript extends BaseScript { - - private static final String flyFishingRod = "/images/user/Fly_fishing_rod.png"; - private static final String feather = "/images/user/Feather.png"; - - private static final Logger logger = LogManager.getLogger(DemoFishingScript.class); - - private static final int IDLE_TIMEOUT_SECONDS = 300; - private static final int WALK_TIMEOUT_SECONDS = 17; - - /** - * Overridden cycle. Repeats all tasks within, until stop() is called from either the Web UI, or - * from within the script. - */ - @Override - protected void cycle() { - if (!checkIfCorrectInventoryLayout()) { - logger.warn("Fly-fishing rod must be in inventory slot 27 / idx 26"); - logger.warn("Feathers must be in inventory slot 28 / idx 27"); - logger.info("The top of feather image should be cropped by 10 px"); - stop(); - } - - clickFishingSpot(); - - waitUntilStoppedMoving(); - - waitUntilStoppedFishing(); - logger.info("Is idle"); - - // If ran out of bait, stop - if (checkChatPopup("have")) { - logger.warn("Ran out of bait!"); - stop(); - } - - // If inventory full, drop all fish - if (checkChatPopup("carry")) { - logger.warn("Pop-up found"); - dropAllFish(); - } - } - - /** - * Queries {@link DemoFishingScript#getCurrentWorldPos()} every tick until the player has stopped - * moving or the WALK_TIMEOUT_SECONDS is reached. Blocks execution of the script until either - * condition is met. - */ - private void waitUntilStoppedMoving() { - LocalDateTime end = LocalDateTime.now().plusSeconds(WALK_TIMEOUT_SECONDS); - String currentTile = getCurrentWorldPos(); - while (LocalDateTime.now().isBefore(end)) { - waitMillis(650); - if (currentTile.equals(getCurrentWorldPos())) { - return; - } - currentTile = getCurrentWorldPos(); - } - } - - /** - * Uses Ocr on the Grid Info box's Tile subzone. - * - * @return the tile position as a String. - */ - private String getCurrentWorldPos() { - Rectangle zone = controller().zones().getGridInfo().get("Tile"); - ColourObj colour = ColourInstances.getByName("White"); - return Ocr.extractText(zone, "Plain 12", colour, true); - } - - /** - * Checks if the inventory layout is as expected. The inventory layout needs to be in a specific - * format to ensure that the dropping of items looks human. Will check for a fly-fishing rod in - * index 26 and feathers in index 27. - * - * @return {@code boolean} true if correct, false if not. - */ - private boolean checkIfCorrectInventoryLayout() { - logger.info("Checking if inventory layout is valid"); - - Rectangle invSlot27 = controller().zones().getInventorySlots().get(26); - Rectangle invSlot28 = controller().zones().getInventorySlots().get(27); - - BufferedImage invSlot27Image = ScreenManager.captureZone(invSlot27); - BufferedImage invSlot28Image = ScreenManager.captureZone(invSlot28); - - MatchResult slot27Match = TemplateMatching.match(flyFishingRod, invSlot27Image, 0.15); - MatchResult slot28Match = TemplateMatching.match(feather, invSlot28Image, 0.15); - - if (!slot27Match.success()) { - logger.error("Slot 27 / idx 26 does not contain a fly fishing rod."); - return false; - } - - if (!slot28Match.success()) { - logger.error("Slot 28 / idx 27 does not contain feathers."); - return false; - } - - return true; - } - - /** - * Drops all fish in the inventory in a human-like manner. Designed to only be called when the - * inventory is full, because it doesn't check whether there is an item in any specific slot. - */ - private void dropAllFish() { - logger.info("Dropping all fish"); - - int[] excludeSlots = {26, 27}; - ItemDropper.dropAll(this, ItemDropper.DropPattern.ZIGZAG, excludeSlots); - } - - /** - * Checks if the chat contains a specified phrase in the font {@code Quill 8}. Uses the Ocr module - * to look for the phrase in the {@code Chat} zone. - * - * @param phrase The phrase to look for in the chat. - * @return true if found, else false. - */ - private boolean checkChatPopup(String phrase) { - Rectangle chat = controller().zones().getChatTabs().get("Chat"); - ColourObj black = ColourInstances.getByName("Black"); - String extraction = Ocr.extractText(chat, "Quill 8", black, true); - return extraction.contains(phrase); - } - - /** - * Clicks the {@code Cyan} colour which denotes a fishing spot within the GameView {@link - * BufferedImage}. Generates a random click point to click within the contour of the found {@code - * Cyan} object. - */ - private void clickFishingSpot() { - logger.info("Clicking fishing spot"); - BufferedImage gameView = controller().zones().getGameView(); - - Point clickLocation = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); - if (clickLocation == null) { - logger.error("clickLocation is null!"); - stop(); - } - controller().mouse().moveTo(clickLocation, "medium"); - controller().mouse().leftClick(); - } - - /** - * Iterates over checking for idle, if the player can't carry any more fish, and if the player has - * run out of bait. Blocks the main thread until one of these events occurs or the TIMEOUT_SECONDS - * have elapsed. - */ - private void waitUntilStoppedFishing() { - LocalDateTime end = LocalDateTime.now().plusSeconds(IDLE_TIMEOUT_SECONDS); - while (LocalDateTime.now().isBefore(end)) { - if (Idler.waitUntilIdle(this, 3)) { - return; - } - if (checkChatPopup("carry")) { - return; - } - if (checkChatPopup("have")) { - return; - } - } - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Idler; +import com.chromascape.utils.actions.ItemDropper; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.time.LocalDateTime; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A demo script. Created to show off how to use certain utilities, namely: + * + *

    + *
  • Ocr and how to read text in screen regions. + *
  • How to use the Idler + *
  • Dropping items in a human like manner using the mouse + *
  • Template matching, how to use the MatchResult, and how to search for images on-screen + *
  • Colour detection within the gameView + *
  • The use of the {@link PointSelector} actions utility + *
+ * + *

This is a DEMO script. It's not intended to be used, but rather as a reference. ChromaScape is + * not liable for any damages incurred whilst using DEMO scripts. Images not included. + */ +public class DemoFishingScript extends BaseScript { + + private static final String flyFishingRod = "/images/user/Fly_fishing_rod.png"; + private static final String feather = "/images/user/Feather.png"; + + private static final Logger logger = LogManager.getLogger(DemoFishingScript.class); + + private static final int IDLE_TIMEOUT_SECONDS = 300; + private static final int WALK_TIMEOUT_SECONDS = 17; + + /** + * Overridden cycle. Repeats all tasks within, until stop() is called from either the Web UI, or + * from within the script. + */ + @Override + protected void cycle() { + if (!checkIfCorrectInventoryLayout()) { + logger.warn("Fly-fishing rod must be in inventory slot 27 / idx 26"); + logger.warn("Feathers must be in inventory slot 28 / idx 27"); + logger.info("The top of feather image should be cropped by 10 px"); + stop(); + } + + clickFishingSpot(); + + waitUntilStoppedMoving(); + + waitUntilStoppedFishing(); + logger.info("Is idle"); + + // If ran out of bait, stop + if (checkChatPopup("have")) { + logger.warn("Ran out of bait!"); + stop(); + } + + // If inventory full, drop all fish + if (checkChatPopup("carry")) { + logger.warn("Pop-up found"); + dropAllFish(); + } + } + + /** + * Queries {@link DemoFishingScript#getCurrentWorldPos()} every tick until the player has stopped + * moving or the WALK_TIMEOUT_SECONDS is reached. Blocks execution of the script until either + * condition is met. + */ + private void waitUntilStoppedMoving() { + LocalDateTime end = LocalDateTime.now().plusSeconds(WALK_TIMEOUT_SECONDS); + String currentTile = getCurrentWorldPos(); + while (LocalDateTime.now().isBefore(end)) { + waitMillis(650); + if (currentTile.equals(getCurrentWorldPos())) { + return; + } + currentTile = getCurrentWorldPos(); + } + } + + /** + * Uses Ocr on the Grid Info box's Tile subzone. + * + * @return the tile position as a String. + */ + private String getCurrentWorldPos() { + Rectangle zone = controller().zones().getGridInfo().get("Tile"); + ColourObj colour = ColourInstances.getByName("White"); + return Ocr.extractText(zone, "Plain 12", colour, true); + } + + /** + * Checks if the inventory layout is as expected. The inventory layout needs to be in a specific + * format to ensure that the dropping of items looks human. Will check for a fly-fishing rod in + * index 26 and feathers in index 27. + * + * @return {@code boolean} true if correct, false if not. + */ + private boolean checkIfCorrectInventoryLayout() { + logger.info("Checking if inventory layout is valid"); + + Rectangle invSlot27 = controller().zones().getInventorySlots().get(26); + Rectangle invSlot28 = controller().zones().getInventorySlots().get(27); + + BufferedImage invSlot27Image = ScreenManager.captureZone(invSlot27); + BufferedImage invSlot28Image = ScreenManager.captureZone(invSlot28); + + MatchResult slot27Match = TemplateMatching.match(flyFishingRod, invSlot27Image, 0.15); + MatchResult slot28Match = TemplateMatching.match(feather, invSlot28Image, 0.15); + + if (!slot27Match.success()) { + logger.error("Slot 27 / idx 26 does not contain a fly fishing rod."); + return false; + } + + if (!slot28Match.success()) { + logger.error("Slot 28 / idx 27 does not contain feathers."); + return false; + } + + return true; + } + + /** + * Drops all fish in the inventory in a human-like manner. Designed to only be called when the + * inventory is full, because it doesn't check whether there is an item in any specific slot. + */ + private void dropAllFish() { + logger.info("Dropping all fish"); + + int[] excludeSlots = {26, 27}; + ItemDropper.dropAll(this, ItemDropper.DropPattern.ZIGZAG, excludeSlots); + } + + /** + * Checks if the chat contains a specified phrase in the font {@code Quill 8}. Uses the Ocr module + * to look for the phrase in the {@code Chat} zone. + * + * @param phrase The phrase to look for in the chat. + * @return true if found, else false. + */ + private boolean checkChatPopup(String phrase) { + Rectangle chat = controller().zones().getChatTabs().get("Chat"); + ColourObj black = ColourInstances.getByName("Black"); + String extraction = Ocr.extractText(chat, "Quill 8", black, true); + return extraction.contains(phrase); + } + + /** + * Clicks the {@code Cyan} colour which denotes a fishing spot within the GameView {@link + * BufferedImage}. Generates a random click point to click within the contour of the found {@code + * Cyan} object. + */ + private void clickFishingSpot() { + logger.info("Clicking fishing spot"); + BufferedImage gameView = controller().zones().getGameView(); + + Point clickLocation = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); + if (clickLocation == null) { + logger.error("clickLocation is null!"); + stop(); + } + controller().mouse().moveTo(clickLocation, "medium"); + controller().mouse().leftClick(); + } + + /** + * Iterates over checking for idle, if the player can't carry any more fish, and if the player has + * run out of bait. Blocks the main thread until one of these events occurs or the TIMEOUT_SECONDS + * have elapsed. + */ + private void waitUntilStoppedFishing() { + LocalDateTime end = LocalDateTime.now().plusSeconds(IDLE_TIMEOUT_SECONDS); + while (LocalDateTime.now().isBefore(end)) { + if (Idler.waitUntilIdle(this, 3)) { + return; + } + if (checkChatPopup("carry")) { + return; + } + if (checkChatPopup("have")) { + return; + } + } + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoMiningScript.java b/src/main/java/com/chromascape/scripts/DemoMiningScript.java index e6acdbb..b4e6862 100644 --- a/src/main/java/com/chromascape/scripts/DemoMiningScript.java +++ b/src/main/java/com/chromascape/scripts/DemoMiningScript.java @@ -1,79 +1,79 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Idler; -import com.chromascape.utils.actions.ItemDropper; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * A demo script that automates basic mining behavior in the client. - * - *

The script demonstrates simple bot actions such as: - * - *

    - *
  • Clicking on ore rocks to mine - *
  • Detecting when the inventory is full - *
  • Dropping ore using shift-click - *
  • Idling until "You are now idle!" message appears - *
- * - *

This is intended as an example implementation built on top of {@link BaseScript}. - */ -public class DemoMiningScript extends BaseScript { - - private static final Logger logger = LogManager.getLogger(DemoMiningScript.class); - private static final String ironOre = "/images/user/Iron_ore.png"; - - /** - * Executes one cycle of the script logic. - * - *

If the inventory is full, the script drops ore. Otherwise, it attempts to click an ore rock - * and mine it, then idles briefly before repeating. - */ - @Override - protected void cycle() { - if (isInventoryFull()) { - ItemDropper.dropAll(this); - } - clickOre(); - waitRandomMillis(800, 1000); - Idler.waitUntilIdle(this, 20); - } - - /** - * Attempts to locate and click on an ore rock in the game view. - * - *

If no suitable rock is found, the script stops. - */ - private void clickOre() { - BufferedImage gameView = controller().zones().getGameView(); - Point clickLoc = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); - if (clickLoc == null) { - logger.error("Click location is null"); - stop(); - return; - } - controller().mouse().moveTo(clickLoc, "medium"); - controller().mouse().leftClick(); - } - - /** - * Checks whether the player’s inventory is full by examining the final inventory slot for the - * presence of an iron ore image. - * - * @return {@code true} if the inventory is full, otherwise {@code false} - */ - private boolean isInventoryFull() { - Rectangle invSlot = controller().zones().getInventorySlots().get(27); - BufferedImage invSlotImg = ScreenManager.captureZone(invSlot); - Rectangle match = TemplateMatching.match(ironOre, invSlotImg, 0.05).bounds(); - return match != null; - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Idler; +import com.chromascape.utils.actions.ItemDropper; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A demo script that automates basic mining behavior in the client. + * + *

The script demonstrates simple bot actions such as: + * + *

    + *
  • Clicking on ore rocks to mine + *
  • Detecting when the inventory is full + *
  • Dropping ore using shift-click + *
  • Idling until "You are now idle!" message appears + *
+ * + *

This is intended as an example implementation built on top of {@link BaseScript}. + */ +public class DemoMiningScript extends BaseScript { + + private static final Logger logger = LogManager.getLogger(DemoMiningScript.class); + private static final String ironOre = "/images/user/Iron_ore.png"; + + /** + * Executes one cycle of the script logic. + * + *

If the inventory is full, the script drops ore. Otherwise, it attempts to click an ore rock + * and mine it, then idles briefly before repeating. + */ + @Override + protected void cycle() { + if (isInventoryFull()) { + ItemDropper.dropAll(this); + } + clickOre(); + waitRandomMillis(800, 1000); + Idler.waitUntilIdle(this, 20); + } + + /** + * Attempts to locate and click on an ore rock in the game view. + * + *

If no suitable rock is found, the script stops. + */ + private void clickOre() { + BufferedImage gameView = controller().zones().getGameView(); + Point clickLoc = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); + if (clickLoc == null) { + logger.error("Click location is null"); + stop(); + return; + } + controller().mouse().moveTo(clickLoc, "medium"); + controller().mouse().leftClick(); + } + + /** + * Checks whether the player’s inventory is full by examining the final inventory slot for the + * presence of an iron ore image. + * + * @return {@code true} if the inventory is full, otherwise {@code false} + */ + private boolean isInventoryFull() { + Rectangle invSlot = controller().zones().getInventorySlots().get(27); + BufferedImage invSlotImg = ScreenManager.captureZone(invSlot); + Rectangle match = TemplateMatching.match(ironOre, invSlotImg, 0.05).bounds(); + return match != null; + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoWineScript.java b/src/main/java/com/chromascape/scripts/DemoWineScript.java index f04eb66..ce5edee 100644 --- a/src/main/java/com/chromascape/scripts/DemoWineScript.java +++ b/src/main/java/com/chromascape/scripts/DemoWineScript.java @@ -1,192 +1,192 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.awt.image.BufferedImage; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * DemoWineScript serves as a tutorial and example script to demonstrate how to automate basic tasks - * using the ChromaScape framework. - * - *

Warning: This script is NOT intended for actual use or to be run at all! Running it may - * violate terms of service of the target application and result in a ban. - * - *

The script automates a simplified "wine making" task by interacting with a game UI through - * template matching, clicking, and keyboard inputs. - */ -public class DemoWineScript extends BaseScript { - - private final Logger logger = LogManager.getLogger(this.getClass()); - - private static final String grapes = "/images/user/Grapes.png"; - private static final String jugs = "/images/user/Jug_of_water.png"; - private static final String dumpBank = "/images/user/Dump_bank.png"; - private static final String unfermented = "/images/user/Unfermented_wine.png"; - - private static final int MAX_ATTEMPTS = 15; - private static final int INVENT_SLOT_GRAPES = 13; - private static final int INVENT_SLOT_JUGS = 14; - - private boolean bankFlag = true; - - /** - * The core logic of the script. This function will loop repeatedly until {@link #stop()} is - * called. You should avoid putting all your script logic directly inside this function, instead - * split it up into other functions as shown below. - */ - @Override - protected void cycle() { - if (bankFlag) { - clickBank(); // Open the bank once at the start of the script - waitRandomMillis(700, 900); - bankFlag = false; - // Cannot start in bank because UI needs to initialise - } - - clickImage(grapes, "fast", 0.07); // Take out grapes - waitRandomMillis(300, 600); - - clickImage(jugs, "slow", 0.065); // Take out water jugs - waitRandomMillis(400, 500); - - pressEscape(); // Exit bank UI - waitRandomMillis(600, 800); - - clickInvSlot(INVENT_SLOT_JUGS, "fast"); // Click the jugs of water in the inventory - waitRandomMillis(400, 500); - - clickInvSlot(INVENT_SLOT_GRAPES, "medium"); // Use the jugs on the grapes to start making wine - waitRandomMillis(800, 900); - - pressSpace(); // Accept the start button - waitRandomMillis(17000, 18000); // Wait for wines to combine - - clickBank(); // Open the bank to drop off items - waitRandomMillis(700, 900); - - clickImage(dumpBank, "medium", 0.055); // Put the fermenting wines in the bank to repeat - waitRandomMillis(650, 750); - - if (checkIfImageInvSlot1(unfermented, 0.055)) { // Repeating because bank is weird - controller().mouse().leftClick(); - waitRandomMillis(600, 800); - } - } - - /** - * Simulates pressing the Escape key by sending the key press and release events to the client - * keyboard controller. - */ - private void pressEscape() { - controller().keyboard().sendKeyDown(KeyEvent.VK_ESCAPE); - waitRandomMillis(80, 100); - controller().keyboard().sendKeyRelease(KeyEvent.VK_ESCAPE); - } - - /** - * Simulates pressing the Space key by sending the key press and release events to the client - * keyboard controller. - */ - private void pressSpace() { - controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); - waitRandomMillis(300, 500); - controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); - } - - /** - * Attempts to locate and click the purple bank object within the game view. It searches for - * purple contours, then clicks a randomly distributed point inside the contour bounding box, - * retrying up to a maximum number of attempts. Logs failures and stops the script if unable to - * click successfully. - */ - private void clickBank() { - Point clickLocation = - PointSelector.getRandomPointInColour( - controller().zones().getGameView(), "Cyan", MAX_ATTEMPTS); - - if (clickLocation == null) { - logger.error("clickBank click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, "medium"); - logger.info("Clicked on purple bank object at {}", clickLocation); - - controller().mouse().leftClick(); - } - - /** - * Searches for the provided image template within the current game view, then clicks a random - * point within the detected bounding box if the match exceeds the defined threshold. - * - * @param imagePath the BufferedImage template to locate and click within the game view - * @param speed the speed that the mouse moves to click the image - * @param threshold the openCV threshold to decide if a match exists - */ - private void clickImage(String imagePath, String speed, double threshold) { - BufferedImage gameView = controller().zones().getGameView(); - Point clickLocation = PointSelector.getRandomPointInImage(imagePath, gameView, threshold); - - if (clickLocation == null) { - logger.error("clickImage click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, speed); - - controller().mouse().leftClick(); - logger.info("Clicked on image at {}", clickLocation); - } - - /** - * Clicks a random point within the bounding box of a given inventory slot. - * - * @param slot the index of the inventory slot to click (0-27) - * @param speed the speed that the mouse moves to click the image - */ - private void clickInvSlot(int slot, String speed) { - Rectangle boundingBox = controller().zones().getInventorySlots().get(slot); - if (boundingBox == null || boundingBox.isEmpty()) { - logger.info("Inventory slot {} not found.", slot); - stop(); - return; - } - - Point clickLocation = ClickDistribution.generateRandomPoint(boundingBox); - - if (clickLocation == null) { - logger.error("clickInventSlot click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, speed); - - controller().mouse().leftClick(); - logger.info("Clicked inventory slot {} at {}", slot, clickLocation); - } - - /** - * Checks if an image exists in the first inventory slot. - * - * @param imagePath the path to the image being searched - * @param threshold the openCV threshold to decide if a match exists - * @return true if the image exists in the inventory slot 1, else false - */ - private boolean checkIfImageInvSlot1(String imagePath, double threshold) { - BufferedImage inventorySlot1 = - ScreenManager.captureZone(controller().zones().getInventorySlots().get(0)); - - MatchResult result = TemplateMatching.match(imagePath, inventorySlot1, threshold); - - return result.success(); - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * DemoWineScript serves as a tutorial and example script to demonstrate how to automate basic tasks + * using the ChromaScape framework. + * + *

Warning: This script is NOT intended for actual use or to be run at all! Running it may + * violate terms of service of the target application and result in a ban. + * + *

The script automates a simplified "wine making" task by interacting with a game UI through + * template matching, clicking, and keyboard inputs. + */ +public class DemoWineScript extends BaseScript { + + private final Logger logger = LogManager.getLogger(this.getClass()); + + private static final String grapes = "/images/user/Grapes.png"; + private static final String jugs = "/images/user/Jug_of_water.png"; + private static final String dumpBank = "/images/user/Dump_bank.png"; + private static final String unfermented = "/images/user/Unfermented_wine.png"; + + private static final int MAX_ATTEMPTS = 15; + private static final int INVENT_SLOT_GRAPES = 13; + private static final int INVENT_SLOT_JUGS = 14; + + private boolean bankFlag = true; + + /** + * The core logic of the script. This function will loop repeatedly until {@link #stop()} is + * called. You should avoid putting all your script logic directly inside this function, instead + * split it up into other functions as shown below. + */ + @Override + protected void cycle() { + if (bankFlag) { + clickBank(); // Open the bank once at the start of the script + waitRandomMillis(700, 900); + bankFlag = false; + // Cannot start in bank because UI needs to initialise + } + + clickImage(grapes, "fast", 0.07); // Take out grapes + waitRandomMillis(300, 600); + + clickImage(jugs, "slow", 0.065); // Take out water jugs + waitRandomMillis(400, 500); + + pressEscape(); // Exit bank UI + waitRandomMillis(600, 800); + + clickInvSlot(INVENT_SLOT_JUGS, "fast"); // Click the jugs of water in the inventory + waitRandomMillis(400, 500); + + clickInvSlot(INVENT_SLOT_GRAPES, "medium"); // Use the jugs on the grapes to start making wine + waitRandomMillis(800, 900); + + pressSpace(); // Accept the start button + waitRandomMillis(17000, 18000); // Wait for wines to combine + + clickBank(); // Open the bank to drop off items + waitRandomMillis(700, 900); + + clickImage(dumpBank, "medium", 0.055); // Put the fermenting wines in the bank to repeat + waitRandomMillis(650, 750); + + if (checkIfImageInvSlot1(unfermented, 0.055)) { // Repeating because bank is weird + controller().mouse().leftClick(); + waitRandomMillis(600, 800); + } + } + + /** + * Simulates pressing the Escape key by sending the key press and release events to the client + * keyboard controller. + */ + private void pressEscape() { + controller().keyboard().sendKeyDown(KeyEvent.VK_ESCAPE); + waitRandomMillis(80, 100); + controller().keyboard().sendKeyRelease(KeyEvent.VK_ESCAPE); + } + + /** + * Simulates pressing the Space key by sending the key press and release events to the client + * keyboard controller. + */ + private void pressSpace() { + controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); + waitRandomMillis(300, 500); + controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); + } + + /** + * Attempts to locate and click the purple bank object within the game view. It searches for + * purple contours, then clicks a randomly distributed point inside the contour bounding box, + * retrying up to a maximum number of attempts. Logs failures and stops the script if unable to + * click successfully. + */ + private void clickBank() { + Point clickLocation = + PointSelector.getRandomPointInColour( + controller().zones().getGameView(), "Cyan", MAX_ATTEMPTS); + + if (clickLocation == null) { + logger.error("clickBank click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, "medium"); + logger.info("Clicked on purple bank object at {}", clickLocation); + + controller().mouse().leftClick(); + } + + /** + * Searches for the provided image template within the current game view, then clicks a random + * point within the detected bounding box if the match exceeds the defined threshold. + * + * @param imagePath the BufferedImage template to locate and click within the game view + * @param speed the speed that the mouse moves to click the image + * @param threshold the openCV threshold to decide if a match exists + */ + private void clickImage(String imagePath, String speed, double threshold) { + BufferedImage gameView = controller().zones().getGameView(); + Point clickLocation = PointSelector.getRandomPointInImage(imagePath, gameView, threshold); + + if (clickLocation == null) { + logger.error("clickImage click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, speed); + + controller().mouse().leftClick(); + logger.info("Clicked on image at {}", clickLocation); + } + + /** + * Clicks a random point within the bounding box of a given inventory slot. + * + * @param slot the index of the inventory slot to click (0-27) + * @param speed the speed that the mouse moves to click the image + */ + private void clickInvSlot(int slot, String speed) { + Rectangle boundingBox = controller().zones().getInventorySlots().get(slot); + if (boundingBox == null || boundingBox.isEmpty()) { + logger.info("Inventory slot {} not found.", slot); + stop(); + return; + } + + Point clickLocation = ClickDistribution.generateRandomPoint(boundingBox); + + if (clickLocation == null) { + logger.error("clickInventSlot click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, speed); + + controller().mouse().leftClick(); + logger.info("Clicked inventory slot {} at {}", slot, clickLocation); + } + + /** + * Checks if an image exists in the first inventory slot. + * + * @param imagePath the path to the image being searched + * @param threshold the openCV threshold to decide if a match exists + * @return true if the image exists in the inventory slot 1, else false + */ + private boolean checkIfImageInvSlot1(String imagePath, double threshold) { + BufferedImage inventorySlot1 = + ScreenManager.captureZone(controller().zones().getInventorySlots().get(0)); + + MatchResult result = TemplateMatching.match(imagePath, inventorySlot1, threshold); + + return result.success(); + } +} diff --git a/src/main/java/com/chromascape/scripts/Screenshotter.java b/src/main/java/com/chromascape/scripts/Screenshotter.java index 3617923..ad51b15 100644 --- a/src/main/java/com/chromascape/scripts/Screenshotter.java +++ b/src/main/java/com/chromascape/scripts/Screenshotter.java @@ -1,49 +1,49 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.image.BufferedImage; -import java.io.File; -import javax.imageio.ImageIO; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Creates screenshots of the client and stores it in an external folder "/output". Screenshots are - * saved as "original.png". Screenshots taken by this class are used by the colour picker in the web - * UI. This is a single cycle program that exits out immediately after completion. Served as a "user - * script" in the web UI, however started via a dedicated button, rather than started as a normal - * script. - */ -public class Screenshotter extends BaseScript { - - /** - * The logger is specially initialised to be used in this program. This is exactly how you should - * access it in a user script. - */ - private final Logger logger = LogManager.getLogger(Screenshotter.class); - - public static final String ORIGINAL_IMAGE_PATH = "output/original.png"; - - /** Same constructor as super (BaseScript). */ - public Screenshotter() { - super(); - } - - /** - * Takes a screenshot and saves it in the "/output" directory on the same level as /src. Although - * in the BaseScript - this function is repeated until the specified time duration is met - Here, - * because this is a one time task it exits out early by calling "stop()". - */ - @Override - protected void cycle() { - BufferedImage sc = ScreenManager.captureWindow(); - System.out.println("Screenshotter cycle"); - try { - ImageIO.write(sc, "png", new File(ORIGINAL_IMAGE_PATH)); - } catch (Exception e) { - logger.error(e.getMessage()); - } - stop(); - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Creates screenshots of the client and stores it in an external folder "/output". Screenshots are + * saved as "original.png". Screenshots taken by this class are used by the colour picker in the web + * UI. This is a single cycle program that exits out immediately after completion. Served as a "user + * script" in the web UI, however started via a dedicated button, rather than started as a normal + * script. + */ +public class Screenshotter extends BaseScript { + + /** + * The logger is specially initialised to be used in this program. This is exactly how you should + * access it in a user script. + */ + private final Logger logger = LogManager.getLogger(Screenshotter.class); + + public static final String ORIGINAL_IMAGE_PATH = "output/original.png"; + + /** Same constructor as super (BaseScript). */ + public Screenshotter() { + super(); + } + + /** + * Takes a screenshot and saves it in the "/output" directory on the same level as /src. Although + * in the BaseScript - this function is repeated until the specified time duration is met - Here, + * because this is a one time task it exits out early by calling "stop()". + */ + @Override + protected void cycle() { + BufferedImage sc = ScreenManager.captureWindow(); + System.out.println("Screenshotter cycle"); + try { + ImageIO.write(sc, "png", new File(ORIGINAL_IMAGE_PATH)); + } catch (Exception e) { + logger.error(e.getMessage()); + } + stop(); + } +} diff --git a/src/main/java/com/chromascape/utils/actions/Idler.java b/src/main/java/com/chromascape/utils/actions/Idler.java index a6165cf..55a61e4 100644 --- a/src/main/java/com/chromascape/utils/actions/Idler.java +++ b/src/main/java/com/chromascape/utils/actions/Idler.java @@ -1,61 +1,61 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import java.time.Duration; -import java.time.Instant; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Utility class for handling idle behavior in scripts. - * - *

This class provides functionality to pause execution for a given amount of time, or until the - * game client indicates the player has become idle again through a chat message. - */ -public class Idler { - - private static final Logger logger = LogManager.getLogger(Idler.class); - private static volatile String lastMessage = ""; - - private static final ColourObj black = - new ColourObj("black", new Scalar(0, 0, 0, 0), new Scalar(0, 0, 0, 0)); - private static final ColourObj chatRed = - new ColourObj("chatRed", new Scalar(177, 229, 239, 0), new Scalar(179, 240, 240, 0)); - - /** - * Waits until either the specified timeout has elapsed or until the client chatbox reports that - * the player is idle. - * - *

Specifically, this method monitors the "Latest Message" zone in the chatbox for a red - * message containing the substring {@code "idle"} or {@code "moving"}, which typically appears - * when using the Idle Notifier plugin - * - * @param base the active {@link BaseScript} instance, usually passed as {@code this} - * @param timeoutSeconds the maximum number of seconds to remain idle before continuing - * @return {@code true} if the idle message was found, {@code false} if the timeout was reached - */ - public static boolean waitUntilIdle(BaseScript base, int timeoutSeconds) { - // Initial wait to prevent race condition to previous idle message. - BaseScript.waitMillis(600); - BaseScript.checkInterrupted(); - Instant start = Instant.now(); - Instant deadline = start.plus(Duration.ofSeconds(timeoutSeconds)); - while (Instant.now().isBefore(deadline)) { - // Throttle wait to reduce lag, this is enough. - BaseScript.waitMillis(300); - Rectangle latestMessage = base.controller().zones().getChatTabs().get("Latest Message"); - String idleText = Ocr.extractText(latestMessage, "Plain 12", chatRed, true); - String timeStamp = Ocr.extractText(latestMessage, "Plain 12", black, true); - if ((idleText.contains("moving") || idleText.contains("idle")) - && !timeStamp.equals(lastMessage)) { - lastMessage = timeStamp; - return true; - } - } - return false; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import java.time.Duration; +import java.time.Instant; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Utility class for handling idle behavior in scripts. + * + *

This class provides functionality to pause execution for a given amount of time, or until the + * game client indicates the player has become idle again through a chat message. + */ +public class Idler { + + private static final Logger logger = LogManager.getLogger(Idler.class); + private static volatile String lastMessage = ""; + + private static final ColourObj black = + new ColourObj("black", new Scalar(0, 0, 0, 0), new Scalar(0, 0, 0, 0)); + private static final ColourObj chatRed = + new ColourObj("chatRed", new Scalar(177, 229, 239, 0), new Scalar(179, 240, 240, 0)); + + /** + * Waits until either the specified timeout has elapsed or until the client chatbox reports that + * the player is idle. + * + *

Specifically, this method monitors the "Latest Message" zone in the chatbox for a red + * message containing the substring {@code "idle"} or {@code "moving"}, which typically appears + * when using the Idle Notifier plugin + * + * @param base the active {@link BaseScript} instance, usually passed as {@code this} + * @param timeoutSeconds the maximum number of seconds to remain idle before continuing + * @return {@code true} if the idle message was found, {@code false} if the timeout was reached + */ + public static boolean waitUntilIdle(BaseScript base, int timeoutSeconds) { + // Initial wait to prevent race condition to previous idle message. + BaseScript.waitMillis(600); + BaseScript.checkInterrupted(); + Instant start = Instant.now(); + Instant deadline = start.plus(Duration.ofSeconds(timeoutSeconds)); + while (Instant.now().isBefore(deadline)) { + // Throttle wait to reduce lag, this is enough. + BaseScript.waitMillis(300); + Rectangle latestMessage = base.controller().zones().getChatTabs().get("Latest Message"); + String idleText = Ocr.extractText(latestMessage, "Plain 12", chatRed, true); + String timeStamp = Ocr.extractText(latestMessage, "Plain 12", black, true); + if ((idleText.contains("moving") || idleText.contains("idle")) + && !timeStamp.equals(lastMessage)) { + lastMessage = timeStamp; + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/ItemDropper.java b/src/main/java/com/chromascape/utils/actions/ItemDropper.java index b416e3b..cceb2da 100644 --- a/src/main/java/com/chromascape/utils/actions/ItemDropper.java +++ b/src/main/java/com/chromascape/utils/actions/ItemDropper.java @@ -1,126 +1,126 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * An actions utility - a utility that does commonly repeated tasks found in bot scripts. This - * utility provides functionality for dropping items using human-like patterns. - */ -public class ItemDropper { - - private static final Logger logger = LogManager.getLogger(ItemDropper.class); - private static final int INVENTORY_SIZE = 28; - - /** Defines the order in which items should be dropped. */ - public enum DropPattern { - - /** Drops items left-to-right, top-to-bottom (0, 1, 2...). */ - STANDARD, - - /** - * Drops items using a "2-Row Vertical Strip" logic. - * - *

Drops pairs of items vertically (e.g., 0 then 4, 1 then 5) moving across, then moves to - * the next set of two rows, imo this looks most human. - */ - ZIGZAG - } - - /** - * Drops all items in the inventory using the default ZigZag (2-Row Strip) pattern. - * - * @param baseScript The script that's running (Keyword: {@code this}). - */ - public static void dropAll(BaseScript baseScript) { - dropAll(baseScript, DropPattern.ZIGZAG, new int[0]); - } - - /** - * Drops all items in the inventory using a specified pattern. - * - * @param baseScript The script that's running (Keyword: {@code this}). - * @param pattern The {@link DropPattern} to use for index generation. - * @param exclude An int array with indexes NOT to be dropped. - */ - public static void dropAll(BaseScript baseScript, DropPattern pattern, int[] exclude) { - if (baseScript.controller() == null) { - logger.error("Controller is null, cannot drop items."); - return; - } - - logger.info("Dropping all items using pattern: {}", pattern); - - List slotsToDrop = generateSlotIndices(pattern); - - // Start Shift-Drop - baseScript.controller().keyboard().sendKeyDown(KeyEvent.VK_SHIFT); - BaseScript.waitRandomMillis(400, 850); - - try { - for (int slotIndex : slotsToDrop) { - if (slotIndex >= baseScript.controller().zones().getInventorySlots().size()) { - continue; - } - - if (Arrays.stream(exclude).anyMatch(x -> x == slotIndex)) { - continue; - } - - Rectangle slotZone = baseScript.controller().zones().getInventorySlots().get(slotIndex); - Point clickPoint = ClickDistribution.generateRandomPoint(slotZone); - - baseScript.controller().mouse().moveTo(clickPoint, "fast"); - baseScript.controller().mouse().leftClick(); - BaseScript.waitRandomMillis(40, 90); - } - } finally { - BaseScript.waitRandomMillis(100, 200); - baseScript.controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT); - } - } - - /** - * Generates a list of inventory slot indices based on the selected pattern. - * - * @param pattern The pattern strategy. - * @return A list of integers representing the order of slots to click. - */ - private static List generateSlotIndices(DropPattern pattern) { - List indices = new ArrayList<>(); - - switch (pattern) { - case ZIGZAG: - // Process rows 0-1, then 2-3, then 4-5 in vertical pairs - for (int rowGroup = 0; rowGroup < 3; rowGroup++) { - int baseRowStart = rowGroup * 8; // 0, 8, 16 - for (int col = 0; col < 4; col++) { - indices.add(baseRowStart + col); // Top of pair (e.g., 0) - indices.add(baseRowStart + col + 4); // Bottom of pair (e.g., 4) - } - } - // Handle the last row (6) linearly - indices.add(24); - indices.add(25); - indices.add(26); - indices.add(27); - break; - - case STANDARD: - default: - indices = IntStream.range(0, INVENTORY_SIZE).boxed().collect(Collectors.toList()); - break; - } - return indices; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * An actions utility - a utility that does commonly repeated tasks found in bot scripts. This + * utility provides functionality for dropping items using human-like patterns. + */ +public class ItemDropper { + + private static final Logger logger = LogManager.getLogger(ItemDropper.class); + private static final int INVENTORY_SIZE = 28; + + /** Defines the order in which items should be dropped. */ + public enum DropPattern { + + /** Drops items left-to-right, top-to-bottom (0, 1, 2...). */ + STANDARD, + + /** + * Drops items using a "2-Row Vertical Strip" logic. + * + *

Drops pairs of items vertically (e.g., 0 then 4, 1 then 5) moving across, then moves to + * the next set of two rows, imo this looks most human. + */ + ZIGZAG + } + + /** + * Drops all items in the inventory using the default ZigZag (2-Row Strip) pattern. + * + * @param baseScript The script that's running (Keyword: {@code this}). + */ + public static void dropAll(BaseScript baseScript) { + dropAll(baseScript, DropPattern.ZIGZAG, new int[0]); + } + + /** + * Drops all items in the inventory using a specified pattern. + * + * @param baseScript The script that's running (Keyword: {@code this}). + * @param pattern The {@link DropPattern} to use for index generation. + * @param exclude An int array with indexes NOT to be dropped. + */ + public static void dropAll(BaseScript baseScript, DropPattern pattern, int[] exclude) { + if (baseScript.controller() == null) { + logger.error("Controller is null, cannot drop items."); + return; + } + + logger.info("Dropping all items using pattern: {}", pattern); + + List slotsToDrop = generateSlotIndices(pattern); + + // Start Shift-Drop + baseScript.controller().keyboard().sendKeyDown(KeyEvent.VK_SHIFT); + BaseScript.waitRandomMillis(400, 850); + + try { + for (int slotIndex : slotsToDrop) { + if (slotIndex >= baseScript.controller().zones().getInventorySlots().size()) { + continue; + } + + if (Arrays.stream(exclude).anyMatch(x -> x == slotIndex)) { + continue; + } + + Rectangle slotZone = baseScript.controller().zones().getInventorySlots().get(slotIndex); + Point clickPoint = ClickDistribution.generateRandomPoint(slotZone); + + baseScript.controller().mouse().moveTo(clickPoint, "fast"); + baseScript.controller().mouse().leftClick(); + BaseScript.waitRandomMillis(40, 90); + } + } finally { + BaseScript.waitRandomMillis(100, 200); + baseScript.controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT); + } + } + + /** + * Generates a list of inventory slot indices based on the selected pattern. + * + * @param pattern The pattern strategy. + * @return A list of integers representing the order of slots to click. + */ + private static List generateSlotIndices(DropPattern pattern) { + List indices = new ArrayList<>(); + + switch (pattern) { + case ZIGZAG: + // Process rows 0-1, then 2-3, then 4-5 in vertical pairs + for (int rowGroup = 0; rowGroup < 3; rowGroup++) { + int baseRowStart = rowGroup * 8; // 0, 8, 16 + for (int col = 0; col < 4; col++) { + indices.add(baseRowStart + col); // Top of pair (e.g., 0) + indices.add(baseRowStart + col + 4); // Bottom of pair (e.g., 4) + } + } + // Handle the last row (6) linearly + indices.add(24); + indices.add(25); + indices.add(26); + indices.add(27); + break; + + case STANDARD: + default: + indices = IntStream.range(0, INVENTORY_SIZE).boxed().collect(Collectors.toList()); + break; + } + return indices; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/Minimap.java b/src/main/java/com/chromascape/utils/actions/Minimap.java index b7558ca..18633e9 100644 --- a/src/main/java/com/chromascape/utils/actions/Minimap.java +++ b/src/main/java/com/chromascape/utils/actions/Minimap.java @@ -1,91 +1,91 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Class for retrieving information from the minimap area, such as orb data (HP, prayer, run, and - * spec) and XP data. - */ -public class Minimap { - - private static final ColourObj textColour = - new ColourObj("green", new Scalar(0, 254, 254, 0), new Scalar(60, 255, 255, 0)); - - private static final ColourObj white = - new ColourObj("White", new Scalar(0, 0, 255, 0), new Scalar(0, 0, 255, 0)); - - /** - * Returns the character's current hitpoints, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getHp(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("hpText"); - String hpText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (hpText.isEmpty()) { - return -1; - } - return Integer.parseInt(hpText); - } - - /** - * Returns the character's current prayer, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getPrayer(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("prayerText"); - String prayerText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (prayerText.isEmpty()) { - return -1; - } - return Integer.parseInt(prayerText); - } - - /** - * Returns the character's current run energy, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getRun(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("runText"); - String runText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (runText.isEmpty()) { - return -1; - } - return Integer.parseInt(runText); - } - - /** - * Returns the character's current special attack energy, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getSpec(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("specText"); - String specText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (specText.isEmpty()) { - return -1; - } - return Integer.parseInt(specText); - } - - /** - * Retrieves the current XP from beside the minimap UI element. - * - *

It is highly recommended to set the XP bar to permanent, as seen here: see Requirements - * - * @param script The current running script (typically pass {@code this}) - * @return the XP integer, or empty if not found - */ - public static int getXp(BaseScript script) { - Rectangle xpZone = script.controller().zones().getMinimap().get("totalXP"); - String xpText = Ocr.extractText(xpZone, "Plain 12", white, true); - return Integer.parseInt(xpText.trim().replace(",", "")); - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Class for retrieving information from the minimap area, such as orb data (HP, prayer, run, and + * spec) and XP data. + */ +public class Minimap { + + private static final ColourObj textColour = + new ColourObj("green", new Scalar(0, 254, 254, 0), new Scalar(60, 255, 255, 0)); + + private static final ColourObj white = + new ColourObj("White", new Scalar(0, 0, 255, 0), new Scalar(0, 0, 255, 0)); + + /** + * Returns the character's current hitpoints, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getHp(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("hpText"); + String hpText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (hpText.isEmpty()) { + return -1; + } + return Integer.parseInt(hpText); + } + + /** + * Returns the character's current prayer, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getPrayer(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("prayerText"); + String prayerText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (prayerText.isEmpty()) { + return -1; + } + return Integer.parseInt(prayerText); + } + + /** + * Returns the character's current run energy, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getRun(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("runText"); + String runText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (runText.isEmpty()) { + return -1; + } + return Integer.parseInt(runText); + } + + /** + * Returns the character's current special attack energy, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getSpec(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("specText"); + String specText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (specText.isEmpty()) { + return -1; + } + return Integer.parseInt(specText); + } + + /** + * Retrieves the current XP from beside the minimap UI element. + * + *

It is highly recommended to set the XP bar to permanent, as seen here: see Requirements + * + * @param script The current running script (typically pass {@code this}) + * @return the XP integer, or empty if not found + */ + public static int getXp(BaseScript script) { + Rectangle xpZone = script.controller().zones().getMinimap().get("totalXP"); + String xpText = Ocr.extractText(xpZone, "Plain 12", white, true); + return Integer.parseInt(xpText.trim().replace(",", "")); + } +} diff --git a/src/main/java/com/chromascape/utils/actions/MouseOver.java b/src/main/java/com/chromascape/utils/actions/MouseOver.java index d6c121c..1ecc875 100644 --- a/src/main/java/com/chromascape/utils/actions/MouseOver.java +++ b/src/main/java/com/chromascape/utils/actions/MouseOver.java @@ -1,85 +1,212 @@ -package com.chromascape.utils.actions; - -import static org.bytedeco.opencv.global.opencv_core.bitwise_or; -import static org.bytedeco.opencv.global.opencv_core.inRange; -import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2HSV; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; -import static org.opencv.core.CvType.CV_8UC1; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * An actions utility to provide a high level API for MouseOverText. - * - *

Uses OpenCV to iterate over a list of colours, and collates the resulting image into one - * overall mask. This allows the user to get the text of the whole MouseOverText zone regardless of - * colour. - * - *

Allows the user to grab the MouseOverText immediately as a string, excluding spaces. - */ -public class MouseOver { - - /** Colours that exist within the MouseOverText zone. */ - private static final List colours = - new ArrayList<>( - Arrays.asList( - new ColourObj("TEXT_CYAN", new Scalar(80, 180, 200, 0), new Scalar(100, 255, 255, 0)), - new ColourObj( - "TEXT_OFF_WHITE", new Scalar(0, 0, 190, 0), new Scalar(180, 30, 255, 0)), - new ColourObj("TEXT_ORANGE", new Scalar(8, 140, 180, 0), new Scalar(22, 220, 255, 0)), - new ColourObj("TEXT_GREEN", new Scalar(50, 190, 100, 0), new Scalar(95, 255, 255, 0)), - new ColourObj( - "TEXT_YELLOW", new Scalar(25, 130, 190, 0), new Scalar(35, 255, 255, 0)), - new ColourObj("TEXT_RED", new Scalar(0, 190, 190, 0), new Scalar(8, 255, 255, 0)))); - - /** - * Captures the minimap to extract all possible colours. Layers the captures to create a mask - * containing all text regardless of colour. Searches for text based on this. - * - * @param baseScript Your script instance, typically {@code this}. - * @return The string found within the MouseOverText zone (No spaces). - */ - public static String getText(BaseScript baseScript) { - // Get image of MouseOverText - Rectangle zone = baseScript.controller().zones().getMouseOver(); - BufferedImage capture = ScreenManager.captureZone(zone); - - // Convert the captured BGR image to HSV once here, - // so we don't have to do it inside the loop for every single colour - Mat hsvMat = new Mat(); - try (Mat bgrMat = Java2DFrameUtils.toMat(capture)) { - cvtColor(bgrMat, hsvMat, COLOR_BGR2HSV); - } - - // Accumulate all colour matches into a single binary mask - try (Mat combinedMask = - new Mat(capture.getHeight(), capture.getWidth(), CV_8UC1, new Scalar(0)); - Mat tempMask = new Mat()) { // Reusable mask for the loop using try with resources - - for (ColourObj c : colours) { - // In memory thresholding using the pre-converted HSV Mat - try (Mat min = new Mat(c.hsvMin()); - Mat max = new Mat(c.hsvMax())) { - inRange(hsvMat, min, max, tempMask); - bitwise_or(combinedMask, tempMask, combinedMask); - } - } - - // Cleanup - hsvMat.release(); - - return Ocr.extractTextFromMask(combinedMask, "Bold 12", true); - } - } -} +package com.chromascape.utils.actions; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.bytedeco.javacpp.indexer.UByteIndexer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * An actions utility to provide a high level API for MouseOverText. Allows the user to get the text + * of the MouseOverText zone regardless of colour. + * + *

Allows the user to grab the MouseOverText as a string, excluding spaces. + */ +public class MouseOver { + + // Higher -> brighter pixels are considered shadows + private static final int SHADOW_THRESHOLD = 120; + + // Lower -> stricter tolerance on similar colours + private static final int COLOUR_TOLERANCE = 20; + + private static final ColourObj ORANGE = + new ColourObj("Orange", new Scalar(16, 224, 255, 0), new Scalar(16, 224, 255, 0)); + + private static final Mat OLIVE = new Mat(1, 1, CV_8UC3, new Scalar(17, 73, 73, 0)); + + /** + * Extracts text from the MouseOverText zone. Does not include spaces. + * + * @param baseScript {@code this} the script that the user is running. + * @return a String of all text found. + */ + public static String getText(BaseScript baseScript) { + // Get image of MouseOverText + Rectangle zone = baseScript.controller().zones().getMouseOver(); + BufferedImage capture = ScreenManager.captureZone(zone); + // BGR image of the zone + Mat image = TemplateMatching.bufferedImageToMat(capture); + // Replace orange to olive to exclude interface colours near zone + Mat orangeMask = ColourContours.extractColours(image, ORANGE); + image.setTo(OLIVE, orangeMask); + // Release mask + orangeMask.release(); + // Extract possible character colours based on shadow + Set textColours = extractTextColour(image); + Object[] uniqueTextColours = removeDuplicatesWithinRange(textColours); + // Set non character pixels to black + Mat mask = maskText(uniqueTextColours, image); + // For testing + // DisplayImage.display(TemplateMatching.matToBufferedImage(mask)); + return Ocr.extractTextFromMask(mask, "Bold 12", true); + } + + /** + * Eliminates any colours that can be considered similar enough within the colour threshold. + * + * @param textColours a list of packed RGB integers. + * @return Unique colours that can't be considered similar. + */ + private static Object[] removeDuplicatesWithinRange(Set textColours) { + List palette = new ArrayList<>(); + + for (Integer newColour : textColours) { + boolean found = false; + + for (Integer existingColour : palette) { + + if (isInThreshold(existingColour, newColour)) { + found = true; + break; + } + } + + if (!found) { + palette.add(newColour); + } + } + return palette.toArray(); + } + + /** + * Compares each pixel in an image against a palette of unique colours that represent text colours + * in an image. Creates a mask with white pixels where text should be and the background black. + * + * @param colours a list of unique colours not within the colour threshold of each other. + * @param image the image containing text to mask. + * @return a CV_8UC1 mask with white pixels where text should be and the rest black. + */ + private static Mat maskText(Object[] colours, Mat image) { + Mat mask = new Mat(image.rows(), image.cols(), CV_8UC1, new Scalar(0)); + UByteIndexer img = image.createIndexer(); + UByteIndexer out = mask.createIndexer(); + + for (int y = 0; y < image.rows(); y++) { + for (int x = 0; x < image.cols(); x++) { + int b = img.get(y, x, 0) & 0xFF; + int g = img.get(y, x, 1) & 0xFF; + int r = img.get(y, x, 2) & 0xFF; + int packed = (r << 16) | (g << 8) | b; + + boolean isWhite = false; + + for (Object colour : colours) { + Integer colourInt = (Integer) colour; + + if (isInThreshold(colourInt, packed)) { + isWhite = true; + break; + } + } + + out.put(y, x, isWhite ? 255 : 0); + } + } + + img.release(); + out.release(); + return mask; + } + + /** + * Compares the Euclidean distance between the RGB values of two pixels against a static tolerance + * value to gauge if they are similar enough. + * + * @param colour1 one of the colours to compare. + * @param colour2 one of the colours to compare. + * @return whether the colours are similar enough to consider part of the same text colour. + */ + private static boolean isInThreshold(int colour1, int colour2) { + // Unpack first colour + int r = (colour1 >> 16) & 0xFF; + int g = (colour1 >> 8) & 0xFF; + int b = colour1 & 0xFF; + // Unpack second + int r2 = (colour2 >> 16) & 0xFF; + int g2 = (colour2 >> 8) & 0xFF; + int b2 = colour2 & 0xFF; + // Calculate difference + int dr = r - r2; + int dg = g - g2; + int db = b - b2; + // Calculate distance^2 + int dist2 = dr * dr + dg * dg + db * db; + return dist2 <= COLOUR_TOLERANCE * COLOUR_TOLERANCE; + } + + private static boolean isShadow(int r, int g, int b) { + return ((r + g + b) / 3 < SHADOW_THRESHOLD) + && (r < SHADOW_THRESHOLD * 2) + && (g < SHADOW_THRESHOLD * 2) + && (b < SHADOW_THRESHOLD * 2); + } + + /** + * Iterates through an image's pixels, determines whether a pixel is a shadow of text based on + * brightness, and then saves the pixel top-left of it. Returns a set of unique pixels that are + * considered to be part of text. + * + * @param image the input image which may contain text. + * @return a unique set of packed RGB integers representing pixels belonging to text. + */ + private static Set extractTextColour(Mat image) { + int width = image.cols(); + int height = image.rows(); + + UByteIndexer indexer = image.createIndexer(); + + Set textColours = new HashSet<>(); + + for (int y = 1; y < height; y++) { + for (int x = 1; x < width; x++) { + + int blue = indexer.get(y, x, 0) & 0xFF; + int green = indexer.get(y, x, 1) & 0xFF; + int red = indexer.get(y, x, 2) & 0xFF; + + if (isShadow(red, green, blue)) { + int matchBlue = indexer.get(y - 1, x - 1, 0) & 0xFF; + int matchGreen = indexer.get(y - 1, x - 1, 1) & 0xFF; + int matchRed = indexer.get(y - 1, x - 1, 2) & 0xFF; + + if (isShadow(matchRed, matchGreen, matchBlue)) { + continue; + } + + int r = matchRed & 0xFF; + int g = matchGreen & 0xFF; + int b = matchBlue & 0xFF; + + int packed = (r << 16) | (g << 8) | b; + + textColours.add(packed); + } + } + } + return textColours; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/MovingObject.java b/src/main/java/com/chromascape/utils/actions/MovingObject.java index 6ba717c..1bbfdb6 100644 --- a/src/main/java/com/chromascape/utils/actions/MovingObject.java +++ b/src/main/java/com/chromascape/utils/actions/MovingObject.java @@ -1,175 +1,175 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.concurrent.CompletableFuture; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Provides interaction for moving entities such as Agility obstacles or NPCs. - * - *

This class utilizes the red click sprite's appearance delay to asynchronously pre-calculate - * the next retry point. This ensures that if verification fails, the backup point is ready - * instantly but calculated with fresh screen data to minimize stale click locations. - * - *

Async Verification Pipeline unlike static clicking, this implementation clicks a target - * and validates success by scanning for the red X click sprite. It uses background threads to - * ensure zero downtime between a failed click and the subsequent retry. - */ -public class MovingObject { - - /** - * Half-width of the detection box. A padding of 7 creates a 14x14px capture region which is - * optimized for the 11x11px Red X sprite. Inspired by OSBC. - */ - private static final int PADDING = 7; - - /** Logger that appends to the Web UI. */ - private static final Logger logger = LogManager.getLogger(MovingObject.class); - - private static final String[] RED_CLICK_IMAGES = { - "/images/mouse_clicks/red_1.png", - "/images/mouse_clicks/red_2.png", - "/images/mouse_clicks/red_3.png", - "/images/mouse_clicks/red_4.png" - }; - - /** - * Overload for the primary click method that accepts a colour name string. It performs a lookup - * of the ColourObj from the ColourInstances. - * - * @param colour The unique name of the colour such as Agility_Green - * @param baseScript The active script instance for accessing the Controller - * @return true if a Red X was detected or false if the colour was not found or max retries were - * exceeded - * @throws InterruptedException When mouse movement is interrupted. - */ - public static boolean clickMovingObjectInColourUntilRedClick(String colour, BaseScript baseScript) - throws InterruptedException { - return clickMovingObjectByColourObjUntilRedClick(ColourInstances.getByName(colour), baseScript); - } - - /** - * Attempts to click a moving target defined by a ColourObj and verifies the action by looking for - * a red click. - * - *

    - *
  • Finds a random point within the current screen position of the colour - *
  • Clicks the point and immediately starts a background task to find the next location - *
  • Waits for the game to render the Red X interaction sprite - *
  • Captures a small region around the click and checks for the sprite - *
  • If verification fails, it retrieves the pre-calculated point and retries instantly - *
- * - * @param colour The colour of the moving object - * @param baseScript The active script instance - * @return true if the interaction was verified with a Red X or false otherwise - */ - public static boolean clickMovingObjectByColourObjUntilRedClick( - ColourObj colour, BaseScript baseScript) { - BaseScript.checkInterrupted(); - // Initial Calculation and Click - BufferedImage gameView = baseScript.controller().zones().getGameView(); - Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, colour, 15); - - if (clickLocation == null) { - return false; - } - - baseScript.controller().mouse().moveTo(clickLocation, "fast"); - baseScript.controller().mouse().leftClick(); - - int attempts = 10; - int safetyCounter = 0; - - while (safetyCounter < attempts) { - - // Start calculating the NEXT point immediately - // Running this in the background during the wait below - CompletableFuture nextPointFuture = - CompletableFuture.supplyAsync( - () -> { - BufferedImage futureView = baseScript.controller().zones().getGameView(); - // Increased scan radius for retries to catch moving targets - return PointSelector.getRandomPointByColourObj(futureView, colour, 15); - }); - - // Wait for Red X to appear due to game delay - // This 120ms covers the computation time of nextPointFuture - BaseScript.waitMillis(120); - - // Update the click image - BufferedImage clickImage = getClickImage(clickLocation); - - // Verify click - if (clickImageContainsRedClick(clickImage)) { - // Success so cancel the backup calculation - nextPointFuture.cancel(true); - return true; - } - - // Failure detected so retrieve the backup point - // This should return almost instantly - clickLocation = nextPointFuture.join(); - - if (clickLocation == null) { - logger.warn("Could not find fallback point for colour {}", colour.name()); - break; - } - - // Instant Retry - baseScript.controller().mouse().moveTo(clickLocation, "fast"); - baseScript.controller().mouse().leftClick(); - safetyCounter++; - } - - logger.error("Failed to verify red click on {} after {} attempts", colour.name(), attempts); - return false; - } - - /** - * Captures a screenshot centered on the last click location. - * - *

The region size is calculated using the PADDING constant. By default, it is large enough to - * contain the 11px interaction sprite even with minor rendering offsets. - * - * @param clickLocation The screen coordinate where the mouse last clicked - * @return A BufferedImage of the immediate area around the cursor or null if location is invalid - */ - private static BufferedImage getClickImage(Point clickLocation) { - if (clickLocation == null) { - return null; - } - - Rectangle clickRect = - new Rectangle( - clickLocation.x - PADDING, clickLocation.y - PADDING, PADDING * 2, PADDING * 2); - return ScreenManager.captureZone(clickRect); - } - - /** - * Scans the captured click image for any frame of the Red X animation. - * - *

Iterates through the preloaded red click images using template matching. - * - * @param clickImage The screenshot from the getClickImage method - * @return true if any frame of the rec click animation is present, false otherwise - */ - private static boolean clickImageContainsRedClick(BufferedImage clickImage) { - if (clickImage == null) { - return false; - } - - for (String redClickImage : RED_CLICK_IMAGES) { - return TemplateMatching.match(redClickImage, clickImage, 0.15).success(); - } - return false; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.concurrent.CompletableFuture; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Provides interaction for moving entities such as Agility obstacles or NPCs. + * + *

This class utilizes the red click sprite's appearance delay to asynchronously pre-calculate + * the next retry point. This ensures that if verification fails, the backup point is ready + * instantly but calculated with fresh screen data to minimize stale click locations. + * + *

Async Verification Pipeline unlike static clicking, this implementation clicks a target + * and validates success by scanning for the red X click sprite. It uses background threads to + * ensure zero downtime between a failed click and the subsequent retry. + */ +public class MovingObject { + + /** + * Half-width of the detection box. A padding of 7 creates a 14x14px capture region which is + * optimized for the 11x11px Red X sprite. Inspired by OSBC. + */ + private static final int PADDING = 7; + + /** Logger that appends to the Web UI. */ + private static final Logger logger = LogManager.getLogger(MovingObject.class); + + private static final String[] RED_CLICK_IMAGES = { + "/images/mouse_clicks/red_1.png", + "/images/mouse_clicks/red_2.png", + "/images/mouse_clicks/red_3.png", + "/images/mouse_clicks/red_4.png" + }; + + /** + * Overload for the primary click method that accepts a colour name string. It performs a lookup + * of the ColourObj from the ColourInstances. + * + * @param colour The unique name of the colour such as Agility_Green + * @param baseScript The active script instance for accessing the Controller + * @return true if a Red X was detected or false if the colour was not found or max retries were + * exceeded + * @throws InterruptedException When mouse movement is interrupted. + */ + public static boolean clickMovingObjectInColourUntilRedClick(String colour, BaseScript baseScript) + throws InterruptedException { + return clickMovingObjectByColourObjUntilRedClick(ColourInstances.getByName(colour), baseScript); + } + + /** + * Attempts to click a moving target defined by a ColourObj and verifies the action by looking for + * a red click. + * + *

    + *
  • Finds a random point within the current screen position of the colour + *
  • Clicks the point and immediately starts a background task to find the next location + *
  • Waits for the game to render the Red X interaction sprite + *
  • Captures a small region around the click and checks for the sprite + *
  • If verification fails, it retrieves the pre-calculated point and retries instantly + *
+ * + * @param colour The colour of the moving object + * @param baseScript The active script instance + * @return true if the interaction was verified with a Red X or false otherwise + */ + public static boolean clickMovingObjectByColourObjUntilRedClick( + ColourObj colour, BaseScript baseScript) { + BaseScript.checkInterrupted(); + // Initial Calculation and Click + BufferedImage gameView = baseScript.controller().zones().getGameView(); + Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, colour, 15); + + if (clickLocation == null) { + return false; + } + + baseScript.controller().mouse().moveTo(clickLocation, "fast"); + baseScript.controller().mouse().leftClick(); + + int attempts = 10; + int safetyCounter = 0; + + while (safetyCounter < attempts) { + + // Start calculating the NEXT point immediately + // Running this in the background during the wait below + CompletableFuture nextPointFuture = + CompletableFuture.supplyAsync( + () -> { + BufferedImage futureView = baseScript.controller().zones().getGameView(); + // Increased scan radius for retries to catch moving targets + return PointSelector.getRandomPointByColourObj(futureView, colour, 15); + }); + + // Wait for Red X to appear due to game delay + // This 120ms covers the computation time of nextPointFuture + BaseScript.waitMillis(120); + + // Update the click image + BufferedImage clickImage = getClickImage(clickLocation); + + // Verify click + if (clickImageContainsRedClick(clickImage)) { + // Success so cancel the backup calculation + nextPointFuture.cancel(true); + return true; + } + + // Failure detected so retrieve the backup point + // This should return almost instantly + clickLocation = nextPointFuture.join(); + + if (clickLocation == null) { + logger.warn("Could not find fallback point for colour {}", colour.name()); + break; + } + + // Instant Retry + baseScript.controller().mouse().moveTo(clickLocation, "fast"); + baseScript.controller().mouse().leftClick(); + safetyCounter++; + } + + logger.error("Failed to verify red click on {} after {} attempts", colour.name(), attempts); + return false; + } + + /** + * Captures a screenshot centered on the last click location. + * + *

The region size is calculated using the PADDING constant. By default, it is large enough to + * contain the 11px interaction sprite even with minor rendering offsets. + * + * @param clickLocation The screen coordinate where the mouse last clicked + * @return A BufferedImage of the immediate area around the cursor or null if location is invalid + */ + private static BufferedImage getClickImage(Point clickLocation) { + if (clickLocation == null) { + return null; + } + + Rectangle clickRect = + new Rectangle( + clickLocation.x - PADDING, clickLocation.y - PADDING, PADDING * 2, PADDING * 2); + return ScreenManager.captureZone(clickRect); + } + + /** + * Scans the captured click image for any frame of the Red X animation. + * + *

Iterates through the preloaded red click images using template matching. + * + * @param clickImage The screenshot from the getClickImage method + * @return true if any frame of the rec click animation is present, false otherwise + */ + private static boolean clickImageContainsRedClick(BufferedImage clickImage) { + if (clickImage == null) { + return false; + } + + for (String redClickImage : RED_CLICK_IMAGES) { + return TemplateMatching.match(redClickImage, clickImage, 0.15).success(); + } + return false; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/PointSelector.java b/src/main/java/com/chromascape/utils/actions/PointSelector.java index e9d3cf8..90abeab 100644 --- a/src/main/java/com/chromascape/utils/actions/PointSelector.java +++ b/src/main/java/com/chromascape/utils/actions/PointSelector.java @@ -1,253 +1,253 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ChromaObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.List; -import java.util.function.Function; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * The {@code PointSelector} class provides utility methods for selecting random points within zones - * and colours. These methods are designed to reduce code duplication and focus common actions when - * automating interactions with graphical objects like colours or images. - * - *

Features: - * - *

    - *
  • Finds a random point within the bounding box of a detected image template. - *
  • Finds a random point inside the contour of the first detected object of a specified colour. - *
  • Supports both heuristic-based distributions (dynamic sizing) and explicit - * tightness control. - *
- * - *

These utilities are commonly reused across scripts. The class does not perform any input - * actions (such as clicking), but provides the coordinates needed for it. - * - *

Typical Usage: - * - *

- * // Default heuristic distribution
- * Point imgPoint = PointSelector.getRandomPointInImage(templatePath, gameView, 0.15);
- * // Custom tightness (maybe clicking a ground item)
- * Point colorPoint = PointSelector.getRandomPointInColour(gameView, "Purple", 5, 15.0);
- * 
- * - *

All methods are static and thread-safe. - */ -public class PointSelector { - - private static final Logger logger = LogManager.getLogger(PointSelector.class); - - /** - * Searches for the provided image template within a larger image, then returns a random point - * within the detected bounding box if the match exceeds the defined threshold. Relative to the - * larger image. - * - *

This method uses the default {@link ClickDistribution} heuristic, which dynamically adjusts - * distribution spread based on the size of the found target. - * - * @param templatePath the BufferedImage template to locate within the larger image - * @param image the larger image to search inside (e.g. game view) - * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection - * valid - * @return a valid {@link Point} within the detected region, or {@code null} if no match is found - */ - public static Point getRandomPointInImage( - String templatePath, BufferedImage image, double threshold) { - // Defines which function to apply onto the rectangle found - return findPointInTemplate( - templatePath, image, threshold, ClickDistribution::generateRandomPoint); - } - - /** - * Searches for the provided image template within a larger and returns a random point with a - * specific Gaussian distribution tightness. Relative to the larger image. - * - *

This overload allows for control over where the click lands. Higher tightness values force - * the point closer to the center of the image, while lower values spread it towards the edges. - * - * @param templatePath the BufferedImage template to search for within the larger image view - * @param image the larger image to search inside (e.g. game view) - * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection - * valid - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a valid {@link Point} within the detected region, or {@code null} if no match is found - */ - public static Point getRandomPointInImage( - String templatePath, BufferedImage image, double threshold, double tightness) { - // Defines which function to apply onto the rectangle found - return findPointInTemplate( - templatePath, - image, - threshold, - rect -> ClickDistribution.generateRandomPoint(rect, tightness)); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified colour - * using the default distribution heuristic. - * - *

This is an overload for {@link #getRandomPointByColourObj(BufferedImage, ColourObj, int)}. - * It looks up the colour by name from {@link ColourInstances} at runtime. - * - * @param image the image to search in (e.g. game view) - * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. - * "Purple") - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointInColour( - BufferedImage image, String colourName, int maxAttempts) { - // Calls the public API after grabbing the colour - return getRandomPointByColourObj(image, ColourInstances.getByName(colourName), maxAttempts); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified colour - * using a specific Gaussian tightness. - * - * @param image the image to search in (e.g. game view) - * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. - * "Purple") - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointInColour( - BufferedImage image, String colourName, int maxAttempts, double tightness) { - // Call the public API after grabbing the colour - return getRandomPointByColourObj( - image, ColourInstances.getByName(colourName), maxAttempts, tightness); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified {@link - * ColourObj}. - * - *

Uses {@link ColourContours} to mask the image and extract contours. It generates a random - * point within the bounding box of the detected {@link ChromaObj} and verifies if the point lies - * within the actual contour polygon. - * - * @param image the image to search in - * @param colour the specific {@link ColourObj} to detect - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointByColourObj( - BufferedImage image, ColourObj colour, int maxAttempts) { - // Defines which function to apply onto the rectangle found - return findPointInColourInternal( - image, colour, maxAttempts, ClickDistribution::generateRandomPoint); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified {@link - * ColourObj} using a specific Gaussian tightness. - * - * @param image the image to search in - * @param colour the specific {@link ColourObj} to detect - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointByColourObj( - BufferedImage image, ColourObj colour, int maxAttempts, double tightness) { - // Defines which function to apply onto the rectangle found - return findPointInColourInternal( - image, colour, maxAttempts, rect -> ClickDistribution.generateRandomPoint(rect, tightness)); - } - - /** - * Internal abstraction for template matching logic. Executes the match and applies the provided - * point generation strategy. - */ - private static Point findPointInTemplate( - String templatePath, - BufferedImage image, - double threshold, - Function pointGenerator) { - BaseScript.checkInterrupted(); - MatchResult result = TemplateMatching.match(templatePath, image, threshold); - - if (!result.success()) { - logger.error("getRandomPointInImage failed: {}", result.message()); - return null; - } - // Applying the desired function parameter onto the bounding box and returning it - return pointGenerator.apply(result.bounds()); - } - - /** - * Internal abstraction for colour contour logic. Handles object detection, contour validation - * loops, and memory cleanup. - */ - private static Point findPointInColourInternal( - BufferedImage image, - ColourObj colour, - int maxAttempts, - Function pointGenerator) { - - List objs; - try { - objs = ColourContours.getChromaObjsInColour(image, colour); - } catch (Exception e) { - logger.error(e.getMessage()); - logger.error(e.getStackTrace()); - return null; - } - - if (objs.isEmpty()) { - logger.error("No objects found for colour: {}", colour); - return null; - } - - // Use the closest object to screen centre since only one object is desired - ChromaObj obj = ColourContours.getChromaObjClosestToCentre(objs); - try { - int attempts = 0; - // Generate initial point using the function provided (Heuristic or Tightness) - Point p = pointGenerator.apply(obj.boundingBox()); - - // Resample if the point is outside the actual pixel contour - while (!ColourContours.isPointInContour(p, obj.contour()) && attempts < maxAttempts) { - BaseScript.checkInterrupted(); - // Apply the desired function on the bounding box - p = pointGenerator.apply(obj.boundingBox()); - attempts++; - } - - if (attempts >= maxAttempts) { - logger.error( - "Failed to find a valid point in {} contour after {} attempts.", colour, maxAttempts); - return null; - } - return p; - } finally { - // Release Mat contours to free memory. - for (ChromaObj chromaObj : objs) { - chromaObj.release(); - } - } - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ChromaObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The {@code PointSelector} class provides utility methods for selecting random points within zones + * and colours. These methods are designed to reduce code duplication and focus common actions when + * automating interactions with graphical objects like colours or images. + * + *

Features: + * + *

    + *
  • Finds a random point within the bounding box of a detected image template. + *
  • Finds a random point inside the contour of the first detected object of a specified colour. + *
  • Supports both heuristic-based distributions (dynamic sizing) and explicit + * tightness control. + *
+ * + *

These utilities are commonly reused across scripts. The class does not perform any input + * actions (such as clicking), but provides the coordinates needed for it. + * + *

Typical Usage: + * + *

+ * // Default heuristic distribution
+ * Point imgPoint = PointSelector.getRandomPointInImage(templatePath, gameView, 0.15);
+ * // Custom tightness (maybe clicking a ground item)
+ * Point colorPoint = PointSelector.getRandomPointInColour(gameView, "Purple", 5, 15.0);
+ * 
+ * + *

All methods are static and thread-safe. + */ +public class PointSelector { + + private static final Logger logger = LogManager.getLogger(PointSelector.class); + + /** + * Searches for the provided image template within a larger image, then returns a random point + * within the detected bounding box if the match exceeds the defined threshold. Relative to the + * larger image. + * + *

This method uses the default {@link ClickDistribution} heuristic, which dynamically adjusts + * distribution spread based on the size of the found target. + * + * @param templatePath the BufferedImage template to locate within the larger image + * @param image the larger image to search inside (e.g. game view) + * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection + * valid + * @return a valid {@link Point} within the detected region, or {@code null} if no match is found + */ + public static Point getRandomPointInImage( + String templatePath, BufferedImage image, double threshold) { + // Defines which function to apply onto the rectangle found + return findPointInTemplate( + templatePath, image, threshold, ClickDistribution::generateRandomPoint); + } + + /** + * Searches for the provided image template within a larger and returns a random point with a + * specific Gaussian distribution tightness. Relative to the larger image. + * + *

This overload allows for control over where the click lands. Higher tightness values force + * the point closer to the center of the image, while lower values spread it towards the edges. + * + * @param templatePath the BufferedImage template to search for within the larger image view + * @param image the larger image to search inside (e.g. game view) + * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection + * valid + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a valid {@link Point} within the detected region, or {@code null} if no match is found + */ + public static Point getRandomPointInImage( + String templatePath, BufferedImage image, double threshold, double tightness) { + // Defines which function to apply onto the rectangle found + return findPointInTemplate( + templatePath, + image, + threshold, + rect -> ClickDistribution.generateRandomPoint(rect, tightness)); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified colour + * using the default distribution heuristic. + * + *

This is an overload for {@link #getRandomPointByColourObj(BufferedImage, ColourObj, int)}. + * It looks up the colour by name from {@link ColourInstances} at runtime. + * + * @param image the image to search in (e.g. game view) + * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. + * "Purple") + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointInColour( + BufferedImage image, String colourName, int maxAttempts) { + // Calls the public API after grabbing the colour + return getRandomPointByColourObj(image, ColourInstances.getByName(colourName), maxAttempts); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified colour + * using a specific Gaussian tightness. + * + * @param image the image to search in (e.g. game view) + * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. + * "Purple") + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointInColour( + BufferedImage image, String colourName, int maxAttempts, double tightness) { + // Call the public API after grabbing the colour + return getRandomPointByColourObj( + image, ColourInstances.getByName(colourName), maxAttempts, tightness); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified {@link + * ColourObj}. + * + *

Uses {@link ColourContours} to mask the image and extract contours. It generates a random + * point within the bounding box of the detected {@link ChromaObj} and verifies if the point lies + * within the actual contour polygon. + * + * @param image the image to search in + * @param colour the specific {@link ColourObj} to detect + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointByColourObj( + BufferedImage image, ColourObj colour, int maxAttempts) { + // Defines which function to apply onto the rectangle found + return findPointInColourInternal( + image, colour, maxAttempts, ClickDistribution::generateRandomPoint); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified {@link + * ColourObj} using a specific Gaussian tightness. + * + * @param image the image to search in + * @param colour the specific {@link ColourObj} to detect + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointByColourObj( + BufferedImage image, ColourObj colour, int maxAttempts, double tightness) { + // Defines which function to apply onto the rectangle found + return findPointInColourInternal( + image, colour, maxAttempts, rect -> ClickDistribution.generateRandomPoint(rect, tightness)); + } + + /** + * Internal abstraction for template matching logic. Executes the match and applies the provided + * point generation strategy. + */ + private static Point findPointInTemplate( + String templatePath, + BufferedImage image, + double threshold, + Function pointGenerator) { + BaseScript.checkInterrupted(); + MatchResult result = TemplateMatching.match(templatePath, image, threshold); + + if (!result.success()) { + logger.error("getRandomPointInImage failed: {}", result.message()); + return null; + } + // Applying the desired function parameter onto the bounding box and returning it + return pointGenerator.apply(result.bounds()); + } + + /** + * Internal abstraction for colour contour logic. Handles object detection, contour validation + * loops, and memory cleanup. + */ + private static Point findPointInColourInternal( + BufferedImage image, + ColourObj colour, + int maxAttempts, + Function pointGenerator) { + + List objs; + try { + objs = ColourContours.getChromaObjsInColour(image, colour); + } catch (Exception e) { + logger.error(e.getMessage()); + logger.error(e.getStackTrace()); + return null; + } + + if (objs.isEmpty()) { + logger.error("No objects found for colour: {}", colour); + return null; + } + + // Use the closest object to screen centre since only one object is desired + ChromaObj obj = ColourContours.getChromaObjClosestToCentre(objs); + try { + int attempts = 0; + // Generate initial point using the function provided (Heuristic or Tightness) + Point p = pointGenerator.apply(obj.boundingBox()); + + // Resample if the point is outside the actual pixel contour + while (!ColourContours.isPointInContour(p, obj.contour()) && attempts < maxAttempts) { + BaseScript.checkInterrupted(); + // Apply the desired function on the bounding box + p = pointGenerator.apply(obj.boundingBox()); + attempts++; + } + + if (attempts >= maxAttempts) { + logger.error( + "Failed to find a valid point in {} contour after {} attempts.", colour, maxAttempts); + return null; + } + return p; + } finally { + // Release Mat contours to free memory. + for (ChromaObj chromaObj : objs) { + chromaObj.release(); + } + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java b/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java index c2bd703..ef8128f 100644 --- a/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java +++ b/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java @@ -1,153 +1,153 @@ -package com.chromascape.utils.core.input.distribution; - -import java.awt.Point; -import java.awt.Rectangle; -import java.security.SecureRandom; -import org.apache.commons.math3.distribution.MultivariateNormalDistribution; -import org.apache.commons.math3.random.MersenneTwister; -import org.apache.commons.math3.random.RandomGenerator; - -/** - * Utility class for generating biased, Gaussian-distributed click points within a rectangular UI - * region. - * - *

Instead of uniformly sampling click coordinates, this utility uses a {@link - * MultivariateNormalDistribution} centered within the given rectangle. This approach simulates - * human-like behavior by favoring points near the center while still allowing edge hits. - */ -public class ClickDistribution { - - /** Shared random generator with a secure, non-deterministic seed. */ - private static final RandomGenerator rng = new MersenneTwister(new SecureRandom().nextLong()); - - /** - * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D - * normal (Gaussian) distribution biased toward the center using internal heuristics. - * - *

The standard deviation is dynamically adjusted based on the rectangle's size to prevent - * excessive out-of-bounds sampling on small targets. - * - * @param rect the rectangular region to sample from - * @return a valid Point within {@code rect} with center-biased Gaussian randomness - */ - public static Point generateRandomPoint(Rectangle rect) { - if (isTooSmall(rect)) { - return getCenter(rect); - } - - // Calculate sigma based on internal heuristic - double stdDevX = rect.width / deviation(rect.getWidth()); - double stdDevY = rect.height / deviation(rect.getHeight()); - - return samplePoint(rect, stdDevX, stdDevY); - } - - /** - * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D - * normal (Gaussian) distribution with a custom tightness factor. - * - *

The {@code tightness} parameter controls the spread of the distribution. It acts as the - * divisor for the rectangle's dimensions when calculating standard deviation. - * - *

    - *
  • High Tightness (> 15.0): Very focused in the center. Useful for Ground items. - *
  • Low Tightness (< 3.0): Broad spread. High probability of points near edges. - *
- * - * @param rect the rectangular region to sample from - * @param tightness the factor by which to divide the dimension to get sigma. Must be positive. - * @return a valid Point within {@code rect} - * @throws IllegalArgumentException if tightness is less than or equal to zero - */ - public static Point generateRandomPoint(Rectangle rect, double tightness) { - if (tightness <= 0) { - throw new IllegalArgumentException("Tightness factor must be greater than 0"); - } - - if (isTooSmall(rect)) { - return getCenter(rect); - } - - double stdDevX = rect.width / tightness; - double stdDevY = rect.height / tightness; - - return samplePoint(rect, stdDevX, stdDevY); - } - - /** Internal helper to execute the sampling logic given specific standard deviations. */ - private static Point samplePoint(Rectangle rect, double stdDevX, double stdDevY) { - MultivariateNormalDistribution mnd = getMultivariateNormalDistribution(rect, stdDevX, stdDevY); - - Point randomPoint; - do { - double[] sample = mnd.sample(); - randomPoint = new Point((int) Math.round(sample[0]), (int) Math.round(sample[1])); - } while (!rect.contains(randomPoint)); // Resample until within bounds - - return randomPoint; - } - - /** - * Constructs a {@link MultivariateNormalDistribution} centered within the given rectangle using - * explicit standard deviations. - * - * @param rect the rectangle to derive center from - * @param stdDevX the standard deviation for the X axis - * @param stdDevY the standard deviation for the Y axis - * @return a 2D normal distribution configured with the provided spread - */ - private static MultivariateNormalDistribution getMultivariateNormalDistribution( - Rectangle rect, double stdDevX, double stdDevY) { - - double meanX = rect.getX() + rect.getWidth() / 2.0; - double meanY = rect.getY() + rect.getHeight() / 2.0; - double[] mean = {meanX, meanY}; - - double[][] covariance = { - {stdDevX * stdDevX, 0}, // No correlation between X and Y - {0, stdDevY * stdDevY} - }; - - return new MultivariateNormalDistribution(ClickDistribution.rng, mean, covariance); - } - - /** - * Heuristic used to adjust the spread of the Gaussian distribution based on rectangle size. - * - *

This prevents excessive sampling outside of bounds by reducing standard deviation for small - * targets. - * - * @param length the width or height (in pixels) of a side of the rectangle - * @return a divisor used to calculate standard deviation - */ - private static double deviation(double length) { - if (length >= 50) { - return 4.0; - } else if (length >= 25) { - return 7.0; - } else if (length >= 15) { - return 8.0; - } - return 9.0; - } - - /** - * Helper method to justify whether a rectangle is too small to conduct sampling. - * - * @param rect Rectangle to test. - * @return {@code true} if too small, else {@code false}. - */ - private static boolean isTooSmall(Rectangle rect) { - return rect.width < 5 || rect.height < 5; - } - - /** - * Helper method to return the center of a given Rectangle. - * - * @param rect The rectangle to return the center of. - * @return The {@link Point} center of the given Rectangle. - */ - private static Point getCenter(Rectangle rect) { - return new Point((int) rect.getCenterX(), (int) rect.getCenterY()); - } -} +package com.chromascape.utils.core.input.distribution; + +import java.awt.Point; +import java.awt.Rectangle; +import java.security.SecureRandom; +import org.apache.commons.math3.distribution.MultivariateNormalDistribution; +import org.apache.commons.math3.random.MersenneTwister; +import org.apache.commons.math3.random.RandomGenerator; + +/** + * Utility class for generating biased, Gaussian-distributed click points within a rectangular UI + * region. + * + *

Instead of uniformly sampling click coordinates, this utility uses a {@link + * MultivariateNormalDistribution} centered within the given rectangle. This approach simulates + * human-like behavior by favoring points near the center while still allowing edge hits. + */ +public class ClickDistribution { + + /** Shared random generator with a secure, non-deterministic seed. */ + private static final RandomGenerator rng = new MersenneTwister(new SecureRandom().nextLong()); + + /** + * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D + * normal (Gaussian) distribution biased toward the center using internal heuristics. + * + *

The standard deviation is dynamically adjusted based on the rectangle's size to prevent + * excessive out-of-bounds sampling on small targets. + * + * @param rect the rectangular region to sample from + * @return a valid Point within {@code rect} with center-biased Gaussian randomness + */ + public static Point generateRandomPoint(Rectangle rect) { + if (isTooSmall(rect)) { + return getCenter(rect); + } + + // Calculate sigma based on internal heuristic + double stdDevX = rect.width / deviation(rect.getWidth()); + double stdDevY = rect.height / deviation(rect.getHeight()); + + return samplePoint(rect, stdDevX, stdDevY); + } + + /** + * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D + * normal (Gaussian) distribution with a custom tightness factor. + * + *

The {@code tightness} parameter controls the spread of the distribution. It acts as the + * divisor for the rectangle's dimensions when calculating standard deviation. + * + *

    + *
  • High Tightness (> 15.0): Very focused in the center. Useful for Ground items. + *
  • Low Tightness (< 3.0): Broad spread. High probability of points near edges. + *
+ * + * @param rect the rectangular region to sample from + * @param tightness the factor by which to divide the dimension to get sigma. Must be positive. + * @return a valid Point within {@code rect} + * @throws IllegalArgumentException if tightness is less than or equal to zero + */ + public static Point generateRandomPoint(Rectangle rect, double tightness) { + if (tightness <= 0) { + throw new IllegalArgumentException("Tightness factor must be greater than 0"); + } + + if (isTooSmall(rect)) { + return getCenter(rect); + } + + double stdDevX = rect.width / tightness; + double stdDevY = rect.height / tightness; + + return samplePoint(rect, stdDevX, stdDevY); + } + + /** Internal helper to execute the sampling logic given specific standard deviations. */ + private static Point samplePoint(Rectangle rect, double stdDevX, double stdDevY) { + MultivariateNormalDistribution mnd = getMultivariateNormalDistribution(rect, stdDevX, stdDevY); + + Point randomPoint; + do { + double[] sample = mnd.sample(); + randomPoint = new Point((int) Math.round(sample[0]), (int) Math.round(sample[1])); + } while (!rect.contains(randomPoint)); // Resample until within bounds + + return randomPoint; + } + + /** + * Constructs a {@link MultivariateNormalDistribution} centered within the given rectangle using + * explicit standard deviations. + * + * @param rect the rectangle to derive center from + * @param stdDevX the standard deviation for the X axis + * @param stdDevY the standard deviation for the Y axis + * @return a 2D normal distribution configured with the provided spread + */ + private static MultivariateNormalDistribution getMultivariateNormalDistribution( + Rectangle rect, double stdDevX, double stdDevY) { + + double meanX = rect.getX() + rect.getWidth() / 2.0; + double meanY = rect.getY() + rect.getHeight() / 2.0; + double[] mean = {meanX, meanY}; + + double[][] covariance = { + {stdDevX * stdDevX, 0}, // No correlation between X and Y + {0, stdDevY * stdDevY} + }; + + return new MultivariateNormalDistribution(ClickDistribution.rng, mean, covariance); + } + + /** + * Heuristic used to adjust the spread of the Gaussian distribution based on rectangle size. + * + *

This prevents excessive sampling outside of bounds by reducing standard deviation for small + * targets. + * + * @param length the width or height (in pixels) of a side of the rectangle + * @return a divisor used to calculate standard deviation + */ + private static double deviation(double length) { + if (length >= 50) { + return 4.0; + } else if (length >= 25) { + return 7.0; + } else if (length >= 15) { + return 8.0; + } + return 9.0; + } + + /** + * Helper method to justify whether a rectangle is too small to conduct sampling. + * + * @param rect Rectangle to test. + * @return {@code true} if too small, else {@code false}. + */ + private static boolean isTooSmall(Rectangle rect) { + return rect.width < 5 || rect.height < 5; + } + + /** + * Helper method to return the center of a given Rectangle. + * + * @param rect The rectangle to return the center of. + * @return The {@link Point} center of the given Rectangle. + */ + private static Point getCenter(Rectangle rect) { + return new Point((int) rect.getCenterX(), (int) rect.getCenterY()); + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java b/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java index a9fffc0..dcfc8f0 100644 --- a/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java +++ b/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java @@ -1,119 +1,119 @@ -package com.chromascape.utils.core.input.keyboard; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.event.KeyEvent; -import java.util.Random; - -/** - * Provides high-level methods for simulating keyboard input using the RemoteInput API. The user can - * use {@link KeyEvent} objects to dictate exactly what to press and for how long. There is - * functionality to type out a string, compensating for modifier keys where necessary. - */ -public class VirtualKeyboardUtils { - - private final RemoteInput input; - - private static final Random RANDOM = new Random(); - - /** - * Constructs a VirtualKeyboardUtils instance that wraps a RemoteInput instance. - * - * @param input The RemoteInput object that can operate IO - */ - public VirtualKeyboardUtils(RemoteInput input) { - this.input = input; - } - - /** - * Updates the state of the bot for the {@link BaseScript}'s stop() function and updates the - * BotState for the UI. - */ - private void prepareInput() { - BaseScript.checkInterrupted(); - StateManager.setState(BotState.ACTING); - StatisticsManager.incrementInputs(); - } - - /** - * Sends a key down, given that it isn't already held. As this function requires an int keycode, - * please use {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your - * IDE of choice, showing you available keys which are mapped to integer keycodes. You may opt to - * look for Java VK keycodes online, however this is the best approach. - * - *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} - * - * @param javaKeyCode the {@link KeyEvent} key to hold - */ - public void sendKeyDown(int javaKeyCode) { - prepareInput(); - if (!input.isKeyHeld(javaKeyCode)) { - input.holdKey(javaKeyCode); - } - } - - /** - * Releases a key, given that it is held. As this function requires an int keycode, please use - * {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your IDE of - * choice, showing you available keys which are mapped to integer keycodes. You may opt to look - * for Java VK keycodes online, however this is the best approach. - * - *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} - * - * @param javaKeyCode the {@link KeyEvent} key to release - */ - public void sendKeyRelease(int javaKeyCode) { - prepareInput(); - if (input.isKeyHeld(javaKeyCode)) { - input.releaseKey(javaKeyCode); - } - } - - /** - * Checks whether a key is currently being held. - * - * @param javaKeyCode the {@link KeyEvent} key to check - * @return Whether the key is currently being held or not - */ - public boolean isKeyHeld(int javaKeyCode) { - return input.isKeyHeld(javaKeyCode); - } - - /** - * Types out a given string to the client window using heuristics to mimic a human. Uses default - * heuristic settings for convenience. - * - * @param string The String of characters to type out in a human like fashion - */ - public synchronized void sendString(String string) { - prepareInput(); - for (char c : string.toCharArray()) { - int keyWait = RANDOM.nextInt(30, 60); - int keyModWait = RANDOM.nextInt(30, 60); - int keyPressWait = RANDOM.nextInt(40, 85); - input.sendString(String.valueOf(c), keyWait, keyModWait); - BaseScript.waitMillis(keyPressWait); - } - } - - /** - * Types out a given string to the client window using heuristics to mimic a human. Internally - * randomises between 1x - 1.1x the given modifier value. - * - * @param string The String of characters to type out in a human like fashion - * @param keyWait The amount of time to hold a key down - * @param keyModWait The amount of time to hold a modifier key down (e.g., shift) - * @param keyPressWait The amount of time to wait between pressing keys - */ - public synchronized void sendString( - String string, int keyWait, int keyModWait, int keyPressWait) { - prepareInput(); - for (char c : string.toCharArray()) { - input.sendString(String.valueOf(c), keyWait, keyModWait); - BaseScript.waitMillis(keyPressWait); - } - } -} +package com.chromascape.utils.core.input.keyboard; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.event.KeyEvent; +import java.util.Random; + +/** + * Provides high-level methods for simulating keyboard input using the RemoteInput API. The user can + * use {@link KeyEvent} objects to dictate exactly what to press and for how long. There is + * functionality to type out a string, compensating for modifier keys where necessary. + */ +public class VirtualKeyboardUtils { + + private final RemoteInput input; + + private static final Random RANDOM = new Random(); + + /** + * Constructs a VirtualKeyboardUtils instance that wraps a RemoteInput instance. + * + * @param input The RemoteInput object that can operate IO + */ + public VirtualKeyboardUtils(RemoteInput input) { + this.input = input; + } + + /** + * Updates the state of the bot for the {@link BaseScript}'s stop() function and updates the + * BotState for the UI. + */ + private void prepareInput() { + BaseScript.checkInterrupted(); + StateManager.setState(BotState.ACTING); + StatisticsManager.incrementInputs(); + } + + /** + * Sends a key down, given that it isn't already held. As this function requires an int keycode, + * please use {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your + * IDE of choice, showing you available keys which are mapped to integer keycodes. You may opt to + * look for Java VK keycodes online, however this is the best approach. + * + *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} + * + * @param javaKeyCode the {@link KeyEvent} key to hold + */ + public void sendKeyDown(int javaKeyCode) { + prepareInput(); + if (!input.isKeyHeld(javaKeyCode)) { + input.holdKey(javaKeyCode); + } + } + + /** + * Releases a key, given that it is held. As this function requires an int keycode, please use + * {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your IDE of + * choice, showing you available keys which are mapped to integer keycodes. You may opt to look + * for Java VK keycodes online, however this is the best approach. + * + *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} + * + * @param javaKeyCode the {@link KeyEvent} key to release + */ + public void sendKeyRelease(int javaKeyCode) { + prepareInput(); + if (input.isKeyHeld(javaKeyCode)) { + input.releaseKey(javaKeyCode); + } + } + + /** + * Checks whether a key is currently being held. + * + * @param javaKeyCode the {@link KeyEvent} key to check + * @return Whether the key is currently being held or not + */ + public boolean isKeyHeld(int javaKeyCode) { + return input.isKeyHeld(javaKeyCode); + } + + /** + * Types out a given string to the client window using heuristics to mimic a human. Uses default + * heuristic settings for convenience. + * + * @param string The String of characters to type out in a human like fashion + */ + public synchronized void sendString(String string) { + prepareInput(); + for (char c : string.toCharArray()) { + int keyWait = RANDOM.nextInt(30, 60); + int keyModWait = RANDOM.nextInt(30, 60); + int keyPressWait = RANDOM.nextInt(40, 85); + input.sendString(String.valueOf(c), keyWait, keyModWait); + BaseScript.waitMillis(keyPressWait); + } + } + + /** + * Types out a given string to the client window using heuristics to mimic a human. Internally + * randomises between 1x - 1.1x the given modifier value. + * + * @param string The String of characters to type out in a human like fashion + * @param keyWait The amount of time to hold a key down + * @param keyModWait The amount of time to hold a modifier key down (e.g., shift) + * @param keyPressWait The amount of time to wait between pressing keys + */ + public synchronized void sendString( + String string, int keyWait, int keyModWait, int keyPressWait) { + prepareInput(); + for (char c : string.toCharArray()) { + input.sendString(String.valueOf(c), keyWait, keyModWait); + BaseScript.waitMillis(keyPressWait); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java b/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java index 3889cbf..ed9d873 100644 --- a/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java +++ b/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java @@ -1,280 +1,280 @@ -package com.chromascape.utils.core.input.mouse; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.remoteinput.MouseButton; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.util.Objects; -import java.util.Random; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -/** - * High level abstraction for a user to handle mouse IO through the {@link - * com.chromascape.controller.Controller}. Orchestrator of both the mouse movement physics - * calculation and dispatching of IO via the JNA layer. Uses a producer -> consumer threading model - * to prevent a mouse movement from lagging if the IO fails or stutters. Provides IO capabilities - * such as mouse movement, clicking, and holding of {@link MouseButton}s. - */ -public class VirtualMouseUtils { - - /** The current virtual mouse position. */ - private Point currentPosition; - - /** Interface for injecting low-level mouse events independently of system mouse. */ - private final RemoteInput input; - - /** Humanised mouse movement service. */ - private final WindMouse windMouse; - - private final Random random = new Random(); - - /** The latest point generated by the physics engine, waiting to be consumed by input. */ - private final AtomicReference pendingInputPoint = new AtomicReference<>(); - - /** Flag to indicate if the virtual mouse is currently performing a movement path. */ - private final AtomicBoolean isMoving = new AtomicBoolean(false); - - /** - * Lock object to ensure input is accessed by only one thread at a time. - * - *

Required because the underlying WebSocket/Connection in input is not thread-safe and cannot - * handle simultaneous write operations. - */ - private final Object inputLock = new Object(); - - /** - * Constructs the VMU class. Initialises the mouse overlay. Gives the mouse cursor a random start - * position. Starts the input consumer thread. - * - * @param input A hardware RemoteInput capable object - */ - public VirtualMouseUtils(RemoteInput input) { - this.input = input; - windMouse = new WindMouse(); - // Randomize starting position within the client window - randomiseStartPos(); - // Initialize atomic reference to prevent null pointer in consumer - pendingInputPoint.set(currentPosition); - // Start the background Input Consumer thread - startInputConsumerThread(); - } - - /** Starts the input consumer thread to prepare for IO. */ - private void startInputConsumerThread() { - Thread inputConsumerThread = new Thread(this::consumeInputLoop, "VirtualMouse-Input-Consumer"); - inputConsumerThread.setDaemon(true); // Ensure thread dies when JVM shuts down - inputConsumerThread.start(); - } - - /** - * Randomises the start position of the cursor within the client's bounds. Only used at startup. - * If the bot ran before and has a persisting cursor, it will default to that instead. - */ - private void randomiseStartPos() { - if (Objects.equals(input.getMousePosition(), new Point(0, 0)) - || input.getMousePosition() == null) { - - Rectangle bounds = input.getTargetDimensions(); - int startX = bounds.x + random.nextInt(bounds.width); - int startY = bounds.y + random.nextInt(bounds.height); - currentPosition = new Point(startX, startY); - } else { - currentPosition = input.getMousePosition(); - } - currentPosition = input.getMousePosition(); - // Initialize atomic reference to prevent null pointer in consumer - pendingInputPoint.set(currentPosition); - } - - /** - * Used alongside the producer thread to reliably move the mouse. While the flag is set to true, - * consumes the latest snapshot of the mouse movement simulation and sends a synchronised call to - * the IO layer to move the mouse. Discards duplicates. - */ - private void consumeInputLoop() { - Point lastSentPoint = null; - - while (true) { - if (isMoving.get()) { - // Practically final value, will not change during execution - Point target = pendingInputPoint.get(); - - // Only send input if the point is new - if (target != null && !target.equals(lastSentPoint)) { - synchronized (inputLock) { - input.moveMouse(target); - } - lastSentPoint = target; - } - } else { - // If not moving, sleep longer to save resources - BaseScript.waitMillis(5); - } - } - } - - /** - * Intended to be called before IO execution. Checks whether the script has been stopped to - * reliably prevent execution. Sets the bot state to acting and increments inputs for the web UI's - * statistics. - */ - private void prepareInput() { - BaseScript.checkInterrupted(); - StateManager.setState(BotState.ACTING); - StatisticsManager.incrementInputs(); - } - - /** - * Moves the mouse to a target destination point onscreen at a given speed using a humanised mouse - * movement algorithm. - * - * @param target The {@link Point} location to travel to - * @param speed How fast the mouse should travel, "slow", "medium" or "fast" - */ - public void moveTo(final Point target, final String speed) { - prepareInput(); - - // Flag start of movement for the Consumer thread - isMoving.set(true); - - try { - // WindMouse's physics loop will run on the current thread, producer - // The hardware IO execution (plus overlay/current state) is the consumer thread - windMouse.move(currentPosition, target, speed, this::moveMouseImpl); - } finally { - finaliseMovement(target); - } - } - - /** - * Callback for the WindMouse algorithm, where it used to execute the mouse movement action. This - * function only updates the state for the consumer thread, however serves as a nod to the source. - * - * @param p The point generated by WindMouse. - */ - private void moveMouseImpl(Point p) { - pendingInputPoint.set(p); - currentPosition = p; - } - - /** - * Disables the flag for the consumer thread, signaling there is no more work to do. Forces the - * destination point for the internal state, remote input, and overlay; to ensure scripts do not - * fail. - * - * @param target The final mouse destination. - */ - private void finaliseMovement(Point target) { - // Movement finished. - isMoving.set(false); - // Force the final position update to ensure exact accuracy. - currentPosition = target; - pendingInputPoint.set(target); - // Snap to final position to force script safety - // synchronized to prevent race conditions with other IO - synchronized (inputLock) { - input.moveMouse(target); - } - } - - /** - * Executes a mouse button press and release in a human-like fashion, given which button to press. - * Synchronised as not to collide with other IO. - * - * @param button the mouse button to press - */ - private void click(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - input.holdMouse(button); - BaseScript.waitRandomMillis(50, 80); - input.releaseMouse(button); - } - } - - /** Left clicks at the current mouse position. */ - public void leftClick() { - click(MouseButton.left); - } - - /** Right clicks at the current mouse position. */ - public void rightClick() { - click(MouseButton.right); - } - - /** Middle clicks at the current mouse position. */ - public void middleClick() { - click(MouseButton.middle); - } - - /** - * Holds down a Mouse button given which button to hold. - * - * @param button the {@link MouseButton} to hold. - */ - public void holdMouseButton(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - if (!input.isMouseHeld(button)) { - input.holdMouse(button); - } - } - } - - /** - * Queries whether a {@link MouseButton} is currently being held. - * - * @param button The {@link MouseButton} to check - * @return Whether it's currently being held - */ - public boolean isMouseButtonHeld(MouseButton button) { - return input.isMouseHeld(button); - } - - /** - * Releases a Mouse button given which button to release. - * - * @param button the {@link MouseButton} to release. - */ - public void releaseMouseButton(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - if (input.isMouseHeld(button)) { - input.releaseMouse(button); - } - } - } - - /** - * Scrolls the mouse given the direction and amount. The mouse must have moved in the particular - * session for scrolling to take effect. - * - * @param totalNotches Amount of notches to scroll - * @param down true if scrolling down, false if scrolling up - */ - public void scrollMouse(int totalNotches, boolean down) { - int notchesSent = 0; - int k = 1; - - int step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); - - while (notchesSent < totalNotches) { - input.scrollMouse(down ? 1 : -1); - notchesSent++; - - if (k % step == 0) { - step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); - BaseScript.waitRandomMillis(215, 410); - k = 0; - } else { - BaseScript.waitRandomMillis(25, 46); - } - k++; - } - } -} +package com.chromascape.utils.core.input.mouse; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.remoteinput.MouseButton; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.util.Objects; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * High level abstraction for a user to handle mouse IO through the {@link + * com.chromascape.controller.Controller}. Orchestrator of both the mouse movement physics + * calculation and dispatching of IO via the JNA layer. Uses a producer -> consumer threading model + * to prevent a mouse movement from lagging if the IO fails or stutters. Provides IO capabilities + * such as mouse movement, clicking, and holding of {@link MouseButton}s. + */ +public class VirtualMouseUtils { + + /** The current virtual mouse position. */ + private Point currentPosition; + + /** Interface for injecting low-level mouse events independently of system mouse. */ + private final RemoteInput input; + + /** Humanised mouse movement service. */ + private final WindMouse windMouse; + + private final Random random = new Random(); + + /** The latest point generated by the physics engine, waiting to be consumed by input. */ + private final AtomicReference pendingInputPoint = new AtomicReference<>(); + + /** Flag to indicate if the virtual mouse is currently performing a movement path. */ + private final AtomicBoolean isMoving = new AtomicBoolean(false); + + /** + * Lock object to ensure input is accessed by only one thread at a time. + * + *

Required because the underlying WebSocket/Connection in input is not thread-safe and cannot + * handle simultaneous write operations. + */ + private final Object inputLock = new Object(); + + /** + * Constructs the VMU class. Initialises the mouse overlay. Gives the mouse cursor a random start + * position. Starts the input consumer thread. + * + * @param input A hardware RemoteInput capable object + */ + public VirtualMouseUtils(RemoteInput input) { + this.input = input; + windMouse = new WindMouse(); + // Randomize starting position within the client window + randomiseStartPos(); + // Initialize atomic reference to prevent null pointer in consumer + pendingInputPoint.set(currentPosition); + // Start the background Input Consumer thread + startInputConsumerThread(); + } + + /** Starts the input consumer thread to prepare for IO. */ + private void startInputConsumerThread() { + Thread inputConsumerThread = new Thread(this::consumeInputLoop, "VirtualMouse-Input-Consumer"); + inputConsumerThread.setDaemon(true); // Ensure thread dies when JVM shuts down + inputConsumerThread.start(); + } + + /** + * Randomises the start position of the cursor within the client's bounds. Only used at startup. + * If the bot ran before and has a persisting cursor, it will default to that instead. + */ + private void randomiseStartPos() { + if (Objects.equals(input.getMousePosition(), new Point(0, 0)) + || input.getMousePosition() == null) { + + Rectangle bounds = input.getTargetDimensions(); + int startX = bounds.x + random.nextInt(bounds.width); + int startY = bounds.y + random.nextInt(bounds.height); + currentPosition = new Point(startX, startY); + } else { + currentPosition = input.getMousePosition(); + } + currentPosition = input.getMousePosition(); + // Initialize atomic reference to prevent null pointer in consumer + pendingInputPoint.set(currentPosition); + } + + /** + * Used alongside the producer thread to reliably move the mouse. While the flag is set to true, + * consumes the latest snapshot of the mouse movement simulation and sends a synchronised call to + * the IO layer to move the mouse. Discards duplicates. + */ + private void consumeInputLoop() { + Point lastSentPoint = null; + + while (true) { + if (isMoving.get()) { + // Practically final value, will not change during execution + Point target = pendingInputPoint.get(); + + // Only send input if the point is new + if (target != null && !target.equals(lastSentPoint)) { + synchronized (inputLock) { + input.moveMouse(target); + } + lastSentPoint = target; + } + } else { + // If not moving, sleep longer to save resources + BaseScript.waitMillis(5); + } + } + } + + /** + * Intended to be called before IO execution. Checks whether the script has been stopped to + * reliably prevent execution. Sets the bot state to acting and increments inputs for the web UI's + * statistics. + */ + private void prepareInput() { + BaseScript.checkInterrupted(); + StateManager.setState(BotState.ACTING); + StatisticsManager.incrementInputs(); + } + + /** + * Moves the mouse to a target destination point onscreen at a given speed using a humanised mouse + * movement algorithm. + * + * @param target The {@link Point} location to travel to + * @param speed How fast the mouse should travel, "slow", "medium" or "fast" + */ + public void moveTo(final Point target, final String speed) { + prepareInput(); + + // Flag start of movement for the Consumer thread + isMoving.set(true); + + try { + // WindMouse's physics loop will run on the current thread, producer + // The hardware IO execution (plus overlay/current state) is the consumer thread + windMouse.move(currentPosition, target, speed, this::moveMouseImpl); + } finally { + finaliseMovement(target); + } + } + + /** + * Callback for the WindMouse algorithm, where it used to execute the mouse movement action. This + * function only updates the state for the consumer thread, however serves as a nod to the source. + * + * @param p The point generated by WindMouse. + */ + private void moveMouseImpl(Point p) { + pendingInputPoint.set(p); + currentPosition = p; + } + + /** + * Disables the flag for the consumer thread, signaling there is no more work to do. Forces the + * destination point for the internal state, remote input, and overlay; to ensure scripts do not + * fail. + * + * @param target The final mouse destination. + */ + private void finaliseMovement(Point target) { + // Movement finished. + isMoving.set(false); + // Force the final position update to ensure exact accuracy. + currentPosition = target; + pendingInputPoint.set(target); + // Snap to final position to force script safety + // synchronized to prevent race conditions with other IO + synchronized (inputLock) { + input.moveMouse(target); + } + } + + /** + * Executes a mouse button press and release in a human-like fashion, given which button to press. + * Synchronised as not to collide with other IO. + * + * @param button the mouse button to press + */ + private void click(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + input.holdMouse(button); + BaseScript.waitRandomMillis(50, 80); + input.releaseMouse(button); + } + } + + /** Left clicks at the current mouse position. */ + public void leftClick() { + click(MouseButton.left); + } + + /** Right clicks at the current mouse position. */ + public void rightClick() { + click(MouseButton.right); + } + + /** Middle clicks at the current mouse position. */ + public void middleClick() { + click(MouseButton.middle); + } + + /** + * Holds down a Mouse button given which button to hold. + * + * @param button the {@link MouseButton} to hold. + */ + public void holdMouseButton(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + if (!input.isMouseHeld(button)) { + input.holdMouse(button); + } + } + } + + /** + * Queries whether a {@link MouseButton} is currently being held. + * + * @param button The {@link MouseButton} to check + * @return Whether it's currently being held + */ + public boolean isMouseButtonHeld(MouseButton button) { + return input.isMouseHeld(button); + } + + /** + * Releases a Mouse button given which button to release. + * + * @param button the {@link MouseButton} to release. + */ + public void releaseMouseButton(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + if (input.isMouseHeld(button)) { + input.releaseMouse(button); + } + } + } + + /** + * Scrolls the mouse given the direction and amount. The mouse must have moved in the particular + * session for scrolling to take effect. + * + * @param totalNotches Amount of notches to scroll + * @param down true if scrolling down, false if scrolling up + */ + public void scrollMouse(int totalNotches, boolean down) { + int notchesSent = 0; + int k = 1; + + int step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); + + while (notchesSent < totalNotches) { + input.scrollMouse(down ? 1 : -1); + notchesSent++; + + if (k % step == 0) { + step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); + BaseScript.waitRandomMillis(215, 410); + k = 0; + } else { + BaseScript.waitRandomMillis(25, 46); + } + k++; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java b/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java index 9d0d28f..9935ea0 100644 --- a/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java +++ b/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java @@ -1,308 +1,308 @@ -/** - * Copyright 2006-2013 by Benjamin J. Land (a.k.a. BenLand100) - * - *

This file is part of the SMART Minimizing Autoing Resource Thing (SMART) - * - *

SMART is free software: you can redistribute it and/or modify it under the terms of the GNU - * General Public License as published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - *

SMART is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without - * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - *

You should have received a copy of the GNU General Public License along with SMART. If not, - * see . - */ -package com.chromascape.utils.core.input.mouse; - -import static java.util.concurrent.locks.LockSupport.parkNanos; - -import java.awt.Point; -import java.util.Random; -import java.util.function.Consumer; - -/** - * - * - *

- * - * "The WindMouse algorithm is inspired by highschool physics that me-of-fifteen-years-ago was just - * getting interested in. The cursor is modeled as an object with some inertia (mass) that is acted - * on by two forces: - * - *
    - *
  • Gravity, which is constant in magnitude (a configurable parameter) and always points - * towards the final destination. - *
  • Wind, which exerts a random force in a random direction, and smoothly changes in both - * magnitude and direction over time." - BenLand100 - *
- * - *
- * - *

This modified impl has been tuned to 60hz as opposed to 30 - * - *

Original Algorithm by BenLand100. Tweaked - * "WindMouse2" implementation by holic. - * Adapted for ChromaScape. - */ -public class WindMouse { - - private final Random random = new Random(); - - /** - * Moves the mouse from a starting point to a destination using the WindMouse physics model. - * - *

This method selects a speed profile and delegates to the internal physics engine. - * - * @param start The current coordinates of the mouse cursor. - * @param target The destination coordinates. - * @param speedProfile A string constant determining movement characteristics ("slow", "medium", - * "fast"). Defaults to "medium" if the profile is unrecognized. - * @param moveMouseImpl A {@link Consumer} that accepts a {@link Point} for every step of the - * path. This is typically used to trigger the actual hardware or robot input. - */ - public void move(Point start, Point target, String speedProfile, Consumer moveMouseImpl) { - double mouseSpeed = 30; - double mouseGravity = 4.5; - double mouseWind = 1.5; - - switch (speedProfile.toLowerCase()) { - case "slow" -> { - mouseSpeed = 20; - mouseGravity = 5.0; - mouseWind = 1.0; - } - case "fast" -> { - mouseSpeed = 50; - mouseGravity = 6.0; - mouseWind = 2.0; - } - case "medium", "default" -> { - // Keeps defaults - } - } - - windMouse2(start, target, mouseGravity, mouseWind, mouseSpeed, moveMouseImpl); - } - - /** - * Moves the mouse from the current position to the specified position. Approximates human - * movement in a way where smoothness and accuracy are relative to speed, as it should be. - * - *

Algorithm by BenLand100, modified by holic and later ChromaScape. - * - * @param start The starting point. - * @param target The final destination. - * @param gravity The gravitational pull towards the target. - * @param wind The magnitude of random perturbations. - * @param speed The timing speed factor. - * @param moveMouseImpl The callback for cursor updates. - */ - private void windMouse2( - Point start, - Point target, - double gravity, - double wind, - double speed, - Consumer moveMouseImpl) { - - Point intermediate = - (distance(target, start) > 250 && random.nextInt(2) == 1) - ? randomPoint(target, start) - : null; - - if (intermediate != null) { - windMouseImpl( - start.x, - start.y, - intermediate.x, - intermediate.y, - gravity, - wind, - speed, - random.nextInt(10, 25), - moveMouseImpl); - - // Small pause between each movement - sleepPrecise(random.nextInt(1, 150)); - start = intermediate; // Continue from intermediate - } - - // Move to final target - windMouseImpl( - start.x, - start.y, - target.x, - target.y, - gravity, - wind, - speed, - random.nextInt(10, 25), - moveMouseImpl); - } - - /** - * Internal mouse movement algorithm. Do not use this without credit to either Benjamin J. Land or - * BenLand100. This is synchronized to prevent multiple motions and bannage. - * - * @param xs The x start - * @param ys The y start - * @param xe The x destination - * @param ye The y destination - * @param gravity Strength pulling the position towards the destination - * @param wind Strength pulling the position in random directions - * @param speed Influences the rate of sleeps, speeding up or slowing down the routine - * @param targetArea Radius of area around the destination that should trigger slowing, prevents - * spiraling - */ - private void windMouseImpl( - double xs, - double ys, - double xe, - double ye, - double gravity, - double wind, - double speed, - double targetArea, - Consumer onMove) { - - double dist, veloX = 0, veloY = 0, windX = 0, windY = 0; - - double sqrt2 = Math.sqrt(2); - double sqrt3 = Math.sqrt(3); - double sqrt5 = Math.sqrt(5); - - int tDist = (int) distance(new Point((int) xs, (int) ys), new Point((int) xe, (int) ye)); - long t = System.currentTimeMillis() + 10000; // 10-second timeout safety - - while ((dist = Math.hypot((xs - xe), (ys - ye))) >= 3) { - if (System.currentTimeMillis() > t) break; - - wind = Math.min(wind, dist); - - long d = (Math.round((Math.round(((double) (tDist))) * 0.3)) / 7); - if (d > 20) d = 20; - if (d < 5) d = 5; - - if (random.nextInt(6) == 0) { - d = 2; - } - - double maxStep = (Math.min(d, Math.round(dist))) * 1.5; - - if (dist >= targetArea) { - // Apply normal wind - int windRange = (int) (Math.round(wind) * 2) + 1; - windX = (windX / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); - windY = (windY / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); - } else { - - windX = (windX / sqrt2); - windY = (windY / sqrt2); - - veloX *= 0.64; - veloY *= 0.64; - } - - veloX += windX + gravity * (xe - xs) / dist; - veloY += windY + gravity * (ye - ys) / dist; - - if (Math.hypot(veloX, veloY) > maxStep) { - maxStep = ((maxStep / 2) < 1) ? 2 : maxStep; - double randomDist = (maxStep / 2) + random.nextInt((int) (Math.round(maxStep) / 2)); - double veloMag = Math.sqrt(((veloX * veloX) + (veloY * veloY))); - veloX = (veloX / veloMag) * randomDist; - veloY = (veloY / veloMag) * randomDist; - } - - int lastX = ((int) (Math.round(xs))); - int lastY = ((int) (Math.round(ys))); - xs += veloX; - ys += veloY; - - if ((lastX != Math.round(xs)) || (lastY != Math.round(ys))) { - Point newP = new Point((int) Math.round(xs), (int) Math.round(ys)); - if (onMove != null) onMove.accept(newP); - } - - int w = random.nextInt((int) (Math.round(100.0 / speed))) * 12; - if (w < 10) { - w = 10; - } - - w = (int) Math.round(w * 0.9); - sleepPrecise(w); - } - - if ((Math.round(xe) != Math.round(xs)) || (Math.round(ye) != Math.round(ys))) { - Point finalP = new Point((int) Math.round(xe), (int) Math.round(ye)); - if (onMove != null) onMove.accept(finalP); - } - } - - /** - * Precisely sleeps for a given length of time, as other approaches aren't as accurate. - * - * @param millis The duration to sleep in milliseconds. - */ - private void sleepPrecise(long millis) { - long end = System.nanoTime() + millis * 1_000_000L; - long timeLeft = end - System.nanoTime(); - while (timeLeft > 2_000_000L) { - parkNanos(timeLeft - 1_000_000L); - timeLeft = end - System.nanoTime(); - } - - while (System.nanoTime() < end) { - try { - java.lang.Thread.onSpinWait(); - } catch (NoSuchMethodError e) { - // Fallback for older JDKs (implicitly just busy-waits) - } - } - } - - /** - * Calculates distance between 2 points. - * - * @param p1 The first point. - * @param p2 The second point. - * @return The distance. - */ - private double distance(Point p1, Point p2) { - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - } - - /** - * Get a random point between 2 points. - * - * @param p1 The first point. - * @param p2 The second point. - * @return The random point. - */ - private Point randomPoint(Point p1, Point p2) { - int randomX = (int) randomPointBetween(p1.x, p2.x); - int randomY = (int) randomPointBetween(p1.y, p2.y); - return new Point(randomX, randomY); - } - - /** - * Generates a random floating-point value between two bounds. - * - * @param corner1 The first boundary (e.g., the starting coordinate). - * @param corner2 The second boundary (e.g., the target coordinate). - * @return A random float value falling between {@code corner1} and {@code corner2}. If both - * bounds are equal, returns that value immediately to avoid processing. - */ - private float randomPointBetween(float corner1, float corner2) { - if (corner1 == corner2) { - return corner1; - } - float delta = corner2 - corner1; - float offset = random.nextFloat() * delta; - return corner1 + offset; - } -} +/** + * Copyright 2006-2013 by Benjamin J. Land (a.k.a. BenLand100) + * + *

This file is part of the SMART Minimizing Autoing Resource Thing (SMART) + * + *

SMART is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + *

SMART is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + *

You should have received a copy of the GNU General Public License along with SMART. If not, + * see . + */ +package com.chromascape.utils.core.input.mouse; + +import static java.util.concurrent.locks.LockSupport.parkNanos; + +import java.awt.Point; +import java.util.Random; +import java.util.function.Consumer; + +/** + * + * + *

+ * + * "The WindMouse algorithm is inspired by highschool physics that me-of-fifteen-years-ago was just + * getting interested in. The cursor is modeled as an object with some inertia (mass) that is acted + * on by two forces: + * + *
    + *
  • Gravity, which is constant in magnitude (a configurable parameter) and always points + * towards the final destination. + *
  • Wind, which exerts a random force in a random direction, and smoothly changes in both + * magnitude and direction over time." - BenLand100 + *
+ * + *
+ * + *

This modified impl has been tuned to 60hz as opposed to 30 + * + *

Original Algorithm by BenLand100. Tweaked + * "WindMouse2" implementation by holic. + * Adapted for ChromaScape. + */ +public class WindMouse { + + private final Random random = new Random(); + + /** + * Moves the mouse from a starting point to a destination using the WindMouse physics model. + * + *

This method selects a speed profile and delegates to the internal physics engine. + * + * @param start The current coordinates of the mouse cursor. + * @param target The destination coordinates. + * @param speedProfile A string constant determining movement characteristics ("slow", "medium", + * "fast"). Defaults to "medium" if the profile is unrecognized. + * @param moveMouseImpl A {@link Consumer} that accepts a {@link Point} for every step of the + * path. This is typically used to trigger the actual hardware or robot input. + */ + public void move(Point start, Point target, String speedProfile, Consumer moveMouseImpl) { + double mouseSpeed = 30; + double mouseGravity = 4.5; + double mouseWind = 1.5; + + switch (speedProfile.toLowerCase()) { + case "slow" -> { + mouseSpeed = 20; + mouseGravity = 5.0; + mouseWind = 1.0; + } + case "fast" -> { + mouseSpeed = 50; + mouseGravity = 6.0; + mouseWind = 2.0; + } + case "medium", "default" -> { + // Keeps defaults + } + } + + windMouse2(start, target, mouseGravity, mouseWind, mouseSpeed, moveMouseImpl); + } + + /** + * Moves the mouse from the current position to the specified position. Approximates human + * movement in a way where smoothness and accuracy are relative to speed, as it should be. + * + *

Algorithm by BenLand100, modified by holic and later ChromaScape. + * + * @param start The starting point. + * @param target The final destination. + * @param gravity The gravitational pull towards the target. + * @param wind The magnitude of random perturbations. + * @param speed The timing speed factor. + * @param moveMouseImpl The callback for cursor updates. + */ + private void windMouse2( + Point start, + Point target, + double gravity, + double wind, + double speed, + Consumer moveMouseImpl) { + + Point intermediate = + (distance(target, start) > 250 && random.nextInt(2) == 1) + ? randomPoint(target, start) + : null; + + if (intermediate != null) { + windMouseImpl( + start.x, + start.y, + intermediate.x, + intermediate.y, + gravity, + wind, + speed, + random.nextInt(10, 25), + moveMouseImpl); + + // Small pause between each movement + sleepPrecise(random.nextInt(1, 150)); + start = intermediate; // Continue from intermediate + } + + // Move to final target + windMouseImpl( + start.x, + start.y, + target.x, + target.y, + gravity, + wind, + speed, + random.nextInt(10, 25), + moveMouseImpl); + } + + /** + * Internal mouse movement algorithm. Do not use this without credit to either Benjamin J. Land or + * BenLand100. This is synchronized to prevent multiple motions and bannage. + * + * @param xs The x start + * @param ys The y start + * @param xe The x destination + * @param ye The y destination + * @param gravity Strength pulling the position towards the destination + * @param wind Strength pulling the position in random directions + * @param speed Influences the rate of sleeps, speeding up or slowing down the routine + * @param targetArea Radius of area around the destination that should trigger slowing, prevents + * spiraling + */ + private void windMouseImpl( + double xs, + double ys, + double xe, + double ye, + double gravity, + double wind, + double speed, + double targetArea, + Consumer onMove) { + + double dist, veloX = 0, veloY = 0, windX = 0, windY = 0; + + double sqrt2 = Math.sqrt(2); + double sqrt3 = Math.sqrt(3); + double sqrt5 = Math.sqrt(5); + + int tDist = (int) distance(new Point((int) xs, (int) ys), new Point((int) xe, (int) ye)); + long t = System.currentTimeMillis() + 10000; // 10-second timeout safety + + while ((dist = Math.hypot((xs - xe), (ys - ye))) >= 3) { + if (System.currentTimeMillis() > t) break; + + wind = Math.min(wind, dist); + + long d = (Math.round((Math.round(((double) (tDist))) * 0.3)) / 7); + if (d > 20) d = 20; + if (d < 5) d = 5; + + if (random.nextInt(6) == 0) { + d = 2; + } + + double maxStep = (Math.min(d, Math.round(dist))) * 1.5; + + if (dist >= targetArea) { + // Apply normal wind + int windRange = (int) (Math.round(wind) * 2) + 1; + windX = (windX / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); + windY = (windY / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); + } else { + + windX = (windX / sqrt2); + windY = (windY / sqrt2); + + veloX *= 0.64; + veloY *= 0.64; + } + + veloX += windX + gravity * (xe - xs) / dist; + veloY += windY + gravity * (ye - ys) / dist; + + if (Math.hypot(veloX, veloY) > maxStep) { + maxStep = ((maxStep / 2) < 1) ? 2 : maxStep; + double randomDist = (maxStep / 2) + random.nextInt((int) (Math.round(maxStep) / 2)); + double veloMag = Math.sqrt(((veloX * veloX) + (veloY * veloY))); + veloX = (veloX / veloMag) * randomDist; + veloY = (veloY / veloMag) * randomDist; + } + + int lastX = ((int) (Math.round(xs))); + int lastY = ((int) (Math.round(ys))); + xs += veloX; + ys += veloY; + + if ((lastX != Math.round(xs)) || (lastY != Math.round(ys))) { + Point newP = new Point((int) Math.round(xs), (int) Math.round(ys)); + if (onMove != null) onMove.accept(newP); + } + + int w = random.nextInt((int) (Math.round(100.0 / speed))) * 12; + if (w < 10) { + w = 10; + } + + w = (int) Math.round(w * 0.9); + sleepPrecise(w); + } + + if ((Math.round(xe) != Math.round(xs)) || (Math.round(ye) != Math.round(ys))) { + Point finalP = new Point((int) Math.round(xe), (int) Math.round(ye)); + if (onMove != null) onMove.accept(finalP); + } + } + + /** + * Precisely sleeps for a given length of time, as other approaches aren't as accurate. + * + * @param millis The duration to sleep in milliseconds. + */ + private void sleepPrecise(long millis) { + long end = System.nanoTime() + millis * 1_000_000L; + long timeLeft = end - System.nanoTime(); + while (timeLeft > 2_000_000L) { + parkNanos(timeLeft - 1_000_000L); + timeLeft = end - System.nanoTime(); + } + + while (System.nanoTime() < end) { + try { + java.lang.Thread.onSpinWait(); + } catch (NoSuchMethodError e) { + // Fallback for older JDKs (implicitly just busy-waits) + } + } + } + + /** + * Calculates distance between 2 points. + * + * @param p1 The first point. + * @param p2 The second point. + * @return The distance. + */ + private double distance(Point p1, Point p2) { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + } + + /** + * Get a random point between 2 points. + * + * @param p1 The first point. + * @param p2 The second point. + * @return The random point. + */ + private Point randomPoint(Point p1, Point p2) { + int randomX = (int) randomPointBetween(p1.x, p2.x); + int randomY = (int) randomPointBetween(p1.y, p2.y); + return new Point(randomX, randomY); + } + + /** + * Generates a random floating-point value between two bounds. + * + * @param corner1 The first boundary (e.g., the starting coordinate). + * @param corner2 The second boundary (e.g., the target coordinate). + * @return A random float value falling between {@code corner1} and {@code corner2}. If both + * bounds are equal, returns that value immediately to avoid processing. + */ + private float randomPointBetween(float corner1, float corner2) { + if (corner1 == corner2) { + return corner1; + } + float delta = corner2 - corner1; + float offset = random.nextFloat() * delta; + return corner1 + offset; + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java index 1bc349f..f8d1870 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java @@ -1,24 +1,24 @@ -package com.chromascape.utils.core.input.remoteinput; - -/** - * Key value pair enum containing keys and their Java key code. Used to define the preferred Java - * keycode for RemoteInput. e.g. enter is typically 10, we want 13. - */ -public enum ControlKey { - VK_LEFT_CONTROL(162), - VK_LEFT_ALT(164), - VK_RIGHT_ALT(165), - VK_LEFT_WINDOWS(91), - VK_RETURN(13); - - public final int nativeCode; - - /** - * Constructs the enum with an extra value, which contains the java keycode. - * - * @param nativeCode the Java keycode - */ - ControlKey(int nativeCode) { - this.nativeCode = nativeCode; - } -} +package com.chromascape.utils.core.input.remoteinput; + +/** + * Key value pair enum containing keys and their Java key code. Used to define the preferred Java + * keycode for RemoteInput. e.g. enter is typically 10, we want 13. + */ +public enum ControlKey { + VK_LEFT_CONTROL(162), + VK_LEFT_ALT(164), + VK_RIGHT_ALT(165), + VK_LEFT_WINDOWS(91), + VK_RETURN(13); + + public final int nativeCode; + + /** + * Constructs the enum with an extra value, which contains the java keycode. + * + * @param nativeCode the Java keycode + */ + ControlKey(int nativeCode) { + this.nativeCode = nativeCode; + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java index e62b0fa..f45db04 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java @@ -1,13 +1,13 @@ -package com.chromascape.utils.core.input.remoteinput; - -/** - * Enum containing the available mouse buttons for use with RemoteInput. Namely, the functions - * {@link RemoteInput#holdMouse(MouseButton)}, {@link RemoteInput#releaseMouse(MouseButton)}, and - * {@link RemoteInput#isMouseHeld(MouseButton)}. The ordinal of each mouse button refers to the - * integer value required by RI. - */ -public enum MouseButton { - right, - left, - middle -} +package com.chromascape.utils.core.input.remoteinput; + +/** + * Enum containing the available mouse buttons for use with RemoteInput. Namely, the functions + * {@link RemoteInput#holdMouse(MouseButton)}, {@link RemoteInput#releaseMouse(MouseButton)}, and + * {@link RemoteInput#isMouseHeld(MouseButton)}. The ordinal of each mouse button refers to the + * integer value required by RI. + */ +public enum MouseButton { + right, + left, + middle +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java index b682edd..9e526e7 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java @@ -1,333 +1,333 @@ -package com.chromascape.utils.core.input.remoteinput; - -import com.sun.jna.Native; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.IntByReference; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * RemoteInput allows ChromaScape to send Java AWT signals to a target Java app, to simulate IO. - * This approach as opposed to sending OS signals, allows the user to fully minimise and or cover - * the target app. It also allows the user to keep using their computer as they wish, as if - * ChromaScape was never running. This class provides functionality to load a RemoteInput binary - * regardless of operating system, provide IO to the target application, and receive the most - * up-to-date snapshot of the application's Java canvas (updated whenever they draw a new frame). - */ -public class RemoteInput implements AutoCloseable { - - private static final String COMPILED_BINARY_FILENAME = "libRemoteInput" + getExtension(); - - private final int pid; - - /** JNA needs to load the binary as an interface, the interface acts as the exported headers. */ - private final RemoteInputInterface remoteInput; - - /** - * RemoteInput returns a Pointer which acts as a reference to a specific client/target. A single - * instance of RI can support several targets. RI requests this pointer when performing IO, to - * specify which target to use. - */ - private Pointer target; - - /** - * Constructs the RemoteInput class. - * - * @param pid The process ID of the target Java application - */ - public RemoteInput(int pid) { - this.pid = pid; - this.remoteInput = loadRemoteInput(); - initialise(); - } - - /** - * The RI binary can be compiled on Linux, Mac and Windows. This function detects OS and applies - * the corresponding filetype. - * - * @return OS specific filetype - */ - private static String getExtension() { - String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win")) { - return ".dll"; - } - if (os.contains("mac")) { - return ".dylib"; - } - return ".so"; - } - - /** - * RemoteInput expects specific Java keycodes for several keys, compared to generic keycodes. - * e.g., enter is typically 10, we want 13. - * - * @param javaKeyCode The {@link KeyEvent} Java keycode - * @return RemoteInput's preferred keycode - */ - private int toNativeCode(int javaKeyCode) { - return switch (javaKeyCode) { - case KeyEvent.VK_ENTER -> ControlKey.VK_RETURN.nativeCode; - case KeyEvent.VK_CONTROL -> ControlKey.VK_LEFT_CONTROL.nativeCode; - case KeyEvent.VK_ALT -> ControlKey.VK_LEFT_ALT.nativeCode; - case KeyEvent.VK_ALT_GRAPH -> ControlKey.VK_RIGHT_ALT.nativeCode; - case KeyEvent.VK_WINDOWS -> ControlKey.VK_LEFT_WINDOWS.nativeCode; - default -> javaKeyCode; - }; - } - - /** - * Checks if the target application is already a registered client. If not, will inject RI into - * the application. Finally, it pairs with the target - */ - private void initialise() { - if (!isInjectedClient()) { - remoteInput.EIOS_Inject_PID(pid); - } - pairClient(); - } - - /** - * Checks if the target application is already registered within RI's internal list of connected - * clients. A registered client is one that already has RI injected into it. - * - * @return Whether the target application is registered - */ - private boolean isInjectedClient() { - long totalClients = remoteInput.EIOS_GetClients(false); - - for (long i = 0; i < totalClients; i++) { - if (remoteInput.EIOS_GetClientPID(i) == pid) { - return true; - } - } - return false; - } - - /** - * Grants IO operation over a registered client by returning a pointer to reference a specific - * client. - * - * @see #target <- The returned pointer - */ - private void pairClient() { - target = remoteInput.EIOS_RequestTarget(String.valueOf(pid)); - if (target == null) { - throw new RuntimeException("Target Not Found with pid: " + pid); - } - } - - /** - * Loads the RemoteInput binary as a {@link RemoteInputInterface} object to allow Java to - * communicate directly to the native binary. Will first check if a user compiled binary exists, - * if not, uses a provided pre-compiled binary. - * - * @return An interface that acts as a bridge to talk to the binary in Java, used within this - * class to provide IO operations - */ - private static RemoteInputInterface loadRemoteInput() { - Path binaryFile = - Paths.get("third-party", "RemoteInput", "cmake-build-release", COMPILED_BINARY_FILENAME); - if (!Files.exists(binaryFile)) { - binaryFile = Paths.get("third-party", "RemoteInput", "precompiled", COMPILED_BINARY_FILENAME); - } - - try { - return Native.load(binaryFile.toString(), RemoteInputInterface.class); - } catch (UnsatisfiedLinkError e) { - throw new RuntimeException("Unable to load RemoteInput binary from path", e); - } - } - - /** - * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel - * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of - * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native - * hooks in the target application whenever a frame is rendered. - * - * @return A pointer to the start of the BGRA pixel array - */ - public synchronized Pointer getImageBuffer() { - Pointer p = remoteInput.EIOS_GetImageBuffer(target); - if (p == null) { - throw new RuntimeException("Image Buffer Not Found with pid: " + pid); - } - return p; - } - - /** - * Retrieves a memory pointer similarly to {@link #getImageBuffer()}. This method however will - * contain the mouse pointer and any objects drawn onto the canvas. - * - * @return A pointer to the start of the BGRA pixel array - */ - public synchronized Pointer getDebugImageBuffer() { - Pointer p = remoteInput.EIOS_GetDebugImageBuffer(target); - if (p == null) { - throw new RuntimeException("Image Buffer Not Found with pid: " + pid); - } - return p; - } - - /** Will get focus of the client if in an unfocused state, necessary for mouse input. */ - private void getFocusIfNotFocused() { - if (!remoteInput.EIOS_HasFocus(target)) { - remoteInput.EIOS_GainFocus(target); - } - } - - /** Will enable keyboard input if it's currently disabled, necessary for keyboard input. */ - private void setKeyboardInputIfDisabled() { - if (!remoteInput.EIOS_IsKeyboardInputEnabled(target)) { - remoteInput.EIOS_SetKeyboardInputEnabled(target, true); - } - } - - /** - * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. - * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless - * RemoteInput is pairing for the first time, where it'll randomise mouse position. - * - * @return A {@link Point} referring to the client relative mouse position - */ - public synchronized Point getMousePosition() { - IntByReference x = new IntByReference(); - IntByReference y = new IntByReference(); - remoteInput.EIOS_GetMousePosition(target, x, y); - return new Point(x.getValue(), y.getValue()); - } - - /** - * Gets the target app's window dimensions. Due to all IO being performed in client relative - * space, the user can assume the origin as 0,0. - * - * @return A rectangle defining the origin and bounds of the target application - */ - public synchronized Rectangle getTargetDimensions() { - IntByReference x = new IntByReference(); - IntByReference y = new IntByReference(); - remoteInput.EIOS_GetTargetDimensions(target, x, y); - return new Rectangle(0, 0, x.getValue(), y.getValue()); - } - - /** - * Sends a key down in the target java app. To get the keycode, use a {@link - * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. - * - * @param javaKeyCode The Java keycode corresponding to the key being pressed - */ - public synchronized void holdKey(int javaKeyCode) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_HoldKey(target, toNativeCode(javaKeyCode)); - } - - /** - * Queries whether a key is currently being held. - * - * @param javaKeyCode The Java keycode of the key in question - * @return Whether the key is currently being held - */ - public synchronized boolean isKeyHeld(int javaKeyCode) { - return remoteInput.EIOS_IsKeyHeld(target, toNativeCode(javaKeyCode)); - } - - /** - * Sends a key release event to the target Java app. This should be used in conjunction with - * {@link #holdKey(int)} to simulate a full key press. - * - * @param javaKeyCode The Java keycode corresponding to the key being released - */ - public synchronized void releaseKey(int javaKeyCode) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_ReleaseKey(target, toNativeCode(javaKeyCode)); - } - - /** - * Sends a mouse button down in the target Java app. - * - * @param button The {@link MouseButton} button to hold - */ - public synchronized void holdMouse(MouseButton button) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_HoldMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); - } - - /** - * Queries whether a {@link MouseButton} is currently held. - * - * @param button The {@link MouseButton} to check - * @return If the mouse button is currently being held - */ - public synchronized boolean isMouseHeld(MouseButton button) { - return remoteInput.EIOS_IsMouseHeld(target, button.ordinal()); - } - - /** - * Releases a mouse button at the designated client local co-ordinates. - * - * @param button The {@link MouseButton} to release - */ - public synchronized void releaseMouse(MouseButton button) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_ReleaseMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); - } - - /** - * Moves a mouse to a designated client local co-ordinate. - * - * @param location The {@link Point} location to snap the mouse to - */ - public synchronized void moveMouse(Point location) { - getFocusIfNotFocused(); - remoteInput.EIOS_MoveMouse(target, location.x, location.y); - } - - /** - * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds - * a small float value to this, to simulate human imperfection - * - * @param notches The number of mouse notches to scroll, down is positive, up is negative - */ - public synchronized void scrollMouse(int notches) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_ScrollMouse(target, mousePosition.x, mousePosition.y, notches); - } - - /** - * Types out a string of characters whilst compensating for the need of modifier keys. Useful when - * typing something to a dialogue box, will compensate for special characters, however lacks delay - * between keypresses. - * - * @param string The text to be typed - * @param keyWait The time in milliseconds to hold down a key - * @param keyModWait The time in milliseconds to hold down modifier keys - */ - public synchronized void sendString(String string, int keyWait, int keyModWait) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_SendString(target, string, keyWait, keyModWait); - } - - /** - * Since this class implements the {@link AutoCloseable} interface, it must be closed to relieve - * native memory. This method will release the target, effectively shutting down RemoteInput for - * the particular ChromaScape instance. However, this does not delete the injected part of RI in - * the target, simply shuts down control over it. - */ - @Override - public void close() { - if (target != null) { - remoteInput.EIOS_ReleaseTarget(target); - target = null; - } - } -} +package com.chromascape.utils.core.input.remoteinput; + +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * RemoteInput allows ChromaScape to send Java AWT signals to a target Java app, to simulate IO. + * This approach as opposed to sending OS signals, allows the user to fully minimise and or cover + * the target app. It also allows the user to keep using their computer as they wish, as if + * ChromaScape was never running. This class provides functionality to load a RemoteInput binary + * regardless of operating system, provide IO to the target application, and receive the most + * up-to-date snapshot of the application's Java canvas (updated whenever they draw a new frame). + */ +public class RemoteInput implements AutoCloseable { + + private static final String COMPILED_BINARY_FILENAME = "libRemoteInput" + getExtension(); + + private final int pid; + + /** JNA needs to load the binary as an interface, the interface acts as the exported headers. */ + private final RemoteInputInterface remoteInput; + + /** + * RemoteInput returns a Pointer which acts as a reference to a specific client/target. A single + * instance of RI can support several targets. RI requests this pointer when performing IO, to + * specify which target to use. + */ + private Pointer target; + + /** + * Constructs the RemoteInput class. + * + * @param pid The process ID of the target Java application + */ + public RemoteInput(int pid) { + this.pid = pid; + this.remoteInput = loadRemoteInput(); + initialise(); + } + + /** + * The RI binary can be compiled on Linux, Mac and Windows. This function detects OS and applies + * the corresponding filetype. + * + * @return OS specific filetype + */ + private static String getExtension() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + return ".dll"; + } + if (os.contains("mac")) { + return ".dylib"; + } + return ".so"; + } + + /** + * RemoteInput expects specific Java keycodes for several keys, compared to generic keycodes. + * e.g., enter is typically 10, we want 13. + * + * @param javaKeyCode The {@link KeyEvent} Java keycode + * @return RemoteInput's preferred keycode + */ + private int toNativeCode(int javaKeyCode) { + return switch (javaKeyCode) { + case KeyEvent.VK_ENTER -> ControlKey.VK_RETURN.nativeCode; + case KeyEvent.VK_CONTROL -> ControlKey.VK_LEFT_CONTROL.nativeCode; + case KeyEvent.VK_ALT -> ControlKey.VK_LEFT_ALT.nativeCode; + case KeyEvent.VK_ALT_GRAPH -> ControlKey.VK_RIGHT_ALT.nativeCode; + case KeyEvent.VK_WINDOWS -> ControlKey.VK_LEFT_WINDOWS.nativeCode; + default -> javaKeyCode; + }; + } + + /** + * Checks if the target application is already a registered client. If not, will inject RI into + * the application. Finally, it pairs with the target + */ + private void initialise() { + if (!isInjectedClient()) { + remoteInput.EIOS_Inject_PID(pid); + } + pairClient(); + } + + /** + * Checks if the target application is already registered within RI's internal list of connected + * clients. A registered client is one that already has RI injected into it. + * + * @return Whether the target application is registered + */ + private boolean isInjectedClient() { + long totalClients = remoteInput.EIOS_GetClients(false); + + for (long i = 0; i < totalClients; i++) { + if (remoteInput.EIOS_GetClientPID(i) == pid) { + return true; + } + } + return false; + } + + /** + * Grants IO operation over a registered client by returning a pointer to reference a specific + * client. + * + * @see #target <- The returned pointer + */ + private void pairClient() { + target = remoteInput.EIOS_RequestTarget(String.valueOf(pid)); + if (target == null) { + throw new RuntimeException("Target Not Found with pid: " + pid); + } + } + + /** + * Loads the RemoteInput binary as a {@link RemoteInputInterface} object to allow Java to + * communicate directly to the native binary. Will first check if a user compiled binary exists, + * if not, uses a provided pre-compiled binary. + * + * @return An interface that acts as a bridge to talk to the binary in Java, used within this + * class to provide IO operations + */ + private static RemoteInputInterface loadRemoteInput() { + Path binaryFile = + Paths.get("third-party", "RemoteInput", "cmake-build-release", COMPILED_BINARY_FILENAME); + if (!Files.exists(binaryFile)) { + binaryFile = Paths.get("third-party", "RemoteInput", "precompiled", COMPILED_BINARY_FILENAME); + } + + try { + return Native.load(binaryFile.toString(), RemoteInputInterface.class); + } catch (UnsatisfiedLinkError e) { + throw new RuntimeException("Unable to load RemoteInput binary from path", e); + } + } + + /** + * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel + * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of + * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native + * hooks in the target application whenever a frame is rendered. + * + * @return A pointer to the start of the BGRA pixel array + */ + public synchronized Pointer getImageBuffer() { + Pointer p = remoteInput.EIOS_GetImageBuffer(target); + if (p == null) { + throw new RuntimeException("Image Buffer Not Found with pid: " + pid); + } + return p; + } + + /** + * Retrieves a memory pointer similarly to {@link #getImageBuffer()}. This method however will + * contain the mouse pointer and any objects drawn onto the canvas. + * + * @return A pointer to the start of the BGRA pixel array + */ + public synchronized Pointer getDebugImageBuffer() { + Pointer p = remoteInput.EIOS_GetDebugImageBuffer(target); + if (p == null) { + throw new RuntimeException("Image Buffer Not Found with pid: " + pid); + } + return p; + } + + /** Will get focus of the client if in an unfocused state, necessary for mouse input. */ + private void getFocusIfNotFocused() { + if (!remoteInput.EIOS_HasFocus(target)) { + remoteInput.EIOS_GainFocus(target); + } + } + + /** Will enable keyboard input if it's currently disabled, necessary for keyboard input. */ + private void setKeyboardInputIfDisabled() { + if (!remoteInput.EIOS_IsKeyboardInputEnabled(target)) { + remoteInput.EIOS_SetKeyboardInputEnabled(target, true); + } + } + + /** + * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. + * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless + * RemoteInput is pairing for the first time, where it'll randomise mouse position. + * + * @return A {@link Point} referring to the client relative mouse position + */ + public synchronized Point getMousePosition() { + IntByReference x = new IntByReference(); + IntByReference y = new IntByReference(); + remoteInput.EIOS_GetMousePosition(target, x, y); + return new Point(x.getValue(), y.getValue()); + } + + /** + * Gets the target app's window dimensions. Due to all IO being performed in client relative + * space, the user can assume the origin as 0,0. + * + * @return A rectangle defining the origin and bounds of the target application + */ + public synchronized Rectangle getTargetDimensions() { + IntByReference x = new IntByReference(); + IntByReference y = new IntByReference(); + remoteInput.EIOS_GetTargetDimensions(target, x, y); + return new Rectangle(0, 0, x.getValue(), y.getValue()); + } + + /** + * Sends a key down in the target java app. To get the keycode, use a {@link + * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. + * + * @param javaKeyCode The Java keycode corresponding to the key being pressed + */ + public synchronized void holdKey(int javaKeyCode) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_HoldKey(target, toNativeCode(javaKeyCode)); + } + + /** + * Queries whether a key is currently being held. + * + * @param javaKeyCode The Java keycode of the key in question + * @return Whether the key is currently being held + */ + public synchronized boolean isKeyHeld(int javaKeyCode) { + return remoteInput.EIOS_IsKeyHeld(target, toNativeCode(javaKeyCode)); + } + + /** + * Sends a key release event to the target Java app. This should be used in conjunction with + * {@link #holdKey(int)} to simulate a full key press. + * + * @param javaKeyCode The Java keycode corresponding to the key being released + */ + public synchronized void releaseKey(int javaKeyCode) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_ReleaseKey(target, toNativeCode(javaKeyCode)); + } + + /** + * Sends a mouse button down in the target Java app. + * + * @param button The {@link MouseButton} button to hold + */ + public synchronized void holdMouse(MouseButton button) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_HoldMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); + } + + /** + * Queries whether a {@link MouseButton} is currently held. + * + * @param button The {@link MouseButton} to check + * @return If the mouse button is currently being held + */ + public synchronized boolean isMouseHeld(MouseButton button) { + return remoteInput.EIOS_IsMouseHeld(target, button.ordinal()); + } + + /** + * Releases a mouse button at the designated client local co-ordinates. + * + * @param button The {@link MouseButton} to release + */ + public synchronized void releaseMouse(MouseButton button) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_ReleaseMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); + } + + /** + * Moves a mouse to a designated client local co-ordinate. + * + * @param location The {@link Point} location to snap the mouse to + */ + public synchronized void moveMouse(Point location) { + getFocusIfNotFocused(); + remoteInput.EIOS_MoveMouse(target, location.x, location.y); + } + + /** + * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds + * a small float value to this, to simulate human imperfection + * + * @param notches The number of mouse notches to scroll, down is positive, up is negative + */ + public synchronized void scrollMouse(int notches) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_ScrollMouse(target, mousePosition.x, mousePosition.y, notches); + } + + /** + * Types out a string of characters whilst compensating for the need of modifier keys. Useful when + * typing something to a dialogue box, will compensate for special characters, however lacks delay + * between keypresses. + * + * @param string The text to be typed + * @param keyWait The time in milliseconds to hold down a key + * @param keyModWait The time in milliseconds to hold down modifier keys + */ + public synchronized void sendString(String string, int keyWait, int keyModWait) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_SendString(target, string, keyWait, keyModWait); + } + + /** + * Since this class implements the {@link AutoCloseable} interface, it must be closed to relieve + * native memory. This method will release the target, effectively shutting down RemoteInput for + * the particular ChromaScape instance. However, this does not delete the injected part of RI in + * the target, simply shuts down control over it. + */ + @Override + public void close() { + if (target != null) { + remoteInput.EIOS_ReleaseTarget(target); + target = null; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java index 7cff1bc..798127d 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java @@ -1,226 +1,226 @@ -package com.chromascape.utils.core.input.remoteinput; - -import com.sun.jna.Library; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.IntByReference; - -/** - * JNA interface to load the RemoteInput DLL as a {@link RemoteInput} object. Contains the available - * exported headers available for ChromaScape to use. - */ -public interface RemoteInputInterface extends Library { - - /** - * Injects part of the RemoteInput Process into the target Java app. RI will only work after this - * pairing process has been performed. - * - * @param pid The OS process ID of which to inject into. - */ - void EIOS_Inject_PID(int pid); - - /** - * Requests to control a specific target app after it's been registered using {@link - * #EIOS_Inject_PID(int)}. Many targets can be controlled using a single instance of RemoteInput. - * - * @param initargs The String value of the target Java app's process ID (PID) - * @return A pointer reference to the target's EIOS object. This object is used in IO operations - * as a reference to which target should receive IO - */ - Pointer EIOS_RequestTarget(String initargs); - - /** - * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel - * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of - * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native - * hooks in the target application whenever a frame is rendered. - * - * @param eios The pointer to the paired EIOS target instance - * @return A pointer to the start of the BGRA pixel array - */ - Pointer EIOS_GetImageBuffer(Pointer eios); - - /** - * Retrieves a memory pointer similarly to {@link #EIOS_GetImageBuffer(Pointer)}. This method - * however will contain the mouse pointer and any objects drawn onto the canvas. - * - * @param eios The pointer to the paired EIOS target instance - * @return A pointer to the start of the BGRA pixel array - */ - Pointer EIOS_GetDebugImageBuffer(Pointer eios); - - /** - * Checks if the target application has keyboard input enabled. This is useful when using {@link - * #EIOS_HoldKey(Pointer, int)}, {@link #EIOS_ReleaseKey(Pointer, int)} and or {@link - * #EIOS_SendString(Pointer, String, int, int)}. - * - * @param eios The pointer to the paired EIOS target instance - * @return If the target has keyboard input enabled - */ - boolean EIOS_IsKeyboardInputEnabled(Pointer eios); - - /** - * Sets the keyboard input to either enabled or disabled, useful when conducting keyboard input. - * - * @param eios The pointer to the paired EIOS target instance - * @param enabled Whether you want keyboard input to be enabled - */ - void EIOS_SetKeyboardInputEnabled(Pointer eios, boolean enabled); - - /** - * Gets the total number of clients that currently have RemoteInput injected into them. - * - * @param unpaired_only Whether to count only unpaired targets. - * @return The total number of connected targets. - */ - long EIOS_GetClients(boolean unpaired_only); - - /** - * Fetches the process ID of a client given the index of the client in RemoteInput's internal - * client array. - * - * @param index The index position of the client - * @return The process ID - */ - int EIOS_GetClientPID(long index); - - /** - * Whether the host machine has focus over the target Java app. Focus is required when sending - * inputs to the client, it's recommended to use {@link #EIOS_GainFocus(Pointer)} to do so. - * - * @param eios The pointer to the paired EIOS target instance - * @return Whether the host has focus over the client - */ - boolean EIOS_HasFocus(Pointer eios); - - /** - * Used to gain focus over the target app. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_GainFocus(Pointer eios); - - /** - * Used to lose focus over the target app, might be useful for antiban possibly. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_LoseFocus(Pointer eios); - - /** - * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. - * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless - * RemoteInput is pairing for the first time, where it'll randomise mouse position. - * - * @param eios The pointer to the paired EIOS target instance - * @param x An {@link IntByReference} which gets mutated with the x value - * @param y An {@link IntByReference} which gets mutated with the y value - */ - void EIOS_GetMousePosition(Pointer eios, IntByReference x, IntByReference y); - - /** - * Gets the target app's window dimensions. Due to all IO being performed in client relative - * space, the user can assume the origin as 0,0. - * - * @param eios The pointer to the paired EIOS target instance - * @param width An {@link IntByReference} which gets mutated with the width's value in pixels - * @param height An {@link IntByReference} which gets mutated with the height's value in pixels - */ - void EIOS_GetTargetDimensions(Pointer eios, IntByReference width, IntByReference height); - - /** - * Sends a key down in the target java app. To get the keycode, use a {@link - * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode corresponding to the key being pressed - */ - void EIOS_HoldKey(Pointer eios, int key); - - /** - * Sends a mouse button down in the target java app. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param button The {@link MouseButton} to use - */ - void EIOS_HoldMouse(Pointer eios, int x, int y, int button); - - /** - * Queries whether a key is currently held. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode of the key - * @return If the key is currently being held - */ - boolean EIOS_IsKeyHeld(Pointer eios, int key); - - /** - * Queries whether a {@link MouseButton} is currently held. - * - * @param eios The pointer to the paired EIOS target instance - * @param button The {@link MouseButton} to check - * @return If the mouse button is currently being held - */ - boolean EIOS_IsMouseHeld(Pointer eios, int button); - - /** - * Moves a mouse to a designated client local co-ordinate. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - */ - void EIOS_MoveMouse(Pointer eios, int x, int y); - - /** - * Sends a key release event to the target Java app. This should be used in conjunction with - * {@link #EIOS_HoldKey(Pointer, int)} to simulate a full key press. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode corresponding to the key being released - */ - void EIOS_ReleaseKey(Pointer eios, int key); - - /** - * Releases a mouse button at the designated client local co-ordinates. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param button The {@link MouseButton} to release - */ - void EIOS_ReleaseMouse(Pointer eios, int x, int y, int button); - - /** - * Severs the connection to the target EIOS object and releases native resources. This should be - * called when the script stops or the controller shuts down to avoid memory leaks in the target - * app. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_ReleaseTarget(Pointer eios); - - /** - * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds - * a small float value to this, to simulate human imperfection - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param lines The number of notches to scroll; positive for down, negative for up - */ - void EIOS_ScrollMouse(Pointer eios, int x, int y, int lines); - - /** - * Types out a string of characters whilst compensating for the need of modifier keys. Useful when - * typing something to a dialogue box, will compensate for special characters, however lacks delay - * between keypresses. - * - * @param eios The pointer to the paired EIOS target instance - * @param string The text to be typed - * @param keywait The time in milliseconds to hold down a key - * @param keymodwait The time in milliseconds to hold down modifier keys - */ - void EIOS_SendString(Pointer eios, String string, int keywait, int keymodwait); -} +package com.chromascape.utils.core.input.remoteinput; + +import com.sun.jna.Library; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; + +/** + * JNA interface to load the RemoteInput DLL as a {@link RemoteInput} object. Contains the available + * exported headers available for ChromaScape to use. + */ +public interface RemoteInputInterface extends Library { + + /** + * Injects part of the RemoteInput Process into the target Java app. RI will only work after this + * pairing process has been performed. + * + * @param pid The OS process ID of which to inject into. + */ + void EIOS_Inject_PID(int pid); + + /** + * Requests to control a specific target app after it's been registered using {@link + * #EIOS_Inject_PID(int)}. Many targets can be controlled using a single instance of RemoteInput. + * + * @param initargs The String value of the target Java app's process ID (PID) + * @return A pointer reference to the target's EIOS object. This object is used in IO operations + * as a reference to which target should receive IO + */ + Pointer EIOS_RequestTarget(String initargs); + + /** + * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel + * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of + * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native + * hooks in the target application whenever a frame is rendered. + * + * @param eios The pointer to the paired EIOS target instance + * @return A pointer to the start of the BGRA pixel array + */ + Pointer EIOS_GetImageBuffer(Pointer eios); + + /** + * Retrieves a memory pointer similarly to {@link #EIOS_GetImageBuffer(Pointer)}. This method + * however will contain the mouse pointer and any objects drawn onto the canvas. + * + * @param eios The pointer to the paired EIOS target instance + * @return A pointer to the start of the BGRA pixel array + */ + Pointer EIOS_GetDebugImageBuffer(Pointer eios); + + /** + * Checks if the target application has keyboard input enabled. This is useful when using {@link + * #EIOS_HoldKey(Pointer, int)}, {@link #EIOS_ReleaseKey(Pointer, int)} and or {@link + * #EIOS_SendString(Pointer, String, int, int)}. + * + * @param eios The pointer to the paired EIOS target instance + * @return If the target has keyboard input enabled + */ + boolean EIOS_IsKeyboardInputEnabled(Pointer eios); + + /** + * Sets the keyboard input to either enabled or disabled, useful when conducting keyboard input. + * + * @param eios The pointer to the paired EIOS target instance + * @param enabled Whether you want keyboard input to be enabled + */ + void EIOS_SetKeyboardInputEnabled(Pointer eios, boolean enabled); + + /** + * Gets the total number of clients that currently have RemoteInput injected into them. + * + * @param unpaired_only Whether to count only unpaired targets. + * @return The total number of connected targets. + */ + long EIOS_GetClients(boolean unpaired_only); + + /** + * Fetches the process ID of a client given the index of the client in RemoteInput's internal + * client array. + * + * @param index The index position of the client + * @return The process ID + */ + int EIOS_GetClientPID(long index); + + /** + * Whether the host machine has focus over the target Java app. Focus is required when sending + * inputs to the client, it's recommended to use {@link #EIOS_GainFocus(Pointer)} to do so. + * + * @param eios The pointer to the paired EIOS target instance + * @return Whether the host has focus over the client + */ + boolean EIOS_HasFocus(Pointer eios); + + /** + * Used to gain focus over the target app. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_GainFocus(Pointer eios); + + /** + * Used to lose focus over the target app, might be useful for antiban possibly. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_LoseFocus(Pointer eios); + + /** + * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. + * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless + * RemoteInput is pairing for the first time, where it'll randomise mouse position. + * + * @param eios The pointer to the paired EIOS target instance + * @param x An {@link IntByReference} which gets mutated with the x value + * @param y An {@link IntByReference} which gets mutated with the y value + */ + void EIOS_GetMousePosition(Pointer eios, IntByReference x, IntByReference y); + + /** + * Gets the target app's window dimensions. Due to all IO being performed in client relative + * space, the user can assume the origin as 0,0. + * + * @param eios The pointer to the paired EIOS target instance + * @param width An {@link IntByReference} which gets mutated with the width's value in pixels + * @param height An {@link IntByReference} which gets mutated with the height's value in pixels + */ + void EIOS_GetTargetDimensions(Pointer eios, IntByReference width, IntByReference height); + + /** + * Sends a key down in the target java app. To get the keycode, use a {@link + * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode corresponding to the key being pressed + */ + void EIOS_HoldKey(Pointer eios, int key); + + /** + * Sends a mouse button down in the target java app. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param button The {@link MouseButton} to use + */ + void EIOS_HoldMouse(Pointer eios, int x, int y, int button); + + /** + * Queries whether a key is currently held. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode of the key + * @return If the key is currently being held + */ + boolean EIOS_IsKeyHeld(Pointer eios, int key); + + /** + * Queries whether a {@link MouseButton} is currently held. + * + * @param eios The pointer to the paired EIOS target instance + * @param button The {@link MouseButton} to check + * @return If the mouse button is currently being held + */ + boolean EIOS_IsMouseHeld(Pointer eios, int button); + + /** + * Moves a mouse to a designated client local co-ordinate. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + */ + void EIOS_MoveMouse(Pointer eios, int x, int y); + + /** + * Sends a key release event to the target Java app. This should be used in conjunction with + * {@link #EIOS_HoldKey(Pointer, int)} to simulate a full key press. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode corresponding to the key being released + */ + void EIOS_ReleaseKey(Pointer eios, int key); + + /** + * Releases a mouse button at the designated client local co-ordinates. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param button The {@link MouseButton} to release + */ + void EIOS_ReleaseMouse(Pointer eios, int x, int y, int button); + + /** + * Severs the connection to the target EIOS object and releases native resources. This should be + * called when the script stops or the controller shuts down to avoid memory leaks in the target + * app. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_ReleaseTarget(Pointer eios); + + /** + * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds + * a small float value to this, to simulate human imperfection + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param lines The number of notches to scroll; positive for down, negative for up + */ + void EIOS_ScrollMouse(Pointer eios, int x, int y, int lines); + + /** + * Types out a string of characters whilst compensating for the need of modifier keys. Useful when + * typing something to a dialogue box, will compensate for special characters, however lacks delay + * between keypresses. + * + * @param eios The pointer to the paired EIOS target instance + * @param string The text to be typed + * @param keywait The time in milliseconds to hold down a key + * @param keymodwait The time in milliseconds to hold down modifier keys + */ + void EIOS_SendString(Pointer eios, String string, int keywait, int keymodwait); +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java index 4c4886e..996542f 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java @@ -1,13 +1,13 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This error is thrown when the API key used to authenticate with the Dax API does not work. - * Contact a developer to inquire about the service's availability and or a change of Public key. - */ -public class DaxAuthException extends DaxException { - - /** Constructs a new DaxAuthException with a default message. */ - public DaxAuthException() { - super("Invalid credentials"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This error is thrown when the API key used to authenticate with the Dax API does not work. + * Contact a developer to inquire about the service's availability and or a change of Public key. + */ +public class DaxAuthException extends DaxException { + + /** Constructs a new DaxAuthException with a default message. */ + public DaxAuthException() { + super("Invalid credentials"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java index 1cbb5ed..8fcb257 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java @@ -1,14 +1,14 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This is the parent of all the Dax API related exceptions. If this is the only error thrown, it - * signifies that it was an unexpected error that is unaccounted for, contact a developer. If this - * error is the parent of an error thrown, that error will contain more details. - */ -public class DaxException extends RuntimeException { - - /** Constructs a new DaxException with a default message. */ - public DaxException(String message) { - super(message); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This is the parent of all the Dax API related exceptions. If this is the only error thrown, it + * signifies that it was an unexpected error that is unaccounted for, contact a developer. If this + * error is the parent of an error thrown, that error will contain more details. + */ +public class DaxException extends RuntimeException { + + /** Constructs a new DaxException with a default message. */ + public DaxException(String message) { + super(message); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java index a070138..7f5470c 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This exception is thrown when the Publicly available endpoint for the Dax API is overloaded. When - * the service exceeds a certain threshold in a given timeframe, they stop taking more requests. The - * user experiencing this exception should implement a fallback to wait for the API, or do something - * else. - */ -public class DaxRateLimitException extends DaxException { - - /** Constructs a new DaxRateLimitException with a default message. */ - public DaxRateLimitException() { - super("Rate limit exceeded"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This exception is thrown when the Publicly available endpoint for the Dax API is overloaded. When + * the service exceeds a certain threshold in a given timeframe, they stop taking more requests. The + * user experiencing this exception should implement a fallback to wait for the API, or do something + * else. + */ +public class DaxRateLimitException extends DaxException { + + /** Constructs a new DaxRateLimitException with a default message. */ + public DaxRateLimitException() { + super("Rate limit exceeded"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java index 80e9697..a509460 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * Exception used to indicate that a running script has been requested to stop. - * - *

This unchecked exception is thrown internally to signal that the script execution should be - * terminated gracefully. It can be caught by the script runner to halt execution without treating - * the stop as an error. - * - *

Typically, this exception is thrown by calling {@code stop()} methods in the script lifecycle - * to immediately exit the current execution cycle. - */ -public class ScriptStoppedException extends RuntimeException { - - /** Constructs a new ScriptStoppedException with a default message. */ - public ScriptStoppedException() { - super("Script stopped"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * Exception used to indicate that a running script has been requested to stop. + * + *

This unchecked exception is thrown internally to signal that the script execution should be + * terminated gracefully. It can be caught by the script runner to halt execution without treating + * the stop as an error. + * + *

Typically, this exception is thrown by calling {@code stop()} methods in the script lifecycle + * to immediately exit the current execution cycle. + */ +public class ScriptStoppedException extends RuntimeException { + + /** Constructs a new ScriptStoppedException with a default message. */ + public ScriptStoppedException() { + super("Script stopped"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java b/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java index 94fce47..5eb71fd 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java @@ -1,23 +1,23 @@ -package com.chromascape.utils.core.runtime.profile; - -/** - * Represents a RuneLite profile configuration. - * - *

Each profile corresponds to a {@code .properties} file in the RuneLite profiles directory and - * an entry in {@code profiles.json}. This record stores the metadata RuneLite uses to identify and - * manage the profile. - * - *

Fields: - * - *

    - *
  • {@code id} – unique identifier for the profile (used in the filename) - *
  • {@code name} – display name of the profile - *
  • {@code sync} – whether this profile is synced via RuneLite cloud - *
  • {@code active} – whether this profile is currently selected - *
  • {@code rev} – revision number for internal tracking - *
  • {@code defaultForRsProfiles} – array of RuneLite internal profile IDs that this profile is - * the default for - *
- */ -public record Profile( - long id, String name, boolean sync, boolean active, int rev, String[] defaultForRsProfiles) {} +package com.chromascape.utils.core.runtime.profile; + +/** + * Represents a RuneLite profile configuration. + * + *

Each profile corresponds to a {@code .properties} file in the RuneLite profiles directory and + * an entry in {@code profiles.json}. This record stores the metadata RuneLite uses to identify and + * manage the profile. + * + *

Fields: + * + *

    + *
  • {@code id} – unique identifier for the profile (used in the filename) + *
  • {@code name} – display name of the profile + *
  • {@code sync} – whether this profile is synced via RuneLite cloud + *
  • {@code active} – whether this profile is currently selected + *
  • {@code rev} – revision number for internal tracking + *
  • {@code defaultForRsProfiles} – array of RuneLite internal profile IDs that this profile is + * the default for + *
+ */ +public record Profile( + long id, String name, boolean sync, boolean active, int rev, String[] defaultForRsProfiles) {} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java index aba62ce..435f3a0 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.runtime.profile; - -import java.util.List; - -/** - * A container for multiple {@link Profile} objects. - * - *

This class is primarily used for JSON serialization and deserialization of RuneLite profiles. - * The {@code profiles.json} file is mapped to a {@code ProfileContainer}, which holds a list of - * individual {@link Profile} records. - * - *

Each {@link Profile} in the list represents one profile configuration, including its metadata - * and settings. - */ -public record ProfileContainer(List profiles) {} +package com.chromascape.utils.core.runtime.profile; + +import java.util.List; + +/** + * A container for multiple {@link Profile} objects. + * + *

This class is primarily used for JSON serialization and deserialization of RuneLite profiles. + * The {@code profiles.json} file is mapped to a {@code ProfileContainer}, which holds a list of + * individual {@link Profile} records. + * + *

Each {@link Profile} in the list represents one profile configuration, including its metadata + * and settings. + */ +public record ProfileContainer(List profiles) {} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java index b094d0f..485ff35 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java @@ -1,186 +1,186 @@ -package com.chromascape.utils.core.runtime.profile; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.List; -import java.util.Objects; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Manages RuneLite profile configuration for ChromaScape. - * - *

This class is responsible for loading existing RuneLite profiles, checking whether a - * ChromaScape-specific profile already exists, and creating one if necessary. A template profile is - * bundled with the project resources and copied into RuneLite's profile directory when required. - * - *

Profiles are tracked in two ways: - * - *

    - *
  • The {@code profiles.json} file maintained by RuneLite - *
  • A corresponding {@code .properties} file for each profile - *
- * - *

The ChromaScape profile is added only if it is missing. The profile data is then saved back to - * {@code profiles.json}. - */ -@SuppressWarnings("checkstyle:SummaryJavadoc") -public class ProfileManager { - /** Path to the current user's home directory. */ - private final String userHome = System.getProperty("user.home"); - - /** RuneLite profile directory. On Linux with native Bolt, uses the Bolt data path. */ - private final Path profileDir = resolveProfileDir(); - - /** List of all loaded RuneLite profiles from profiles.json. */ - private List profiles = null; - - /** JSON mapper for serializing and deserializing profile data. */ - private final ObjectMapper mapper; - - /** Logger for status and diagnostic messages. */ - private static final Logger logger = LogManager.getLogger(ProfileManager.class); - - /** Creates a new {@code ProfileManager} with a default Jackson ObjectMapper. */ - public ProfileManager() { - mapper = new ObjectMapper(); - } - - /** - * Resolves the RuneLite profile directory for the current operating system. - * - *

On Windows, returns the standard {@code %APPDATA%\Local\RuneLite\profiles2} path. - * - *

On Linux, checks for a native Bolt installation first ({@code - * ~/.var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2}), falling back to the - * standard {@code ~/.runelite/profiles2} path if Bolt is not detected. - * - *

On macOS, returns {@code ~/Library/Application Support/RuneLite/profiles2}. - * - * @return The resolved profile directory path - */ - private Path resolveProfileDir() { - String home = System.getProperty("user.home"); - String os = System.getProperty("os.name").toLowerCase(); - - if (os.contains("win")) { - return Path.of(home, "AppData", "Local", "RuneLite", "profiles2"); - } - - if (os.contains("linux")) { - Path boltPath = - Path.of(home, ".var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2"); - if (Files.exists(boltPath.getParent())) { - logger.info("ProfileManager: using Bolt RuneLite path: {}", boltPath); - return boltPath; - } - } - - if (os.contains("mac")) { - return Path.of(home, "Library/Application Support/RuneLite/profiles2"); - } - - // Fallback (Linux standard or unknown OS) - return Path.of(home, ".runelite/profiles2"); - } - - /** - * Ensures that the ChromaScape profile exists in the RuneLite configuration. - * - *

If the profile is already present, no changes are made. Otherwise: - * - *

    - *
  1. The bundled template properties file is copied into the profile directory - *
  2. A new entry is added to the in-memory profile list - *
  3. The updated profiles.json is written to disk - *
- * - * @throws IOException if profile data cannot be read or written - */ - public void loadBotProfile() throws IOException { - loadProfileInfoFromDisk(); - if (hasChromaScapeProfile()) { - logger.info("ChromaScape RuneLite profile already loaded"); - return; - } - logger.info("ChromaScape RuneLite profile doesn't exist, loading profile..."); - addProfile(); - saveProfileInfoToDisk(); - } - - /** - * Loads the current RuneLite profile information from {@code profiles.json}. - * - * @throws IOException if the file cannot be read - */ - private void loadProfileInfoFromDisk() throws IOException { - try (InputStream in = Files.newInputStream(profileDir.resolve("profiles.json"))) { - profiles = mapper.readValue(in, ProfileContainer.class).profiles(); - } - } - - /** - * Saves the current in-memory profile list back to {@code profiles.json}. - * - * @throws IOException if the file cannot be written - */ - private void saveProfileInfoToDisk() throws IOException { - ProfileContainer profileContainer = new ProfileContainer(profiles); - mapper.writeValue(profileDir.resolve("profiles.json").toFile(), profileContainer); - } - - /** - * Checks whether a ChromaScape profile is already defined. - * - * @return {@code true} if a profile with name "ChromaScape" exists, {@code false} otherwise - */ - private boolean hasChromaScapeProfile() { - for (Profile profile : profiles) { - if (Objects.equals(profile.name(), "ChromaScape")) { - return true; - } - } - return false; - } - - /** - * Adds a new ChromaScape profile by: - * - *
    - *
  • Generating a unique identifier for the profile - *
  • Copying the template properties file from resources to the RuneLite directory - *
  • Appending a new {@link Profile} entry to the in-memory list - *
- * - * @throws IOException if the template file cannot be found or written - */ - private void addProfile() throws IOException { - // Generate unique ID - long id = System.currentTimeMillis(); - for (Profile profile : profiles) { - if (profile.id() == id) { - id = System.currentTimeMillis(); - } - } - // Copy profile to the directory and rename it using the ID - try (InputStream savedProfile = - this.getClass().getResourceAsStream("/profiles/ChromaScape.properties")) { - if (savedProfile != null) { - Files.copy( - savedProfile, - profileDir.resolve("ChromaScape-" + id + ".properties"), - StandardCopyOption.REPLACE_EXISTING); - } else { - throw new FileNotFoundException("Resource not found: /profiles/ChromaScape.properties"); - } - } - // Add new profile to the locally saved copy - Profile botProfile = new Profile(id, "ChromaScape", false, false, -1, new String[0]); - profiles.add(botProfile); - } -} +package com.chromascape.utils.core.runtime.profile; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Objects; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Manages RuneLite profile configuration for ChromaScape. + * + *

This class is responsible for loading existing RuneLite profiles, checking whether a + * ChromaScape-specific profile already exists, and creating one if necessary. A template profile is + * bundled with the project resources and copied into RuneLite's profile directory when required. + * + *

Profiles are tracked in two ways: + * + *

    + *
  • The {@code profiles.json} file maintained by RuneLite + *
  • A corresponding {@code .properties} file for each profile + *
+ * + *

The ChromaScape profile is added only if it is missing. The profile data is then saved back to + * {@code profiles.json}. + */ +@SuppressWarnings("checkstyle:SummaryJavadoc") +public class ProfileManager { + /** Path to the current user's home directory. */ + private final String userHome = System.getProperty("user.home"); + + /** RuneLite profile directory. On Linux with native Bolt, uses the Bolt data path. */ + private final Path profileDir = resolveProfileDir(); + + /** List of all loaded RuneLite profiles from profiles.json. */ + private List profiles = null; + + /** JSON mapper for serializing and deserializing profile data. */ + private final ObjectMapper mapper; + + /** Logger for status and diagnostic messages. */ + private static final Logger logger = LogManager.getLogger(ProfileManager.class); + + /** Creates a new {@code ProfileManager} with a default Jackson ObjectMapper. */ + public ProfileManager() { + mapper = new ObjectMapper(); + } + + /** + * Resolves the RuneLite profile directory for the current operating system. + * + *

On Windows, returns the standard {@code %APPDATA%\Local\RuneLite\profiles2} path. + * + *

On Linux, checks for a native Bolt installation first ({@code + * ~/.var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2}), falling back to the + * standard {@code ~/.runelite/profiles2} path if Bolt is not detected. + * + *

On macOS, returns {@code ~/Library/Application Support/RuneLite/profiles2}. + * + * @return The resolved profile directory path + */ + private Path resolveProfileDir() { + String home = System.getProperty("user.home"); + String os = System.getProperty("os.name").toLowerCase(); + + if (os.contains("win")) { + return Path.of(home, "AppData", "Local", "RuneLite", "profiles2"); + } + + if (os.contains("linux")) { + Path boltPath = + Path.of(home, ".var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2"); + if (Files.exists(boltPath.getParent())) { + logger.info("ProfileManager: using Bolt RuneLite path: {}", boltPath); + return boltPath; + } + } + + if (os.contains("mac")) { + return Path.of(home, "Library/Application Support/RuneLite/profiles2"); + } + + // Fallback (Linux standard or unknown OS) + return Path.of(home, ".runelite/profiles2"); + } + + /** + * Ensures that the ChromaScape profile exists in the RuneLite configuration. + * + *

If the profile is already present, no changes are made. Otherwise: + * + *

    + *
  1. The bundled template properties file is copied into the profile directory + *
  2. A new entry is added to the in-memory profile list + *
  3. The updated profiles.json is written to disk + *
+ * + * @throws IOException if profile data cannot be read or written + */ + public void loadBotProfile() throws IOException { + loadProfileInfoFromDisk(); + if (hasChromaScapeProfile()) { + logger.info("ChromaScape RuneLite profile already loaded"); + return; + } + logger.info("ChromaScape RuneLite profile doesn't exist, loading profile..."); + addProfile(); + saveProfileInfoToDisk(); + } + + /** + * Loads the current RuneLite profile information from {@code profiles.json}. + * + * @throws IOException if the file cannot be read + */ + private void loadProfileInfoFromDisk() throws IOException { + try (InputStream in = Files.newInputStream(profileDir.resolve("profiles.json"))) { + profiles = mapper.readValue(in, ProfileContainer.class).profiles(); + } + } + + /** + * Saves the current in-memory profile list back to {@code profiles.json}. + * + * @throws IOException if the file cannot be written + */ + private void saveProfileInfoToDisk() throws IOException { + ProfileContainer profileContainer = new ProfileContainer(profiles); + mapper.writeValue(profileDir.resolve("profiles.json").toFile(), profileContainer); + } + + /** + * Checks whether a ChromaScape profile is already defined. + * + * @return {@code true} if a profile with name "ChromaScape" exists, {@code false} otherwise + */ + private boolean hasChromaScapeProfile() { + for (Profile profile : profiles) { + if (Objects.equals(profile.name(), "ChromaScape")) { + return true; + } + } + return false; + } + + /** + * Adds a new ChromaScape profile by: + * + *
    + *
  • Generating a unique identifier for the profile + *
  • Copying the template properties file from resources to the RuneLite directory + *
  • Appending a new {@link Profile} entry to the in-memory list + *
+ * + * @throws IOException if the template file cannot be found or written + */ + private void addProfile() throws IOException { + // Generate unique ID + long id = System.currentTimeMillis(); + for (Profile profile : profiles) { + if (profile.id() == id) { + id = System.currentTimeMillis(); + } + } + // Copy profile to the directory and rename it using the ID + try (InputStream savedProfile = + this.getClass().getResourceAsStream("/profiles/ChromaScape.properties")) { + if (savedProfile != null) { + Files.copy( + savedProfile, + profileDir.resolve("ChromaScape-" + id + ".properties"), + StandardCopyOption.REPLACE_EXISTING); + } else { + throw new FileNotFoundException("Resource not found: /profiles/ChromaScape.properties"); + } + } + // Add new profile to the locally saved copy + Profile botProfile = new Profile(id, "ChromaScape", false, false, -1, new String[0]); + profiles.add(botProfile); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java b/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java index 391bdd4..af3fc9b 100644 --- a/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java +++ b/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java @@ -1,44 +1,43 @@ -package com.chromascape.utils.core.screen; - -import java.awt.BorderLayout; -import java.awt.image.BufferedImage; -import javax.swing.ImageIcon; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.WindowConstants; -import org.bytedeco.javacv.Java2DFrameUtils; - -/** - * Utility class for temporarily displaying {@link BufferedImage} instances in a Swing window for - * debugging and visualization purposes (e.g., testing masks or OpenCV Mats). - */ -public class DisplayImage { - - private static JFrame frame; - private static JLabel label; - - /** - * Displays the provided {@link BufferedImage} in a simple Swing window. - * - *

If the display window has not been created yet, it will be initialized and shown. Otherwise, - * the image inside the existing window will be updated. - * - *

This method is primarily intended for testing and debugging purposes during development. - * - * @param image The image to display. If the source is an OpenCV {@code Mat}, convert it first - * using {@link Java2DFrameUtils#toBufferedImage(org.bytedeco.opencv.opencv_core.Mat)}. - */ - public static void display(BufferedImage image) { - if (frame == null) { - frame = new JFrame("ScreenShot"); - frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); - label = new JLabel(new ImageIcon(image)); - frame.getContentPane().add(label, BorderLayout.CENTER); - frame.pack(); - frame.setLocationRelativeTo(null); - frame.setVisible(true); - } else { - label.setIcon(new ImageIcon(image)); - } - } -} +package com.chromascape.utils.core.screen; + +import java.awt.BorderLayout; +import java.awt.image.BufferedImage; +import javax.swing.ImageIcon; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.WindowConstants; + +/** + * Utility class for temporarily displaying {@link BufferedImage} instances in a Swing window for + * debugging and visualization purposes (e.g., testing masks or OpenCV Mats). + */ +public class DisplayImage { + + private static JFrame frame; + private static JLabel label; + + /** + * Displays the provided {@link BufferedImage} in a simple Swing window. + * + *

If the display window has not been created yet, it will be initialized and shown. Otherwise, + * the image inside the existing window will be updated. + * + *

This method is primarily intended for testing and debugging purposes during development. + * + * @param image The image to display. If the source is an OpenCV {@code Mat}, convert it first + * using {@code TemplateMatching.matToBufferedImage(Mat)}. + */ + public static void display(BufferedImage image) { + if (frame == null) { + frame = new JFrame("ScreenShot"); + frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + label = new JLabel(new ImageIcon(image)); + frame.getContentPane().add(label, BorderLayout.CENTER); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } else { + label.setIcon(new ImageIcon(image)); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java b/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java index e610b56..55e10ec 100644 --- a/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java +++ b/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java @@ -1,84 +1,84 @@ -package com.chromascape.utils.core.screen.colour; - -import com.chromascape.web.image.ColourData; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * A utility class for loading and accessing named colour definitions used for screen detection. - * - *

Colour data is loaded once at class initialization from a {@code colours.json} file located - * relative to the current working directory (usually the project root). Each colour definition - * includes a name and a min-max HSV range, which is used to construct {@link ColourObj} instances. - * - *

Note: The fourth component of the {@link Scalar} is always zero due to JavaCV's Scalar - * structure, so only the first three channels (H, S, V) are meaningful. - */ -public class ColourInstances { - - private static final Logger logger = LogManager.getLogger(ColourInstances.class); - - /** The cached list of all colour definitions loaded from the configuration file. */ - private static List COLOURS; - - /** - * Path to the colours JSON file, relative to working directory. Adjust this path if your file - * location changes. - */ - private static final String COLOURS_JSON_PATH = "colours/colours.json"; - - // Static block to load colour data once at class load time - static { - try (InputStream is = Files.newInputStream(Path.of(COLOURS_JSON_PATH))) { - ObjectMapper mapper = new ObjectMapper(); - - List colourDataList = mapper.readValue(is, new TypeReference<>() {}); - - COLOURS = - colourDataList.stream() - .map( - data -> - new ColourObj( - data.getName(), - new Scalar( - data.getMin()[0], - data.getMin()[1], - data.getMin()[2], - data.getMin()[3]), - new Scalar( - data.getMax()[0], - data.getMax()[1], - data.getMax()[2], - data.getMax()[3]))) - .toList(); - - } catch (IOException e) { - logger.error( - "Could not load colours.json from path '" + COLOURS_JSON_PATH + "': {}", e.getMessage()); - COLOURS = List.of(); // Initialize as empty list to avoid null pointer issues - } - } - - /** - * Retrieves a {@link ColourObj} by its name. - * - * @param name The name of the colour to retrieve. - * @return The {@link ColourObj} matching the given name, or {@code null} if not found. - */ - public static ColourObj getByName(String name) { - for (ColourObj colour : COLOURS) { - if (colour.name().equals(name)) { - return colour; - } - } - return null; - } -} +package com.chromascape.utils.core.screen.colour; + +import com.chromascape.web.image.ColourData; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * A utility class for loading and accessing named colour definitions used for screen detection. + * + *

Colour data is loaded once at class initialization from a {@code colours.json} file located + * relative to the current working directory (usually the project root). Each colour definition + * includes a name and a min-max HSV range, which is used to construct {@link ColourObj} instances. + * + *

Note: The fourth component of the {@link Scalar} is always zero due to JavaCV's Scalar + * structure, so only the first three channels (H, S, V) are meaningful. + */ +public class ColourInstances { + + private static final Logger logger = LogManager.getLogger(ColourInstances.class); + + /** The cached list of all colour definitions loaded from the configuration file. */ + private static List COLOURS; + + /** + * Path to the colours JSON file, relative to working directory. Adjust this path if your file + * location changes. + */ + private static final String COLOURS_JSON_PATH = "colours/colours.json"; + + // Static block to load colour data once at class load time + static { + try (InputStream is = Files.newInputStream(Path.of(COLOURS_JSON_PATH))) { + ObjectMapper mapper = new ObjectMapper(); + + List colourDataList = mapper.readValue(is, new TypeReference<>() {}); + + COLOURS = + colourDataList.stream() + .map( + data -> + new ColourObj( + data.getName(), + new Scalar( + data.getMin()[0], + data.getMin()[1], + data.getMin()[2], + data.getMin()[3]), + new Scalar( + data.getMax()[0], + data.getMax()[1], + data.getMax()[2], + data.getMax()[3]))) + .toList(); + + } catch (IOException e) { + logger.error( + "Could not load colours.json from path '" + COLOURS_JSON_PATH + "': {}", e.getMessage()); + COLOURS = List.of(); // Initialize as empty list to avoid null pointer issues + } + } + + /** + * Retrieves a {@link ColourObj} by its name. + * + * @param name The name of the colour to retrieve. + * @return The {@link ColourObj} matching the given name, or {@code null} if not found. + */ + public static ColourObj getByName(String name) { + for (ColourObj colour : COLOURS) { + if (colour.name().equals(name)) { + return colour; + } + } + return null; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java b/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java index a19c250..cc13243 100644 --- a/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java +++ b/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java @@ -1,49 +1,49 @@ -package com.chromascape.utils.core.screen.colour; - -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * This record class stores the name, min threshold, and max threshold of an HSV colour. Note: The - * fourth channel (alpha) is always zero and unused due to how JavaCV handles Scalar. - * - * @param name Name of the colour. - * @param hsvMin Minimum HSV threshold; alpha channel is ignored (always zero). - * @param hsvMax Maximum HSV threshold; alpha channel is ignored (always zero). - */ -public record ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { - - /** - * Constructs a ColourObj with copies of the provided HSV scalar bounds. This ensures immutability - * by duplicating the passed Scalar objects. The fourth (alpha) channel is preserved from input - * but unused in HSV processing. - * - * @param name The name identifier for the colour. - * @param hsvMin The lower HSV bound (inclusive). - * @param hsvMax The upper HSV bound (inclusive). - */ - public ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { - this.name = name; - this.hsvMin = new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); - this.hsvMax = new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); - } - - /** - * Fetches the minimum HSV values of this colour. - * - * @return A copy of the internal hsvMin to avoid mutability. The 4th channel is always zero. - */ - @Override - public Scalar hsvMin() { - return new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); - } - - /** - * Fetches the maximum HSV values of this colour. - * - * @return A copy of the internal hsvMax to avoid mutability. The 4th channel is always zero. - */ - @Override - public Scalar hsvMax() { - return new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); - } -} +package com.chromascape.utils.core.screen.colour; + +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * This record class stores the name, min threshold, and max threshold of an HSV colour. Note: The + * fourth channel (alpha) is always zero and unused due to how JavaCV handles Scalar. + * + * @param name Name of the colour. + * @param hsvMin Minimum HSV threshold; alpha channel is ignored (always zero). + * @param hsvMax Maximum HSV threshold; alpha channel is ignored (always zero). + */ +public record ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { + + /** + * Constructs a ColourObj with copies of the provided HSV scalar bounds. This ensures immutability + * by duplicating the passed Scalar objects. The fourth (alpha) channel is preserved from input + * but unused in HSV processing. + * + * @param name The name identifier for the colour. + * @param hsvMin The lower HSV bound (inclusive). + * @param hsvMax The upper HSV bound (inclusive). + */ + public ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { + this.name = name; + this.hsvMin = new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); + this.hsvMax = new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); + } + + /** + * Fetches the minimum HSV values of this colour. + * + * @return A copy of the internal hsvMin to avoid mutability. The 4th channel is always zero. + */ + @Override + public Scalar hsvMin() { + return new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); + } + + /** + * Fetches the maximum HSV values of this colour. + * + * @return A copy of the internal hsvMax to avoid mutability. The 4th channel is always zero. + */ + @Override + public Scalar hsvMax() { + return new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java b/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java index a4106fa..73af7ad 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java @@ -1,21 +1,21 @@ -package com.chromascape.utils.core.screen.topology; - -import java.awt.Rectangle; -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * Represents a detected object in the ChromaScape pipeline with a unique ID, its contour as an - * OpenCV {@link Mat}, and a bounding box for interaction. - * - * @param id A unique identifier assigned based on the object's index among detected contours. - * @param contour The OpenCV matrix representing the object's contour. - * @param boundingBox The bounding rectangle used to sample interaction points. - */ -public record ChromaObj(int id, Mat contour, Rectangle boundingBox) { - /** Releases the native OpenCV memory associated with the contour. */ - public void release() { - if (contour != null) { - contour.release(); - } - } -} +package com.chromascape.utils.core.screen.topology; + +import java.awt.Rectangle; +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * Represents a detected object in the ChromaScape pipeline with a unique ID, its contour as an + * OpenCV {@link Mat}, and a bounding box for interaction. + * + * @param id A unique identifier assigned based on the object's index among detected contours. + * @param contour The OpenCV matrix representing the object's contour. + * @param boundingBox The bounding rectangle used to sample interaction points. + */ +public record ChromaObj(int id, Mat contour, Rectangle boundingBox) { + /** Releases the native OpenCV memory associated with the contour. */ + public void release() { + if (contour != null) { + contour.release(); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java b/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java index 2c679ff..fa3ea64 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java @@ -1,201 +1,200 @@ -package com.chromascape.utils.core.screen.topology; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; -import static org.bytedeco.opencv.global.opencv_core.inRange; -import static org.bytedeco.opencv.global.opencv_imgproc.*; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.List; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.*; - -/** - * Utility class for extracting and processing colour-based contours from images. Uses OpenCV to - * convert images to HSV, extract colours within HSV ranges, find contours, and create ChromaObj - * objects representing these contours. - */ -public class ColourContours { - - private static final Mat DILATE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); - private static final Mat ERODE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); - private static final Scalar COLOUR_WHITE = new Scalar(255); - private static final Mat EMPTY_HIERARCHY = new Mat(); - private static final org.bytedeco.opencv.opencv_core.Point OFFSET_ZERO = - new org.bytedeco.opencv.opencv_core.Point(0, 0); - - /** - * Finds and returns a list of ChromaObj instances representing contours in the given image that - * match the specified colour range. - * - * @param image the BufferedImage to process - * @param colourObj the ColourObj specifying the HSV colour range to extract - * @return a list of ChromaObj objects representing detected contours of the specified colour - */ - public static List getChromaObjsInColour(BufferedImage image, ColourObj colourObj) { - Mat mask = extractColours(image, colourObj); - morphClose(mask); - ViewportManager.getInstance().updateState(mask); - MatVector contours = extractContours(mask); - mask.release(); - return createChromaObjects(contours); - } - - /** - * Iterates over a list of ChromaObjs to calculate and return whichever is closest to the - * player/screen centre. Useful in a wide range of activities and preferred over arbitrary choice - * by detection. - * - * @param chromaObjs {@code List} of which to iterate over. - * @return a single {@link ChromaObj} which is closest to the player. - */ - public static ChromaObj getChromaObjClosestToCentre(List chromaObjs) { - if (chromaObjs == null || chromaObjs.isEmpty()) { - return null; - } - - Point screenCentre = - new Point( - (int) ScreenManager.getWindowBounds().getCenterX(), - (int) ScreenManager.getWindowBounds().getCenterY()); - - double minDistance = Double.MAX_VALUE; - ChromaObj closestChromaObj = null; - - for (ChromaObj chromaObj : chromaObjs) { - Point objCentre = - new Point( - (int) chromaObj.boundingBox().getCenterX(), - (int) chromaObj.boundingBox().getCenterY()); - - double currentDistance = objCentre.distance(screenCentre); - - if (currentDistance < minDistance) { - minDistance = currentDistance; - closestChromaObj = chromaObj; - } - } - - return closestChromaObj; - } - - /** - * Converts the input image to HSV colour space and extracts a binary mask where pixels within the - * HSV range specified by the colourObj are white (255), and others are black (0). - * - * @param image the BufferedImage to convert and threshold - * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds - * @return a Mat binary mask with pixels in range set to 255, others 0 - */ - public static Mat extractColours(BufferedImage image, ColourObj colourObj) { - // Convert BufferedImage to Mat explicitly - try (Mat hsvImage = Java2DFrameUtils.toMat(image)) { - return extractColours(hsvImage, colourObj); - } - } - - /** - * Converts the input Mat to HSV colour space and extracts a binary mask. - * - * @param inputMat the source image Mat (BGR) - * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds - * @return a Mat binary mask with pixels in range set to 255, others 0 - */ - public static Mat extractColours(Mat inputMat, ColourObj colourObj) { - StateManager.setState(com.chromascape.utils.core.state.BotState.SEARCHING); - Mat hsvImage = inputMat.clone(); - cvtColor(hsvImage, hsvImage, COLOR_BGR2HSV); - Mat result = new Mat(hsvImage.size(), CV_8UC1); - Mat hsvMin = new Mat(colourObj.hsvMin()); - Mat hsvMax = new Mat(colourObj.hsvMax()); - inRange(hsvImage, hsvMin, hsvMax, result); - hsvImage.release(); - hsvMin.release(); - hsvMax.release(); - - return result; - } - - /** - * Uses Morphological Closing via dilation and erosion, to ensure that no breaks appear in the - * contour. Fills object's contours to ensure consistency and to reduce duplicate contours. - * Mutates the given Mat object rather than assigning separate objects. - * - * @param result The 8UC1 {@link Mat} mask which to mutate. - */ - public static void morphClose(Mat result) { - // Dilate the contour to fix breaks e.g., C should become O - morphologyEx(result, result, MORPH_DILATE, DILATE_KERNEL); - - // Completely fill internal space with white - // For consistency and improved contour calculation - try (MatVector contours = new MatVector()) { - findContours(result, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); - // Using static constants for reused variables to reduce CPU allocation fatigue - drawContours( - result, - contours, - -1, - COLOUR_WHITE, - -1, - LINE_8, - EMPTY_HIERARCHY, - Integer.MAX_VALUE, - OFFSET_ZERO); - } - - // Restore original size through erosion whilst closing contour breaks - morphologyEx(result, result, MORPH_ERODE, ERODE_KERNEL); - } - - /** - * Finds contours in a binary mask image. - * - * @param binaryMask a binary Mat mask where contours are to be found - * @return a MatVector containing all detected contours - */ - public static MatVector extractContours(Mat binaryMask) { - MatVector contours = new MatVector(); - findContours(binaryMask, contours, CV_RETR_LIST, CHAIN_APPROX_SIMPLE); - return contours; - } - - /** - * Creates a list of ChromaObj objects from the given contours. Each ChromaObj contains the - * contour index, the contour Mat itself, and its bounding rectangle as a Java AWT Rectangle. - * - * @param contours MatVector containing contours detected in the image - * @return list of ChromaObj objects representing each contour with bounding box - */ - public static List createChromaObjects(MatVector contours) { - List chromaObjects = new ArrayList<>(); - for (int i = 0; i < contours.size(); i++) { - Mat contour = contours.get(i); - Rect rect = boundingRect(contour); - Rectangle contourBounds = new Rectangle(rect.x(), rect.y(), rect.width(), rect.height()); - chromaObjects.add(new ChromaObj(i, contour, contourBounds)); - StatisticsManager.incrementObjectsDetected(); - } - return chromaObjects; - } - - /** - * Checks whether a given point lies inside a specified contour. - * - * @param point the Point to test - * @param contour the Mat representing the contour to test against - * @return true if the point lies inside the contour; false otherwise - */ - public static boolean isPointInContour(Point point, Mat contour) { - try (Point2f point2f = new Point2f(point.x, point.y)) { - return pointPolygonTest(contour, point2f, false) > 0; - } - } -} +package com.chromascape.utils.core.screen.topology; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.inRange; +import static org.bytedeco.opencv.global.opencv_imgproc.*; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import org.bytedeco.opencv.opencv_core.*; + +/** + * Utility class for extracting and processing colour-based contours from images. Uses OpenCV to + * convert images to HSV, extract colours within HSV ranges, find contours, and create ChromaObj + * objects representing these contours. + */ +public class ColourContours { + + private static final Mat DILATE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); + private static final Mat ERODE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); + private static final Scalar COLOUR_WHITE = new Scalar(255); + private static final Mat EMPTY_HIERARCHY = new Mat(); + private static final org.bytedeco.opencv.opencv_core.Point OFFSET_ZERO = + new org.bytedeco.opencv.opencv_core.Point(0, 0); + + /** + * Finds and returns a list of ChromaObj instances representing contours in the given image that + * match the specified colour range. + * + * @param image the BufferedImage to process + * @param colourObj the ColourObj specifying the HSV colour range to extract + * @return a list of ChromaObj objects representing detected contours of the specified colour + */ + public static List getChromaObjsInColour(BufferedImage image, ColourObj colourObj) { + Mat mask = extractColours(image, colourObj); + morphClose(mask); + ViewportManager.getInstance().updateState(mask); + MatVector contours = extractContours(mask); + mask.release(); + return createChromaObjects(contours); + } + + /** + * Iterates over a list of ChromaObjs to calculate and return whichever is closest to the + * player/screen centre. Useful in a wide range of activities and preferred over arbitrary choice + * by detection. + * + * @param chromaObjs {@code List} of which to iterate over. + * @return a single {@link ChromaObj} which is closest to the player. + */ + public static ChromaObj getChromaObjClosestToCentre(List chromaObjs) { + if (chromaObjs == null || chromaObjs.isEmpty()) { + return null; + } + + Point screenCentre = + new Point( + (int) ScreenManager.getWindowBounds().getCenterX(), + (int) ScreenManager.getWindowBounds().getCenterY()); + + double minDistance = Double.MAX_VALUE; + ChromaObj closestChromaObj = null; + + for (ChromaObj chromaObj : chromaObjs) { + Point objCentre = + new Point( + (int) chromaObj.boundingBox().getCenterX(), + (int) chromaObj.boundingBox().getCenterY()); + + double currentDistance = objCentre.distance(screenCentre); + + if (currentDistance < minDistance) { + minDistance = currentDistance; + closestChromaObj = chromaObj; + } + } + + return closestChromaObj; + } + + /** + * Converts the input image to HSV colour space and extracts a binary mask where pixels within the + * HSV range specified by the colourObj are white (255), and others are black (0). + * + * @param image the BufferedImage to convert and threshold + * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds + * @return a Mat binary mask with pixels in range set to 255, others 0 + */ + public static Mat extractColours(BufferedImage image, ColourObj colourObj) { + // Convert BufferedImage to Mat explicitly + try (Mat hsvImage = TemplateMatching.bufferedImageToMat(image)) { + return extractColours(hsvImage, colourObj); + } + } + + /** + * Converts the input Mat to HSV colour space and extracts a binary mask. + * + * @param inputMat the source image Mat (BGR) + * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds + * @return a Mat binary mask with pixels in range set to 255, others 0 + */ + public static Mat extractColours(Mat inputMat, ColourObj colourObj) { + StateManager.setState(com.chromascape.utils.core.state.BotState.SEARCHING); + Mat hsvImage = inputMat.clone(); + cvtColor(hsvImage, hsvImage, COLOR_BGR2HSV); + Mat result = new Mat(hsvImage.size(), CV_8UC1); + Mat hsvMin = new Mat(colourObj.hsvMin()); + Mat hsvMax = new Mat(colourObj.hsvMax()); + inRange(hsvImage, hsvMin, hsvMax, result); + hsvImage.release(); + hsvMin.release(); + hsvMax.release(); + + return result; + } + + /** + * Uses Morphological Closing via dilation and erosion, to ensure that no breaks appear in the + * contour. Fills object's contours to ensure consistency and to reduce duplicate contours. + * Mutates the given Mat object rather than assigning separate objects. + * + * @param result The 8UC1 {@link Mat} mask which to mutate. + */ + public static void morphClose(Mat result) { + // Dilate the contour to fix breaks e.g., C should become O + morphologyEx(result, result, MORPH_DILATE, DILATE_KERNEL); + + // Completely fill internal space with white + // For consistency and improved contour calculation + try (MatVector contours = new MatVector()) { + findContours(result, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); + // Using static constants for reused variables to reduce CPU allocation fatigue + drawContours( + result, + contours, + -1, + COLOUR_WHITE, + -1, + LINE_8, + EMPTY_HIERARCHY, + Integer.MAX_VALUE, + OFFSET_ZERO); + } + + // Restore original size through erosion whilst closing contour breaks + morphologyEx(result, result, MORPH_ERODE, ERODE_KERNEL); + } + + /** + * Finds contours in a binary mask image. + * + * @param binaryMask a binary Mat mask where contours are to be found + * @return a MatVector containing all detected contours + */ + public static MatVector extractContours(Mat binaryMask) { + MatVector contours = new MatVector(); + findContours(binaryMask, contours, CV_RETR_LIST, CHAIN_APPROX_SIMPLE); + return contours; + } + + /** + * Creates a list of ChromaObj objects from the given contours. Each ChromaObj contains the + * contour index, the contour Mat itself, and its bounding rectangle as a Java AWT Rectangle. + * + * @param contours MatVector containing contours detected in the image + * @return list of ChromaObj objects representing each contour with bounding box + */ + public static List createChromaObjects(MatVector contours) { + List chromaObjects = new ArrayList<>(); + for (int i = 0; i < contours.size(); i++) { + Mat contour = contours.get(i); + Rect rect = boundingRect(contour); + Rectangle contourBounds = new Rectangle(rect.x(), rect.y(), rect.width(), rect.height()); + chromaObjects.add(new ChromaObj(i, contour, contourBounds)); + StatisticsManager.incrementObjectsDetected(); + } + return chromaObjects; + } + + /** + * Checks whether a given point lies inside a specified contour. + * + * @param point the Point to test + * @param contour the Mat representing the contour to test against + * @return true if the point lies inside the contour; false otherwise + */ + public static boolean isPointInContour(Point point, Mat contour) { + try (Point2f point2f = new Point2f(point.x, point.y)) { + return pointPolygonTest(contour, point2f, false) > 0; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java b/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java index ee13f4b..a86a251 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java @@ -1,18 +1,18 @@ -package com.chromascape.utils.core.screen.topology; - -import java.awt.Rectangle; - -/** - * The object returned by {@link TemplateMatching}. Contains all the necessary information to react - * to the state of the template image. It is the consumer's responsibility to act on the result. - * There are no errors thrown. - * - * @param bounds The bounding box and location of the detected image. If success is false/match not - * found, this value is {@code null}. - * @param score The minVal/threshold/confidence at which the image correlated to the base (lower = - * better). If success is false/match not found, this value is {@code Double.MAX_VALUE()}. - * @param success Whether the template was successfully found within the base image. {@code boolean} - * @param message A message associated to state, in case there is no match, this will contain - * further information. - */ -public record MatchResult(Rectangle bounds, double score, boolean success, String message) {} +package com.chromascape.utils.core.screen.topology; + +import java.awt.Rectangle; + +/** + * The object returned by {@link TemplateMatching}. Contains all the necessary information to react + * to the state of the template image. It is the consumer's responsibility to act on the result. + * There are no errors thrown. + * + * @param bounds The bounding box and location of the detected image. If success is false/match not + * found, this value is {@code null}. + * @param score The minVal/threshold/confidence at which the image correlated to the base (lower = + * better). If success is false/match not found, this value is {@code Double.MAX_VALUE()}. + * @param success Whether the template was successfully found within the base image. {@code boolean} + * @param message A message associated to state, in case there is no match, this will contain + * further information. + */ +public record MatchResult(Rectangle bounds, double score, boolean success, String message) {} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java b/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java index 0c9f3d7..eb7de88 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java @@ -1,197 +1,290 @@ -package com.chromascape.utils.core.screen.topology; - -import static org.bytedeco.opencv.global.opencv_core.extractChannel; -import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; -import static org.bytedeco.opencv.global.opencv_imgproc.*; -import static org.opencv.imgproc.Imgproc.TM_SQDIFF_NORMED; - -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import org.bytedeco.javacpp.DoublePointer; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.global.opencv_imgcodecs; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point; - -/** - * Utility class for performing alpha-aware template matching using OpenCV and JavaCV. - * - *

This class provides a single static method, {@link #match}, which uses the TM_SQDIFF_NORMED - * algorithm to locate a template image within a larger base image. It uses an alpha mask to ignore - * transparent pixels in the template. - * - *

This is commonly to locate UI elements or sprites in the client window, based on screen - * captures and template assets. - */ -public class TemplateMatching { - - /** - * Performs template matching to locate a smaller image (template) within a larger image (base), - * using normalized squared difference matching with an alpha channel mask to ignore transparent - * pixels. - * - *

The method requires both images to have 4 channels (BGRA). If they do not, they are - * converted internally. The matching ignores fully transparent pixels in the template by applying - * a mask based on its alpha channel. - * - *

The method returns the bounding rectangle of the best match if its matching score is below - * the given threshold. If no match satisfies the threshold, the method returns {@code null}. - * - * @param templateImg The template image (smaller), expected as a {@link BufferedImage} in BGRA - * format or convertible to it. - * @param baseImg The base image (larger) where the template is searched, expected as a {@link - * BufferedImage} in BGRA format or convertible to it. - * @param threshold The maximum allowed normalized squared difference score for a valid match. - * Lower values mean better matches. - * @return A {@link MatchResult} representing the position and size of the matching area in the - * base image, or {@code null} if no match meets the threshold criteria. - */ - public static MatchResult match(String templateImg, BufferedImage baseImg, double threshold) { - - // Update bot's semantic state - StateManager.setState(BotState.SEARCHING); - - Mat template = null; - Mat base = null; - Mat convolution = null; - Mat alpha = null; - Mat mask = null; - - try { - // Read template image from disk and load it as a Mat - try { - template = loadMatFromResource(templateImg); - } catch (IOException e) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); - } - - // Prepare a mat in RGB to send to the viewport - Mat view = new Mat(); - // Use the template as source and view as destination. - // This handles data copying/conversion safely without modifying template. - if (template.channels() == 4) { - cvtColor(template, view, COLOR_BGRA2RGB); - } else { - cvtColor(template, view, COLOR_BGR2RGB); - } - ViewportManager.getInstance().updateState(view); - // Release the view Mat immediately as ViewportManager handles the data. - view.release(); - - if (template.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); - } - - base = Java2DFrameUtils.toMat(baseImg); - - if (base.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Base image is empty"); - } - - if (template.channels() != 4) { - cvtColor(template, template, COLOR_BGR2BGRA); - } - - if (base.channels() != 4) { - cvtColor(base, base, COLOR_BGR2BGRA); - } - - if (template.cols() > base.cols() || template.rows() > base.rows()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template is larger than base image"); - } - - int convRows = base.rows() - template.rows() + 1; - int convCols = base.cols() - template.cols() + 1; - - alpha = new Mat(); - extractChannel(template, alpha, 3); - - convolution = new Mat(convRows, convCols); - - matchTemplate(base, template, convolution, TM_SQDIFF_NORMED, alpha); - - if (convolution.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Convolution matrix is empty"); - } - - DoublePointer minVal = new DoublePointer(1); - DoublePointer maxVal = new DoublePointer(1); - Point minLoc = new Point(); - Point maxLoc = new Point(); - - mask = new Mat(); - minMaxLoc(convolution, minVal, maxVal, minLoc, maxLoc, mask); - - if (minVal.get() > threshold) { - return new MatchResult(null, minVal.get(), false, "MinVal greater than threshold"); - } - - Rectangle match = new Rectangle(minLoc.x(), minLoc.y(), template.cols(), template.rows()); - - // Update singleton state manager to update stats in UI - StatisticsManager.incrementObjectsDetected(); - - return new MatchResult(match, minVal.get(), true, "Match found"); - } finally { - - // Release native memory - if (template != null && !template.isNull()) { - template.release(); - } - if (base != null && !base.isNull()) { - base.release(); - } - if (convolution != null && !convolution.isNull()) { - convolution.release(); - } - if (alpha != null && !alpha.isNull()) { - alpha.release(); - } - if (mask != null && !mask.isNull()) { - mask.release(); - } - } - } - - /** - * Loads an image as a Mat from a resource path, preserving alpha channel. - * - * @param resourcePath path to image resource, e.g. "/images/user/myTemplate.png" (first "/" is - * necessary) - * @return Mat with image data including alpha - * @throws IOException if resource not found or temp file write fails - */ - public static Mat loadMatFromResource(String resourcePath) throws IOException { - // Get resource as stream from classpath - InputStream is = TemplateMatching.class.getResourceAsStream(resourcePath); - if (is == null) { - throw new IllegalArgumentException("Resource not found: " + resourcePath); - } - - // Create a temp file to write the resource contents (OpenCV imread needs a file - // path) - Path tempFile = Files.createTempFile("opencv-temp-", ".png"); - tempFile.toFile().deleteOnExit(); - - // Copy resource stream to temp file - Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - - // Load with imread and IMREAD_UNCHANGED to keep alpha - Mat mat = opencv_imgcodecs.imread(tempFile.toString(), opencv_imgcodecs.IMREAD_UNCHANGED); - - if (mat.empty()) { - throw new IllegalStateException("Failed to load Mat from resource: " + resourcePath); - } - - return mat; - } -} +package com.chromascape.utils.core.screen.topology; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; +import static org.bytedeco.opencv.global.opencv_core.extractChannel; +import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; +import static org.bytedeco.opencv.global.opencv_imgproc.*; +import static org.opencv.imgproc.Imgproc.TM_SQDIFF_NORMED; + +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferByte; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.bytedeco.javacpp.DoublePointer; +import org.bytedeco.opencv.global.opencv_imgcodecs; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point; + +/** + * Utility class for performing alpha-aware template matching using OpenCV and JavaCV. + * + *

This class provides a single static method, {@link #match}, which uses the TM_SQDIFF_NORMED + * algorithm to locate a template image within a larger base image. It uses an alpha mask to ignore + * transparent pixels in the template. + * + *

This is commonly to locate UI elements or sprites in the client window, based on screen + * captures and template assets. + */ +public class TemplateMatching { + + /** + * Performs template matching to locate a smaller image (template) within a larger image (base), + * using normalised squared difference matching with an alpha channel mask to ignore transparent + * pixels. + * + *

The method returns the bounding rectangle of the best match if its matching score is below + * the given threshold. If no match satisfies the threshold, the method returns {@code null}. + * + * @param templateImg The template image (smaller), expected as a {@link BufferedImage}. + * @param baseImg The base image (larger) where the template is searched, expected as a {@link + * BufferedImage} in RGB format. + * @param threshold The maximum allowed normalised squared difference score for a valid match. + * Lower values mean better matches. + * @return A {@link MatchResult} representing the position and size of the matching area in the + * base image, or {@code null} if no match meets the threshold criteria. + */ + public static MatchResult match(String templateImg, BufferedImage baseImg, double threshold) { + + // Update bot's semantic state + StateManager.setState(BotState.SEARCHING); + + Mat template = null; + Mat base = null; + Mat convolution = null; + Mat alpha = null; + Mat mask = null; + + try { + // Read template image from disk and load it as a Mat + try { + template = loadMatFromResource(templateImg); + } catch (IOException e) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); + } + + // Prepare a mat in RGB to send to the viewport + Mat view = new Mat(); + // Use the template as source and view as destination. + // This handles data copying/conversion safely without modifying template. + if (template.channels() == 4) { + cvtColor(template, view, COLOR_BGRA2RGB); + } else { + cvtColor(template, view, COLOR_BGR2RGB); + } + ViewportManager.getInstance().updateState(template); + // Release the view Mat immediately as ViewportManager handles the data. + view.release(); + + if (template.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); + } + + // Internally swaps channels to from RGBA to BGRA or RGB to BGR + base = bufferedImageToMat(baseImg); + + if (base.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Base image is empty"); + } + + if (template.channels() != 4) { + cvtColor(template, template, COLOR_BGR2BGRA); + } + + if (base.channels() != 4) { + cvtColor(base, base, COLOR_BGR2BGRA); + } + + if (template.cols() > base.cols() || template.rows() > base.rows()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template is larger than base image"); + } + + int convRows = base.rows() - template.rows() + 1; + int convCols = base.cols() - template.cols() + 1; + + alpha = new Mat(); + extractChannel(template, alpha, 3); + + convolution = new Mat(convRows, convCols); + + matchTemplate(base, template, convolution, TM_SQDIFF_NORMED, alpha); + + if (convolution.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Convolution matrix is empty"); + } + + DoublePointer minVal = new DoublePointer(1); + DoublePointer maxVal = new DoublePointer(1); + Point minLoc = new Point(); + Point maxLoc = new Point(); + + mask = new Mat(); + minMaxLoc(convolution, minVal, maxVal, minLoc, maxLoc, mask); + + if (minVal.get() > threshold) { + return new MatchResult(null, minVal.get(), false, "MinVal greater than threshold"); + } + + Rectangle match = new Rectangle(minLoc.x(), minLoc.y(), template.cols(), template.rows()); + + // Update singleton state manager to update stats in UI + StatisticsManager.incrementObjectsDetected(); + + return new MatchResult(match, minVal.get(), true, "Match found"); + } finally { + + // Release native memory + if (template != null && !template.isNull()) { + template.release(); + } + if (base != null && !base.isNull()) { + base.release(); + } + if (convolution != null && !convolution.isNull()) { + convolution.release(); + } + if (alpha != null && !alpha.isNull()) { + alpha.release(); + } + if (mask != null && !mask.isNull()) { + mask.release(); + } + } + } + + /** + * Loads an image as a Mat from a resource path, preserving alpha channel. + * + * @param resourcePath path to image resource, e.g. "/images/user/myTemplate.png" (first "/" is + * necessary) + * @return Mat with image data including alpha + * @throws IOException if resource not found or temp file write fails + */ + public static Mat loadMatFromResource(String resourcePath) throws IOException { + // Get resource as stream from classpath + InputStream is = TemplateMatching.class.getResourceAsStream(resourcePath); + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + + // Create a temp file to write the resource contents + Path tempFile = Files.createTempFile("opencv-temp-", ".png"); + tempFile.toFile().deleteOnExit(); + + // Copy resource stream to temp file + Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); + + // Load with imread and IMREAD_UNCHANGED to keep alpha + Mat mat = opencv_imgcodecs.imread(tempFile.toString(), opencv_imgcodecs.IMREAD_UNCHANGED); + + if (mat.empty()) { + throw new IllegalStateException("Failed to load Mat from resource: " + resourcePath); + } + + return mat; + } + + /** + * Converts a Java {@link BufferedImage} into an OpenCV {@link Mat} object. To ensure + * compatibility with standard OpenCV processing pipeline expectations, this method forces a + * standardisation step. It draws the input image onto a fresh canvas explicitly formatted as + * {@code BufferedImage.TYPE_3BYTE_BGR}. + * + * @param image the source {@code BufferedImage} to convert + * @return a {@code Mat} containing the BGR pixel data of the image, or an empty {@code Mat} if + * the input image is null + */ + public static Mat bufferedImageToMat(BufferedImage image) { + if (image == null) { + return new Mat(); + } + // Convert ARGB/GRAY images to BGR for standardisation + BufferedImage bgrImage = + new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_3BYTE_BGR); + Graphics2D g = bgrImage.createGraphics(); + g.drawImage(image, 0, 0, null); + g.dispose(); + // Extract data + byte[] cleanData = ((DataBufferByte) bgrImage.getRaster().getDataBuffer()).getData(); + // Create mat of correct format and size + Mat mat = new Mat(bgrImage.getHeight(), bgrImage.getWidth(), CV_8UC3); + // Put data into mat + mat.data().put(cleanData); + return mat; + } + + /** + * Converts an OpenCV {@link Mat} object into a Java {@link BufferedImage}. + * + *

This method dynamically uses the number of {@code sourceMat.channels()} to create output + * mats: + * + *

    + *
  • 1 Channel: Maps directly to {@code BufferedImage.TYPE_BYTE_GRAY} (Grayscale). + *
  • 3 Channels: Maps directly to {@code BufferedImage.TYPE_3BYTE_BGR} (Standard BGR + * Colour). + *
  • 4 Channels: Maps to {@code BufferedImage.TYPE_4BYTE_ABGR}, manually reordering the + * byte alignment from OpenCV's BGRA format to Java's expected ABGR format. + *
+ * + * @param mat the source OpenCV matrix to convert; may be uncontinuous, but must not be null or + * empty + * @return a {@code BufferedImage} matching the dimensions and colour depth of the input, or + * {@code null} if the input matrix is null or empty + * @throws IllegalArgumentException if the matrix has an unsupported number of channels (e.g., 2 + * channels) + */ + public static BufferedImage matToBufferedImage(Mat mat) { + if (mat == null || mat.empty()) { + return null; + } + + Mat sourceMat = mat.isContinuous() ? mat : mat.clone(); + + byte[] sourcePixels = new byte[sourceMat.cols() * sourceMat.rows() * sourceMat.channels()]; + sourceMat.data().get(sourcePixels); + + BufferedImage image; + byte[] targetPixels; + + if (sourceMat.channels() == 3) { + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_3BYTE_BGR); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); + + } else if (sourceMat.channels() == 4) { + + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_4BYTE_ABGR); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + + for (int i = 0; i < sourcePixels.length; i += 4) { + targetPixels[i] = sourcePixels[i + 3]; + targetPixels[i + 1] = sourcePixels[i]; + targetPixels[i + 2] = sourcePixels[i + 1]; + targetPixels[i + 3] = sourcePixels[i + 2]; + } + + } else if (sourceMat.channels() == 1) { + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_BYTE_GRAY); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); + + } else { + throw new IllegalArgumentException("Unsupported channel count: " + sourceMat.channels()); + } + + if (!sourceMat.isContinuous() && sourceMat != mat) { + sourceMat.release(); + } + + return image; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java b/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java index ef4cce8..4fcecf8 100644 --- a/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java +++ b/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.screen.viewport; - -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * Interface that defines the contract for a viewport implementation. - * - *

A viewport is responsible for visualising the bot's sensor data (such as masks or templates) - * to an external observer, usually via a web interface. - */ -public interface Viewport { - - /** - * Updates the visual state of the viewport with a new image. - * - * @param image The matrix (image) to be displayed in the viewport. - */ - void updateState(Mat image); -} +package com.chromascape.utils.core.screen.viewport; + +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * Interface that defines the contract for a viewport implementation. + * + *

A viewport is responsible for visualising the bot's sensor data (such as masks or templates) + * to an external observer, usually via a web interface. + */ +public interface Viewport { + + /** + * Updates the visual state of the viewport with a new image. + * + * @param image The matrix (image) to be displayed in the viewport. + */ + void updateState(Mat image); +} diff --git a/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java b/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java index 7ac115a..7380e80 100644 --- a/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java @@ -1,58 +1,58 @@ -package com.chromascape.utils.core.screen.viewport; - -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * A singleton manager that holds the active {@link Viewport} instance. - * - *

This class ensures that core utilities can send visual data without knowing the specific - * implementation details (e.g. whether it's running headless or via websockets). - */ -public class ViewportManager { - - /** The current active viewport instance. Defaults to a no-op implementation. */ - private static Viewport instance = new NoOpViewport(); - - /** Private constructor to prevent instantiation. */ - private ViewportManager() {} - - /** - * Retrieves the current viewport instance. - * - * @return The active {@link Viewport}. - */ - public static Viewport getInstance() { - return instance; - } - - /** - * Sets the active viewport instance. - * - *

This is typically called by the Spring application startup to inject the websocket-based - * implementation. - * - * @param viewport The new {@link Viewport} implementation to use. - */ - public static void setInstance(Viewport viewport) { - instance = viewport; - } - - /** - * A default no-operation implementation of the Viewport interface. - * - *

This is used when the application is running in headless mode or otherwise has no mechanism - * to display visual data. It simply discards updates to prevent errors and overhead. - */ - private static class NoOpViewport implements Viewport { - - /** - * Discards the update as this is a no-op implementation. - * - * @param image The image to discard. - */ - @Override - public void updateState(Mat image) { - // Do nothing - } - } -} +package com.chromascape.utils.core.screen.viewport; + +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * A singleton manager that holds the active {@link Viewport} instance. + * + *

This class ensures that core utilities can send visual data without knowing the specific + * implementation details (e.g. whether it's running headless or via websockets). + */ +public class ViewportManager { + + /** The current active viewport instance. Defaults to a no-op implementation. */ + private static Viewport instance = new NoOpViewport(); + + /** Private constructor to prevent instantiation. */ + private ViewportManager() {} + + /** + * Retrieves the current viewport instance. + * + * @return The active {@link Viewport}. + */ + public static Viewport getInstance() { + return instance; + } + + /** + * Sets the active viewport instance. + * + *

This is typically called by the Spring application startup to inject the websocket-based + * implementation. + * + * @param viewport The new {@link Viewport} implementation to use. + */ + public static void setInstance(Viewport viewport) { + instance = viewport; + } + + /** + * A default no-operation implementation of the Viewport interface. + * + *

This is used when the application is running in headless mode or otherwise has no mechanism + * to display visual data. It simply discards updates to prevent errors and overhead. + */ + private static class NoOpViewport implements Viewport { + + /** + * Discards the update as this is a no-op implementation. + * + * @param image The image to discard. + */ + @Override + public void updateState(Mat image) { + // Do nothing + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java index babd127..3cc6dfc 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java @@ -1,56 +1,56 @@ -package com.chromascape.utils.core.screen.window; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Utility class for locating and identifying a specific native process (e.g., "RuneLite") on the - * Linux operating system by scanning {@code /proc/[pid]/cmdline} entries. - * - *

Assumes a shared PID namespace — {@code /proc} must be visible. - */ -public class LinuxProcessManager implements ProcessManager { - - private static final Logger logger = LogManager.getLogger(LinuxProcessManager.class); - private static final String RUNELITE_MAIN_CLASS = "net.runelite.client.RuneLite"; - - /** - * Returns the Process ID of RuneLite. Scans {@code /proc/[pid]/cmdline} entries for the RuneLite - * main class ({@code net.runelite.client.RuneLite}). - * - * @return The integer process ID of RuneLite, or {@code -1} if not found - */ - @Override - public int getPid() { - Path proc = Paths.get("/proc"); - try (DirectoryStream stream = Files.newDirectoryStream(proc, "[0-9]*")) { - for (Path pidDir : stream) { - Path cmdlinePath = pidDir.resolve("cmdline"); - try { - byte[] bytes = Files.readAllBytes(cmdlinePath); - // /proc/[pid]/cmdline is null-byte delimited — replace for string matching - String cmdline = new String(bytes, StandardCharsets.UTF_8).replace('\0', ' ').trim(); - if (cmdline.contains(RUNELITE_MAIN_CLASS)) { - return Integer.parseInt(pidDir.getFileName().toString()); - } - } catch (NoSuchFileException ignored) { - // Process exited between directory listing and read — skip silently - } catch (NumberFormatException ignored) { - // pidDir name is not a valid integer — skip - } catch (IOException e) { - logger.debug("Failed to read cmdline for {}: {}", pidDir, e.getMessage()); - } - } - } catch (IOException e) { - logger.error("Failed to iterate /proc: {}", e.getMessage()); - } - return -1; // May be -1 if not found - } -} +package com.chromascape.utils.core.screen.window; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Utility class for locating and identifying a specific native process (e.g., "RuneLite") on the + * Linux operating system by scanning {@code /proc/[pid]/cmdline} entries. + * + *

Assumes a shared PID namespace — {@code /proc} must be visible. + */ +public class LinuxProcessManager implements ProcessManager { + + private static final Logger logger = LogManager.getLogger(LinuxProcessManager.class); + private static final String RUNELITE_MAIN_CLASS = "net.runelite.client.RuneLite"; + + /** + * Returns the Process ID of RuneLite. Scans {@code /proc/[pid]/cmdline} entries for the RuneLite + * main class ({@code net.runelite.client.RuneLite}). + * + * @return The integer process ID of RuneLite, or {@code -1} if not found + */ + @Override + public int getPid() { + Path proc = Paths.get("/proc"); + try (DirectoryStream stream = Files.newDirectoryStream(proc, "[0-9]*")) { + for (Path pidDir : stream) { + Path cmdlinePath = pidDir.resolve("cmdline"); + try { + byte[] bytes = Files.readAllBytes(cmdlinePath); + // /proc/[pid]/cmdline is null-byte delimited — replace for string matching + String cmdline = new String(bytes, StandardCharsets.UTF_8).replace('\0', ' ').trim(); + if (cmdline.contains(RUNELITE_MAIN_CLASS)) { + return Integer.parseInt(pidDir.getFileName().toString()); + } + } catch (NoSuchFileException ignored) { + // Process exited between directory listing and read — skip silently + } catch (NumberFormatException ignored) { + // pidDir name is not a valid integer — skip + } catch (IOException e) { + logger.debug("Failed to read cmdline for {}: {}", pidDir, e.getMessage()); + } + } + } catch (IOException e) { + logger.error("Failed to iterate /proc: {}", e.getMessage()); + } + return -1; // May be -1 if not found + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java index e36367e..a37d18a 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.screen.window; - -/** - * A class whose sole responsibility is to provide a native macOS implementation to return the - * process ID of RuneLite. - */ -public class MacProcessManager implements ProcessManager { - - /** - * To provide a macOS native way of grabbing and returning the Process ID of RuneLite. This is to - * be used by RemoteInput. - * - * @return An integer Process ID - */ - @Override - public int getPid() { - return -1; - } -} +package com.chromascape.utils.core.screen.window; + +/** + * A class whose sole responsibility is to provide a native macOS implementation to return the + * process ID of RuneLite. + */ +public class MacProcessManager implements ProcessManager { + + /** + * To provide a macOS native way of grabbing and returning the Process ID of RuneLite. This is to + * be used by RemoteInput. + * + * @return An integer Process ID + */ + @Override + public int getPid() { + return -1; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java index 4a1f02b..37b0eec 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.screen.window; - -/** - * An interface to be implemented by Windows, Mac and Linux implementors. The sole responsibility of - * each implementor is to provide an OS native method to return the Process ID of RuneLite. - */ -public interface ProcessManager { - /** - * To provide an OS native way of grabbing and returning the Process ID of RuneLite. This is to be - * used by RemoteInput. - * - * @return An integer Process ID - */ - int getPid(); -} +package com.chromascape.utils.core.screen.window; + +/** + * An interface to be implemented by Windows, Mac and Linux implementors. The sole responsibility of + * each implementor is to provide an OS native method to return the Process ID of RuneLite. + */ +public interface ProcessManager { + /** + * To provide an OS native way of grabbing and returning the Process ID of RuneLite. This is to be + * used by RemoteInput. + * + * @return An integer Process ID + */ + int getPid(); +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java index ee22cd8..340e489 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java @@ -1,27 +1,27 @@ -package com.chromascape.utils.core.screen.window; - -/** - * Factory class to return the OS specific implementation of a {@link ProcessManager}. Detects OS by - * OS-name and returns the native implementor of the PM interface. - */ -public class ProcessManagerFactory { - - /** - * A factory that creates an OS specific {@link ProcessManager}. - * - * @return The OS specific ProcessManager implementor, used to extract Process ID. - */ - public static ProcessManager getProcessManager() { - String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win")) { - return new WindowsProcessManager(); - } - if (os.contains("mac")) { - return new MacProcessManager(); - } - if (os.contains("linux")) { - return new LinuxProcessManager(); - } - throw new UnsupportedOperationException("Unsupported OS: " + System.getProperty("os.name")); - } -} +package com.chromascape.utils.core.screen.window; + +/** + * Factory class to return the OS specific implementation of a {@link ProcessManager}. Detects OS by + * OS-name and returns the native implementor of the PM interface. + */ +public class ProcessManagerFactory { + + /** + * A factory that creates an OS specific {@link ProcessManager}. + * + * @return The OS specific ProcessManager implementor, used to extract Process ID. + */ + public static ProcessManager getProcessManager() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + return new WindowsProcessManager(); + } + if (os.contains("mac")) { + return new MacProcessManager(); + } + if (os.contains("linux")) { + return new LinuxProcessManager(); + } + throw new UnsupportedOperationException("Unsupported OS: " + System.getProperty("os.name")); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java b/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java index ada48b9..09d6df7 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java @@ -1,113 +1,112 @@ -package com.chromascape.utils.core.screen.window; - -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.sun.jna.Pointer; -import java.awt.Rectangle; -import java.awt.Transparency; -import java.awt.color.ColorSpace; -import java.awt.image.BufferedImage; -import java.awt.image.ColorModel; -import java.awt.image.ComponentColorModel; -import java.awt.image.DataBuffer; -import java.awt.image.DataBufferByte; -import java.awt.image.Raster; -import java.awt.image.WritableRaster; - -/** - * Utility class for capturing screen regions and retrieving window bounds. Screen capture utilities - * are intended to be used with colour contour extraction and template matching. - */ -public class ScreenManager { - - private static RemoteInput remoteInput; - - private static Pointer screenBuffer = null; - - /** - * Captures a {@link Rectangle} region on the client screen, intended to be used when - * screenshotting zones for template matching and or colour extraction. - * - * @param zone The rectangle area in client relative screen co-ordinates - * @return A {@link BufferedImage} of the captured area - */ - public static BufferedImage captureZone(Rectangle zone) { - BufferedImage screen = captureWindow(); - if (screen == null) { - throw new RuntimeException("Screen could not be captured"); - } - return screen.getSubimage(zone.x, zone.y, zone.width, zone.height); - } - - /** - * Grabs the latest rendered frame of the target application, regardless of if the client is - * maximised, minimised, partially or fully covered. This is to be used with template matching and - * {@link com.chromascape.utils.core.screen.topology.ChromaObj} detection. - * - * @return A {@link BufferedImage} of the client's screen - */ - public static synchronized BufferedImage captureWindow() { - Rectangle dims = remoteInput.getTargetDimensions(); - int width = dims.width; - int height = dims.height; - - if (width <= 0 || height <= 0) { - return null; - } - - if (screenBuffer == null) { - screenBuffer = remoteInput.getImageBuffer(); - } - - int bufferSize = width * height * 4; - byte[] data = screenBuffer.getByteArray(0, bufferSize); - - return createBufferedImage(data, width, height); - } - - /** - * Internal helper to create a buffered image from a C++ style byte array of pixels in BGRA - * format. - * - * @param pixels The byte array of pixel data in BGRA format - * @param width The width of the client in pixels - * @param height The height of the client in pixels - * @return A {@link BufferedImage} representing the image - */ - private static BufferedImage createBufferedImage(byte[] pixels, int width, int height) { - DataBufferByte buffer = new DataBufferByte(pixels, pixels.length); - WritableRaster raster = - Raster.createInterleavedRaster( - buffer, width, height, width * 4, 4, new int[] {2, 1, 0, 3}, null); - - ColorModel cm = - new ComponentColorModel( - ColorSpace.getInstance(ColorSpace.CS_sRGB), - new int[] {8, 8, 8, 8}, - true, - false, - Transparency.TRANSLUCENT, - DataBuffer.TYPE_BYTE); - - return new BufferedImage(cm, raster, false, null); - } - - /** - * Gets the bounds of the (game view) RuneLite AWT Canvas object. - * - * @return A {@link Rectangle} representing the size of RuneLite's client area, excluding possible - * window borders, title or scrollbars. - */ - public static Rectangle getWindowBounds() { - return remoteInput.getTargetDimensions(); - } - - /** - * Sets the RemoteInput object in the ScreenManager, allowing it to access to the client's screen - * buffer. - * - * @param remoteInput The {@link RemoteInput} object - */ - public static void setRemoteInput(RemoteInput remoteInput) { - ScreenManager.remoteInput = remoteInput; - } -} +package com.chromascape.utils.core.screen.window; + +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.sun.jna.Pointer; +import java.awt.Rectangle; +import java.awt.Transparency; +import java.awt.color.ColorSpace; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.ComponentColorModel; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferByte; +import java.awt.image.Raster; +import java.awt.image.WritableRaster; + +/** + * Utility class for capturing screen regions and retrieving window bounds. Screen capture utilities + * are intended to be used with colour contour extraction and template matching. + */ +public class ScreenManager { + + private static RemoteInput remoteInput; + + /** + * Captures a {@link Rectangle} region on the client screen, intended to be used when + * screenshotting zones for template matching and or colour extraction. + * + * @param zone The rectangle area in client relative screen co-ordinates + * @return A {@link BufferedImage} of the captured area + */ + public static BufferedImage captureZone(Rectangle zone) { + BufferedImage screen = captureWindow(); + if (screen == null) { + throw new RuntimeException("Screen could not be captured"); + } + return screen.getSubimage(zone.x, zone.y, zone.width, zone.height); + } + + /** + * Grabs the latest rendered frame of the target application, regardless of if the client is + * maximised, minimised, partially or fully covered. This is to be used with template matching and + * {@link com.chromascape.utils.core.screen.topology.ChromaObj} detection. + * + * @return A {@link BufferedImage} of the client's screen + */ + public static synchronized BufferedImage captureWindow() { + Rectangle dims = remoteInput.getTargetDimensions(); + int width = dims.width; + int height = dims.height; + + if (width <= 0 || height <= 0) { + return null; + } + + Pointer currentScreenBuffer = remoteInput.getImageBuffer(); + if (currentScreenBuffer == null) { + return null; + } + + int bufferSize = width * height * 4; + byte[] data = currentScreenBuffer.getByteArray(0, bufferSize); + + return createBufferedImage(data, width, height); + } + + /** + * Internal helper to create a buffered image from a C++ style byte array of pixels in BGRA + * format. + * + * @param pixels The byte array of pixel data in [B, G, R, A] format + * @param width The width of the client in pixels + * @param height The height of the client in pixels + * @return A {@link BufferedImage} representing the image + */ + private static BufferedImage createBufferedImage(byte[] pixels, int width, int height) { + DataBufferByte buffer = new DataBufferByte(pixels, pixels.length); + WritableRaster raster = + Raster.createInterleavedRaster( + buffer, width, height, width * 4, 4, new int[] {2, 1, 0}, null); + + ColorModel cm = + new ComponentColorModel( + ColorSpace.getInstance(ColorSpace.CS_sRGB), + new int[] {8, 8, 8}, + false, + false, + Transparency.OPAQUE, + DataBuffer.TYPE_BYTE); + + return new BufferedImage(cm, raster, false, null); + } + + /** + * Gets the bounds of the (game view) RuneLite AWT Canvas object. + * + * @return A {@link Rectangle} representing the size of RuneLite's client area, excluding possible + * window borders, title or scrollbars. + */ + public static Rectangle getWindowBounds() { + return remoteInput.getTargetDimensions(); + } + + /** + * Sets the RemoteInput object in the ScreenManager, allowing it to access to the client's screen + * buffer. + * + * @param remoteInput The {@link RemoteInput} object + */ + public static void setRemoteInput(RemoteInput remoteInput) { + ScreenManager.remoteInput = remoteInput; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java index fb3f3e1..0ebe78c 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java @@ -1,91 +1,91 @@ -package com.chromascape.utils.core.screen.window; - -import com.sun.jna.Native; -import com.sun.jna.Pointer; -import com.sun.jna.platform.win32.WinDef.HWND; -import com.sun.jna.platform.win32.WinUser.WNDENUMPROC; -import com.sun.jna.ptr.IntByReference; -import com.sun.jna.win32.StdCallLibrary; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Utility class for locating and identifying a specific native window (e.g., "RuneLite") on the - * Windows operating system using JNA and Win32 APIs. - */ -public class WindowsProcessManager implements ProcessManager { - - private static final String WINDOW_NAME = "RuneLite"; - - /** - * JNA interface for accessing low-level Win32 User32 functions that are not provided by the - * default JNA platform mappings. - */ - public interface User32 extends StdCallLibrary { - User32 INSTANCE = Native.load("user32", User32.class); - - /** - * Enumerates all top-level windows on the screen by invoking the provided callback. - * - * @param lpEnumFunc The callback to be called for each window. - * @param arg A user-defined value passed to the callback (usually null). - */ - void EnumWindows(WNDENUMPROC lpEnumFunc, Pointer arg); - - /** - * Retrieves the title text of the specified window. - * - * @param hwnd Handle to the window. - * @param lpString Buffer that receives the window title. - * @param maxCount Maximum number of characters to copy. - */ - void GetWindowTextA(HWND hwnd, byte[] lpString, int maxCount); - - /** - * Retrieves the process identifier (PID) for the specified window. - * - * @param hwnd Handle to the window. - * @param lpDword Receives the process ID. - */ - void GetWindowThreadProcessId(HWND hwnd, IntByReference lpDword); - } - - /** - * Attempts to locate the window whose title matches the {@code WINDOW_NAME}. - * - * @return The {@link HWND} handle of the target window, or {@code null} if not found. - */ - public static HWND getTargetWindow() { - AtomicReference targetHwnd = new AtomicReference<>(); - User32 user32 = User32.INSTANCE; - - user32.EnumWindows( - (hwnd, arg) -> { - byte[] buffer = new byte[512]; - user32.GetWindowTextA(hwnd, buffer, 512); - String title = Native.toString(buffer); - - if (title.trim().equals(WINDOW_NAME)) { - targetHwnd.set(hwnd); - return false; // stop enumeration - } - return true; - }, - null); - - return targetHwnd.get(); // May be null if not found - } - - /** - * To return the Process ID of RuneLite. Avoids non-ChromaScape related instances by searching for - * "RuneLite" only as opposed to "RuneLite - {UserName}" - * - * @return The integer process ID of RuneLite loaded with the ChromaScape profile - */ - @Override - public int getPid() { - HWND windowHandle = getTargetWindow(); - IntByReference pid = new IntByReference(); - User32.INSTANCE.GetWindowThreadProcessId(windowHandle, pid); - return pid.getValue(); - } -} +package com.chromascape.utils.core.screen.window; + +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.win32.WinDef.HWND; +import com.sun.jna.platform.win32.WinUser.WNDENUMPROC; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.win32.StdCallLibrary; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Utility class for locating and identifying a specific native window (e.g., "RuneLite") on the + * Windows operating system using JNA and Win32 APIs. + */ +public class WindowsProcessManager implements ProcessManager { + + private static final String WINDOW_NAME = "RuneLite"; + + /** + * JNA interface for accessing low-level Win32 User32 functions that are not provided by the + * default JNA platform mappings. + */ + public interface User32 extends StdCallLibrary { + User32 INSTANCE = Native.load("user32", User32.class); + + /** + * Enumerates all top-level windows on the screen by invoking the provided callback. + * + * @param lpEnumFunc The callback to be called for each window. + * @param arg A user-defined value passed to the callback (usually null). + */ + void EnumWindows(WNDENUMPROC lpEnumFunc, Pointer arg); + + /** + * Retrieves the title text of the specified window. + * + * @param hwnd Handle to the window. + * @param lpString Buffer that receives the window title. + * @param maxCount Maximum number of characters to copy. + */ + void GetWindowTextA(HWND hwnd, byte[] lpString, int maxCount); + + /** + * Retrieves the process identifier (PID) for the specified window. + * + * @param hwnd Handle to the window. + * @param lpDword Receives the process ID. + */ + void GetWindowThreadProcessId(HWND hwnd, IntByReference lpDword); + } + + /** + * Attempts to locate the window whose title matches the {@code WINDOW_NAME}. + * + * @return The {@link HWND} handle of the target window, or {@code null} if not found. + */ + public static HWND getTargetWindow() { + AtomicReference targetHwnd = new AtomicReference<>(); + User32 user32 = User32.INSTANCE; + + user32.EnumWindows( + (hwnd, arg) -> { + byte[] buffer = new byte[512]; + user32.GetWindowTextA(hwnd, buffer, 512); + String title = Native.toString(buffer); + + if (title.trim().equals(WINDOW_NAME)) { + targetHwnd.set(hwnd); + return false; // stop enumeration + } + return true; + }, + null); + + return targetHwnd.get(); // May be null if not found + } + + /** + * To return the Process ID of RuneLite. Avoids non-ChromaScape related instances by searching for + * "RuneLite" only as opposed to "RuneLite - {UserName}" + * + * @return The integer process ID of RuneLite loaded with the ChromaScape profile + */ + @Override + public int getPid() { + HWND windowHandle = getTargetWindow(); + IntByReference pid = new IntByReference(); + User32.INSTANCE.GetWindowThreadProcessId(windowHandle, pid); + return pid.getValue(); + } +} diff --git a/src/main/java/com/chromascape/utils/core/state/BotState.java b/src/main/java/com/chromascape/utils/core/state/BotState.java index 77d2457..134396c 100644 --- a/src/main/java/com/chromascape/utils/core/state/BotState.java +++ b/src/main/java/com/chromascape/utils/core/state/BotState.java @@ -1,38 +1,38 @@ -package com.chromascape.utils.core.state; - -/** - * definitions of the various high-level semantic states the bot can be in. - * - *

These states are used for visualization on the frontend to give the user insight into what the - * bot is currently "thinking" or doing. - */ -public enum BotState { - - /** The bot is actively scanning the screen for targets (e.g. finding colours). */ - SEARCHING("Searching", "primary"), - - /** The bot is performing an input action (e.g. clicking, typing). */ - ACTING("Acting", "success"), - - /** The bot is waiting or idle (e.g. sleeping between actions). */ - WAITING("Waiting", "warning"), - - /** The bot has encountered an error or exception. */ - ERROR("Error", "danger"); - - private final String displayName; - private final String cssClass; - - BotState(String displayName, String cssClass) { - this.displayName = displayName; - this.cssClass = cssClass; - } - - public String getDisplayName() { - return displayName; - } - - public String getCssClass() { - return cssClass; - } -} +package com.chromascape.utils.core.state; + +/** + * definitions of the various high-level semantic states the bot can be in. + * + *

These states are used for visualization on the frontend to give the user insight into what the + * bot is currently "thinking" or doing. + */ +public enum BotState { + + /** The bot is actively scanning the screen for targets (e.g. finding colours). */ + SEARCHING("Searching", "primary"), + + /** The bot is performing an input action (e.g. clicking, typing). */ + ACTING("Acting", "success"), + + /** The bot is waiting or idle (e.g. sleeping between actions). */ + WAITING("Waiting", "warning"), + + /** The bot has encountered an error or exception. */ + ERROR("Error", "danger"); + + private final String displayName; + private final String cssClass; + + BotState(String displayName, String cssClass) { + this.displayName = displayName; + this.cssClass = cssClass; + } + + public String getDisplayName() { + return displayName; + } + + public String getCssClass() { + return cssClass; + } +} diff --git a/src/main/java/com/chromascape/utils/core/state/BotStateListener.java b/src/main/java/com/chromascape/utils/core/state/BotStateListener.java index a62285f..08435e6 100644 --- a/src/main/java/com/chromascape/utils/core/state/BotStateListener.java +++ b/src/main/java/com/chromascape/utils/core/state/BotStateListener.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.core.state; - -/** Interface for listening to changes in the bot's semantic state. */ -public interface BotStateListener { - - /** - * Called when the bot transitions to a new state. - * - * @param state The new {@link BotState}. - */ - void onStateChange(BotState state); -} +package com.chromascape.utils.core.state; + +/** Interface for listening to changes in the bot's semantic state. */ +public interface BotStateListener { + + /** + * Called when the bot transitions to a new state. + * + * @param state The new {@link BotState}. + */ + void onStateChange(BotState state); +} diff --git a/src/main/java/com/chromascape/utils/core/state/StateManager.java b/src/main/java/com/chromascape/utils/core/state/StateManager.java index 5e236c6..f147fc0 100644 --- a/src/main/java/com/chromascape/utils/core/state/StateManager.java +++ b/src/main/java/com/chromascape/utils/core/state/StateManager.java @@ -1,47 +1,47 @@ -package com.chromascape.utils.core.state; - -/** - * Singleton manager for tracking and broadcasting the bot's semantic state. - * - *

This class serves as the bridge between core bot logic (which triggers state changes) and the - * presentation layer (which listens for them), without introducing direct dependencies. - */ -public class StateManager { - - private static BotStateListener listener = state -> {}; // Default No-Op - private static BotState currentState = BotState.WAITING; - - private StateManager() {} - - /** - * Sets the listener that will receive state change updates. - * - * @param newListener The listener implementation (e.g. a websocket bridge). - */ - public static void setListener(BotStateListener newListener) { - listener = newListener; - } - - /** - * Transitions the bot to a new semantic state. - * - *

If the new state is different from the current state, the registered listener is notified. - * - * @param newState The state to transition to. - */ - public static void setState(BotState newState) { - if (currentState != newState) { - currentState = newState; - listener.onStateChange(newState); - } - } - - /** - * Gets the current state of the bot. - * - * @return The active {@link BotState}. - */ - public static BotState getState() { - return currentState; - } -} +package com.chromascape.utils.core.state; + +/** + * Singleton manager for tracking and broadcasting the bot's semantic state. + * + *

This class serves as the bridge between core bot logic (which triggers state changes) and the + * presentation layer (which listens for them), without introducing direct dependencies. + */ +public class StateManager { + + private static BotStateListener listener = state -> {}; // Default No-Op + private static BotState currentState = BotState.WAITING; + + private StateManager() {} + + /** + * Sets the listener that will receive state change updates. + * + * @param newListener The listener implementation (e.g. a websocket bridge). + */ + public static void setListener(BotStateListener newListener) { + listener = newListener; + } + + /** + * Transitions the bot to a new semantic state. + * + *

If the new state is different from the current state, the registered listener is notified. + * + * @param newState The state to transition to. + */ + public static void setState(BotState newState) { + if (currentState != newState) { + currentState = newState; + listener.onStateChange(newState); + } + } + + /** + * Gets the current state of the bot. + * + * @return The active {@link BotState}. + */ + public static BotState getState() { + return currentState; + } +} diff --git a/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java b/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java index e575155..7803bed 100644 --- a/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java +++ b/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java @@ -1,105 +1,105 @@ -package com.chromascape.utils.core.statistics; - -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Singleton manager for tracking bot statistics such as runtime, cycles, inputs, and objects - * detected. - * - *

Uses thread-safe atomic variables to allow concurrent updates from different parts of the bot - * (e.g. input thread, vision thread, main loop) without blocking. - */ -public class StatisticsManager { - - private static final AtomicLong startTime = new AtomicLong(0); - private static final AtomicLong endTime = new AtomicLong(0); - private static volatile boolean running = false; - - private static final AtomicInteger cycles = new AtomicInteger(0); - private static final AtomicInteger inputs = new AtomicInteger(0); - private static final AtomicInteger objectsDetected = new AtomicInteger(0); - - private StatisticsManager() {} - - /** - * Resets all statistics to zero and sets the start time to the current system time. - * - *

Also resets the {@code endTime} and sets {@code running} to true. - */ - public static void reset() { - startTime.set(System.currentTimeMillis()); - endTime.set(0); - running = true; - cycles.set(0); - inputs.set(0); - objectsDetected.set(0); - } - - /** - * Stops the statistics tracking, freezing the elapsed time. - * - *

Sets {@code running} to false and records the current time as {@code endTime}. This ensures - * {@link #getElapsedTime()} returns a static duration after stopping. - */ - public static void stop() { - running = false; - endTime.set(System.currentTimeMillis()); - } - - /** Increments the cycle count by one. */ - public static void incrementCycles() { - cycles.incrementAndGet(); - } - - /** Increments the total input count by one. */ - public static void incrementInputs() { - inputs.incrementAndGet(); - } - - /** Increments the total objects detected count by one. */ - public static void incrementObjectsDetected() { - objectsDetected.incrementAndGet(); - } - - // Getters - - public static long getStartTime() { - return startTime.get(); - } - - public static int getCycles() { - return cycles.get(); - } - - public static int getInputs() { - return inputs.get(); - } - - public static int getObjectsDetected() { - return objectsDetected.get(); - } - - /** - * Calculates the elapsed time in milliseconds. - * - *

If the bot is running, returns {@code now - startTime}. If the bot is stopped, returns - * {@code endTime - startTime}. - * - * @return runtime in ms, or 0 if not started. - */ - public static long getElapsedTime() { - long start = startTime.get(); - if (start == 0) { - return 0; - } - if (running) { - return System.currentTimeMillis() - start; - } else { - long end = endTime.get(); - // If end is somehow invalid or 0 (shouldn't happen if stop called), return 0 or - // current diff - return end > start ? end - start : 0; - } - } -} +package com.chromascape.utils.core.statistics; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Singleton manager for tracking bot statistics such as runtime, cycles, inputs, and objects + * detected. + * + *

Uses thread-safe atomic variables to allow concurrent updates from different parts of the bot + * (e.g. input thread, vision thread, main loop) without blocking. + */ +public class StatisticsManager { + + private static final AtomicLong startTime = new AtomicLong(0); + private static final AtomicLong endTime = new AtomicLong(0); + private static volatile boolean running = false; + + private static final AtomicInteger cycles = new AtomicInteger(0); + private static final AtomicInteger inputs = new AtomicInteger(0); + private static final AtomicInteger objectsDetected = new AtomicInteger(0); + + private StatisticsManager() {} + + /** + * Resets all statistics to zero and sets the start time to the current system time. + * + *

Also resets the {@code endTime} and sets {@code running} to true. + */ + public static void reset() { + startTime.set(System.currentTimeMillis()); + endTime.set(0); + running = true; + cycles.set(0); + inputs.set(0); + objectsDetected.set(0); + } + + /** + * Stops the statistics tracking, freezing the elapsed time. + * + *

Sets {@code running} to false and records the current time as {@code endTime}. This ensures + * {@link #getElapsedTime()} returns a static duration after stopping. + */ + public static void stop() { + running = false; + endTime.set(System.currentTimeMillis()); + } + + /** Increments the cycle count by one. */ + public static void incrementCycles() { + cycles.incrementAndGet(); + } + + /** Increments the total input count by one. */ + public static void incrementInputs() { + inputs.incrementAndGet(); + } + + /** Increments the total objects detected count by one. */ + public static void incrementObjectsDetected() { + objectsDetected.incrementAndGet(); + } + + // Getters + + public static long getStartTime() { + return startTime.get(); + } + + public static int getCycles() { + return cycles.get(); + } + + public static int getInputs() { + return inputs.get(); + } + + public static int getObjectsDetected() { + return objectsDetected.get(); + } + + /** + * Calculates the elapsed time in milliseconds. + * + *

If the bot is running, returns {@code now - startTime}. If the bot is stopped, returns + * {@code endTime - startTime}. + * + * @return runtime in ms, or 0 if not started. + */ + public static long getElapsedTime() { + long start = startTime.get(); + if (start == 0) { + return 0; + } + if (running) { + return System.currentTimeMillis() - start; + } else { + long end = endTime.get(); + // If end is somehow invalid or 0 (shouldn't happen if stop called), return 0 or + // current diff + return end > start ? end - start : 0; + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java b/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java index 7d1069e..9220ffc 100644 --- a/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java +++ b/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.domain.ocr; - -/** - * Objects to store Ocr match information. - * - * @param character The character found. - * @param x Top left X co-ordinate. - * @param y Top left Y co-ordinate. - * @param width Width of the character's image. - * @param height Height of the character's image. - */ -public record CharMatch(String character, int x, int y, int width, int height) {} +package com.chromascape.utils.domain.ocr; + +/** + * Objects to store Ocr match information. + * + * @param character The character found. + * @param x Top left X co-ordinate. + * @param y Top left Y co-ordinate. + * @param width Width of the character's image. + * @param height Height of the character's image. + */ +public record CharMatch(String character, int x, int y, int width, int height) {} diff --git a/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java b/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java index 14e7701..e7e425e 100644 --- a/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java +++ b/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java @@ -1,365 +1,365 @@ -package com.chromascape.utils.domain.ocr; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; -import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; -import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2GRAY; -import static org.bytedeco.opencv.global.opencv_imgproc.FILLED; -import static org.bytedeco.opencv.global.opencv_imgproc.LINE_8; -import static org.bytedeco.opencv.global.opencv_imgproc.TM_CCOEFF_NORMED; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; -import static org.bytedeco.opencv.global.opencv_imgproc.matchTemplate; -import static org.bytedeco.opencv.global.opencv_imgproc.rectangle; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.zones.MaskZones; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.io.BufferedReader; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.UncheckedIOException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import javax.imageio.ImageIO; -import org.bytedeco.javacpp.DoublePointer; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point; -import org.bytedeco.opencv.opencv_core.Rect; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Provides Ocr (Optical Character Recognition) functionality using JavaCV/OpenCV. Allows for - * font-based glyph matching in screen-captured images to extract text. - */ -public class Ocr { - - /** Stores successful character matches during Ocr extraction. */ - private static final List matches = new ArrayList<>(); - - /** Cached zero scalar to prevent CPU allocation fatigue. */ - private static final Scalar ZERO_SCALAR = new Scalar(0); - - /** Cache for loaded fonts to prevent disk I/O on every OCR call. */ - private static final Map> fontCache = new HashMap<>(); - - /** - * Allowed characters for OCR to remove runtime overhead for unnecessary glyphs. Most common - * characters found. - */ - private static final String ALLOWED_CHARS = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()[],&-:/*'_\"?<>"; - - /** - * Loads a font glyph set from disk, converts each glyph to grayscale, and stores in a map. Uses - * an internal cache to avoid repeated disk I/O. Only allows whitelisted glyphs ( please add if - * necessary). - * - * @param font Name of the font folder inside resources. - * @return A map from character string to Mat (glyph image). - */ - public static synchronized Map loadFont(String font) { - // computeIfAbsent allows for the calculation of a value if it doesn't exist in a Map - return fontCache.computeIfAbsent( - font, - f -> { - Map fontMap = new HashMap<>(); - String basePath = "/fonts/" + f + "/"; - String indexPath = basePath + f + ".index"; - - try (InputStream indexStream = Ocr.class.getResourceAsStream(indexPath)) { - if (indexStream == null) { - // Throw runtime unchecked exception to fail if fonts are downloaded incorrectly and - // are unavailable - throw new UncheckedIOException(new IOException("Font index not found: " + indexPath)); - } - - // Stream the index file and load the files listed into the fontMap - try (BufferedReader reader = new BufferedReader(new InputStreamReader(indexStream))) { - String fontFileName; - while ((fontFileName = reader.readLine()) != null) { - processFontFile(basePath, fontFileName, fontMap); - } - } - } catch (IOException e) { - // It's necessary for the project files to exist, so fail fast - throw new RuntimeException( - "Failed to load font library " - + f - + " essential for runtime execution with error: " - + e); - } - return fontMap; - }); - } - - /** - * Private helper for loading a specified font bitmap into a font library. Mutates the given map, - * does not return anything, intended to be called in a loop. Expects the bitmap to be named as - * ascii codepoints and to be stored as resources. Loads each glyph as a greyscale mat with its - * corresponding character in String form. - * - * @param path {@link String} path of the font bitmap inside resources - * @param fileName the name of the file including type (e.g., 68.bmp) - * @param map the map to mutate and add the name + Mat object to - * @throws IOException In the case that a glyph fails to load - */ - private static void processFontFile(String path, String fileName, Map map) - throws IOException { - // Get the name ASCII codepoint from the filename - String cleanName = fileName.replace(".bmp", ""); - int codePoint = Integer.parseInt(cleanName); - String character = Character.toString(codePoint); - - if (!ALLOWED_CHARS.contains(character)) { - return; - } - - try (InputStream is = Ocr.class.getResourceAsStream(path + fileName)) { - if (is == null) { - // It's necessary for the project files to exist, so fail fast - throw new FileNotFoundException("Font file not found: " + fileName); - } - - // Add the character and Mat to the map - Mat img = Java2DFrameUtils.toMat(ImageIO.read(is)); - cvtColor(img, img, COLOR_BGR2GRAY); - map.put(character, img); - } - } - - /** - * Extracts a string of text from a screen region ({@link Rectangle} zone) by template-matching - * glyphs from a font. Note: this will not include any spaces. - * - * @param zone Rectangle on screen to extract text from. - * @param font Font name to use for glyph matching. - * @param colour ColourObj specifying the color to isolate. - * @param clean Whether to clear internal match storage after use. - * @return The extracted text string from the zone. - */ - public static String extractText(Rectangle zone, String font, ColourObj colour, boolean clean) { - Map fontMap = loadFont(font); - matches.clear(); - BufferedImage zoneImage = ScreenManager.captureZone(zone); - Mat zoneMat = ColourContours.extractColours(zoneImage, colour); - return extraction(fontMap, zoneMat, font, clean); - } - - /** - * Extracts a string of text from a screen region by template-matching glyphs from a font. Note: - * this will not include any spaces. - * - * @param mask Mat CU81 mask to extract text from - * @param font Font name to use for glyph matching. - * @param clean Whether to clear internal match storage after use. - * @return The extracted text string from the zone. - */ - public static String extractTextFromMask(Mat mask, String font, boolean clean) { - Map fontMap = loadFont(font); - matches.clear(); - return extraction(fontMap, mask.clone(), font, clean); - } - - /** - * Internal function to perform Template matched OCR. Iterates over a font map, zeroing out the - * convolution as it goes. - * - * @param fontMap List of glyphs, string character & Mat bitmap. - * @param zoneMat Mat image of the source being searched within. - * @param font Type of font. - * @param clean Delete matches? - * @return String of extracted letters, no spaces. - */ - private static String extraction( - Map fontMap, Mat zoneMat, String font, boolean clean) { - double threshold = 0.99; - // Supports (CV_8UC1) binary greyscale. - // Holds pointers and correlation as reusable memory allocation to avoid JNI overhead - try (DoublePointer minVal = new DoublePointer(1); - DoublePointer maxVal = new DoublePointer(1); - Point minLoc = new Point(); - Point maxLoc = new Point(); - Mat correlation = new Mat()) { - // Template match each glyph in the font to the zoneMat. - for (String glyph : fontMap.keySet()) { - // These are to store the glyph sizes outside of try with resources scope. - int glyphImgRows; - int glyphImgCols; - - // We are trimming the font images and template matching - - // Based on the font type and how the image is stored. - int ycropModifier = getCropModifierForFont(font); - - try (Rect roi = - new Rect( - 0, - ycropModifier, - fontMap.get(glyph).arrayWidth(), - fontMap.get(glyph).arrayHeight() - ycropModifier); - Mat croppedGlyph = new Mat(fontMap.get(glyph), roi)) { - // Match template with cropped glyph and store size outside try-with-resources. - matchTemplate(zoneMat, croppedGlyph, correlation, TM_CCOEFF_NORMED); - glyphImgRows = croppedGlyph.rows(); - glyphImgCols = croppedGlyph.cols(); - } - // Call minMaxLoc repeatedly, zero out the area based on glyph size, save locations as - // CharMatch objs. - while (true) { // Loop breaks when threshold is not met. - minMaxLoc(correlation, minVal, maxVal, minLoc, maxLoc, null); - - if (maxVal.get() < threshold) { - break; - } - - Rectangle matchLocation = - new Rectangle(maxLoc.x(), maxLoc.y(), glyphImgCols, glyphImgRows); - matches.add( - new CharMatch(glyph, matchLocation.x, matchLocation.y, glyphImgCols, glyphImgRows)); - - zeroOutRegion(correlation, matchLocation); - - Mat oldZoneMat = zoneMat; - zoneMat = MaskZones.maskZonesMat(zoneMat.clone(), matchLocation); - oldZoneMat.release(); - } - } - } finally { - zoneMat.release(); - } - - // Sort CharMatch objects based on left-most positions. - matches.sort(Comparator.comparingInt(CharMatch::y).thenComparingInt(CharMatch::x)); - - StringBuilder result = new StringBuilder(); - for (CharMatch match : matches) { - result.append(match.character()); - } - - if (clean) { - matches.clear(); - } - - return result.toString(); - } - - /** - * Returns a BufferedImage mask representing matched glyph positions within a screen region. This - * is useful for clicking text. You are intended to extract contours from this and use it as a - * ChromaObj. - * - * @param zone Rectangle on screen to perform Ocr in. - * @param font Font name to use for glyph matching. - * @param text Expected string result; skips mask generation if mismatched. - * @param colour ColourObj specifying the color to isolate. - * @return A BufferedImage mask of the matched character zones, or null if text doesn't match. - */ - public static BufferedImage extractTextLocationMask( - Rectangle zone, String font, String text, ColourObj colour) { - // Get the full window bounds (this must match the screen capture bounds) - Rectangle window = ScreenManager.getWindowBounds(); - - // Create a black mask matching the window size - Mat fullScreenMask = new Mat(window.height, window.width, CV_8UC1, new Scalar(0)); - - // Early exit: text doesn't match expected - if (!extractText(zone, font, colour, false).equals(text)) { - return null; - } - - // Create a zone-sized mask where matched characters will be drawn - Mat zoneMask = new Mat(zone.height, zone.width, CV_8UC1, new Scalar(0)); - - // Draw rectangles for matched characters - for (CharMatch match : matches) { - rectangle( - zoneMask, - new Point(match.x(), match.y()), - new Point(match.x() + match.width(), match.y() + match.height()), - new Scalar(255), - FILLED, - LINE_8, - 0); - } - - // Convert screen-relative zone to window-relative position - Mat roiMat = getMat(zone, window, fullScreenMask); - zoneMask.copyTo(roiMat); - - // Release temporary mats - zoneMask.release(); - roiMat.release(); - - return Java2DFrameUtils.toBufferedImage(fullScreenMask); - } - - /** - * Converts a zone-relative rectangle to a window-relative Mat region for masking. - * - * @param zone Ocr region. - * @param window Full window bounds from capture. - * @param fullScreenMask The full-screen output mask. - * @return A Mat region of interest inside the full screen mask. - * @throws IllegalArgumentException if the zone is outside the screen bounds. - */ - private static Mat getMat(Rectangle zone, Rectangle window, Mat fullScreenMask) { - int relX = zone.x - window.x; - int relY = zone.y - window.y; - - // Validate bounds to avoid OpenCV crash - if (relX < 0 - || relY < 0 - || relX + zone.width > window.width - || relY + zone.height > window.height) { - throw new IllegalArgumentException( - "Zone is outside the window bounds: zone=" + zone + ", window=" + window); - } - - // Create region of interest in the full mask and copy the zone mask into it - Rect roi = new Rect(relX, relY, zone.width, zone.height); - return new Mat(fullScreenMask, roi); - } - - /** - * Sets all values in a rectangular region of a correlation matrix to zero. This prevents repeated - * template matches in the same area. - * - * @param correlation The template match result matrix. - * @param match The rectangle area to zero out. - */ - public static void zeroOutRegion(Mat correlation, Rectangle match) { - // Make sure the rectangle is within bounds of the correlation Mat - int x = Math.max(match.x, 0); - int y = Math.max(match.y, 0); - int width = Math.min(match.width, correlation.cols() - x); - int height = Math.min(match.height, correlation.rows() - y); - - if (width <= 0 || height <= 0) { - return; - } - - Rect roi = new Rect(x, y, width, height); - Mat subMat = new Mat(correlation, roi); - // Set all pixels in this region to 0 (lowest confidence) - subMat.setTo(new Mat(ZERO_SCALAR)); - subMat.release(); - } - - /** - * Returns a vertical crop offset used when slicing glyph images, depending on font type. - * - * @param font Font name. - * @return Crop offset in pixels. - */ - private static int getCropModifierForFont(String font) { - return Objects.equals(font, "Plain 12") ? 2 : 1; - } -} +package com.chromascape.utils.domain.ocr; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; +import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2GRAY; +import static org.bytedeco.opencv.global.opencv_imgproc.FILLED; +import static org.bytedeco.opencv.global.opencv_imgproc.LINE_8; +import static org.bytedeco.opencv.global.opencv_imgproc.TM_CCOEFF_NORMED; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; +import static org.bytedeco.opencv.global.opencv_imgproc.matchTemplate; +import static org.bytedeco.opencv.global.opencv_imgproc.rectangle; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.zones.MaskZones; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.imageio.ImageIO; +import org.bytedeco.javacpp.DoublePointer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point; +import org.bytedeco.opencv.opencv_core.Rect; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Provides Ocr (Optical Character Recognition) functionality using JavaCV/OpenCV. Allows for + * font-based glyph matching in screen-captured images to extract text. + */ +public class Ocr { + + /** Stores successful character matches during Ocr extraction. */ + private static final List matches = new ArrayList<>(); + + /** Cached zero scalar to prevent CPU allocation fatigue. */ + private static final Scalar ZERO_SCALAR = new Scalar(0); + + /** Cache for loaded fonts to prevent disk I/O on every OCR call. */ + private static final Map> fontCache = new HashMap<>(); + + /** + * Allowed characters for OCR to remove runtime overhead for unnecessary glyphs. Most common + * characters found. + */ + private static final String ALLOWED_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()[],&-:/*'_\"?<>"; + + /** + * Loads a font glyph set from disk, converts each glyph to grayscale, and stores in a map. Uses + * an internal cache to avoid repeated disk I/O. Only allows whitelisted glyphs ( please add if + * necessary). + * + * @param font Name of the font folder inside resources. + * @return A map from character string to Mat (glyph image). + */ + public static synchronized Map loadFont(String font) { + // computeIfAbsent allows for the calculation of a value if it doesn't exist in a Map + return fontCache.computeIfAbsent( + font, + f -> { + Map fontMap = new HashMap<>(); + String basePath = "/fonts/" + f + "/"; + String indexPath = basePath + f + ".index"; + + try (InputStream indexStream = Ocr.class.getResourceAsStream(indexPath)) { + if (indexStream == null) { + // Throw runtime unchecked exception to fail if fonts are downloaded incorrectly and + // are unavailable + throw new UncheckedIOException(new IOException("Font index not found: " + indexPath)); + } + + // Stream the index file and load the files listed into the fontMap + try (BufferedReader reader = new BufferedReader(new InputStreamReader(indexStream))) { + String fontFileName; + while ((fontFileName = reader.readLine()) != null) { + processFontFile(basePath, fontFileName, fontMap); + } + } + } catch (IOException e) { + // It's necessary for the project files to exist, so fail fast + throw new RuntimeException( + "Failed to load font library " + + f + + " essential for runtime execution with error: " + + e); + } + return fontMap; + }); + } + + /** + * Private helper for loading a specified font bitmap into a font library. Mutates the given map, + * does not return anything, intended to be called in a loop. Expects the bitmap to be named as + * ascii codepoints and to be stored as resources. Loads each glyph as a greyscale mat with its + * corresponding character in String form. + * + * @param path {@link String} path of the font bitmap inside resources + * @param fileName the name of the file including type (e.g., 68.bmp) + * @param map the map to mutate and add the name + Mat object to + * @throws IOException In the case that a glyph fails to load + */ + private static void processFontFile(String path, String fileName, Map map) + throws IOException { + // Get the name ASCII codepoint from the filename + String cleanName = fileName.replace(".bmp", ""); + int codePoint = Integer.parseInt(cleanName); + String character = Character.toString(codePoint); + + if (!ALLOWED_CHARS.contains(character)) { + return; + } + + try (InputStream is = Ocr.class.getResourceAsStream(path + fileName)) { + if (is == null) { + // It's necessary for the project files to exist, so fail fast + throw new FileNotFoundException("Font file not found: " + fileName); + } + + // Add the character and Mat to the map + Mat img = TemplateMatching.bufferedImageToMat(ImageIO.read(is)); + cvtColor(img, img, COLOR_BGR2GRAY); + map.put(character, img); + } + } + + /** + * Extracts a string of text from a screen region ({@link Rectangle} zone) by template-matching + * glyphs from a font. Note: this will not include any spaces. + * + * @param zone Rectangle on screen to extract text from. + * @param font Font name to use for glyph matching. + * @param colour ColourObj specifying the color to isolate. + * @param clean Whether to clear internal match storage after use. + * @return The extracted text string from the zone. + */ + public static String extractText(Rectangle zone, String font, ColourObj colour, boolean clean) { + Map fontMap = loadFont(font); + matches.clear(); + BufferedImage zoneImage = ScreenManager.captureZone(zone); + Mat zoneMat = ColourContours.extractColours(zoneImage, colour); + return extraction(fontMap, zoneMat, font, clean); + } + + /** + * Extracts a string of text from a screen region by template-matching glyphs from a font. Note: + * this will not include any spaces. + * + * @param mask Mat CU81 mask to extract text from + * @param font Font name to use for glyph matching. + * @param clean Whether to clear internal match storage after use. + * @return The extracted text string from the zone. + */ + public static String extractTextFromMask(Mat mask, String font, boolean clean) { + Map fontMap = loadFont(font); + matches.clear(); + return extraction(fontMap, mask.clone(), font, clean); + } + + /** + * Internal function to perform Template matched OCR. Iterates over a font map, zeroing out the + * convolution as it goes. + * + * @param fontMap List of glyphs, string character & Mat bitmap. + * @param zoneMat Mat image of the source being searched within. + * @param font Type of font. + * @param clean Delete matches? + * @return String of extracted letters, no spaces. + */ + private static String extraction( + Map fontMap, Mat zoneMat, String font, boolean clean) { + double threshold = 0.99; + // Supports (CV_8UC1) binary greyscale. + // Holds pointers and correlation as reusable memory allocation to avoid JNI overhead + try (DoublePointer minVal = new DoublePointer(1); + DoublePointer maxVal = new DoublePointer(1); + Point minLoc = new Point(); + Point maxLoc = new Point(); + Mat correlation = new Mat()) { + // Template match each glyph in the font to the zoneMat. + for (String glyph : fontMap.keySet()) { + // These are to store the glyph sizes outside of try with resources scope. + int glyphImgRows; + int glyphImgCols; + + // We are trimming the font images and template matching - + // Based on the font type and how the image is stored. + int ycropModifier = getCropModifierForFont(font); + + try (Rect roi = + new Rect( + 0, + ycropModifier, + fontMap.get(glyph).arrayWidth(), + fontMap.get(glyph).arrayHeight() - ycropModifier); + Mat croppedGlyph = new Mat(fontMap.get(glyph), roi)) { + // Match template with cropped glyph and store size outside try-with-resources. + matchTemplate(zoneMat, croppedGlyph, correlation, TM_CCOEFF_NORMED); + glyphImgRows = croppedGlyph.rows(); + glyphImgCols = croppedGlyph.cols(); + } + // Call minMaxLoc repeatedly, zero out the area based on glyph size, save locations as + // CharMatch objs. + while (true) { // Loop breaks when threshold is not met. + minMaxLoc(correlation, minVal, maxVal, minLoc, maxLoc, null); + + if (maxVal.get() < threshold) { + break; + } + + Rectangle matchLocation = + new Rectangle(maxLoc.x(), maxLoc.y(), glyphImgCols, glyphImgRows); + matches.add( + new CharMatch(glyph, matchLocation.x, matchLocation.y, glyphImgCols, glyphImgRows)); + + zeroOutRegion(correlation, matchLocation); + + Mat oldZoneMat = zoneMat; + zoneMat = MaskZones.maskZonesMat(zoneMat.clone(), matchLocation); + oldZoneMat.release(); + } + } + } finally { + zoneMat.release(); + } + + // Sort CharMatch objects based on left-most positions. + matches.sort(Comparator.comparingInt(CharMatch::y).thenComparingInt(CharMatch::x)); + + StringBuilder result = new StringBuilder(); + for (CharMatch match : matches) { + result.append(match.character()); + } + + if (clean) { + matches.clear(); + } + + return result.toString(); + } + + /** + * Returns a BufferedImage mask representing matched glyph positions within a screen region. This + * is useful for clicking text. You are intended to extract contours from this and use it as a + * ChromaObj. + * + * @param zone Rectangle on screen to perform Ocr in. + * @param font Font name to use for glyph matching. + * @param text Expected string result; skips mask generation if mismatched. + * @param colour ColourObj specifying the color to isolate. + * @return A BufferedImage mask of the matched character zones, or null if text doesn't match. + */ + public static BufferedImage extractTextLocationMask( + Rectangle zone, String font, String text, ColourObj colour) { + // Get the full window bounds (this must match the screen capture bounds) + Rectangle window = ScreenManager.getWindowBounds(); + + // Create a black mask matching the window size + Mat fullScreenMask = new Mat(window.height, window.width, CV_8UC1, new Scalar(0)); + + // Early exit: text doesn't match expected + if (!extractText(zone, font, colour, false).equals(text)) { + return null; + } + + // Create a zone-sized mask where matched characters will be drawn + Mat zoneMask = new Mat(zone.height, zone.width, CV_8UC1, new Scalar(0)); + + // Draw rectangles for matched characters + for (CharMatch match : matches) { + rectangle( + zoneMask, + new Point(match.x(), match.y()), + new Point(match.x() + match.width(), match.y() + match.height()), + new Scalar(255), + FILLED, + LINE_8, + 0); + } + + // Convert screen-relative zone to window-relative position + Mat roiMat = getMat(zone, window, fullScreenMask); + zoneMask.copyTo(roiMat); + + // Release temporary mats + zoneMask.release(); + roiMat.release(); + + return TemplateMatching.matToBufferedImage(fullScreenMask); + } + + /** + * Converts a zone-relative rectangle to a window-relative Mat region for masking. + * + * @param zone Ocr region. + * @param window Full window bounds from capture. + * @param fullScreenMask The full-screen output mask. + * @return A Mat region of interest inside the full screen mask. + * @throws IllegalArgumentException if the zone is outside the screen bounds. + */ + private static Mat getMat(Rectangle zone, Rectangle window, Mat fullScreenMask) { + int relX = zone.x - window.x; + int relY = zone.y - window.y; + + // Validate bounds to avoid OpenCV crash + if (relX < 0 + || relY < 0 + || relX + zone.width > window.width + || relY + zone.height > window.height) { + throw new IllegalArgumentException( + "Zone is outside the window bounds: zone=" + zone + ", window=" + window); + } + + // Create region of interest in the full mask and copy the zone mask into it + Rect roi = new Rect(relX, relY, zone.width, zone.height); + return new Mat(fullScreenMask, roi); + } + + /** + * Sets all values in a rectangular region of a correlation matrix to zero. This prevents repeated + * template matches in the same area. + * + * @param correlation The template match result matrix. + * @param match The rectangle area to zero out. + */ + public static void zeroOutRegion(Mat correlation, Rectangle match) { + // Make sure the rectangle is within bounds of the correlation Mat + int x = Math.max(match.x, 0); + int y = Math.max(match.y, 0); + int width = Math.min(match.width, correlation.cols() - x); + int height = Math.min(match.height, correlation.rows() - y); + + if (width <= 0 || height <= 0) { + return; + } + + Rect roi = new Rect(x, y, width, height); + Mat subMat = new Mat(correlation, roi); + // Set all pixels in this region to 0 (lowest confidence) + subMat.setTo(new Mat(ZERO_SCALAR)); + subMat.release(); + } + + /** + * Returns a vertical crop offset used when slicing glyph images, depending on font type. + * + * @param font Font name. + * @return Crop offset in pixels. + */ + private static int getCropModifierForFont(String font) { + return Objects.equals(font, "Plain 12") ? 2 : 1; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Compass.java b/src/main/java/com/chromascape/utils/domain/walker/Compass.java index 0b9dbf5..6b81cec 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Compass.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Compass.java @@ -1,288 +1,288 @@ -package com.chromascape.utils.domain.walker; - -import static org.bytedeco.opencv.global.opencv_core.fastAtan2; -import static org.bytedeco.opencv.global.opencv_core.inRange; -import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2HSV; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; - -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.List; -import org.bytedeco.javacpp.PointerScope; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point2f; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Handles detection of the in-game compass orientation by calculating the bearing, based on the - * cardinal markers within the compass (East, West and South). - * - *

This class enables angle-based transformations, such as rotating click positions on the - * minimap to match the player's camera orientation. - */ -public class Compass { - - // Minimum length from centre that the outermost cardinal compass marker pixels should be - private static final double MIN_MARKER_RADIUS = 12.0; - // Minimum length from centre that the outermost cardinal compass marker pixels should be - private static final double MAX_MARKER_RADIUS = 17.0; - // Proximity of pixels to define a cluster (within 4 px? -> part of the same cluster) - private static final int CLUSTER_PROXIMITY_THRESHOLD = 4; - double[] cardinals = {0.0, 90.0, 180.0, 270.0, 360.0}; - // How close to the cardinal an angle should be to snap to it - double cardinalSnapThreshold = 3.0; - - private final Controller controller; - - // Colour of the outer cardinal markers within the compass - private final ColourObj compassRed = - new ColourObj("CompassRed", new Scalar(0, 200, 140, 0), new Scalar(20, 255, 200, 0)); - - /** - * Constructs the Compass class. Uses the BaseScript's {@link Controller} object to access zones. - * - * @param controller the BaseScript's controller object. - */ - public Compass(Controller controller) { - this.controller = controller; - } - - /** - * Calculates the current compass angle by using the 3 red markers that denote East, South and - * West. Dependant that compassRed is accurate in the user's environment. Releases native memory - * related to JavaCV. Heavily inspired by SRL. Thank you. - * - * @return The detected angle in degrees (0-359.9). - */ - public double getCompassAngle() { - try (PointerScope ignored = new PointerScope()) { - - // Mask out the compass image by compassRed - Mat mask = captureRedMarkerMask(); - // Keep only the outermost pixels (to erase the compass needle) - // Convert them to Points for clustering - List cardinalPoints = extractCardinalPoints(mask); - // If the pixels are within 4 pixels of each other, class them as the same cluster - List> clusters = clusterPoints(cardinalPoints); - // There should be exactly 3 clusters (E, S, W) - if (clusters.size() < 3) { - return 0.0; - } - // Average each cluster into a single point (weight) - Point2f[] markers = getClusterWeights(clusters); - // Move the south cluster to index 0 by judging the longest chord (between E and W) - sortClusterWeights(markers); - // Sort the array into S, E, W by comparing the predicted south vs real south - identifyEastAndWest(markers); - // Calculate the final bearing using E and W - double degrees = fastAtan2(markers[1].y() - markers[2].y(), markers[1].x() - markers[2].x()); - // Snap to a cardinal angle if within the threshold - for (double cardinal : cardinals) { - // We use deltaAngle to handle the 359 -> 0 wrap-around - if (Math.abs(deltaAngle((float) degrees, (float) cardinal)) <= cardinalSnapThreshold) { - return (cardinal == 360.0) ? 0.0 : cardinal; - } - } - - return degrees; - } - } - - /** - * Compares predicted south to true south to sort the clusters into [South, East, West]. Uses arc - * tangents to compare the relationship between the E/S vector and S, Pivot vector. Flips the East - * and West value to sort the array. - * - * @param sortedClusterWeights an array of cluster weights sorted to [South, East, West]. - */ - private void identifyEastAndWest(Point2f[] sortedClusterWeights) { - float eastOrWestAngle = - fastAtan2( - sortedClusterWeights[1].y() - getPivot().y(), - sortedClusterWeights[1].x() - getPivot().x()); - float southAngle = - fastAtan2( - sortedClusterWeights[0].y() - getPivot().y(), - sortedClusterWeights[0].x() - getPivot().x()); - if (Math.abs(deltaAngle(eastOrWestAngle + 90, southAngle)) > 90) { - Point2f temp = sortedClusterWeights[1]; - sortedClusterWeights[1] = sortedClusterWeights[2]; - sortedClusterWeights[2] = temp; - } - } - - /** - * Finds the shortest angle between two angles. Wraps around the circle correctly as opposed to a - * traditional modulus. - * - * @param a1 The first angle. - * @param a2 The second angle. - * @return the smallest angle between the two given values. - */ - public static float deltaAngle(float a1, float a2) { - float result = (a1 - a2); - while (result > 180) { - result -= 360; - } - while (result <= -180) { - result += 360; - } - return result; - } - - /** - * Assigns each cluster a weight value by calculating the mean average between each pixel within. - * This is useful when considering the cluster as a whole rather than an individual point for - * calculation. - * - * @param clusters a {@link List} containing {@code List}s that each refer to a cluster. - * @return an array of {@link Point2f}s which refer to the weighted average of each respective - * cluster. - */ - private Point2f[] getClusterWeights(List> clusters) { - Point2f[] clusterWeights = new Point2f[clusters.size()]; - - for (int i = 0; i < clusters.size(); i++) { - float weightX = 0; - float weightY = 0; - - for (int j = 0; j < clusters.get(i).size(); j++) { - weightX += clusters.get(i).get(j).x(); - weightY += clusters.get(i).get(j).y(); - } - clusterWeights[i] = - new Point2f((weightX / clusters.get(i).size()), (weightY / clusters.get(i).size())); - } - return clusterWeights; - } - - /** - * Calculates the largest chord between each of the cluster weights and places south at the first - * index, with east or west following afterward. This mutates the given array and does not return - * any value. - * - * @param clusterWeights an array of points referring to the weighted average of the cardinal - * clusters. - */ - private void sortClusterWeights(Point2f[] clusterWeights) { - - double d1 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[1]); - double d2 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[2]); - - if (d1 > 25) { - Point2f temp = clusterWeights[0]; - clusterWeights[0] = clusterWeights[2]; - clusterWeights[2] = temp; - } - if (d2 > 25) { - Point2f temp = clusterWeights[0]; - clusterWeights[0] = clusterWeights[1]; - clusterWeights[1] = temp; - } - } - - /** - * Gets the Euclidean distance between two {@link Point2f} values. - * - * @param a The first value to compare. - * @param b The second value to compare. - * @return The distance between the two points. - */ - private float getDistanceBetweenTwoPoints(Point2f a, Point2f b) { - return (float) - Math.sqrt(((a.x() - b.x()) * (a.x() - b.x())) + ((a.y() - b.y()) * (a.y() - b.y()))); - } - - /** - * Uses the BaseScript's controller to access the compass. Masks out the cardinal markers in the - * colour compassRed. - * - * @return the masked compass image in {@link Mat} form. - */ - private Mat captureRedMarkerMask() { - Rectangle zone = controller.zones().getMinimap().get("compassSimilarity"); - BufferedImage img = ScreenManager.captureZone(zone); - - Mat src = Java2DFrameUtils.toMat(img); - Mat hsv = new Mat(); - Mat mask = new Mat(); - - Mat lower = new Mat(compassRed.hsvMin()); - Mat upper = new Mat(compassRed.hsvMax()); - - cvtColor(src, hsv, COLOR_BGR2HSV); - inRange(hsv, lower, upper, mask); - - src.release(); - hsv.release(); - lower.release(); - upper.release(); - - return mask; - } - - /** - * Uses a mask of the compass filtered by compassRed to remove the inner compass needle. Leaving - * only clusters of the outermost cardinal markers. - * - * @param mask a CU81 greyscale mask of the compass, filtered by compassRed. - * @return a list of {@link Point2f} objects to denote the outermost cardinal markers. - */ - private List extractCardinalPoints(Mat mask) { - List cardinalPoints = new ArrayList<>(); - for (int y = 0; y < mask.rows(); y++) { - for (int x = 0; x < mask.cols(); x++) { - if (mask.ptr(y, x).get() != 0) { - double dist = Math.hypot(x - getPivot().x(), y - getPivot().y()); - if (dist >= MIN_MARKER_RADIUS && dist <= MAX_MARKER_RADIUS) { - cardinalPoints.add(new Point2f(x, y)); - } - } - } - } - return cardinalPoints; - } - - /** - * Provides the compass pivot/centre based on fixed-classic or resizable-classic. - * - * @return the coordinate offset for the compass center based on the ZoneManager. - */ - private Point2f getPivot() { - if (controller.zones().getIsFixed()) { - return new Point2f(17, 17); - } else { - return new Point2f(18, 18); - } - } - - /** - * Groups nearby points together based on the CLUSTER_PROXIMITY_THRESHOLD. - * - * @param points the list of detected red pixels. - * @return a list of lists, where each inner list represents a distinct marker cluster. - */ - private List> clusterPoints(List points) { - List> clusters = new ArrayList<>(); - while (!points.isEmpty()) { - List cluster = new ArrayList<>(); - Point2f root = points.remove(0); - cluster.add(root); - points.removeIf( - p -> { - if (Math.hypot(root.x() - p.x(), root.y() - p.y()) < CLUSTER_PROXIMITY_THRESHOLD) { - cluster.add(p); - return true; - } - return false; - }); - clusters.add(cluster); - } - return clusters; - } -} +package com.chromascape.utils.domain.walker; + +import static org.bytedeco.opencv.global.opencv_core.fastAtan2; +import static org.bytedeco.opencv.global.opencv_core.inRange; +import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2HSV; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; + +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import org.bytedeco.javacpp.PointerScope; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point2f; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Handles detection of the in-game compass orientation by calculating the bearing, based on the + * cardinal markers within the compass (East, West and South). + * + *

This class enables angle-based transformations, such as rotating click positions on the + * minimap to match the player's camera orientation. + */ +public class Compass { + + // Minimum length from centre that the outermost cardinal compass marker pixels should be + private static final double MIN_MARKER_RADIUS = 12.0; + // Minimum length from centre that the outermost cardinal compass marker pixels should be + private static final double MAX_MARKER_RADIUS = 17.0; + // Proximity of pixels to define a cluster (within 4 px? -> part of the same cluster) + private static final int CLUSTER_PROXIMITY_THRESHOLD = 4; + double[] cardinals = {0.0, 90.0, 180.0, 270.0, 360.0}; + // How close to the cardinal an angle should be to snap to it + double cardinalSnapThreshold = 3.0; + + private final Controller controller; + + // Colour of the outer cardinal markers within the compass + private final ColourObj compassRed = + new ColourObj("CompassRed", new Scalar(0, 200, 140, 0), new Scalar(20, 255, 200, 0)); + + /** + * Constructs the Compass class. Uses the BaseScript's {@link Controller} object to access zones. + * + * @param controller the BaseScript's controller object. + */ + public Compass(Controller controller) { + this.controller = controller; + } + + /** + * Calculates the current compass angle by using the 3 red markers that denote East, South and + * West. Dependant that compassRed is accurate in the user's environment. Releases native memory + * related to JavaCV. Heavily inspired by SRL. Thank you. + * + * @return The detected angle in degrees (0-359.9). + */ + public double getCompassAngle() { + try (PointerScope ignored = new PointerScope()) { + + // Mask out the compass image by compassRed + Mat mask = captureRedMarkerMask(); + // Keep only the outermost pixels (to erase the compass needle) + // Convert them to Points for clustering + List cardinalPoints = extractCardinalPoints(mask); + // If the pixels are within 4 pixels of each other, class them as the same cluster + List> clusters = clusterPoints(cardinalPoints); + // There should be exactly 3 clusters (E, S, W) + if (clusters.size() < 3) { + return 0.0; + } + // Average each cluster into a single point (weight) + Point2f[] markers = getClusterWeights(clusters); + // Move the south cluster to index 0 by judging the longest chord (between E and W) + sortClusterWeights(markers); + // Sort the array into S, E, W by comparing the predicted south vs real south + identifyEastAndWest(markers); + // Calculate the final bearing using E and W + double degrees = fastAtan2(markers[1].y() - markers[2].y(), markers[1].x() - markers[2].x()); + // Snap to a cardinal angle if within the threshold + for (double cardinal : cardinals) { + // We use deltaAngle to handle the 359 -> 0 wrap-around + if (Math.abs(deltaAngle((float) degrees, (float) cardinal)) <= cardinalSnapThreshold) { + return (cardinal == 360.0) ? 0.0 : cardinal; + } + } + + return degrees; + } + } + + /** + * Compares predicted south to true south to sort the clusters into [South, East, West]. Uses arc + * tangents to compare the relationship between the E/S vector and S, Pivot vector. Flips the East + * and West value to sort the array. + * + * @param sortedClusterWeights an array of cluster weights sorted to [South, East, West]. + */ + private void identifyEastAndWest(Point2f[] sortedClusterWeights) { + float eastOrWestAngle = + fastAtan2( + sortedClusterWeights[1].y() - getPivot().y(), + sortedClusterWeights[1].x() - getPivot().x()); + float southAngle = + fastAtan2( + sortedClusterWeights[0].y() - getPivot().y(), + sortedClusterWeights[0].x() - getPivot().x()); + if (Math.abs(deltaAngle(eastOrWestAngle + 90, southAngle)) > 90) { + Point2f temp = sortedClusterWeights[1]; + sortedClusterWeights[1] = sortedClusterWeights[2]; + sortedClusterWeights[2] = temp; + } + } + + /** + * Finds the shortest angle between two angles. Wraps around the circle correctly as opposed to a + * traditional modulus. + * + * @param a1 The first angle. + * @param a2 The second angle. + * @return the smallest angle between the two given values. + */ + public static float deltaAngle(float a1, float a2) { + float result = (a1 - a2); + while (result > 180) { + result -= 360; + } + while (result <= -180) { + result += 360; + } + return result; + } + + /** + * Assigns each cluster a weight value by calculating the mean average between each pixel within. + * This is useful when considering the cluster as a whole rather than an individual point for + * calculation. + * + * @param clusters a {@link List} containing {@code List}s that each refer to a cluster. + * @return an array of {@link Point2f}s which refer to the weighted average of each respective + * cluster. + */ + private Point2f[] getClusterWeights(List> clusters) { + Point2f[] clusterWeights = new Point2f[clusters.size()]; + + for (int i = 0; i < clusters.size(); i++) { + float weightX = 0; + float weightY = 0; + + for (int j = 0; j < clusters.get(i).size(); j++) { + weightX += clusters.get(i).get(j).x(); + weightY += clusters.get(i).get(j).y(); + } + clusterWeights[i] = + new Point2f((weightX / clusters.get(i).size()), (weightY / clusters.get(i).size())); + } + return clusterWeights; + } + + /** + * Calculates the largest chord between each of the cluster weights and places south at the first + * index, with east or west following afterward. This mutates the given array and does not return + * any value. + * + * @param clusterWeights an array of points referring to the weighted average of the cardinal + * clusters. + */ + private void sortClusterWeights(Point2f[] clusterWeights) { + + double d1 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[1]); + double d2 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[2]); + + if (d1 > 25) { + Point2f temp = clusterWeights[0]; + clusterWeights[0] = clusterWeights[2]; + clusterWeights[2] = temp; + } + if (d2 > 25) { + Point2f temp = clusterWeights[0]; + clusterWeights[0] = clusterWeights[1]; + clusterWeights[1] = temp; + } + } + + /** + * Gets the Euclidean distance between two {@link Point2f} values. + * + * @param a The first value to compare. + * @param b The second value to compare. + * @return The distance between the two points. + */ + private float getDistanceBetweenTwoPoints(Point2f a, Point2f b) { + return (float) + Math.sqrt(((a.x() - b.x()) * (a.x() - b.x())) + ((a.y() - b.y()) * (a.y() - b.y()))); + } + + /** + * Uses the BaseScript's controller to access the compass. Masks out the cardinal markers in the + * colour compassRed. + * + * @return the masked compass image in {@link Mat} form. + */ + private Mat captureRedMarkerMask() { + Rectangle zone = controller.zones().getMinimap().get("compassSimilarity"); + BufferedImage img = ScreenManager.captureZone(zone); + + Mat src = TemplateMatching.bufferedImageToMat(img); + Mat hsv = new Mat(); + Mat mask = new Mat(); + + Mat lower = new Mat(compassRed.hsvMin()); + Mat upper = new Mat(compassRed.hsvMax()); + + cvtColor(src, hsv, COLOR_BGR2HSV); + inRange(hsv, lower, upper, mask); + + src.release(); + hsv.release(); + lower.release(); + upper.release(); + + return mask; + } + + /** + * Uses a mask of the compass filtered by compassRed to remove the inner compass needle. Leaving + * only clusters of the outermost cardinal markers. + * + * @param mask a CU81 greyscale mask of the compass, filtered by compassRed. + * @return a list of {@link Point2f} objects to denote the outermost cardinal markers. + */ + private List extractCardinalPoints(Mat mask) { + List cardinalPoints = new ArrayList<>(); + for (int y = 0; y < mask.rows(); y++) { + for (int x = 0; x < mask.cols(); x++) { + if (mask.ptr(y, x).get() != 0) { + double dist = Math.hypot(x - getPivot().x(), y - getPivot().y()); + if (dist >= MIN_MARKER_RADIUS && dist <= MAX_MARKER_RADIUS) { + cardinalPoints.add(new Point2f(x, y)); + } + } + } + } + return cardinalPoints; + } + + /** + * Provides the compass pivot/centre based on fixed-classic or resizable-classic. + * + * @return the coordinate offset for the compass center based on the ZoneManager. + */ + private Point2f getPivot() { + if (controller.zones().getIsFixed()) { + return new Point2f(17, 17); + } else { + return new Point2f(18, 18); + } + } + + /** + * Groups nearby points together based on the CLUSTER_PROXIMITY_THRESHOLD. + * + * @param points the list of detected red pixels. + * @return a list of lists, where each inner list represents a distinct marker cluster. + */ + private List> clusterPoints(List points) { + List> clusters = new ArrayList<>(); + while (!points.isEmpty()) { + List cluster = new ArrayList<>(); + Point2f root = points.remove(0); + cluster.add(root); + points.removeIf( + p -> { + if (Math.hypot(root.x() - p.x(), root.y() - p.y()) < CLUSTER_PROXIMITY_THRESHOLD) { + cluster.add(p); + return true; + } + return false; + }); + clusters.add(cluster); + } + return clusters; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java b/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java index e1929de..50e5d98 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java +++ b/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java @@ -1,16 +1,16 @@ -package com.chromascape.utils.domain.walker; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; - -/** - * Record class to deserialize the raw output of the DAX API's walker. - * - * @param pathStatus Status of the request: SUCCESS or FAILURE. - * @param path A {@link List} of Tile objects leading from current to destination positions. - * @param cost How many tokens used. - */ -public record DaxPath( - @JsonProperty("pathStatus") String pathStatus, - @JsonProperty("path") List path, - @JsonProperty("cost") int cost) {} +package com.chromascape.utils.domain.walker; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Record class to deserialize the raw output of the DAX API's walker. + * + * @param pathStatus Status of the request: SUCCESS or FAILURE. + * @param path A {@link List} of Tile objects leading from current to destination positions. + * @param cost How many tokens used. + */ +public record DaxPath( + @JsonProperty("pathStatus") String pathStatus, + @JsonProperty("path") List path, + @JsonProperty("cost") int cost) {} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Tile.java b/src/main/java/com/chromascape/utils/domain/walker/Tile.java index 9101cee..be94c0a 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Tile.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Tile.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.domain.walker; - -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Record class to deserialize and store a DAX API path as a set of Tiles. - * - * @param x x co-ordinate. - * @param y y co-ordinate. - * @param z z co-ordinate. - */ -public record Tile(@JsonProperty("x") int x, @JsonProperty("y") int y, @JsonProperty("z") int z) {} +package com.chromascape.utils.domain.walker; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Record class to deserialize and store a DAX API path as a set of Tiles. + * + * @param x x co-ordinate. + * @param y y co-ordinate. + * @param z z co-ordinate. + */ +public record Tile(@JsonProperty("x") int x, @JsonProperty("y") int y, @JsonProperty("z") int z) {} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Walker.java b/src/main/java/com/chromascape/utils/domain/walker/Walker.java index f464099..4e7ebf0 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Walker.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Walker.java @@ -1,316 +1,316 @@ -package com.chromascape.utils.domain.walker; - -import static com.chromascape.base.BaseScript.waitRandomMillis; - -import com.chromascape.api.Dax; -import com.chromascape.base.BaseScript; -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.runtime.exception.DaxAuthException; -import com.chromascape.utils.core.runtime.exception.DaxException; -import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.awt.Point; -import java.awt.Rectangle; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Provides high-level pathfinding and walking functionality for the bot. - * - *

The {@code Walker} integrates with the {@link Dax} pathfinding API, in-game OCR, and the - * minimap/compass systems to move the player character to a given destination tile. It has access - * to the {@link Controller}, granting it access to screen zones, the virtual mouse, and other - * utilities. - * - *

Walking is achieved by: - * - *

    - *
  • Using OCR to read the player's current position from the game client. - *
  • Querying the DAX API for a path between the current position and the destination. - *
  • Projecting intermediate path tiles onto the minimap using pixel-per-tile scaling and - * compass rotation. - *
  • Issuing randomized mouse clicks on the minimap to simulate human-like input. - *
  • Polling player movement until the character stops, recalculating the path if necessary. - *
- * - *

The {@code Walker} assumes: - * - *

    - *
  • The minimap is at the default zoom level. - *
  • OCR can reliably extract the player's current coordinates from the Tile zone. - *
  • The compass direction is available and accurate for rotation calculations. - *
- * - *

Typical usage: - * - *

{@code
- * controller().walker.pathTo(new Point(3200, 3200), true);
- * }
- * - *

This will walk the player to the given tile, respecting camera rotation and randomized path - * horizons, while logging progress to the provided {@link Logger}. - */ -public class Walker { - - private final Controller controller; - private static final Logger logger = LogManager.getLogger(Walker.class); - private final Dax dax; - private final ObjectMapper objectMapper; - private final Compass compass; - private final Random random; - private CompletableFuture pointFuture; - - /** - * Creates a new Walker for controlling player movement. Initializes dependencies including - * controller access, logging, DAX API, and compass handling. - * - * @param controller The bot's controller - */ - public Walker(Controller controller) { - this.controller = controller; - this.dax = new Dax(); - this.objectMapper = new ObjectMapper(); - this.random = new Random(); - this.compass = new Compass(controller); - this.pointFuture = new CompletableFuture<>(); - } - - /** - * Gets the player's position by using runtime OCR on the GridInfo's "Tile" zone. - * - * @return An integer array with 3 elements - x, y and z. - */ - public Tile getPlayerPosition() { - Rectangle zone = controller.zones().getGridInfo().get("Tile"); - ColourObj colour = ColourInstances.getByName("White"); - // Extracts the position using OCR and splits it into a 3 value list (x, y, z) - List stringPos = - Arrays.asList(Ocr.extractText(zone, "Plain 12", colour, true).split(",")); - return new Tile( - Integer.parseInt(stringPos.get(0)), - Integer.parseInt(stringPos.get(1)), - Integer.parseInt(stringPos.get(2))); - } - - /** - * Sends a payload to the DAX API with start/end positions and members availability. In return - - * receives a path that it deserializes and turns into {@link Tile} objects. - * - * @param destination A {@link Point} object defining the co-ordinates of your destination. - * @param isMembers A boolean dictating whether your character is a member or free to play. - * @return A {@link List} list of {@link Tile} objects with the first tile being your current - * position. - * @throws IOException If a transport error occurs during calling a path from the Dax API. - * @throws InterruptedException If the thread is interrupted or the watchdog freezes the thread. - */ - private List getPath(Point destination, boolean isMembers) - throws IOException, InterruptedException { - Tile position = getPlayerPosition(); - DaxPath daxPath = null; - int retries = 20; - int attempt = 0; - - while (attempt < retries) { - try { - String rawPath = - dax.generatePath(new Point(position.x(), position.y()), destination, isMembers); - daxPath = objectMapper.readValue(rawPath, DaxPath.class); - break; - - } catch (DaxRateLimitException e) { - // Handle the rate limit exception by waiting and retrying - attempt++; - logger.warn("Dax Rate Limit reached (Attempt {}/{}). Waiting...", attempt, retries); - waitRandomMillis(600, 1200); - - } catch (DaxAuthException e) { - // Throw RuntimeException if the API key is invalid - logger.error("Dax Authentication Failed: {}", e.getMessage()); - throw new IOException("Invalid DAX credentials. Check your API key: ", e); - - } catch (DaxException e) { - // Retry if server error - attempt++; - logger.error("Dax API error: {}. Retrying...", e.getMessage()); - waitRandomMillis(1000, 2000); - } - } - if (daxPath == null) { - throw new IOException( - "Failed to get a successful path from DAX after " + retries + " retries."); - } - return daxPath.path(); - } - - /** - * Walks the player to a given destination tile using intermediate clicks on the minimap, while - * asynchronously precomputing the next click point to improve responsiveness. - * - *

This approach ensures that the next click location is calculated while waiting, reducing - * idle time and keeping movement smooth and efficient. The path list is modified in-place by - * {@link #chooseNextTarget(List, int, int)}. - * - * @param destination the destination {@link Point} to walk to - * @param isMembers whether the player is a members account, affecting path calculation - * @throws IOException if path retrieval from DAX fails due to transport error - * @throws InterruptedException if the thread is interrupted while in the process of calling DAX - */ - public void pathTo(Point destination, boolean isMembers) - throws IOException, InterruptedException { - List path = getPath(destination, isMembers); - // How far away from the current tile the bot should click - int maxHorizon = 10; - int minHorizon = 8; - // Synchronously path once - Tile target = chooseNextTarget(path, minHorizon, maxHorizon); - logger.info("Synchronously clicking once at {}, {}", target.x(), target.y()); - controller.mouse().moveTo(getClickLocation(target, getPlayerPosition()), "medium"); - controller.mouse().leftClick(); - // Looping until at destination - while (getPlayerPosition().x() != destination.getX() - || getPlayerPosition().y() != destination.getY()) { - if (path.isEmpty()) { - break; - } - // Effectively final variables for the lambda function. - Tile newTarget = chooseNextTarget(path, minHorizon, maxHorizon); - Tile oldTarget = target; - // Async precomputing the next click point while waiting for the bot to stop - pointFuture = CompletableFuture.supplyAsync(() -> getClickLocation(newTarget, oldTarget)); - // This blocks the main thread, but the next point is being computed already. - logger.info("Precomputing next click at {}, {}", newTarget.x(), newTarget.y()); - waitToStop(); - // Recalculate path and cancel async if not at expected location - Tile position = getPlayerPosition(); - Point clickpoint; - if (position.x() != target.x() || position.y() != target.y()) { - logger.error("Veered off path, recalculating..."); - pointFuture.cancel(false); - try { - pointFuture.join(); - } catch (CancellationException | CompletionException e) { - logger.error("Async task was cancelled and thread joined"); - } - // If the path is out of range recalculate whole path - target = chooseNextTarget(path, 5, 7); - if (Math.abs(position.x() - target.x()) > 7 || Math.abs(position.y() - target.y()) > 7) { - logger.error("Too far from path, calling Dax..."); - path = getPath(destination, isMembers); - target = chooseNextTarget(path, minHorizon, maxHorizon); - } - clickpoint = getClickLocation(target, getPlayerPosition()); - } else { - clickpoint = pointFuture.join(); - // Update target - target = newTarget; - } - // Both scenarios saved as the clickPoint - controller.mouse().moveTo(clickpoint, "medium"); - controller.mouse().leftClick(); - } - } - - /** - * Selects the next intermediate target tile from the given path for the bot to click on the - * minimap. - * - *

The method randomly chooses a target a few tiles ahead of the player's current position - * (between {@code minHorizon} and {@code maxHorizon}) to simulate human-like movement and avoid - * predictable straight-line clicking. - * - *

If the path is shorter than the randomly selected horizon, the last tile in the path is - * chosen. Once a target is chosen, all preceding tiles up to the chosen target are removed from - * the path, effectively updating the path for the next iteration. - * - * @param path the list of {@link Tile} objects representing the remaining path to the - * destination; this list will be modified by removing tiles up to the chosen target - * @return the {@link Tile} selected as the next click target - */ - private Tile chooseNextTarget(List path, int minHorizon, int maxHorizon) { - if (path == null || path.isEmpty()) { - return getPlayerPosition(); - } - - int targetPos = random.nextInt(minHorizon, maxHorizon + 1); - Tile target; - - // If we're about to overshoot the last tile, just click the last tile - if (path.size() > targetPos) { - target = path.get(targetPos); - path.subList(0, targetPos).clear(); - } else { - target = path.get(path.size() - 1); - path.clear(); - } - return target; - } - - /** - * Uses the given players position and the position of the target {@link Tile} to project the - * click location of the given {@link Tile} onto the minimap. Uses the compass' angle to rotate - * the click location so it can conform to any camera position. Requires that the minimap be at - * default zoom level. - * - * @param target The target {@link Tile} to path to. - * @param playerPosition The position of the player to calculate form. - * @return Returns the {@link Point} click location to click. - * @implNote Requires default minimap zoom. Other zoom levels will misalign tile clicks. - */ - private Point getClickLocation(Tile target, Tile playerPosition) { - // 4 pixels per tile at normal zoom - int pixelsPerTile = 4; - // Save player position - int x = playerPosition.x(); - int y = playerPosition.y(); - // Calculating distance from the player to the target, in pixels - // and adding an offset of a few pixels to click randomly within the tile - double dx = ((target.x() - x) * pixelsPerTile); - double dy = ((y - target.y()) * pixelsPerTile); - // Locating the player's tile on the minimap - Rectangle playerMinimap = controller.zones().getMinimap().get("playerPos"); - // Origins dictate the perfect center - used to rotate the click location - double originX = playerMinimap.x + ((double) (pixelsPerTile - 1) / 2); - double originY = playerMinimap.y + ((double) (pixelsPerTile - 1) / 2); - // Calculate the radian based on compass rotation - double theta = Math.toRadians(compass.getCompassAngle()); - // Calculate rotated x and y - double rotX = Math.cos(theta) * dx - Math.sin(theta) * dy; - double rotY = Math.sin(theta) * dx + Math.cos(theta) * dy; - // Generate the rotated point - return new Point((int) Math.round(originX + rotX), (int) Math.round(originY + rotY)); - } - - /** - * Polls the player's position to check if the player has stopped moving. Exits out when stopped. - */ - private void waitToStop() { - // Ticks on some worlds can vary, it's usual on world 302 to be 0.618 per tick - long tick = 650; - Tile position = getPlayerPosition(); - // Wait to start moving - int attempts = 0; - while (position.equals(getPlayerPosition()) && attempts < 3) { - BaseScript.waitMillis(tick); - attempts++; - } - // Wait to stop moving - while (true) { - if (position.equals(getPlayerPosition())) { - return; - } else { - position = getPlayerPosition(); - BaseScript.waitMillis(tick); - } - } - } -} +package com.chromascape.utils.domain.walker; + +import static com.chromascape.base.BaseScript.waitRandomMillis; + +import com.chromascape.api.Dax; +import com.chromascape.base.BaseScript; +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.runtime.exception.DaxAuthException; +import com.chromascape.utils.core.runtime.exception.DaxException; +import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.Point; +import java.awt.Rectangle; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Provides high-level pathfinding and walking functionality for the bot. + * + *

The {@code Walker} integrates with the {@link Dax} pathfinding API, in-game OCR, and the + * minimap/compass systems to move the player character to a given destination tile. It has access + * to the {@link Controller}, granting it access to screen zones, the virtual mouse, and other + * utilities. + * + *

Walking is achieved by: + * + *

    + *
  • Using OCR to read the player's current position from the game client. + *
  • Querying the DAX API for a path between the current position and the destination. + *
  • Projecting intermediate path tiles onto the minimap using pixel-per-tile scaling and + * compass rotation. + *
  • Issuing randomized mouse clicks on the minimap to simulate human-like input. + *
  • Polling player movement until the character stops, recalculating the path if necessary. + *
+ * + *

The {@code Walker} assumes: + * + *

    + *
  • The minimap is at the default zoom level. + *
  • OCR can reliably extract the player's current coordinates from the Tile zone. + *
  • The compass direction is available and accurate for rotation calculations. + *
+ * + *

Typical usage: + * + *

{@code
+ * controller().walker.pathTo(new Point(3200, 3200), true);
+ * }
+ * + *

This will walk the player to the given tile, respecting camera rotation and randomized path + * horizons, while logging progress to the provided {@link Logger}. + */ +public class Walker { + + private final Controller controller; + private static final Logger logger = LogManager.getLogger(Walker.class); + private final Dax dax; + private final ObjectMapper objectMapper; + private final Compass compass; + private final Random random; + private CompletableFuture pointFuture; + + /** + * Creates a new Walker for controlling player movement. Initializes dependencies including + * controller access, logging, DAX API, and compass handling. + * + * @param controller The bot's controller + */ + public Walker(Controller controller) { + this.controller = controller; + this.dax = new Dax(); + this.objectMapper = new ObjectMapper(); + this.random = new Random(); + this.compass = new Compass(controller); + this.pointFuture = new CompletableFuture<>(); + } + + /** + * Gets the player's position by using runtime OCR on the GridInfo's "Tile" zone. + * + * @return An integer array with 3 elements - x, y and z. + */ + public Tile getPlayerPosition() { + Rectangle zone = controller.zones().getGridInfo().get("Tile"); + ColourObj colour = ColourInstances.getByName("White"); + // Extracts the position using OCR and splits it into a 3 value list (x, y, z) + List stringPos = + Arrays.asList(Ocr.extractText(zone, "Plain 12", colour, true).split(",")); + return new Tile( + Integer.parseInt(stringPos.get(0)), + Integer.parseInt(stringPos.get(1)), + Integer.parseInt(stringPos.get(2))); + } + + /** + * Sends a payload to the DAX API with start/end positions and members availability. In return - + * receives a path that it deserializes and turns into {@link Tile} objects. + * + * @param destination A {@link Point} object defining the co-ordinates of your destination. + * @param isMembers A boolean dictating whether your character is a member or free to play. + * @return A {@link List} list of {@link Tile} objects with the first tile being your current + * position. + * @throws IOException If a transport error occurs during calling a path from the Dax API. + * @throws InterruptedException If the thread is interrupted or the watchdog freezes the thread. + */ + private List getPath(Point destination, boolean isMembers) + throws IOException, InterruptedException { + Tile position = getPlayerPosition(); + DaxPath daxPath = null; + int retries = 20; + int attempt = 0; + + while (attempt < retries) { + try { + String rawPath = + dax.generatePath(new Point(position.x(), position.y()), destination, isMembers); + daxPath = objectMapper.readValue(rawPath, DaxPath.class); + break; + + } catch (DaxRateLimitException e) { + // Handle the rate limit exception by waiting and retrying + attempt++; + logger.warn("Dax Rate Limit reached (Attempt {}/{}). Waiting...", attempt, retries); + waitRandomMillis(600, 1200); + + } catch (DaxAuthException e) { + // Throw RuntimeException if the API key is invalid + logger.error("Dax Authentication Failed: {}", e.getMessage()); + throw new IOException("Invalid DAX credentials. Check your API key: ", e); + + } catch (DaxException e) { + // Retry if server error + attempt++; + logger.error("Dax API error: {}. Retrying...", e.getMessage()); + waitRandomMillis(1000, 2000); + } + } + if (daxPath == null) { + throw new IOException( + "Failed to get a successful path from DAX after " + retries + " retries."); + } + return daxPath.path(); + } + + /** + * Walks the player to a given destination tile using intermediate clicks on the minimap, while + * asynchronously precomputing the next click point to improve responsiveness. + * + *

This approach ensures that the next click location is calculated while waiting, reducing + * idle time and keeping movement smooth and efficient. The path list is modified in-place by + * {@link #chooseNextTarget(List, int, int)}. + * + * @param destination the destination {@link Point} to walk to + * @param isMembers whether the player is a members account, affecting path calculation + * @throws IOException if path retrieval from DAX fails due to transport error + * @throws InterruptedException if the thread is interrupted while in the process of calling DAX + */ + public void pathTo(Point destination, boolean isMembers) + throws IOException, InterruptedException { + List path = getPath(destination, isMembers); + // How far away from the current tile the bot should click + int maxHorizon = 10; + int minHorizon = 8; + // Synchronously path once + Tile target = chooseNextTarget(path, minHorizon, maxHorizon); + logger.info("Synchronously clicking once at {}, {}", target.x(), target.y()); + controller.mouse().moveTo(getClickLocation(target, getPlayerPosition()), "medium"); + controller.mouse().leftClick(); + // Looping until at destination + while (getPlayerPosition().x() != destination.getX() + || getPlayerPosition().y() != destination.getY()) { + if (path.isEmpty()) { + break; + } + // Effectively final variables for the lambda function. + Tile newTarget = chooseNextTarget(path, minHorizon, maxHorizon); + Tile oldTarget = target; + // Async precomputing the next click point while waiting for the bot to stop + pointFuture = CompletableFuture.supplyAsync(() -> getClickLocation(newTarget, oldTarget)); + // This blocks the main thread, but the next point is being computed already. + logger.info("Precomputing next click at {}, {}", newTarget.x(), newTarget.y()); + waitToStop(); + // Recalculate path and cancel async if not at expected location + Tile position = getPlayerPosition(); + Point clickpoint; + if (position.x() != target.x() || position.y() != target.y()) { + logger.error("Veered off path, recalculating..."); + pointFuture.cancel(false); + try { + pointFuture.join(); + } catch (CancellationException | CompletionException e) { + logger.error("Async task was cancelled and thread joined"); + } + // If the path is out of range recalculate whole path + target = chooseNextTarget(path, 5, 7); + if (Math.abs(position.x() - target.x()) > 7 || Math.abs(position.y() - target.y()) > 7) { + logger.error("Too far from path, calling Dax..."); + path = getPath(destination, isMembers); + target = chooseNextTarget(path, minHorizon, maxHorizon); + } + clickpoint = getClickLocation(target, getPlayerPosition()); + } else { + clickpoint = pointFuture.join(); + // Update target + target = newTarget; + } + // Both scenarios saved as the clickPoint + controller.mouse().moveTo(clickpoint, "medium"); + controller.mouse().leftClick(); + } + } + + /** + * Selects the next intermediate target tile from the given path for the bot to click on the + * minimap. + * + *

The method randomly chooses a target a few tiles ahead of the player's current position + * (between {@code minHorizon} and {@code maxHorizon}) to simulate human-like movement and avoid + * predictable straight-line clicking. + * + *

If the path is shorter than the randomly selected horizon, the last tile in the path is + * chosen. Once a target is chosen, all preceding tiles up to the chosen target are removed from + * the path, effectively updating the path for the next iteration. + * + * @param path the list of {@link Tile} objects representing the remaining path to the + * destination; this list will be modified by removing tiles up to the chosen target + * @return the {@link Tile} selected as the next click target + */ + private Tile chooseNextTarget(List path, int minHorizon, int maxHorizon) { + if (path == null || path.isEmpty()) { + return getPlayerPosition(); + } + + int targetPos = random.nextInt(minHorizon, maxHorizon + 1); + Tile target; + + // If we're about to overshoot the last tile, just click the last tile + if (path.size() > targetPos) { + target = path.get(targetPos); + path.subList(0, targetPos).clear(); + } else { + target = path.get(path.size() - 1); + path.clear(); + } + return target; + } + + /** + * Uses the given players position and the position of the target {@link Tile} to project the + * click location of the given {@link Tile} onto the minimap. Uses the compass' angle to rotate + * the click location so it can conform to any camera position. Requires that the minimap be at + * default zoom level. + * + * @param target The target {@link Tile} to path to. + * @param playerPosition The position of the player to calculate form. + * @return Returns the {@link Point} click location to click. + * @implNote Requires default minimap zoom. Other zoom levels will misalign tile clicks. + */ + private Point getClickLocation(Tile target, Tile playerPosition) { + // 4 pixels per tile at normal zoom + int pixelsPerTile = 4; + // Save player position + int x = playerPosition.x(); + int y = playerPosition.y(); + // Calculating distance from the player to the target, in pixels + // and adding an offset of a few pixels to click randomly within the tile + double dx = ((target.x() - x) * pixelsPerTile); + double dy = ((y - target.y()) * pixelsPerTile); + // Locating the player's tile on the minimap + Rectangle playerMinimap = controller.zones().getMinimap().get("playerPos"); + // Origins dictate the perfect center - used to rotate the click location + double originX = playerMinimap.x + ((double) (pixelsPerTile - 1) / 2); + double originY = playerMinimap.y + ((double) (pixelsPerTile - 1) / 2); + // Calculate the radian based on compass rotation + double theta = Math.toRadians(compass.getCompassAngle()); + // Calculate rotated x and y + double rotX = Math.cos(theta) * dx - Math.sin(theta) * dy; + double rotY = Math.sin(theta) * dx + Math.cos(theta) * dy; + // Generate the rotated point + return new Point((int) Math.round(originX + rotX), (int) Math.round(originY + rotY)); + } + + /** + * Polls the player's position to check if the player has stopped moving. Exits out when stopped. + */ + private void waitToStop() { + // Ticks on some worlds can vary, it's usual on world 302 to be 0.618 per tick + long tick = 650; + Tile position = getPlayerPosition(); + // Wait to start moving + int attempts = 0; + while (position.equals(getPlayerPosition()) && attempts < 3) { + BaseScript.waitMillis(tick); + attempts++; + } + // Wait to stop moving + while (true) { + if (position.equals(getPlayerPosition())) { + return; + } else { + position = getPlayerPosition(); + BaseScript.waitMillis(tick); + } + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java b/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java index 91ac2c8..b051ab8 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java +++ b/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java @@ -1,85 +1,85 @@ -package com.chromascape.utils.domain.zones; - -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import org.bytedeco.javacpp.indexer.UByteRawIndexer; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Rect; - -/** - * Utility class for applying rectangular masks to images. - * - *

Provides static methods for blacking out regions of {@link BufferedImage} or OpenCV {@link - * Mat} objects based on AWT {@link Rectangle} coordinates. Used primarily for excluding visual - * zones from further processing. - */ -public class MaskZones { - - /** - * Applies a rectangular mask to a given {@link BufferedImage} and returns a new image with the - * specified area set to black. - * - * @param originalImg The original input image. - * @param maskArea The rectangular area to mask, in AWT {@link Rectangle} coordinates. - * @return a new {@link BufferedImage} With the specified region zeroed out. - * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. - */ - public static BufferedImage maskZones(BufferedImage originalImg, Rectangle maskArea) { - Mat original = Java2DFrameUtils.toMat(originalImg); - Mat output = maskZonesMat(original, maskArea); - BufferedImage outImg = Java2DFrameUtils.toBufferedImage(output); - original.release(); - output.release(); - return outImg; - } - - /** - * Applies a rectangular mask directly to a {@link Mat} image and returns a new {@link Mat} with - * the specified region zeroed out. - * - * @param original The original input image as an OpenCV {@link Mat}. - * @param maskArea The rectangular area to mask, in AWT {@link Rectangle} coordinates. - * @return A cloned {@link Mat} with the masked region set to zero. - * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. - */ - public static Mat maskZonesMat(Mat original, Rectangle maskArea) { - Mat output = original.clone(); - Rect rect = new Rect(maskArea.x, maskArea.y, maskArea.width, maskArea.height); - - // Bounds check - if (rect.x() < 0 - || rect.y() < 0 - || rect.width() <= 0 - || rect.height() <= 0 - || rect.x() + rect.width() > output.cols() - || rect.y() + rect.height() > output.rows()) { - throw new IllegalArgumentException("Mask rectangle out of bounds: " + rect); - } - - Mat roi = new Mat(output, rect); - - if (output.channels() == 1) { - UByteRawIndexer indexer = roi.createIndexer(); - for (int y = 0; y < rect.height(); y++) { - for (int x = 0; x < rect.width(); x++) { - indexer.put(y, x, 0); - } - } - indexer.release(); - } else if (output.channels() == 3) { - UByteRawIndexer indexer = roi.createIndexer(); - for (int y = 0; y < rect.height(); y++) { - for (int x = 0; x < rect.width(); x++) { - indexer.put(y, x, 0, 0); // B - indexer.put(y, x, 1, 0); // G - indexer.put(y, x, 2, 0); // R - } - } - indexer.release(); - } - - roi.release(); - return output; - } -} +package com.chromascape.utils.domain.zones; + +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import org.bytedeco.javacpp.indexer.UByteRawIndexer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Rect; + +/** + * Utility class for applying rectangular masks to images. + * + *

Provides static methods for blacking out regions of {@link BufferedImage} or OpenCV {@link + * Mat} objects based on AWT {@link Rectangle} coordinates. Used primarily for excluding visual + * zones from further processing. + */ +public class MaskZones { + + /** + * Applies a rectangular mask to a given {@link BufferedImage} and returns a new image with the + * specified area set to black. + * + * @param originalImg The original input image. + * @param maskArea The rectangular area to mask. + * @return a new {@link BufferedImage} With the specified region zeroed out. + * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. + */ + public static BufferedImage maskZones(BufferedImage originalImg, Rectangle maskArea) { + Mat original = TemplateMatching.bufferedImageToMat(originalImg); + Mat output = maskZonesMat(original, maskArea); + BufferedImage outImg = TemplateMatching.matToBufferedImage(output); + original.release(); + output.release(); + return outImg; + } + + /** + * Applies a rectangular mask directly to a {@link Mat} image and returns a new {@link Mat} with + * the specified region zeroed out. + * + * @param original The original input image as an OpenCV {@link Mat}. + * @param maskArea The rectangular area to mask, in AWT {@link Rectangle} coordinates. + * @return A cloned {@link Mat} with the masked region set to zero. + * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. + */ + public static Mat maskZonesMat(Mat original, Rectangle maskArea) { + Mat output = original.clone(); + Rect rect = new Rect(maskArea.x, maskArea.y, maskArea.width, maskArea.height); + + // Bounds check + if (rect.x() < 0 + || rect.y() < 0 + || rect.width() <= 0 + || rect.height() <= 0 + || rect.x() + rect.width() > output.cols() + || rect.y() + rect.height() > output.rows()) { + throw new IllegalArgumentException("Mask rectangle out of bounds: " + rect); + } + + Mat roi = new Mat(output, rect); + + if (output.channels() == 1) { + UByteRawIndexer indexer = roi.createIndexer(); + for (int y = 0; y < rect.height(); y++) { + for (int x = 0; x < rect.width(); x++) { + indexer.put(y, x, 0); + } + } + indexer.release(); + } else if (output.channels() == 3) { + UByteRawIndexer indexer = roi.createIndexer(); + for (int y = 0; y < rect.height(); y++) { + for (int x = 0; x < rect.width(); x++) { + indexer.put(y, x, 0, 0); // B + indexer.put(y, x, 1, 0); // G + indexer.put(y, x, 2, 0); // R + } + } + indexer.release(); + } + + roi.release(); + return output; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java b/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java index 762a746..5d8ed0d 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java +++ b/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java @@ -1,194 +1,194 @@ -package com.chromascape.utils.domain.zones; - -import java.awt.Rectangle; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Utility class for mapping UI component zones within the client window. - * - *

Provides methods to derive sub-zones (like orbs, tabs, inventory slots) from known UI - * containers such as the minimap, chatbox, control panel, and inventory, using fixed offsets for - * consistent bounding boxes. - */ -public class SubZoneMapper { - - /** - * Maps all major UI components in or derived solely from the resizable mode minimap area. - * - * @param zone The base minimap zone. - * @return A map of component names to {@link Rectangle} bounds. - */ - public static Map mapMinimap(Rectangle zone) { - if (zone != null) { - - Map minimap = new HashMap<>(); - - minimap.put("compass", new Rectangle(zone.x + 40, zone.y + 7, 24, 26)); - minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 60, 20, 13)); - minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 86, 20, 20)); - minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 94, 20, 13)); - minimap.put("runOrb", new Rectangle(zone.x + 39, zone.y + 118, 20, 20)); - minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 126, 20, 13)); - minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 144, 18, 20)); - minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 151, 20, 13)); - minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 5, 154, 155)); - minimap.put("totalXP", new Rectangle(zone.x - 147, zone.y + 4, 104, 21)); - minimap.put("playerPos", new Rectangle(zone.x + 127, zone.y + 80, 4, 4)); - minimap.put("compassSimilarity", new Rectangle(zone.x + 33, zone.y + 2, 37, 37)); - return minimap; - } else { - System.out.println("No minimap found"); - return null; - } - } - - /** - * Maps UI components in or derived solely from the fixed (non-resizable) mode minimap. - * - * @param zone The base minimap zone. - * @return A map of minimap component rectangles. - */ - public static Map mapFixedMinimap(Rectangle zone) { - if (zone != null) { - - Map minimap = new HashMap<>(); - - minimap.put("compass", new Rectangle(zone.x + 31, zone.y + 7, 24, 25)); - minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 55, 20, 13)); - minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 4, 147, 160)); - minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 80, 19, 20)); - minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 89, 20, 13)); - minimap.put("runOrb", new Rectangle(zone.x + 40, zone.y + 112, 19, 20)); - minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 121, 20, 13)); - minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 137, 19, 20)); - minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 146, 20, 13)); - minimap.put("totalXP", new Rectangle(zone.x - 104, zone.y + 6, 104, 21)); - minimap.put("playerPos", new Rectangle(zone.x + 123, zone.y + 81, 4, 4)); - minimap.put("compassSimilarity", new Rectangle(zone.x + 27, zone.y + 2, 34, 35)); - return minimap; - } else { - System.out.println("No fixed minimap found"); - return null; - } - } - - /** - * Maps control panel UI tab buttons and the inventory area. - * - * @param zone The bounding zone for the control panel. - * @return A map of control panel component rectangles. - */ - public static Map mapCtrlPanel(Rectangle zone) { - if (zone != null) { - - Map ctrlPanel = new HashMap<>(); - // Top row - ctrlPanel.put("combatTab", new Rectangle(zone.x + 7, zone.y + 6, 26, 24)); - ctrlPanel.put("skillsTab", new Rectangle(zone.x + 41, zone.y + 2, 26, 28)); - ctrlPanel.put("summaryTab", new Rectangle(zone.x + 74, zone.y + 2, 26, 28)); - ctrlPanel.put("inventoryTab", new Rectangle(zone.x + 107, zone.y + 2, 26, 28)); - ctrlPanel.put("equipmentTab", new Rectangle(zone.x + 140, zone.y + 2, 26, 28)); - ctrlPanel.put("prayerTab", new Rectangle(zone.x + 173, zone.y + 2, 26, 28)); - ctrlPanel.put("spellbookTab", new Rectangle(zone.x + 206, zone.y + 6, 27, 24)); - - // Bottom row - ctrlPanel.put("channelTab", new Rectangle(zone.x + 7, zone.y + 300, 28, 25)); - ctrlPanel.put("friendsTab", new Rectangle(zone.x + 41, zone.y + 300, 26, 30)); - ctrlPanel.put("accountTab", new Rectangle(zone.x + 74, zone.y + 300, 26, 30)); - ctrlPanel.put("logoutTab", new Rectangle(zone.x + 107, zone.y + 300, 26, 30)); - ctrlPanel.put("settingsTab", new Rectangle(zone.x + 140, zone.y + 300, 26, 30)); - ctrlPanel.put("emotesTab", new Rectangle(zone.x + 173, zone.y + 300, 26, 30)); - ctrlPanel.put("musicTab", new Rectangle(zone.x + 206, zone.y + 300, 27, 25)); - - // Main inventory area - ctrlPanel.put("inventoryPanel", new Rectangle(zone.x + 28, zone.y + 35, 183, 261)); - return ctrlPanel; - } else { - System.out.println("No ctrlPanel found"); - return null; - } - } - - /** - * Maps the layout of the chat tabs and main chat display area. - * - * @param zone The bounding box for the chat region. - * @return A map of tab names and their rectangles. - */ - public static Map mapChat(Rectangle zone) { - if (zone != null) { - - Map chatTabs = new HashMap<>(); - - String[] tabNames = {"All", "Game", "Public", "Private", "Channel", "Clan", "Group"}; - - int x = 5; - int y = 143; - for (int i = 0; i < 7; i++) { - chatTabs.put(tabNames[i], new Rectangle(zone.x + x, zone.y + y, 52, 19)); - x += 62; - } - chatTabs.put("Chat", new Rectangle(zone.x + 5, zone.y + 5, 506, 129)); - chatTabs.put("Latest Message", new Rectangle(zone.x + 5, zone.y + 104, 488, 15)); - return chatTabs; - } else { - System.out.println("No Chat found"); - return null; - } - } - - /** - * Maps out the three fields contained in the Grid Info box. These fields are meant to be used - * with OCR to extract player location data. - * - * @param zone The bounding box of the parent zone. (Where the box is). - * @return A list of {@link Rectangle} subzones (Tile, ChunkID, RegionID). - */ - public static Map mapGridInfo(Rectangle zone) { - if (zone != null) { - Map gridInfo = new HashMap<>(); - gridInfo.put("Tile", new Rectangle(zone.x + 39, zone.y, 89, 22)); - gridInfo.put("ChunkID", new Rectangle(zone.x + 74, zone.y + 20, 54, 19)); - gridInfo.put("RegionID", new Rectangle(zone.x + 84, zone.y + 36, 45, 19)); - return gridInfo; - } else { - System.out.println("No Grid found"); - return null; - } - } - - /** - * Generates bounding rectangles for each inventory slot in a 4x7 grid. - * - * @param zone The top-left bounding box of the inventory panel. - * @return A list of inventory slot rectangles. - */ - public static List mapInventory(Rectangle zone) { - if (zone != null) { - - List inventorySlots = new ArrayList<>(); - - int slotWidth = 36; - int slotHeight = 32; - int gapX = 6; - int gapY = 4; - - int y = zone.y + 44; - for (int i = 0; i < 7; i++) { - int x = zone.x + 40; - for (int j = 0; j < 4; j++) { - inventorySlots.add(new Rectangle(x, y, slotWidth, slotHeight)); - x += slotWidth + gapX; - } - y += slotHeight + gapY; - } - return inventorySlots; - } else { - System.out.println("No Inventory found"); - return null; - } - } -} +package com.chromascape.utils.domain.zones; + +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for mapping UI component zones within the client window. + * + *

Provides methods to derive sub-zones (like orbs, tabs, inventory slots) from known UI + * containers such as the minimap, chatbox, control panel, and inventory, using fixed offsets for + * consistent bounding boxes. + */ +public class SubZoneMapper { + + /** + * Maps all major UI components in or derived solely from the resizable mode minimap area. + * + * @param zone The base minimap zone. + * @return A map of component names to {@link Rectangle} bounds. + */ + public static Map mapMinimap(Rectangle zone) { + if (zone != null) { + + Map minimap = new HashMap<>(); + + minimap.put("compass", new Rectangle(zone.x + 40, zone.y + 7, 24, 26)); + minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 60, 20, 13)); + minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 86, 20, 20)); + minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 94, 20, 13)); + minimap.put("runOrb", new Rectangle(zone.x + 39, zone.y + 118, 20, 20)); + minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 126, 20, 13)); + minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 144, 18, 20)); + minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 151, 20, 13)); + minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 5, 154, 155)); + minimap.put("totalXP", new Rectangle(zone.x - 147, zone.y + 4, 104, 21)); + minimap.put("playerPos", new Rectangle(zone.x + 127, zone.y + 80, 4, 4)); + minimap.put("compassSimilarity", new Rectangle(zone.x + 33, zone.y + 2, 37, 37)); + return minimap; + } else { + System.out.println("No minimap found"); + return null; + } + } + + /** + * Maps UI components in or derived solely from the fixed (non-resizable) mode minimap. + * + * @param zone The base minimap zone. + * @return A map of minimap component rectangles. + */ + public static Map mapFixedMinimap(Rectangle zone) { + if (zone != null) { + + Map minimap = new HashMap<>(); + + minimap.put("compass", new Rectangle(zone.x + 31, zone.y + 7, 24, 25)); + minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 55, 20, 13)); + minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 4, 147, 160)); + minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 80, 19, 20)); + minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 89, 20, 13)); + minimap.put("runOrb", new Rectangle(zone.x + 40, zone.y + 112, 19, 20)); + minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 121, 20, 13)); + minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 137, 19, 20)); + minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 146, 20, 13)); + minimap.put("totalXP", new Rectangle(zone.x - 104, zone.y + 6, 104, 21)); + minimap.put("playerPos", new Rectangle(zone.x + 123, zone.y + 81, 4, 4)); + minimap.put("compassSimilarity", new Rectangle(zone.x + 27, zone.y + 2, 34, 35)); + return minimap; + } else { + System.out.println("No fixed minimap found"); + return null; + } + } + + /** + * Maps control panel UI tab buttons and the inventory area. + * + * @param zone The bounding zone for the control panel. + * @return A map of control panel component rectangles. + */ + public static Map mapCtrlPanel(Rectangle zone) { + if (zone != null) { + + Map ctrlPanel = new HashMap<>(); + // Top row + ctrlPanel.put("combatTab", new Rectangle(zone.x + 7, zone.y + 6, 26, 24)); + ctrlPanel.put("skillsTab", new Rectangle(zone.x + 41, zone.y + 2, 26, 28)); + ctrlPanel.put("summaryTab", new Rectangle(zone.x + 74, zone.y + 2, 26, 28)); + ctrlPanel.put("inventoryTab", new Rectangle(zone.x + 107, zone.y + 2, 26, 28)); + ctrlPanel.put("equipmentTab", new Rectangle(zone.x + 140, zone.y + 2, 26, 28)); + ctrlPanel.put("prayerTab", new Rectangle(zone.x + 173, zone.y + 2, 26, 28)); + ctrlPanel.put("spellbookTab", new Rectangle(zone.x + 206, zone.y + 6, 27, 24)); + + // Bottom row + ctrlPanel.put("channelTab", new Rectangle(zone.x + 7, zone.y + 300, 28, 25)); + ctrlPanel.put("friendsTab", new Rectangle(zone.x + 41, zone.y + 300, 26, 30)); + ctrlPanel.put("accountTab", new Rectangle(zone.x + 74, zone.y + 300, 26, 30)); + ctrlPanel.put("logoutTab", new Rectangle(zone.x + 107, zone.y + 300, 26, 30)); + ctrlPanel.put("settingsTab", new Rectangle(zone.x + 140, zone.y + 300, 26, 30)); + ctrlPanel.put("emotesTab", new Rectangle(zone.x + 173, zone.y + 300, 26, 30)); + ctrlPanel.put("musicTab", new Rectangle(zone.x + 206, zone.y + 300, 27, 25)); + + // Main inventory area + ctrlPanel.put("inventoryPanel", new Rectangle(zone.x + 28, zone.y + 35, 183, 261)); + return ctrlPanel; + } else { + System.out.println("No ctrlPanel found"); + return null; + } + } + + /** + * Maps the layout of the chat tabs and main chat display area. + * + * @param zone The bounding box for the chat region. + * @return A map of tab names and their rectangles. + */ + public static Map mapChat(Rectangle zone) { + if (zone != null) { + + Map chatTabs = new HashMap<>(); + + String[] tabNames = {"All", "Game", "Public", "Private", "Channel", "Clan", "Group"}; + + int x = 5; + int y = 143; + for (int i = 0; i < 7; i++) { + chatTabs.put(tabNames[i], new Rectangle(zone.x + x, zone.y + y, 52, 19)); + x += 62; + } + chatTabs.put("Chat", new Rectangle(zone.x + 5, zone.y + 5, 506, 129)); + chatTabs.put("Latest Message", new Rectangle(zone.x + 5, zone.y + 104, 488, 15)); + return chatTabs; + } else { + System.out.println("No Chat found"); + return null; + } + } + + /** + * Maps out the three fields contained in the Grid Info box. These fields are meant to be used + * with OCR to extract player location data. + * + * @param zone The bounding box of the parent zone. (Where the box is). + * @return A list of {@link Rectangle} subzones (Tile, ChunkID, RegionID). + */ + public static Map mapGridInfo(Rectangle zone) { + if (zone != null) { + Map gridInfo = new HashMap<>(); + gridInfo.put("Tile", new Rectangle(zone.x + 39, zone.y, 89, 22)); + gridInfo.put("ChunkID", new Rectangle(zone.x + 74, zone.y + 20, 54, 19)); + gridInfo.put("RegionID", new Rectangle(zone.x + 84, zone.y + 36, 45, 19)); + return gridInfo; + } else { + System.out.println("No Grid found"); + return null; + } + } + + /** + * Generates bounding rectangles for each inventory slot in a 4x7 grid. + * + * @param zone The top-left bounding box of the inventory panel. + * @return A list of inventory slot rectangles. + */ + public static List mapInventory(Rectangle zone) { + if (zone != null) { + + List inventorySlots = new ArrayList<>(); + + int slotWidth = 36; + int slotHeight = 32; + int gapX = 6; + int gapY = 4; + + int y = zone.y + 44; + for (int i = 0; i < 7; i++) { + int x = zone.x + 40; + for (int j = 0; j < 4; j++) { + inventorySlots.add(new Rectangle(x, y, slotWidth, slotHeight)); + x += slotWidth + gapX; + } + y += slotHeight + gapY; + } + return inventorySlots; + } else { + System.out.println("No Inventory found"); + return null; + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java b/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java index 9367afc..8ea5161 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java +++ b/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java @@ -1,216 +1,216 @@ -package com.chromascape.utils.domain.zones; - -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.List; -import java.util.Map; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Manages the detection and mapping of key UI zones within the RuneLite client window, including - * the minimap, control panel, chat tabs, and inventory slots. - * - *

Supports both fixed and resizable window modes, adjusting the mapped regions accordingly. Uses - * template matching to locate UI elements within the game window for accurate zone detection. - */ -public class ZoneManager { - - /** Flag indicating whether the client window is in fixed (non-resizable) mode. */ - private final boolean isFixed; - - /** Map of minimap subcomponent names to their bounding rectangles. */ - private Map minimap; - - /** Map of control panel tab names to their bounding rectangles. */ - private Map ctrlPanel; - - /** Map of chat tab names to their bounding rectangles. */ - private Map chatTabs; - - /** List of rectangles representing individual inventory slot locations. */ - private List inventorySlots; - - /** Map of Rectangles defining the grid info box's location info. */ - private Map gridInfo; - - /** Rectangle defining the location of the mouse-over text. */ - private Rectangle mouseOver; - - // Cached bounds for getGameView optimization - private Rectangle minimapBounds; - private Rectangle ctrlPanelBounds; - private Rectangle chatBounds; - - /** Default template matching threshold to verify that an image is matched successfully. */ - private static final double THRESHOLD = 0.15; - - /** File paths to template images used for UI element detection. */ - private final String[] zoneTemplates = { - "/images/ui/minimap.png", - "/images/ui/inv.png", - "/images/ui/chat.png", - "/images/ui/minimap_fixed.png" - }; - - private static final Logger logger = LogManager.getLogger(ZoneManager.class.getName()); - - /** Constructs a new ZoneManager configured for either fixed or resizable mode. */ - public ZoneManager() { - this.isFixed = checkIfFixed(); - mapper(); - } - - /** - * Performs template matching to locate UI elements and maps their respective zones. - * - *

Any exceptions during mapping are caught and logged to standard error. - */ - public void mapper() { - // Cache the bounds first - chatBounds = locateUiElement(zoneTemplates[2]); - ctrlPanelBounds = locateUiElement(zoneTemplates[1]); - - chatTabs = SubZoneMapper.mapChat(chatBounds); - ctrlPanel = SubZoneMapper.mapCtrlPanel(ctrlPanelBounds); - inventorySlots = SubZoneMapper.mapInventory(ctrlPanelBounds); - - mouseOver = new Rectangle(0, 0, 407, 26); - - if (isFixed) { - minimapBounds = locateUiElement(zoneTemplates[3]); - minimap = SubZoneMapper.mapFixedMinimap(minimapBounds); - gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(9, 24, 129, 56)); - } else { - minimapBounds = locateUiElement(zoneTemplates[0]); - minimap = SubZoneMapper.mapMinimap(minimapBounds); - gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(5, 20, 129, 56)); - } - } - - /** - * Checks the two minimap images against the client window, compares them based on accuracy. - * - * @return {@code boolean} True if Fixed classic, false if Resizable classic. - */ - private boolean checkIfFixed() { - BufferedImage screen = ScreenManager.captureWindow(); - - MatchResult result = TemplateMatching.match(zoneTemplates[0], screen, THRESHOLD); - double resizableMinVal = result.score(); - - result = TemplateMatching.match(zoneTemplates[3], screen, THRESHOLD); - double fixedMinVal = result.score(); - - return fixedMinVal < resizableMinVal; - } - - /** - * Captures a screenshot of the current game viewport area. - * - *

Captures the full window and masks out UI zones such as minimap, control panel, and chat to - * isolate the game viewport. - * - *

You are intended to use template matching on this image directly for sprite matching You are - * also intended to use this as the image for colour detection. - * - * @return A {@link BufferedImage} representing the game viewport screenshot. - */ - public BufferedImage getGameView() { - BufferedImage gameViewMask = ScreenManager.captureWindow(); - if (ctrlPanelBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, ctrlPanelBounds); - } - if (chatBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, chatBounds); - } - if (minimapBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, minimapBounds); - } - return gameViewMask; - } - - /** - * Locates the bounding rectangle of a UI element by matching a template image within the current - * game window capture. - * - * @param templatePath The file path to the template image to match. - * @return A {@link Rectangle} representing the bounds of the matched UI element. - */ - public Rectangle locateUiElement(String templatePath) { - return TemplateMatching.match(templatePath, ScreenManager.captureWindow(), THRESHOLD).bounds(); - } - - /** - * Returns the map of minimap zones and their bounding rectangles. See {@link SubZoneMapper} for - * keys. - * - * @return A map where keys are minimap component names and values are their rectangles. - */ - public Map getMinimap() { - return minimap; - } - - /** - * Returns the map of control panel tabs and their bounding rectangles. See {@link SubZoneMapper} - * for keys. - * - * @return A map where keys are control panel tab names and values are their rectangles. - */ - public Map getCtrlPanel() { - return ctrlPanel; - } - - /** - * Returns the map of chat tabs and their bounding rectangles. See {@link SubZoneMapper} for keys. - * - * @return A map where keys are chat tab names and values are their rectangles. - */ - public Map getChatTabs() { - return chatTabs; - } - - /** - * Returns the list of rectangles corresponding to each inventory slot. You are intended to use - * {@link ScreenManager} to take screenshots and template match against them. These slots are - * mapped 0-27, left to right - top to bottom. - * - * @return A list of {@link Rectangle} objects representing inventory slot bounds. - */ - public List getInventorySlots() { - return inventorySlots; - } - - /** - * Returns the list of rectangles corresponding to fields in the Grid info area that contain - * location data. Useful for knowing the player's location in the game. Meant to be used by the - * Walker utility. - * - * @return {@link Rectangle} of the Grid info area. - */ - public Map getGridInfo() { - return gridInfo; - } - - /** - * Returns the mouse-over zone, where text will show if the user hovers over an interactable - * object. Intended to be used alongside the OCR engine. - * - * @return {@link Rectangle} of the mouse-over area. - */ - public Rectangle getMouseOver() { - return mouseOver; - } - - /** - * {@link Boolean} defining whether the client is in fixed or resizable mode. - * - * @return True if fixed, false if resizable. - */ - public boolean getIsFixed() { - return isFixed; - } -} +package com.chromascape.utils.domain.zones; + +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Manages the detection and mapping of key UI zones within the RuneLite client window, including + * the minimap, control panel, chat tabs, and inventory slots. + * + *

Supports both fixed and resizable window modes, adjusting the mapped regions accordingly. Uses + * template matching to locate UI elements within the game window for accurate zone detection. + */ +public class ZoneManager { + + /** Flag indicating whether the client window is in fixed (non-resizable) mode. */ + private final boolean isFixed; + + /** Map of minimap subcomponent names to their bounding rectangles. */ + private Map minimap; + + /** Map of control panel tab names to their bounding rectangles. */ + private Map ctrlPanel; + + /** Map of chat tab names to their bounding rectangles. */ + private Map chatTabs; + + /** List of rectangles representing individual inventory slot locations. */ + private List inventorySlots; + + /** Map of Rectangles defining the grid info box's location info. */ + private Map gridInfo; + + /** Rectangle defining the location of the mouse-over text. */ + private Rectangle mouseOver; + + // Cached bounds for getGameView optimization + private Rectangle minimapBounds; + private Rectangle ctrlPanelBounds; + private Rectangle chatBounds; + + /** Default template matching threshold to verify that an image is matched successfully. */ + private static final double THRESHOLD = 0.05; + + /** File paths to template images used for UI element detection. */ + private final String[] zoneTemplates = { + "/images/ui/minimap.png", + "/images/ui/inv.png", + "/images/ui/chat.png", + "/images/ui/minimap_fixed.png" + }; + + private static final Logger logger = LogManager.getLogger(ZoneManager.class.getName()); + + /** Constructs a new ZoneManager configured for either fixed or resizable mode. */ + public ZoneManager() { + this.isFixed = checkIfFixed(); + mapper(); + } + + /** + * Performs template matching to locate UI elements and maps their respective zones. + * + *

Any exceptions during mapping are caught and logged to standard error. + */ + public void mapper() { + // Cache the bounds first + chatBounds = locateUiElement(zoneTemplates[2]); + ctrlPanelBounds = locateUiElement(zoneTemplates[1]); + + chatTabs = SubZoneMapper.mapChat(chatBounds); + ctrlPanel = SubZoneMapper.mapCtrlPanel(ctrlPanelBounds); + inventorySlots = SubZoneMapper.mapInventory(ctrlPanelBounds); + + mouseOver = new Rectangle(0, 0, 407, 26); + + if (isFixed) { + minimapBounds = locateUiElement(zoneTemplates[3]); + minimap = SubZoneMapper.mapFixedMinimap(minimapBounds); + gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(9, 24, 129, 56)); + } else { + minimapBounds = locateUiElement(zoneTemplates[0]); + minimap = SubZoneMapper.mapMinimap(minimapBounds); + gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(5, 20, 129, 56)); + } + } + + /** + * Checks the two minimap images against the client window, compares them based on accuracy. + * + * @return {@code boolean} True if Fixed classic, false if Resizable classic. + */ + private boolean checkIfFixed() { + BufferedImage screen = ScreenManager.captureWindow(); + + MatchResult result = TemplateMatching.match(zoneTemplates[0], screen, THRESHOLD); + double resizableMinVal = result.score(); + + result = TemplateMatching.match(zoneTemplates[3], screen, THRESHOLD); + double fixedMinVal = result.score(); + + return fixedMinVal < resizableMinVal; + } + + /** + * Captures a screenshot of the current game viewport area. + * + *

Captures the full window and masks out UI zones such as minimap, control panel, and chat to + * isolate the game viewport. + * + *

You are intended to use template matching on this image directly for sprite matching You are + * also intended to use this as the image for colour detection. + * + * @return A {@link BufferedImage} representing the game viewport screenshot. + */ + public BufferedImage getGameView() { + BufferedImage gameViewMask = ScreenManager.captureWindow(); + if (ctrlPanelBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, ctrlPanelBounds); + } + if (chatBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, chatBounds); + } + if (minimapBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, minimapBounds); + } + return gameViewMask; + } + + /** + * Locates the bounding rectangle of a UI element by matching a template image within the current + * game window capture. + * + * @param templatePath The file path to the template image to match. + * @return A {@link Rectangle} representing the bounds of the matched UI element. + */ + public Rectangle locateUiElement(String templatePath) { + return TemplateMatching.match(templatePath, ScreenManager.captureWindow(), THRESHOLD).bounds(); + } + + /** + * Returns the map of minimap zones and their bounding rectangles. See {@link SubZoneMapper} for + * keys. + * + * @return A map where keys are minimap component names and values are their rectangles. + */ + public Map getMinimap() { + return minimap; + } + + /** + * Returns the map of control panel tabs and their bounding rectangles. See {@link SubZoneMapper} + * for keys. + * + * @return A map where keys are control panel tab names and values are their rectangles. + */ + public Map getCtrlPanel() { + return ctrlPanel; + } + + /** + * Returns the map of chat tabs and their bounding rectangles. See {@link SubZoneMapper} for keys. + * + * @return A map where keys are chat tab names and values are their rectangles. + */ + public Map getChatTabs() { + return chatTabs; + } + + /** + * Returns the list of rectangles corresponding to each inventory slot. You are intended to use + * {@link ScreenManager} to take screenshots and template match against them. These slots are + * mapped 0-27, left to right - top to bottom. + * + * @return A list of {@link Rectangle} objects representing inventory slot bounds. + */ + public List getInventorySlots() { + return inventorySlots; + } + + /** + * Returns the list of rectangles corresponding to fields in the Grid info area that contain + * location data. Useful for knowing the player's location in the game. Meant to be used by the + * Walker utility. + * + * @return {@link Rectangle} of the Grid info area. + */ + public Map getGridInfo() { + return gridInfo; + } + + /** + * Returns the mouse-over zone, where text will show if the user hovers over an interactable + * object. Intended to be used alongside the OCR engine. + * + * @return {@link Rectangle} of the mouse-over area. + */ + public Rectangle getMouseOver() { + return mouseOver; + } + + /** + * {@link Boolean} defining whether the client is in fixed or resizable mode. + * + * @return True if fixed, false if resizable. + */ + public boolean getIsFixed() { + return isFixed; + } +} diff --git a/src/main/java/com/chromascape/web/ChromaScapeApplication.java b/src/main/java/com/chromascape/web/ChromaScapeApplication.java index faeb1c0..4a0c642 100644 --- a/src/main/java/com/chromascape/web/ChromaScapeApplication.java +++ b/src/main/java/com/chromascape/web/ChromaScapeApplication.java @@ -1,73 +1,73 @@ -package com.chromascape.web; - -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.web.logs.LogWebSocketHandler; -import com.chromascape.web.logs.WebSocketLogAppender; -import com.chromascape.web.state.WebsocketBotStateListener; -import com.chromascape.web.viewport.WebsocketViewport; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * The main entry point for the ChromaScape Spring Boot application. - * - *

This class bootstraps the entire backend system, initializing all Spring components such as - * REST controllers, services, and configuration classes. - */ -@SpringBootApplication -@EnableScheduling -public class ChromaScapeApplication { - - /** - * Launches the ChromaScape application. - * - * @param args command-line arguments passed to the application - */ - public static void main(String[] args) { - // Disable headless mode to allow GUI components (e.g., MouseOverlay) - System.setProperty("java.awt.headless", "false"); - SpringApplication.run(ChromaScapeApplication.class, args); - } - - /** - * Injects the {@link LogWebSocketHandler} bean into the {@link WebSocketLogAppender}. - * - *

This allows the {@link WebSocketLogAppender} to send log messages over WebSocket to - * connected clients. - * - * @param handler the WebSocket handler responsible for sending log messages - */ - @Autowired - public void configureWebSocketHandler(LogWebSocketHandler handler) { - WebSocketLogAppender.setWebSocketHandler(handler); - } - - /** - * Injects the {@link WebsocketViewport} into the {@link ViewportManager}. - * - *

This hooks the static ViewportManager usage in core utils to the Spring WebSocket - * implementation. - * - * @param viewport the WebsocketViewport implementation - */ - @Autowired - public void configureViewport(WebsocketViewport viewport) { - ViewportManager.setInstance(viewport); - } - - /** - * Injects the {@link WebsocketBotStateListener} into the {@link StateManager}. - * - *

This hooks the static StateManager usage in core utils to the Spring WebSocket - * implementation. - * - * @param listener the WebsocketBotStateListener implementation - */ - @Autowired - public void configureStateManager(WebsocketBotStateListener listener) { - StateManager.setListener(listener); - } -} +package com.chromascape.web; + +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.web.logs.LogWebSocketHandler; +import com.chromascape.web.logs.WebSocketLogAppender; +import com.chromascape.web.state.WebsocketBotStateListener; +import com.chromascape.web.viewport.WebsocketViewport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * The main entry point for the ChromaScape Spring Boot application. + * + *

This class bootstraps the entire backend system, initializing all Spring components such as + * REST controllers, services, and configuration classes. + */ +@SpringBootApplication +@EnableScheduling +public class ChromaScapeApplication { + + /** + * Launches the ChromaScape application. + * + * @param args command-line arguments passed to the application + */ + public static void main(String[] args) { + // Disable headless mode to allow GUI components (e.g., MouseOverlay) + System.setProperty("java.awt.headless", "false"); + SpringApplication.run(ChromaScapeApplication.class, args); + } + + /** + * Injects the {@link LogWebSocketHandler} bean into the {@link WebSocketLogAppender}. + * + *

This allows the {@link WebSocketLogAppender} to send log messages over WebSocket to + * connected clients. + * + * @param handler the WebSocket handler responsible for sending log messages + */ + @Autowired + public void configureWebSocketHandler(LogWebSocketHandler handler) { + WebSocketLogAppender.setWebSocketHandler(handler); + } + + /** + * Injects the {@link WebsocketViewport} into the {@link ViewportManager}. + * + *

This hooks the static ViewportManager usage in core utils to the Spring WebSocket + * implementation. + * + * @param viewport the WebsocketViewport implementation + */ + @Autowired + public void configureViewport(WebsocketViewport viewport) { + ViewportManager.setInstance(viewport); + } + + /** + * Injects the {@link WebsocketBotStateListener} into the {@link StateManager}. + * + *

This hooks the static StateManager usage in core utils to the Spring WebSocket + * implementation. + * + * @param listener the WebsocketBotStateListener implementation + */ + @Autowired + public void configureStateManager(WebsocketBotStateListener listener) { + StateManager.setListener(listener); + } +} diff --git a/src/main/java/com/chromascape/web/ServePages.java b/src/main/java/com/chromascape/web/ServePages.java index 0765bc5..6d7aa5f 100644 --- a/src/main/java/com/chromascape/web/ServePages.java +++ b/src/main/java/com/chromascape/web/ServePages.java @@ -1,33 +1,33 @@ -package com.chromascape.web; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -/** - * Controller responsible for serving main web pages. - * - *

Handles requests for the index page and the colour picker page. - */ -@Controller -public class ServePages { - - /** - * Handles GET requests for the root ("/") URL. - * - * @return the logical view name "index" - */ - @GetMapping("/") - public String serveIndexPage() { - return "index"; - } - - /** - * Handles GET requests for the "/colour" URL. - * - * @return the logical view name "colour" - */ - @GetMapping("/colour") - public String serveColourPickerPage() { - return "colour"; - } -} +package com.chromascape.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Controller responsible for serving main web pages. + * + *

Handles requests for the index page and the colour picker page. + */ +@Controller +public class ServePages { + + /** + * Handles GET requests for the root ("/") URL. + * + * @return the logical view name "index" + */ + @GetMapping("/") + public String serveIndexPage() { + return "index"; + } + + /** + * Handles GET requests for the "/colour" URL. + * + * @return the logical view name "colour" + */ + @GetMapping("/colour") + public String serveColourPickerPage() { + return "colour"; + } +} diff --git a/src/main/java/com/chromascape/web/config/StartupConfiguration.java b/src/main/java/com/chromascape/web/config/StartupConfiguration.java index 4d7b740..e5c2869 100644 --- a/src/main/java/com/chromascape/web/config/StartupConfiguration.java +++ b/src/main/java/com/chromascape/web/config/StartupConfiguration.java @@ -1,54 +1,54 @@ -package com.chromascape.web.config; - -import com.chromascape.utils.core.runtime.profile.ProfileManager; -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Configuration; - -/** - * Configuration class responsible for initializing infrastructure components at application - * startup. - * - *

This class runs after all Spring beans are initialized and can be used to set up - * application-wide infrastructure, validate dependencies, and perform one-time initialization - * tasks. - */ -@Configuration -public class StartupConfiguration { - - private static Logger logger = LoggerFactory.getLogger(StartupConfiguration.class); - - /** - * Initializes application infrastructure after Spring context is fully loaded. - * - *

This method runs after all Spring beans are initialized and is the ideal place to perform - * startup tasks such as: - Validating system requirements - Initializing native libraries - - * Setting up application-wide resources - Performing health checks - Loading configuration data - */ - @PostConstruct - public void initializeInfrastructure() { - logger.info("CHROMASCAPE STARTUP CONFIGURATION RUNNING"); - logger.info("Initializing infrastructure..."); - - try { - // Examples: - // - Initialize native libraries (KInput) - // - Validate system requirements - // - Set up application-wide resources - // - Load configuration files - // - Perform health checks - - // Load bot profile config file into RuneLite - ProfileManager profileManager = new ProfileManager(); - profileManager.loadBotProfile(); - - logger.info("CHROMASCAPE STARTUP CONFIGURATION COMPLETED"); - - } catch (Exception e) { - logger.error("Error during infrastructure initialization: {}", e.getMessage()); - // You might want to throw a RuntimeException here to prevent app startup - // if critical infrastructure fails to initialize - } - } -} +package com.chromascape.web.config; + +import com.chromascape.utils.core.runtime.profile.ProfileManager; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class responsible for initializing infrastructure components at application + * startup. + * + *

This class runs after all Spring beans are initialized and can be used to set up + * application-wide infrastructure, validate dependencies, and perform one-time initialization + * tasks. + */ +@Configuration +public class StartupConfiguration { + + private static Logger logger = LoggerFactory.getLogger(StartupConfiguration.class); + + /** + * Initializes application infrastructure after Spring context is fully loaded. + * + *

This method runs after all Spring beans are initialized and is the ideal place to perform + * startup tasks such as: - Validating system requirements - Initializing native libraries - + * Setting up application-wide resources - Performing health checks - Loading configuration data + */ + @PostConstruct + public void initializeInfrastructure() { + logger.info("CHROMASCAPE STARTUP CONFIGURATION RUNNING"); + logger.info("Initializing infrastructure..."); + + try { + // Examples: + // - Initialize native libraries (KInput) + // - Validate system requirements + // - Set up application-wide resources + // - Load configuration files + // - Perform health checks + + // Load bot profile config file into RuneLite + ProfileManager profileManager = new ProfileManager(); + profileManager.loadBotProfile(); + + logger.info("CHROMASCAPE STARTUP CONFIGURATION COMPLETED"); + + } catch (Exception e) { + logger.error("Error during infrastructure initialization: {}", e.getMessage()); + // You might want to throw a RuntimeException here to prevent app startup + // if critical infrastructure fails to initialize + } + } +} diff --git a/src/main/java/com/chromascape/web/config/WebSocketConfig.java b/src/main/java/com/chromascape/web/config/WebSocketConfig.java index 7568e6b..ea909b9 100644 --- a/src/main/java/com/chromascape/web/config/WebSocketConfig.java +++ b/src/main/java/com/chromascape/web/config/WebSocketConfig.java @@ -1,89 +1,89 @@ -package com.chromascape.web.config; - -import com.chromascape.web.instance.WebSocketStateHandler; -import com.chromascape.web.logs.LogWebSocketHandler; -import com.chromascape.web.state.SemanticWebSocketHandler; -import com.chromascape.web.stats.StatisticsWebSocketHandler; -import com.chromascape.web.viewport.ViewportWebSocketHandler; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -/** - * Spring configuration class for enabling WebSocket support and registering WebSocket handlers. - * - *

This configuration enables WebSocket functionality within the Spring Boot application and - * registers the {@link LogWebSocketHandler} at the endpoint {@code /ws/logs} and {@link - * WebSocketStateHandler} at {@code /ws/state}. All origins are allowed for cross-origin WebSocket - * connections, suitable for local development or trusted environments. - * - * @see LogWebSocketHandler - * @see WebSocketStateHandler - * @see org.springframework.web.socket.config.annotation.WebSocketConfigurer - */ -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - - /** The shared handler for broadcasting log messages. */ - private final LogWebSocketHandler logWebSocketHandler; - - /** The shared handler for broadcasting running state updates. */ - private final WebSocketStateHandler stateWebSocketHandler; - - /** The shared handler for broadcasting viewport updates. */ - private final ViewportWebSocketHandler viewportWebSocketHandler; - - /** The shared handler for broadcasting semantic state. */ - private final SemanticWebSocketHandler semanticWebSocketHandler; - - /** The shared handler for broadcasting bot statistic state. */ - private final StatisticsWebSocketHandler statisticsWebSocketHandler; - - /** - * Constructs the configuration with the injected handlers. - * - * @param logWebSocketHandler handler for log messages - * @param stateWebSocketHandler handler for script running state - * @param viewportWebSocketHandler handler for viewport updates - * @param semanticWebSocketHandler handler for semantic state updates - */ - @Autowired - public WebSocketConfig( - LogWebSocketHandler logWebSocketHandler, - WebSocketStateHandler stateWebSocketHandler, - ViewportWebSocketHandler viewportWebSocketHandler, - SemanticWebSocketHandler semanticWebSocketHandler, - StatisticsWebSocketHandler statisticsWebSocketHandler) { - this.logWebSocketHandler = logWebSocketHandler; - this.stateWebSocketHandler = stateWebSocketHandler; - this.viewportWebSocketHandler = viewportWebSocketHandler; - this.semanticWebSocketHandler = semanticWebSocketHandler; - this.statisticsWebSocketHandler = statisticsWebSocketHandler; - } - - /** - * Registers WebSocket handlers for the application. - * - * @param registry the registry for handler mapping - */ - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - // Log messages - registry.addHandler(logWebSocketHandler, "/ws/logs").setAllowedOrigins("*"); - - // Script running state - registry.addHandler(stateWebSocketHandler, "/ws/state").setAllowedOrigins("*"); - - // Viewport image stream - registry.addHandler(viewportWebSocketHandler, "/ws/viewport").setAllowedOrigins("*"); - - // Semantic state stream - registry.addHandler(semanticWebSocketHandler, "/ws/semantic-state").setAllowedOrigins("*"); - - // Statistics stream - registry.addHandler(statisticsWebSocketHandler, "/ws/stats").setAllowedOrigins("*"); - } -} +package com.chromascape.web.config; + +import com.chromascape.web.instance.WebSocketStateHandler; +import com.chromascape.web.logs.LogWebSocketHandler; +import com.chromascape.web.state.SemanticWebSocketHandler; +import com.chromascape.web.stats.StatisticsWebSocketHandler; +import com.chromascape.web.viewport.ViewportWebSocketHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * Spring configuration class for enabling WebSocket support and registering WebSocket handlers. + * + *

This configuration enables WebSocket functionality within the Spring Boot application and + * registers the {@link LogWebSocketHandler} at the endpoint {@code /ws/logs} and {@link + * WebSocketStateHandler} at {@code /ws/state}. All origins are allowed for cross-origin WebSocket + * connections, suitable for local development or trusted environments. + * + * @see LogWebSocketHandler + * @see WebSocketStateHandler + * @see org.springframework.web.socket.config.annotation.WebSocketConfigurer + */ +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + /** The shared handler for broadcasting log messages. */ + private final LogWebSocketHandler logWebSocketHandler; + + /** The shared handler for broadcasting running state updates. */ + private final WebSocketStateHandler stateWebSocketHandler; + + /** The shared handler for broadcasting viewport updates. */ + private final ViewportWebSocketHandler viewportWebSocketHandler; + + /** The shared handler for broadcasting semantic state. */ + private final SemanticWebSocketHandler semanticWebSocketHandler; + + /** The shared handler for broadcasting bot statistic state. */ + private final StatisticsWebSocketHandler statisticsWebSocketHandler; + + /** + * Constructs the configuration with the injected handlers. + * + * @param logWebSocketHandler handler for log messages + * @param stateWebSocketHandler handler for script running state + * @param viewportWebSocketHandler handler for viewport updates + * @param semanticWebSocketHandler handler for semantic state updates + */ + @Autowired + public WebSocketConfig( + LogWebSocketHandler logWebSocketHandler, + WebSocketStateHandler stateWebSocketHandler, + ViewportWebSocketHandler viewportWebSocketHandler, + SemanticWebSocketHandler semanticWebSocketHandler, + StatisticsWebSocketHandler statisticsWebSocketHandler) { + this.logWebSocketHandler = logWebSocketHandler; + this.stateWebSocketHandler = stateWebSocketHandler; + this.viewportWebSocketHandler = viewportWebSocketHandler; + this.semanticWebSocketHandler = semanticWebSocketHandler; + this.statisticsWebSocketHandler = statisticsWebSocketHandler; + } + + /** + * Registers WebSocket handlers for the application. + * + * @param registry the registry for handler mapping + */ + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + // Log messages + registry.addHandler(logWebSocketHandler, "/ws/logs").setAllowedOrigins("*"); + + // Script running state + registry.addHandler(stateWebSocketHandler, "/ws/state").setAllowedOrigins("*"); + + // Viewport image stream + registry.addHandler(viewportWebSocketHandler, "/ws/viewport").setAllowedOrigins("*"); + + // Semantic state stream + registry.addHandler(semanticWebSocketHandler, "/ws/semantic-state").setAllowedOrigins("*"); + + // Statistics stream + registry.addHandler(statisticsWebSocketHandler, "/ws/stats").setAllowedOrigins("*"); + } +} diff --git a/src/main/java/com/chromascape/web/image/AddColour.java b/src/main/java/com/chromascape/web/image/AddColour.java index 6590dce..2441405 100644 --- a/src/main/java/com/chromascape/web/image/AddColour.java +++ b/src/main/java/com/chromascape/web/image/AddColour.java @@ -1,43 +1,43 @@ -package com.chromascape.web.image; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - -/** - * Utility class for adding new colour data to the application's colour configuration file. - * - *

This class provides a method to append a new {@link ColourData} entry to the - * colours/colours.json file. If the file does not exist, it will be created. The file is - * expected to contain a JSON array of colour data objects. - */ -public class AddColour { - - /** - * Adds a new {@link ColourData} entry to the colours/colours.json file. - * - *

If the file already exists, the new colour is appended to the existing list. If the file - * does not exist, a new file is created with the new colour as the first entry. - * - * @param newColour the {@link ColourData} object to add - * @throws IOException if there is an error reading or writing the file - */ - public void addColour(ColourData newColour) throws IOException { - Path file = Paths.get("colours/colours.json"); - ObjectMapper mapper = new ObjectMapper(); - List colours = new ArrayList<>(); - - if (Files.exists(file)) { - colours = mapper.readValue(file.toFile(), new TypeReference<>() {}); - } - - colours.add(newColour); - - mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), colours); - } -} +package com.chromascape.web.image; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for adding new colour data to the application's colour configuration file. + * + *

This class provides a method to append a new {@link ColourData} entry to the + * colours/colours.json file. If the file does not exist, it will be created. The file is + * expected to contain a JSON array of colour data objects. + */ +public class AddColour { + + /** + * Adds a new {@link ColourData} entry to the colours/colours.json file. + * + *

If the file already exists, the new colour is appended to the existing list. If the file + * does not exist, a new file is created with the new colour as the first entry. + * + * @param newColour the {@link ColourData} object to add + * @throws IOException if there is an error reading or writing the file + */ + public void addColour(ColourData newColour) throws IOException { + Path file = Paths.get("colours/colours.json"); + ObjectMapper mapper = new ObjectMapper(); + List colours = new ArrayList<>(); + + if (Files.exists(file)) { + colours = mapper.readValue(file.toFile(), new TypeReference<>() {}); + } + + colours.add(newColour); + + mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), colours); + } +} diff --git a/src/main/java/com/chromascape/web/image/ColourData.java b/src/main/java/com/chromascape/web/image/ColourData.java index bef9fb7..c71ac1d 100644 --- a/src/main/java/com/chromascape/web/image/ColourData.java +++ b/src/main/java/com/chromascape/web/image/ColourData.java @@ -1,82 +1,82 @@ -package com.chromascape.web.image; - -/** - * Represents a color range in OpenCV's HSV color space with a name identifier and minimum and - * maximum HSV bounds. - * - *

The minimum and maximum arrays define the inclusive range of HSV values that this color - * covers. Each array is expected to have exactly three elements representing Hue (0-180), - * Saturation (0-255), and Value (0-255) respectively. Note: there is a last value of 0 to conform - * to JavaCV's scalar. - */ -public class ColourData { - - /** The name identifying this HSV color range. */ - private String name; - - /** - * The minimum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue - * ranges 0-179, Saturation and Value range 0-255. - */ - private int[] min; - - /** - * The maximum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue - * ranges 0-179, Saturation and Value range 0-255. - */ - private int[] max; - - /** - * Returns the name of this HSV color range. - * - * @return the color name. - */ - public String getName() { - return name; - } - - /** - * Returns the minimum HSV values defining this color range. - * - * @return an int array of length 4: [Hue, Saturation, Value, 0]. - */ - public int[] getMin() { - return min; - } - - /** - * Returns the maximum HSV values defining this color range. - * - * @return an int array of length 4: [Hue, Saturation, Value, 0]. - */ - public int[] getMax() { - return max; - } - - /** - * Sets the name of this HSV color range. - * - * @param name the color name to set. - */ - public void setName(String name) { - this.name = name; - } - - /** - * Sets the minimum HSV bounds for this color range. - * - * @param min an int array of length 4 representing [Hue, Saturation, Value, 0]. - */ - public void setMin(int[] min) { - this.min = min; - } - - /** - * Sets the maximum HSV bounds for this color range. - * - * @param max an int array of length 4 representing [Hue, Saturation, Value, 0]. - */ - public void setMax(int[] max) { - this.max = max; - } -} +package com.chromascape.web.image; + +/** + * Represents a color range in OpenCV's HSV color space with a name identifier and minimum and + * maximum HSV bounds. + * + *

The minimum and maximum arrays define the inclusive range of HSV values that this color + * covers. Each array is expected to have exactly three elements representing Hue (0-180), + * Saturation (0-255), and Value (0-255) respectively. Note: there is a last value of 0 to conform + * to JavaCV's scalar. + */ +public class ColourData { + + /** The name identifying this HSV color range. */ + private String name; + + /** + * The minimum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue + * ranges 0-179, Saturation and Value range 0-255. + */ + private int[] min; + + /** + * The maximum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue + * ranges 0-179, Saturation and Value range 0-255. + */ + private int[] max; + + /** + * Returns the name of this HSV color range. + * + * @return the color name. + */ + public String getName() { + return name; + } + + /** + * Returns the minimum HSV values defining this color range. + * + * @return an int array of length 4: [Hue, Saturation, Value, 0]. + */ + public int[] getMin() { + return min; + } + + /** + * Returns the maximum HSV values defining this color range. + * + * @return an int array of length 4: [Hue, Saturation, Value, 0]. + */ + public int[] getMax() { + return max; + } + + /** + * Sets the name of this HSV color range. + * + * @param name the color name to set. + */ + public void setName(String name) { + this.name = name; + } + + /** + * Sets the minimum HSV bounds for this color range. + * + * @param min an int array of length 4 representing [Hue, Saturation, Value, 0]. + */ + public void setMin(int[] min) { + this.min = min; + } + + /** + * Sets the maximum HSV bounds for this color range. + * + * @param max an int array of length 4 representing [Hue, Saturation, Value, 0]. + */ + public void setMax(int[] max) { + this.max = max; + } +} diff --git a/src/main/java/com/chromascape/web/image/ImageController.java b/src/main/java/com/chromascape/web/image/ImageController.java index 54431a8..098afc2 100644 --- a/src/main/java/com/chromascape/web/image/ImageController.java +++ b/src/main/java/com/chromascape/web/image/ImageController.java @@ -1,71 +1,71 @@ -package com.chromascape.web.image; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import org.apache.commons.io.IOUtils; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller to serve image files from the server. - * - *

Provides endpoints to retrieve the original and modified images as PNG byte arrays. If the - * requested image is not found in the output directory, a default fallback image from resources is - * returned. - */ -@RestController -@RequestMapping("/api") -public class ImageController { - - private final ModifyImage modifyImage; - - /** - * Constructor for the ImageController class. - * - * @param modifyImage The dependency injected Spring service class that does image operations for - * the frontend. - */ - public ImageController(ModifyImage modifyImage) { - this.modifyImage = modifyImage; - } - - /** - * Returns the original image as a PNG byte array. - * - *

Attempts to read the file "output/original.png" from disk. If the file does not exist, falls - * back to "resources/images/defaultImage/original.png" on the classpath. - * - * @return byte array representing the original PNG image. - * @throws IOException if the file cannot be read. - */ - @GetMapping(value = "/originalImage", produces = MediaType.IMAGE_PNG_VALUE) - public @ResponseBody byte[] originalImage() throws IOException { - File outputFile = new File("output/original.png"); - try (InputStream in = - outputFile.exists() - ? new FileInputStream(outputFile) - : getClass().getResourceAsStream("/images/defaultImage/original.png")) { - assert in != null; - return IOUtils.toByteArray(in); - } - } - - /** - * Returns the modified image as a PNG byte array. - * - *

Retrieved from the in-memory cache of the {@link ModifyImage} service. If the current - * screenshot is newer than the cache, the original image is returned instead. - * - * @return byte array representing the modified or original PNG image. - * @throws IOException if the file(s) cannot be read. - */ - @GetMapping(value = "/modifiedImage", produces = MediaType.IMAGE_PNG_VALUE) - public @ResponseBody byte[] modifiedImage() throws IOException { - return modifyImage.getModifiedImageBytes(); - } -} +package com.chromascape.web.image; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import org.apache.commons.io.IOUtils; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller to serve image files from the server. + * + *

Provides endpoints to retrieve the original and modified images as PNG byte arrays. If the + * requested image is not found in the output directory, a default fallback image from resources is + * returned. + */ +@RestController +@RequestMapping("/api") +public class ImageController { + + private final ModifyImage modifyImage; + + /** + * Constructor for the ImageController class. + * + * @param modifyImage The dependency injected Spring service class that does image operations for + * the frontend. + */ + public ImageController(ModifyImage modifyImage) { + this.modifyImage = modifyImage; + } + + /** + * Returns the original image as a PNG byte array. + * + *

Attempts to read the file "output/original.png" from disk. If the file does not exist, falls + * back to "resources/images/defaultImage/original.png" on the classpath. + * + * @return byte array representing the original PNG image. + * @throws IOException if the file cannot be read. + */ + @GetMapping(value = "/originalImage", produces = MediaType.IMAGE_PNG_VALUE) + public @ResponseBody byte[] originalImage() throws IOException { + File outputFile = new File("output/original.png"); + try (InputStream in = + outputFile.exists() + ? new FileInputStream(outputFile) + : getClass().getResourceAsStream("/images/defaultImage/original.png")) { + assert in != null; + return IOUtils.toByteArray(in); + } + } + + /** + * Returns the modified image as a PNG byte array. + * + *

Retrieved from the in-memory cache of the {@link ModifyImage} service. If the current + * screenshot is newer than the cache, the original image is returned instead. + * + * @return byte array representing the modified or original PNG image. + * @throws IOException if the file(s) cannot be read. + */ + @GetMapping(value = "/modifiedImage", produces = MediaType.IMAGE_PNG_VALUE) + public @ResponseBody byte[] modifiedImage() throws IOException { + return modifyImage.getModifiedImageBytes(); + } +} diff --git a/src/main/java/com/chromascape/web/image/MaskImage.java b/src/main/java/com/chromascape/web/image/MaskImage.java index af614a8..46c6dd2 100644 --- a/src/main/java/com/chromascape/web/image/MaskImage.java +++ b/src/main/java/com/chromascape/web/image/MaskImage.java @@ -1,65 +1,65 @@ -package com.chromascape.web.image; - -import static org.bytedeco.opencv.global.opencv_core.CV_8U; -import static org.bytedeco.opencv.global.opencv_core.bitwise_and; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; - -import org.bytedeco.opencv.global.opencv_imgproc; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Utility class for applying a mask to an image using OpenCV. - * - *

The mask should be a single-channel 8-bit Mat where white pixels (255) indicate areas to keep - * from the original image and black pixels (0) indicate areas to mask out (set to black). - */ -public class MaskImage { - - /** - * Applies a mask to the original image. - * - *

This method takes an original color image and a mask image and returns a new image where - * pixels outside the white areas of the mask are set to black (masked out). The mask is expected - * to be a single-channel 8-bit Mat where white (255) pixels indicate the region to keep. - * - *

If the mask is not already a single-channel 8-bit Mat, it will be converted to grayscale and - * 8-bit internally. - * - * @param original the original image Mat (e.g. 3- or 4-channel color image). - * @param mask the mask image Mat; should be same size as original. - * @return a new Mat containing the original image masked by the mask. - * @throws IllegalArgumentException if original or mask is null, or if they have different sizes. - */ - public static Mat applyMaskToImage(Mat original, Mat mask) { - if (original == null || mask == null) { - throw new IllegalArgumentException("Original image and mask must not be null"); - } - if (original.rows() != mask.rows() || original.cols() != mask.cols()) { - throw new IllegalArgumentException("Original and mask must be the same size"); - } - - // Ensure mask is single channel 8-bit - Mat maskGray = new Mat(); - if (mask.channels() != 1 || mask.depth() != CV_8U) { - // Convert mask to grayscale and 8-bit if needed - cvtColor(mask, maskGray, opencv_imgproc.COLOR_BGR2GRAY); - maskGray.convertTo(maskGray, CV_8U); - } else { - maskGray = mask.clone(); - } - - // Create output Mat same size and type as original - Mat output = - new Mat( - original.size(), - original.type(), - new Scalar(0, 0, 0, 0)); // black transparent background - - // Copy original pixels where mask is white - // This can be done using bitwise_and with mask applied to each channel - bitwise_and(original, original, output, maskGray); - - return output; - } -} +package com.chromascape.web.image; + +import static org.bytedeco.opencv.global.opencv_core.CV_8U; +import static org.bytedeco.opencv.global.opencv_core.bitwise_and; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; + +import org.bytedeco.opencv.global.opencv_imgproc; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Utility class for applying a mask to an image using OpenCV. + * + *

The mask should be a single-channel 8-bit Mat where white pixels (255) indicate areas to keep + * from the original image and black pixels (0) indicate areas to mask out (set to black). + */ +public class MaskImage { + + /** + * Applies a mask to the original image. + * + *

This method takes an original color image and a mask image and returns a new image where + * pixels outside the white areas of the mask are set to black (masked out). The mask is expected + * to be a single-channel 8-bit Mat where white (255) pixels indicate the region to keep. + * + *

If the mask is not already a single-channel 8-bit Mat, it will be converted to grayscale and + * 8-bit internally. + * + * @param original the original image Mat (e.g. 3- or 4-channel color image). + * @param mask the mask image Mat; should be same size as original. + * @return a new Mat containing the original image masked by the mask. + * @throws IllegalArgumentException if original or mask is null, or if they have different sizes. + */ + public static Mat applyMaskToImage(Mat original, Mat mask) { + if (original == null || mask == null) { + throw new IllegalArgumentException("Original image and mask must not be null"); + } + if (original.rows() != mask.rows() || original.cols() != mask.cols()) { + throw new IllegalArgumentException("Original and mask must be the same size"); + } + + // Ensure mask is single channel 8-bit + Mat maskGray = new Mat(); + if (mask.channels() != 1 || mask.depth() != CV_8U) { + // Convert mask to grayscale and 8-bit if needed + cvtColor(mask, maskGray, opencv_imgproc.COLOR_BGR2GRAY); + maskGray.convertTo(maskGray, CV_8U); + } else { + maskGray = mask.clone(); + } + + // Create output Mat same size and type as original + Mat output = + new Mat( + original.size(), + original.type(), + new Scalar(0, 0, 0, 0)); // black transparent background + + // Copy original pixels where mask is white + // This can be done using bitwise_and with mask applied to each channel + bitwise_and(original, original, output, maskGray); + + return output; + } +} diff --git a/src/main/java/com/chromascape/web/image/ModifyImage.java b/src/main/java/com/chromascape/web/image/ModifyImage.java index 4350d46..0ce0b5f 100644 --- a/src/main/java/com/chromascape/web/image/ModifyImage.java +++ b/src/main/java/com/chromascape/web/image/ModifyImage.java @@ -1,141 +1,141 @@ -package com.chromascape.web.image; - -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.web.slider.CurrentSliderState; -import java.io.File; -import java.io.IOException; -import org.bytedeco.javacpp.BytePointer; -import org.bytedeco.opencv.global.opencv_imgcodecs; -import org.bytedeco.opencv.opencv_core.Mat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -/** - * Service responsible for applying modifications to an image based on slider inputs. - * - *

This service loads an original image from the filesystem, applies colour extraction based on - * the current slider state, and caches the result in memory. It eliminates the need for disk writes - * to improve performance. - */ -@Service -public class ModifyImage { - - private static final Logger logger = LoggerFactory.getLogger(ModifyImage.class); - private static final String ORIGINAL_IMAGE_PATH = "output/original.png"; - - /** Cached bytes of the latest processing result. */ - private byte[] cachedModifiedBytes; - - /** Timestamp of the last successful processing. */ - private long lastProcessedTime = 0; - - /** Timestamp of the original file when it was last loaded or checked. */ - private long lastOriginalModTime = 0; - - private Mat cachedOriginalMat; - - /** - * Applies modifications to the original image based on the given slider state. - * - *

The method performs the following steps: - * - *

    - *
  • Checks if the original image has been modified on disk since the last load. - *
  • Loads the image into memory cache (as OpenCV Mat) if necessary. - *
  • Extracts colour contours using the slider's colour object. - *
  • Applies the extracted mask to the original image. - *
  • Encodes the result to PNG bytes in memory using OpenCV {@code imencode} and updates the - * cache. - *
- * - * @param sliderState the current state of the sliders controlling colour extraction. - * @throws IOException if the original image file is not found or cannot be read. - */ - public void applySliderChanges(CurrentSliderState sliderState) throws IOException { - // Load original image from file system with caching - File originalFile = new File(ORIGINAL_IMAGE_PATH); - if (!originalFile.exists()) { - logger.error("Original image file not found at: {}", ORIGINAL_IMAGE_PATH); - throw new IOException("Original image file not found at: " + ORIGINAL_IMAGE_PATH); - } - - // Refresh cache if file timestamp changed or cache is empty - if (cachedOriginalMat == null || originalFile.lastModified() > lastOriginalModTime) { - logger.info( - "Reloading original image from disk. File time: {}, Last known: {}", - originalFile.lastModified(), - lastOriginalModTime); - - if (cachedOriginalMat != null) { - cachedOriginalMat.release(); - } - - // Use imread for direct Mat loading - cachedOriginalMat = opencv_imgcodecs.imread(originalFile.getAbsolutePath()); - if (cachedOriginalMat == null || cachedOriginalMat.empty()) { - throw new IOException("Failed to read original image from: " + ORIGINAL_IMAGE_PATH); - } - lastOriginalModTime = originalFile.lastModified(); - } - - // Apply colour extraction and encode - try (Mat modifiedMat = - ColourContours.extractColours(cachedOriginalMat, sliderState.getColourObj())) { - Mat result = MaskImage.applyMaskToImage(cachedOriginalMat, modifiedMat); - - // Fast encoding to PNG buffer using OpenCV (skips BufferedImage conversion) - try (BytePointer ext = new BytePointer(".png"); - BytePointer buffer = new BytePointer()) { - - opencv_imgcodecs.imencode(ext, result, buffer); - - // Transfer bytes from native buffer to Java array - long size = buffer.limit(); - byte[] pngBytes = new byte[(int) size]; - buffer.get(pngBytes); - - this.cachedModifiedBytes = pngBytes; - this.lastProcessedTime = System.currentTimeMillis(); - } - - // Clean up local Mats - result.release(); - } catch (Exception e) { - logger.error("Error processing image in applySliderChanges", e); - throw new IOException("Error processing image in applySliderChanges", e); - } - } - - /** - * Retrieves the modified image bytes. - * - *

If the original image on disk is newer than our last processed result (e.g., a new - * screenshot was taken), this returns the raw bytes of the original image instead. This ensures - * users don't see stale processing on a new screenshot. - * - * @return byte array containing the PNG image data (either modified or original). - * @throws IOException if reading the original file fails. - */ - public byte[] getModifiedImageBytes() throws IOException { - File originalFile = new File(ORIGINAL_IMAGE_PATH); - - // If we have no cached result, or the file on disk is newer than our last - // process... - boolean isStale = (originalFile.exists() && originalFile.lastModified() > lastProcessedTime); - - if (cachedModifiedBytes == null || isStale) { - logger.info( - "Serving original image (fallback). Cache empty? {}, Original > Processed? {} (Orig: {}, " - + "Proc: {})", - cachedModifiedBytes == null, - isStale, - originalFile.lastModified(), - lastProcessedTime); - // Serve the original image (fallback logic) - return org.apache.commons.io.FileUtils.readFileToByteArray(originalFile); - } - - return cachedModifiedBytes; - } -} +package com.chromascape.web.image; + +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.web.slider.CurrentSliderState; +import java.io.File; +import java.io.IOException; +import org.bytedeco.javacpp.BytePointer; +import org.bytedeco.opencv.global.opencv_imgcodecs; +import org.bytedeco.opencv.opencv_core.Mat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service responsible for applying modifications to an image based on slider inputs. + * + *

This service loads an original image from the filesystem, applies colour extraction based on + * the current slider state, and caches the result in memory. It eliminates the need for disk writes + * to improve performance. + */ +@Service +public class ModifyImage { + + private static final Logger logger = LoggerFactory.getLogger(ModifyImage.class); + private static final String ORIGINAL_IMAGE_PATH = "output/original.png"; + + /** Cached bytes of the latest processing result. */ + private byte[] cachedModifiedBytes; + + /** Timestamp of the last successful processing. */ + private long lastProcessedTime = 0; + + /** Timestamp of the original file when it was last loaded or checked. */ + private long lastOriginalModTime = 0; + + private Mat cachedOriginalMat; + + /** + * Applies modifications to the original image based on the given slider state. + * + *

The method performs the following steps: + * + *

    + *
  • Checks if the original image has been modified on disk since the last load. + *
  • Loads the image into memory cache (as OpenCV Mat) if necessary. + *
  • Extracts colour contours using the slider's colour object. + *
  • Applies the extracted mask to the original image. + *
  • Encodes the result to PNG bytes in memory using OpenCV {@code imencode} and updates the + * cache. + *
+ * + * @param sliderState the current state of the sliders controlling colour extraction. + * @throws IOException if the original image file is not found or cannot be read. + */ + public void applySliderChanges(CurrentSliderState sliderState) throws IOException { + // Load original image from file system with caching + File originalFile = new File(ORIGINAL_IMAGE_PATH); + if (!originalFile.exists()) { + logger.error("Original image file not found at: {}", ORIGINAL_IMAGE_PATH); + throw new IOException("Original image file not found at: " + ORIGINAL_IMAGE_PATH); + } + + // Refresh cache if file timestamp changed or cache is empty + if (cachedOriginalMat == null || originalFile.lastModified() > lastOriginalModTime) { + logger.info( + "Reloading original image from disk. File time: {}, Last known: {}", + originalFile.lastModified(), + lastOriginalModTime); + + if (cachedOriginalMat != null) { + cachedOriginalMat.release(); + } + + // Use imread for direct Mat loading + cachedOriginalMat = opencv_imgcodecs.imread(originalFile.getAbsolutePath()); + if (cachedOriginalMat == null || cachedOriginalMat.empty()) { + throw new IOException("Failed to read original image from: " + ORIGINAL_IMAGE_PATH); + } + lastOriginalModTime = originalFile.lastModified(); + } + + // Apply colour extraction and encode + try (Mat modifiedMat = + ColourContours.extractColours(cachedOriginalMat, sliderState.getColourObj())) { + Mat result = MaskImage.applyMaskToImage(cachedOriginalMat, modifiedMat); + + // Fast encoding to PNG buffer using OpenCV (skips BufferedImage conversion) + try (BytePointer ext = new BytePointer(".png"); + BytePointer buffer = new BytePointer()) { + + opencv_imgcodecs.imencode(ext, result, buffer); + + // Transfer bytes from native buffer to Java array + long size = buffer.limit(); + byte[] pngBytes = new byte[(int) size]; + buffer.get(pngBytes); + + this.cachedModifiedBytes = pngBytes; + this.lastProcessedTime = System.currentTimeMillis(); + } + + // Clean up local Mats + result.release(); + } catch (Exception e) { + logger.error("Error processing image in applySliderChanges", e); + throw new IOException("Error processing image in applySliderChanges", e); + } + } + + /** + * Retrieves the modified image bytes. + * + *

If the original image on disk is newer than our last processed result (e.g., a new + * screenshot was taken), this returns the raw bytes of the original image instead. This ensures + * users don't see stale processing on a new screenshot. + * + * @return byte array containing the PNG image data (either modified or original). + * @throws IOException if reading the original file fails. + */ + public byte[] getModifiedImageBytes() throws IOException { + File originalFile = new File(ORIGINAL_IMAGE_PATH); + + // If we have no cached result, or the file on disk is newer than our last + // process... + boolean isStale = (originalFile.exists() && originalFile.lastModified() > lastProcessedTime); + + if (cachedModifiedBytes == null || isStale) { + logger.info( + "Serving original image (fallback). Cache empty? {}, Original > Processed? {} (Orig: {}, " + + "Proc: {})", + cachedModifiedBytes == null, + isStale, + originalFile.lastModified(), + lastProcessedTime); + // Serve the original image (fallback logic) + return org.apache.commons.io.FileUtils.readFileToByteArray(originalFile); + } + + return cachedModifiedBytes; + } +} diff --git a/src/main/java/com/chromascape/web/image/SubmitColour.java b/src/main/java/com/chromascape/web/image/SubmitColour.java index 126a2e1..d3d1394 100644 --- a/src/main/java/com/chromascape/web/image/SubmitColour.java +++ b/src/main/java/com/chromascape/web/image/SubmitColour.java @@ -1,72 +1,72 @@ -package com.chromascape.web.image; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.web.slider.CurrentSliderState; -import java.io.IOException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for handling colour submission requests. - * - *

This controller receives colour names via POST requests and saves the corresponding HSV colour - * ranges from the current slider state into persistent storage via AddColour. - */ -@RestController -@RequestMapping("/api") -public class SubmitColour { - - private final CurrentSliderState currentSliderState; - - /** - * Constructs a SubmitColour controller with the provided current slider state. - * - * @param currentSliderState the object holding the current HSV colour range slider values - */ - public SubmitColour(CurrentSliderState currentSliderState) { - this.currentSliderState = currentSliderState; - } - - /** - * Handles POST requests to submit a new colour. - * - *

Extracts the current HSV minimum and maximum values from the slider state, creates a - * ColourData object with the submitted name and HSV ranges, and saves it using the AddColour - * service. - * - * @param name the name of the colour to be added, sent as the raw request body - * @return a ResponseEntity with HTTP 200 OK status if successful - * @throws IOException if adding the colour fails due to IO errors - */ - @PostMapping("/submitColour") - public ResponseEntity submitColour(@RequestBody String name) throws IOException { - ColourObj colourObj = currentSliderState.getColourObj(); - - ColourData colour = new ColourData(); - colour.setName(name); - - colour.setMin( - new int[] { - (int) colourObj.hsvMin().get(0), - (int) colourObj.hsvMin().get(1), - (int) colourObj.hsvMin().get(2), - (int) colourObj.hsvMin().get(3) - }); - - colour.setMax( - new int[] { - (int) colourObj.hsvMax().get(0), - (int) colourObj.hsvMax().get(1), - (int) colourObj.hsvMax().get(2), - (int) colourObj.hsvMax().get(3) - }); - - AddColour addColour = new AddColour(); - addColour.addColour(colour); - - return ResponseEntity.ok().build(); - } -} +package com.chromascape.web.image; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.web.slider.CurrentSliderState; +import java.io.IOException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for handling colour submission requests. + * + *

This controller receives colour names via POST requests and saves the corresponding HSV colour + * ranges from the current slider state into persistent storage via AddColour. + */ +@RestController +@RequestMapping("/api") +public class SubmitColour { + + private final CurrentSliderState currentSliderState; + + /** + * Constructs a SubmitColour controller with the provided current slider state. + * + * @param currentSliderState the object holding the current HSV colour range slider values + */ + public SubmitColour(CurrentSliderState currentSliderState) { + this.currentSliderState = currentSliderState; + } + + /** + * Handles POST requests to submit a new colour. + * + *

Extracts the current HSV minimum and maximum values from the slider state, creates a + * ColourData object with the submitted name and HSV ranges, and saves it using the AddColour + * service. + * + * @param name the name of the colour to be added, sent as the raw request body + * @return a ResponseEntity with HTTP 200 OK status if successful + * @throws IOException if adding the colour fails due to IO errors + */ + @PostMapping("/submitColour") + public ResponseEntity submitColour(@RequestBody String name) throws IOException { + ColourObj colourObj = currentSliderState.getColourObj(); + + ColourData colour = new ColourData(); + colour.setName(name); + + colour.setMin( + new int[] { + (int) colourObj.hsvMin().get(0), + (int) colourObj.hsvMin().get(1), + (int) colourObj.hsvMin().get(2), + (int) colourObj.hsvMin().get(3) + }); + + colour.setMax( + new int[] { + (int) colourObj.hsvMax().get(0), + (int) colourObj.hsvMax().get(1), + (int) colourObj.hsvMax().get(2), + (int) colourObj.hsvMax().get(3) + }); + + AddColour addColour = new AddColour(); + addColour.addColour(colour); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chromascape/web/instance/RunConfig.java b/src/main/java/com/chromascape/web/instance/RunConfig.java index f49342d..31624ef 100644 --- a/src/main/java/com/chromascape/web/instance/RunConfig.java +++ b/src/main/java/com/chromascape/web/instance/RunConfig.java @@ -1,27 +1,27 @@ -package com.chromascape.web.instance; - -/** - * Represents the configuration settings for running a script instance. - * - *

Contains the duration the script should run, the script identifier, and a flag indicating - * whether the client UI is fixed or resizable. - */ -public record RunConfig(String script) { - - /** - * Constructs a new RunConfig with the specified duration, script, and fixed flag. - * - * @param script the identifier or name of the script to run - */ - public RunConfig {} - - /** - * Returns the identifier or name of the script to run. - * - * @return the script name or ID - */ - @Override - public String script() { - return script; - } -} +package com.chromascape.web.instance; + +/** + * Represents the configuration settings for running a script instance. + * + *

Contains the duration the script should run, the script identifier, and a flag indicating + * whether the client UI is fixed or resizable. + */ +public record RunConfig(String script) { + + /** + * Constructs a new RunConfig with the specified duration, script, and fixed flag. + * + * @param script the identifier or name of the script to run + */ + public RunConfig {} + + /** + * Returns the identifier or name of the script to run. + * + * @return the script name or ID + */ + @Override + public String script() { + return script; + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptControl.java b/src/main/java/com/chromascape/web/instance/ScriptControl.java index 9322019..da559c8 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptControl.java +++ b/src/main/java/com/chromascape/web/instance/ScriptControl.java @@ -1,95 +1,95 @@ -package com.chromascape.web.instance; - -import java.lang.reflect.InvocationTargetException; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller that handles starting and stopping of scripts, and querying their running state. - * - *

Provides endpoints to submit a run configuration, start a script instance, stop the currently - * running script, and check if a script is running. All responses include appropriate HTTP status - * codes and messages. - */ -@RestController -@RequestMapping("/api") -public class ScriptControl { - - private static final Logger logger = LogManager.getLogger(ScriptControl.class.getName()); - private final WebSocketStateHandler stateHandler; - - /** - * Constructs the script controller with a state handler. - * - * @param webSocketStateHandler state handler used to send state to the client via web-socket. - */ - public ScriptControl(WebSocketStateHandler webSocketStateHandler) { - this.stateHandler = webSocketStateHandler; - } - - /** - * Starts a script based on the provided run configuration. - * - *

Validates the input configuration fields: script name, duration, and window style. If valid, - * it attempts to instantiate and start the script. Logs relevant information and returns HTTP - * status codes accordingly. - * - * @param config the RunConfig object containing script parameters (JSON in request body) - * @return ResponseEntity with status and message indicating success or error details - */ - @PostMapping(path = "/runConfig", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getRunConfig(@RequestBody RunConfig config) { - try { - // Validation checks - if (config.script() == null || config.script().isEmpty()) { - logger.error("No script is selected"); - return ResponseEntity.badRequest().body("Script must be specified."); - } - - logger.info("Config valid: attempting to run script"); - - // Instantiate and start the script instance - ScriptInstance instance = new ScriptInstance(config, stateHandler); - ScriptInstanceManager.getInstance().setInstance(instance); - instance.start(); - - return ResponseEntity.ok("Script started successfully."); - - } catch (ClassNotFoundException e) { - logger.error("Script class not found: {}", e.getMessage()); - return ResponseEntity.badRequest().body("Script class not found."); - } catch (NoSuchMethodException e) { - logger.error("Script constructor not found: {}", e.getMessage()); - return ResponseEntity.badRequest().body("Script constructor not valid."); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - logger.error("Failed to instantiate script: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to start script."); - } catch (Exception e) { - logger.error("Unexpected error: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Unexpected error: " + e.getMessage()); - } - } - - /** - * Stops the currently running script instance. - * - *

Logs the stop request and interrupts the running script thread. - * - * @return ResponseEntity with HTTP 200 OK status after attempting to stop the script - */ - @PostMapping(path = "/stop", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity stopScript() { - logger.info("Received stop request"); - ScriptInstanceManager.getInstance().getInstanceRef().stop(); - return ResponseEntity.ok().build(); - } -} +package com.chromascape.web.instance; + +import java.lang.reflect.InvocationTargetException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller that handles starting and stopping of scripts, and querying their running state. + * + *

Provides endpoints to submit a run configuration, start a script instance, stop the currently + * running script, and check if a script is running. All responses include appropriate HTTP status + * codes and messages. + */ +@RestController +@RequestMapping("/api") +public class ScriptControl { + + private static final Logger logger = LogManager.getLogger(ScriptControl.class.getName()); + private final WebSocketStateHandler stateHandler; + + /** + * Constructs the script controller with a state handler. + * + * @param webSocketStateHandler state handler used to send state to the client via web-socket. + */ + public ScriptControl(WebSocketStateHandler webSocketStateHandler) { + this.stateHandler = webSocketStateHandler; + } + + /** + * Starts a script based on the provided run configuration. + * + *

Validates the input configuration fields: script name, duration, and window style. If valid, + * it attempts to instantiate and start the script. Logs relevant information and returns HTTP + * status codes accordingly. + * + * @param config the RunConfig object containing script parameters (JSON in request body) + * @return ResponseEntity with status and message indicating success or error details + */ + @PostMapping(path = "/runConfig", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getRunConfig(@RequestBody RunConfig config) { + try { + // Validation checks + if (config.script() == null || config.script().isEmpty()) { + logger.error("No script is selected"); + return ResponseEntity.badRequest().body("Script must be specified."); + } + + logger.info("Config valid: attempting to run script"); + + // Instantiate and start the script instance + ScriptInstance instance = new ScriptInstance(config, stateHandler); + ScriptInstanceManager.getInstance().setInstance(instance); + instance.start(); + + return ResponseEntity.ok("Script started successfully."); + + } catch (ClassNotFoundException e) { + logger.error("Script class not found: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Script class not found."); + } catch (NoSuchMethodException e) { + logger.error("Script constructor not found: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Script constructor not valid."); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + logger.error("Failed to instantiate script: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to start script."); + } catch (Exception e) { + logger.error("Unexpected error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Unexpected error: " + e.getMessage()); + } + } + + /** + * Stops the currently running script instance. + * + *

Logs the stop request and interrupts the running script thread. + * + * @return ResponseEntity with HTTP 200 OK status after attempting to stop the script + */ + @PostMapping(path = "/stop", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity stopScript() { + logger.info("Received stop request"); + ScriptInstanceManager.getInstance().getInstanceRef().stop(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptInstance.java b/src/main/java/com/chromascape/web/instance/ScriptInstance.java index 4a68346..6387dd5 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptInstance.java +++ b/src/main/java/com/chromascape/web/instance/ScriptInstance.java @@ -1,95 +1,95 @@ -package com.chromascape.web.instance; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; - -/** - * Manages the lifecycle of a script instance. - * - *

This class dynamically loads and instantiates a script class based on the provided - * configuration, runs the script in its own thread, and provides control methods to start and stop - * the script execution. - */ -public class ScriptInstance { - - private final BaseScript instance; - private volatile Thread thread; - private final WebSocketStateHandler stateHandler; - - /** - * Constructs a ScriptInstance by dynamically loading the script class specified in the config. - * - * @param config the RunConfig containing script name - * @throws NoSuchMethodException if the expected constructor is not found - * @throws ClassNotFoundException if the script class cannot be found - * @throws InvocationTargetException if the constructor throws an exception - * @throws InstantiationException if the class is abstract or an interface - * @throws IllegalAccessException if the constructor is not accessible - */ - public ScriptInstance(RunConfig config, WebSocketStateHandler stateHandler) - throws NoSuchMethodException, - ClassNotFoundException, - InvocationTargetException, - InstantiationException, - IllegalAccessException { - this.stateHandler = stateHandler; - - String fileName = config.script(); - String className = fileName.replace(".java", "").replace("/", "."); - - Class script = Class.forName("com.chromascape.scripts." + className); - Constructor constructor = script.getDeclaredConstructor(); - instance = (BaseScript) constructor.newInstance(); - } - - /** - * Starts the script execution in a new thread. - * - *

Resets the statistics via {@link StatisticsManager#reset()} before running, so that each run - * starts with fresh metrics. - * - *

Also broadcasts a {@code true} state to clients. - */ - public void start() { - thread = - new Thread( - () -> { - stateHandler.broadcast(true); - StatisticsManager.reset(); - try { - instance.run(); - } finally { - StatisticsManager.stop(); - // Clear the interrupted flag so the blocking WebSocket send in broadcast() - // can acquire its semaphore. The thread was interrupted by stop() to end - // the script; the flag is no longer meaningful at this point. - Thread.interrupted(); - stateHandler.broadcast(false); - } - }); - thread.start(); - } - - /** - * Stops the script execution by requesting the script to stop, interrupting the running thread, - * and waiting for it to terminate. - * - *

explicitly calls {@link StatisticsManager#stop()} to freeze metrics immediately. Also - * broadcasts a {@code false} state to clients. - */ - public void stop() { - instance.stop(); - StatisticsManager.stop(); - if (thread != null) { - thread.interrupt(); - try { - thread.join(); - } catch (InterruptedException ignored) { - // Thread join interrupted, ignore to proceed with shutdown - } - } - stateHandler.broadcast(false); - } -} +package com.chromascape.web.instance; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Manages the lifecycle of a script instance. + * + *

This class dynamically loads and instantiates a script class based on the provided + * configuration, runs the script in its own thread, and provides control methods to start and stop + * the script execution. + */ +public class ScriptInstance { + + private final BaseScript instance; + private volatile Thread thread; + private final WebSocketStateHandler stateHandler; + + /** + * Constructs a ScriptInstance by dynamically loading the script class specified in the config. + * + * @param config the RunConfig containing script name + * @throws NoSuchMethodException if the expected constructor is not found + * @throws ClassNotFoundException if the script class cannot be found + * @throws InvocationTargetException if the constructor throws an exception + * @throws InstantiationException if the class is abstract or an interface + * @throws IllegalAccessException if the constructor is not accessible + */ + public ScriptInstance(RunConfig config, WebSocketStateHandler stateHandler) + throws NoSuchMethodException, + ClassNotFoundException, + InvocationTargetException, + InstantiationException, + IllegalAccessException { + this.stateHandler = stateHandler; + + String fileName = config.script(); + String className = fileName.replace(".java", "").replace("/", "."); + + Class script = Class.forName("com.chromascape.scripts." + className); + Constructor constructor = script.getDeclaredConstructor(); + instance = (BaseScript) constructor.newInstance(); + } + + /** + * Starts the script execution in a new thread. + * + *

Resets the statistics via {@link StatisticsManager#reset()} before running, so that each run + * starts with fresh metrics. + * + *

Also broadcasts a {@code true} state to clients. + */ + public void start() { + thread = + new Thread( + () -> { + stateHandler.broadcast(true); + StatisticsManager.reset(); + try { + instance.run(); + } finally { + StatisticsManager.stop(); + // Clear the interrupted flag so the blocking WebSocket send in broadcast() + // can acquire its semaphore. The thread was interrupted by stop() to end + // the script; the flag is no longer meaningful at this point. + Thread.interrupted(); + stateHandler.broadcast(false); + } + }); + thread.start(); + } + + /** + * Stops the script execution by requesting the script to stop, interrupting the running thread, + * and waiting for it to terminate. + * + *

explicitly calls {@link StatisticsManager#stop()} to freeze metrics immediately. Also + * broadcasts a {@code false} state to clients. + */ + public void stop() { + instance.stop(); + StatisticsManager.stop(); + if (thread != null) { + thread.interrupt(); + try { + thread.join(); + } catch (InterruptedException ignored) { + // Thread join interrupted, ignore to proceed with shutdown + } + } + stateHandler.broadcast(false); + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java b/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java index 2a5a12c..cdd8b56 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java +++ b/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java @@ -1,47 +1,47 @@ -package com.chromascape.web.instance; - -/** - * Singleton manager class that maintains the current active ScriptInstance. - * - *

This class provides thread-safe access to a single ScriptInstance, allowing setting and - * retrieving the currently running script instance. - */ -public class ScriptInstanceManager { - private static ScriptInstanceManager instance; - private ScriptInstance currentInstance; - - /** Private constructor to enforce singleton pattern. */ - private ScriptInstanceManager() {} - - /** - * Returns the singleton instance of ScriptInstanceManager. - * - *

This method is synchronized to ensure thread-safe lazy initialization. - * - * @return the singleton ScriptInstanceManager instance - */ - public static synchronized ScriptInstanceManager getInstance() { - if (instance == null) { - instance = new ScriptInstanceManager(); - } - return instance; - } - - /** - * Sets the current active ScriptInstance. - * - * @param scriptInstance the ScriptInstance to set as current - */ - public void setInstance(ScriptInstance scriptInstance) { - this.currentInstance = scriptInstance; - } - - /** - * Returns a reference to the current active ScriptInstance. - * - * @return the current ScriptInstance, or null if none is set - */ - public ScriptInstance getInstanceRef() { - return currentInstance; - } -} +package com.chromascape.web.instance; + +/** + * Singleton manager class that maintains the current active ScriptInstance. + * + *

This class provides thread-safe access to a single ScriptInstance, allowing setting and + * retrieving the currently running script instance. + */ +public class ScriptInstanceManager { + private static ScriptInstanceManager instance; + private ScriptInstance currentInstance; + + /** Private constructor to enforce singleton pattern. */ + private ScriptInstanceManager() {} + + /** + * Returns the singleton instance of ScriptInstanceManager. + * + *

This method is synchronized to ensure thread-safe lazy initialization. + * + * @return the singleton ScriptInstanceManager instance + */ + public static synchronized ScriptInstanceManager getInstance() { + if (instance == null) { + instance = new ScriptInstanceManager(); + } + return instance; + } + + /** + * Sets the current active ScriptInstance. + * + * @param scriptInstance the ScriptInstance to set as current + */ + public void setInstance(ScriptInstance scriptInstance) { + this.currentInstance = scriptInstance; + } + + /** + * Returns a reference to the current active ScriptInstance. + * + * @return the current ScriptInstance, or null if none is set + */ + public ScriptInstance getInstanceRef() { + return currentInstance; + } +} diff --git a/src/main/java/com/chromascape/web/instance/SendScripts.java b/src/main/java/com/chromascape/web/instance/SendScripts.java index 1c3370b..87487eb 100644 --- a/src/main/java/com/chromascape/web/instance/SendScripts.java +++ b/src/main/java/com/chromascape/web/instance/SendScripts.java @@ -1,46 +1,46 @@ -package com.chromascape.web.instance; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for providing available script names. - * - *

This controller scans the {@code scripts} directory for available script files and returns - * their names to the client. - */ -@RestController -@RequestMapping("/api") -public class SendScripts { - - /** The directory where script classes are located. */ - private static final Path SCRIPTS_DIR = Paths.get("src/main/java/com/chromascape/scripts"); - - /** - * Returns a list of script file names located in the {@code scripts} directory. - * - *

This endpoint scans the directory recursively so nested folders are included in the results. - * - * @return a list of script file names relative to {@code SCRIPTS_DIR} - * @throws IOException if an I/O error occurs while reading the directory - */ - @GetMapping("/scripts") - public List getScripts() throws IOException { - try (Stream stream = Files.walk(SCRIPTS_DIR)) { - return stream - .filter(Files::isRegularFile) - .map(SCRIPTS_DIR::relativize) - .map(path -> path.toString().replace("\\", "/")) - .sorted() - .collect(Collectors.toList()); - } - } -} +package com.chromascape.web.instance; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for providing available script names. + * + *

This controller scans the {@code scripts} directory for available script files and returns + * their names to the client. + */ +@RestController +@RequestMapping("/api") +public class SendScripts { + + /** The directory where script classes are located. */ + private static final Path SCRIPTS_DIR = Paths.get("src/main/java/com/chromascape/scripts"); + + /** + * Returns a list of script file names located in the {@code scripts} directory. + * + *

This endpoint scans the directory recursively so nested folders are included in the results. + * + * @return a list of script file names relative to {@code SCRIPTS_DIR} + * @throws IOException if an I/O error occurs while reading the directory + */ + @GetMapping("/scripts") + public List getScripts() throws IOException { + try (Stream stream = Files.walk(SCRIPTS_DIR)) { + return stream + .filter(Files::isRegularFile) + .map(SCRIPTS_DIR::relativize) + .map(path -> path.toString().replace("\\", "/")) + .sorted() + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java b/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java index 33e4567..932e011 100644 --- a/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java +++ b/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java @@ -1,98 +1,98 @@ -package com.chromascape.web.instance; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler responsible for broadcasting the current running state of a script to all - * connected WebSocket clients. - * - *

Clients subscribing to this endpoint will receive messages containing {@code "true"} if a - * script is running, or {@code "false"} if no script is active. The handler maintains a thread-safe - * set of active sessions and automatically handles client connect/disconnect events. - * - *

This component is typically registered in {@link - * org.springframework.web.socket.config.annotation.WebSocketConfigurer} to expose a `/ws/state` - * endpoint. - */ -@Component -public class WebSocketStateHandler extends TextWebSocketHandler { - - /** Logger for internal events and errors. */ - private final Logger logger = LogManager.getLogger(this.getClass().getName()); - - /** Thread-safe set of all currently connected WebSocket sessions. */ - private final Set sessions = ConcurrentHashMap.newKeySet(); - - /** - * Tracks the last broadcasted state (running or not) to immediately synchronize new connections. - * - *

Initialized to {@code false}. Updated every time {@link #broadcast(boolean)} is called. - */ - private volatile boolean lastState = false; - - /** - * Invoked after a new WebSocket connection is established. - * - *

Adds the session to the active set and immediately sends the {@code lastState} so the client - * UI can sync its start/stop button without waiting for a new event. - * - * @param session the session that was established - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - // Send current state immediately to the new session - if (session != null && session.isOpen()) { - try { - session.sendMessage(new TextMessage(Boolean.toString(lastState))); - } catch (IOException e) { - logger.error("Failed to send initial state to client", e); - } - } - } - - /** - * Invoked after a WebSocket connection is closed. - * - * @param session the session that was closed - * @param status the status describing why the connection was closed - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - } - - /** - * Broadcasts the current script running state to all connected clients. - * - *

The message sent is a simple {@code "true"} or {@code "false"} string, representing whether - * a script is currently active. - * - *

Also updates {@link #lastState} to persist this state for future connections. - * - * @param isRunning {@code true} if a script is running, {@code false} otherwise - */ - public void broadcast(boolean isRunning) { - this.lastState = isRunning; - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage(Boolean.toString(isRunning))); - } catch (IOException e) { - logger.error("Failed to send running state to client", e); - } - } - } - } -} +package com.chromascape.web.instance; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler responsible for broadcasting the current running state of a script to all + * connected WebSocket clients. + * + *

Clients subscribing to this endpoint will receive messages containing {@code "true"} if a + * script is running, or {@code "false"} if no script is active. The handler maintains a thread-safe + * set of active sessions and automatically handles client connect/disconnect events. + * + *

This component is typically registered in {@link + * org.springframework.web.socket.config.annotation.WebSocketConfigurer} to expose a `/ws/state` + * endpoint. + */ +@Component +public class WebSocketStateHandler extends TextWebSocketHandler { + + /** Logger for internal events and errors. */ + private final Logger logger = LogManager.getLogger(this.getClass().getName()); + + /** Thread-safe set of all currently connected WebSocket sessions. */ + private final Set sessions = ConcurrentHashMap.newKeySet(); + + /** + * Tracks the last broadcasted state (running or not) to immediately synchronize new connections. + * + *

Initialized to {@code false}. Updated every time {@link #broadcast(boolean)} is called. + */ + private volatile boolean lastState = false; + + /** + * Invoked after a new WebSocket connection is established. + * + *

Adds the session to the active set and immediately sends the {@code lastState} so the client + * UI can sync its start/stop button without waiting for a new event. + * + * @param session the session that was established + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + // Send current state immediately to the new session + if (session != null && session.isOpen()) { + try { + session.sendMessage(new TextMessage(Boolean.toString(lastState))); + } catch (IOException e) { + logger.error("Failed to send initial state to client", e); + } + } + } + + /** + * Invoked after a WebSocket connection is closed. + * + * @param session the session that was closed + * @param status the status describing why the connection was closed + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + } + + /** + * Broadcasts the current script running state to all connected clients. + * + *

The message sent is a simple {@code "true"} or {@code "false"} string, representing whether + * a script is currently active. + * + *

Also updates {@link #lastState} to persist this state for future connections. + * + * @param isRunning {@code true} if a script is running, {@code false} otherwise + */ + public void broadcast(boolean isRunning) { + this.lastState = isRunning; + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + session.sendMessage(new TextMessage(Boolean.toString(isRunning))); + } catch (IOException e) { + logger.error("Failed to send running state to client", e); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java b/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java index a5183a6..b6ee2ab 100644 --- a/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java @@ -1,121 +1,121 @@ -package com.chromascape.web.logs; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler for broadcasting log messages to connected clients. - * - *

This handler manages active WebSocket sessions and provides a broadcast method for delivering - * log messages to all connected clients. It is designed to be thread-safe and robust against - * session disconnects and transport errors. - * - *

Usage: Register this handler as a bean in your Spring application context and wire it in your - * WebSocket config. The {@code broadcast} method should be called by your log appender whenever a - * new log message is available for real-time delivery. - * - *

All log messages and transport errors are recorded via SLF4J for operational visibility. - * - * @see org.springframework.web.socket.handler.TextWebSocketHandler - * @see com.chromascape.web.logs.WebSocketLogAppender - */ -@Component -public class LogWebSocketHandler extends TextWebSocketHandler { - - /** SLF4J logger for connection and error events. */ - private static final Logger logger = LoggerFactory.getLogger(LogWebSocketHandler.class); - - /** - * Thread-safe set of active WebSocket sessions. {@link CopyOnWriteArraySet} is used to avoid - * concurrent modification issues during broadcast. - */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** Executor service to send WebSocket requests without blocking the main thread. */ - private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); - - /** - * Called when a new WebSocket connection is established. Adds the session to the active set and - * logs the connection. - * - * @param session the new WebSocket session (maybe null, per Spring contract) - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - // Defensive null check; session is typically non-null after establishment. - if (session != null) { - logger.info("WebSocket client connected"); - } - } - - /** - * Called when a WebSocket connection is closed. Removes the session from the active set and logs - * the disconnection. - * - * @param session the closed WebSocket session (maybe null) - * @param status the close status (maybe null) - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - if (session != null) { - logger.info("WebSocket client disconnected"); - } - } - - /** - * Called when a transport error occurs for a session. Removes the session, closes it with a - * server error status, and logs the error. - * - * @param session the affected WebSocket session (maybe null) - * @param exception the thrown error/exception - * @throws Exception if closing the session fails - */ - @Override - public void handleTransportError( - @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { - sessions.remove(session); - if (session != null) { - session.close(CloseStatus.SERVER_ERROR); - assert exception != null; - logger.error("WebSocket transport error for session: {}", exception.getMessage()); - } - } - - /** - * Broadcasts a message to all connected WebSocket clients. If a session fails to receive the - * message, it is removed from the active set and the failure is logged. - * - * @param message the message to broadcast - */ - public void broadcast(String message) { - for (WebSocketSession session : sessions) { - wsExecutor.submit( - () -> { - try { - synchronized (session) { - if (session.isOpen()) { - session.sendMessage(new TextMessage(message)); - } - } - } catch (IOException e) { - sessions.remove(session); - logger.warn("Failed to send message to session: {}", e.getMessage()); - } - }); - } - } -} +package com.chromascape.web.logs; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler for broadcasting log messages to connected clients. + * + *

This handler manages active WebSocket sessions and provides a broadcast method for delivering + * log messages to all connected clients. It is designed to be thread-safe and robust against + * session disconnects and transport errors. + * + *

Usage: Register this handler as a bean in your Spring application context and wire it in your + * WebSocket config. The {@code broadcast} method should be called by your log appender whenever a + * new log message is available for real-time delivery. + * + *

All log messages and transport errors are recorded via SLF4J for operational visibility. + * + * @see org.springframework.web.socket.handler.TextWebSocketHandler + * @see com.chromascape.web.logs.WebSocketLogAppender + */ +@Component +public class LogWebSocketHandler extends TextWebSocketHandler { + + /** SLF4J logger for connection and error events. */ + private static final Logger logger = LoggerFactory.getLogger(LogWebSocketHandler.class); + + /** + * Thread-safe set of active WebSocket sessions. {@link CopyOnWriteArraySet} is used to avoid + * concurrent modification issues during broadcast. + */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** Executor service to send WebSocket requests without blocking the main thread. */ + private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); + + /** + * Called when a new WebSocket connection is established. Adds the session to the active set and + * logs the connection. + * + * @param session the new WebSocket session (maybe null, per Spring contract) + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + // Defensive null check; session is typically non-null after establishment. + if (session != null) { + logger.info("WebSocket client connected"); + } + } + + /** + * Called when a WebSocket connection is closed. Removes the session from the active set and logs + * the disconnection. + * + * @param session the closed WebSocket session (maybe null) + * @param status the close status (maybe null) + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + if (session != null) { + logger.info("WebSocket client disconnected"); + } + } + + /** + * Called when a transport error occurs for a session. Removes the session, closes it with a + * server error status, and logs the error. + * + * @param session the affected WebSocket session (maybe null) + * @param exception the thrown error/exception + * @throws Exception if closing the session fails + */ + @Override + public void handleTransportError( + @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { + sessions.remove(session); + if (session != null) { + session.close(CloseStatus.SERVER_ERROR); + assert exception != null; + logger.error("WebSocket transport error for session: {}", exception.getMessage()); + } + } + + /** + * Broadcasts a message to all connected WebSocket clients. If a session fails to receive the + * message, it is removed from the active set and the failure is logged. + * + * @param message the message to broadcast + */ + public void broadcast(String message) { + for (WebSocketSession session : sessions) { + wsExecutor.submit( + () -> { + try { + synchronized (session) { + if (session.isOpen()) { + session.sendMessage(new TextMessage(message)); + } + } + } catch (IOException e) { + sessions.remove(session); + logger.warn("Failed to send message to session: {}", e.getMessage()); + } + }); + } + } +} diff --git a/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java b/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java index d67ef8d..013ec23 100644 --- a/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java +++ b/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java @@ -1,107 +1,107 @@ -package com.chromascape.web.logs; - -import org.apache.logging.log4j.core.Appender; -import org.apache.logging.log4j.core.LogEvent; -import org.apache.logging.log4j.core.appender.AbstractAppender; -import org.apache.logging.log4j.core.config.plugins.Plugin; -import org.apache.logging.log4j.core.config.plugins.PluginAttribute; -import org.apache.logging.log4j.core.config.plugins.PluginFactory; - -/** - * A custom Log4j2 appender that broadcasts log messages to all connected WebSocket clients. - * - *

This appender is intended for use in a Spring Boot application where {@link - * LogWebSocketHandler} manages client connections. Each log message emitted by Log4j2 will be sent - * to every active WebSocket session via the {@code broadcast} method. - * - *

Typical usage involves registering this appender in {@code log4j2.xml} and configuring the - * WebSocket handler via Spring. See documentation for wiring instructions. - * - *

- * Example XML registration:
- * <Appenders>
- *   <WebSocketLogAppender name="WebSocket"/>
- * </Appenders>
- * 
- * - *

The WebSocket handler must be set using {@link #setWebSocketHandler(LogWebSocketHandler)} - * after the Spring application context has fully initialized. - * - * @see LogWebSocketHandler - */ -@Plugin( - name = "WebSocketLogAppender", - category = "Core", - elementType = Appender.ELEMENT_TYPE, - printObject = true) -public class WebSocketLogAppender extends AbstractAppender { - - /** - * The handler managing WebSocket sessions. This is set by Spring after context initialization. - * Must be thread-safe. - */ - private static LogWebSocketHandler webSocketHandler; - - /** - * Allows Spring to inject the {@link LogWebSocketHandler} instance after application startup. - * This method should be called from a Spring bean, typically in a {@code @Configuration} class. - * - * @param handler the shared WebSocket handler bean - */ - public static void setWebSocketHandler(LogWebSocketHandler handler) { - webSocketHandler = handler; - } - - /** - * Constructs the appender with the provided name. Other parameters (filter, layout) are omitted - * for simplicity, but can be added if needed. - * - * @param name the appender name - */ - protected WebSocketLogAppender(String name) { - super(name, null, null, true, null); - // Start the appender immediately on creation. - start(); - } - - /** - * Factory method required by Log4j2 for plugin discovery and instantiation via XML configuration. - * This method is called when the appender is referenced in {@code log4j2.xml}. - * - * @param name the appender name as specified in XML - * @return a new instance of {@link WebSocketLogAppender} - */ - @PluginFactory - public static WebSocketLogAppender createAppender(@PluginAttribute("name") String name) { - return new WebSocketLogAppender(name); - } - - /** - * Called by Log4j2 for each log event. Broadcasts the formatted log message to all connected - * WebSocket clients. If the handler is not set, the message is silently dropped. - * - * @param event the log event to append/broadcast - */ - @Override - public void append(LogEvent event) { - if (webSocketHandler == null) { - // Handler not configured; drop message. - return; - } - String level = event.getLevel().name(); - String message = event.getMessage().getFormattedMessage(); - // Simple JSON construction (escaping quotes in message is prudent but assuming - // simple logs for now) - // A more robust way would be using a library, but this keeps deps low. - String json = - String.format( - "{\"level\": \"%s\", \"message\": \"%s\"}", - level, message.replace("\"", "\\\"").replace("\n", " ").replace("\r", "")); - - try { - webSocketHandler.broadcast(json); - } catch (Exception e) { - System.err.println("Failed to broadcast log: " + e.getMessage()); - } - } -} +package com.chromascape.web.logs; + +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; + +/** + * A custom Log4j2 appender that broadcasts log messages to all connected WebSocket clients. + * + *

This appender is intended for use in a Spring Boot application where {@link + * LogWebSocketHandler} manages client connections. Each log message emitted by Log4j2 will be sent + * to every active WebSocket session via the {@code broadcast} method. + * + *

Typical usage involves registering this appender in {@code log4j2.xml} and configuring the + * WebSocket handler via Spring. See documentation for wiring instructions. + * + *

+ * Example XML registration:
+ * <Appenders>
+ *   <WebSocketLogAppender name="WebSocket"/>
+ * </Appenders>
+ * 
+ * + *

The WebSocket handler must be set using {@link #setWebSocketHandler(LogWebSocketHandler)} + * after the Spring application context has fully initialized. + * + * @see LogWebSocketHandler + */ +@Plugin( + name = "WebSocketLogAppender", + category = "Core", + elementType = Appender.ELEMENT_TYPE, + printObject = true) +public class WebSocketLogAppender extends AbstractAppender { + + /** + * The handler managing WebSocket sessions. This is set by Spring after context initialization. + * Must be thread-safe. + */ + private static LogWebSocketHandler webSocketHandler; + + /** + * Allows Spring to inject the {@link LogWebSocketHandler} instance after application startup. + * This method should be called from a Spring bean, typically in a {@code @Configuration} class. + * + * @param handler the shared WebSocket handler bean + */ + public static void setWebSocketHandler(LogWebSocketHandler handler) { + webSocketHandler = handler; + } + + /** + * Constructs the appender with the provided name. Other parameters (filter, layout) are omitted + * for simplicity, but can be added if needed. + * + * @param name the appender name + */ + protected WebSocketLogAppender(String name) { + super(name, null, null, true, null); + // Start the appender immediately on creation. + start(); + } + + /** + * Factory method required by Log4j2 for plugin discovery and instantiation via XML configuration. + * This method is called when the appender is referenced in {@code log4j2.xml}. + * + * @param name the appender name as specified in XML + * @return a new instance of {@link WebSocketLogAppender} + */ + @PluginFactory + public static WebSocketLogAppender createAppender(@PluginAttribute("name") String name) { + return new WebSocketLogAppender(name); + } + + /** + * Called by Log4j2 for each log event. Broadcasts the formatted log message to all connected + * WebSocket clients. If the handler is not set, the message is silently dropped. + * + * @param event the log event to append/broadcast + */ + @Override + public void append(LogEvent event) { + if (webSocketHandler == null) { + // Handler not configured; drop message. + return; + } + String level = event.getLevel().name(); + String message = event.getMessage().getFormattedMessage(); + // Simple JSON construction (escaping quotes in message is prudent but assuming + // simple logs for now) + // A more robust way would be using a library, but this keeps deps low. + String json = + String.format( + "{\"level\": \"%s\", \"message\": \"%s\"}", + level, message.replace("\"", "\\\"").replace("\n", " ").replace("\r", "")); + + try { + webSocketHandler.broadcast(json); + } catch (Exception e) { + System.err.println("Failed to broadcast log: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/chromascape/web/slider/CurrentSliderState.java b/src/main/java/com/chromascape/web/slider/CurrentSliderState.java index 435483b..3ffb35b 100644 --- a/src/main/java/com/chromascape/web/slider/CurrentSliderState.java +++ b/src/main/java/com/chromascape/web/slider/CurrentSliderState.java @@ -1,84 +1,84 @@ -package com.chromascape.web.slider; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.bytedeco.opencv.opencv_core.Scalar; -import org.springframework.stereotype.Component; - -/** - * Stores the current HSV slider state for color selection in the UI. - * - *

This class is a thread-safe singleton Spring component used to manage real-time updates to - * hue, saturation, and value (HSV) bounds, which can be retrieved as a {@link ColourObj} for image - * processing or color detection. - */ -@Component -public class CurrentSliderState { - private final Map sliderValues = new ConcurrentHashMap<>(); - - /** Constructs a new {@code CurrentSliderState} with default HSV bounds. */ - public CurrentSliderState() { - reset(); - } - - /** - * Updates a specific slider's value by ID. - * - * @param id the slider identifier (e.g. "hueMin", "satMax") - * @param value the new slider value - */ - public void set(String id, int value) { - sliderValues.put(id, value); - } - - /** - * Retrieves the current value of a slider by ID. - * - * @param id the slider identifier - * @return the current value, or 0 if not present - */ - public int get(String id) { - return sliderValues.getOrDefault(id, 0); - } - - /** - * Retrieves all current slider values as a map. - * - * @return a map of slider IDs to their integer values. - */ - public Map getAll() { - return new ConcurrentHashMap<>(sliderValues); - } - - /** - * Converts the current slider state into a {@link ColourObj} using OpenCV HSV bounds. - * - * @return a {@link ColourObj} representing the selected HSV range - */ - public ColourObj getColourObj() { - Scalar min = - new Scalar( - sliderValues.getOrDefault("hueMin", 0), - sliderValues.getOrDefault("satMin", 0), - sliderValues.getOrDefault("valMin", 0), - 0); - Scalar max = - new Scalar( - sliderValues.getOrDefault("hueMax", 179), - sliderValues.getOrDefault("satMax", 255), - sliderValues.getOrDefault("valMax", 255), - 0); - return new ColourObj("custom-slider-colour", min, max); - } - - /** Resets all sliders to default HSV values: hue [0–179], saturation [0–255], value [0–255]. */ - public void reset() { - sliderValues.put("hueMin", 0); - sliderValues.put("hueMax", 179); - sliderValues.put("satMin", 0); - sliderValues.put("satMax", 255); - sliderValues.put("valMin", 0); - sliderValues.put("valMax", 255); - } -} +package com.chromascape.web.slider; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.bytedeco.opencv.opencv_core.Scalar; +import org.springframework.stereotype.Component; + +/** + * Stores the current HSV slider state for color selection in the UI. + * + *

This class is a thread-safe singleton Spring component used to manage real-time updates to + * hue, saturation, and value (HSV) bounds, which can be retrieved as a {@link ColourObj} for image + * processing or color detection. + */ +@Component +public class CurrentSliderState { + private final Map sliderValues = new ConcurrentHashMap<>(); + + /** Constructs a new {@code CurrentSliderState} with default HSV bounds. */ + public CurrentSliderState() { + reset(); + } + + /** + * Updates a specific slider's value by ID. + * + * @param id the slider identifier (e.g. "hueMin", "satMax") + * @param value the new slider value + */ + public void set(String id, int value) { + sliderValues.put(id, value); + } + + /** + * Retrieves the current value of a slider by ID. + * + * @param id the slider identifier + * @return the current value, or 0 if not present + */ + public int get(String id) { + return sliderValues.getOrDefault(id, 0); + } + + /** + * Retrieves all current slider values as a map. + * + * @return a map of slider IDs to their integer values. + */ + public Map getAll() { + return new ConcurrentHashMap<>(sliderValues); + } + + /** + * Converts the current slider state into a {@link ColourObj} using OpenCV HSV bounds. + * + * @return a {@link ColourObj} representing the selected HSV range + */ + public ColourObj getColourObj() { + Scalar min = + new Scalar( + sliderValues.getOrDefault("hueMin", 0), + sliderValues.getOrDefault("satMin", 0), + sliderValues.getOrDefault("valMin", 0), + 0); + Scalar max = + new Scalar( + sliderValues.getOrDefault("hueMax", 179), + sliderValues.getOrDefault("satMax", 255), + sliderValues.getOrDefault("valMax", 255), + 0); + return new ColourObj("custom-slider-colour", min, max); + } + + /** Resets all sliders to default HSV values: hue [0–179], saturation [0–255], value [0–255]. */ + public void reset() { + sliderValues.put("hueMin", 0); + sliderValues.put("hueMax", 179); + sliderValues.put("satMin", 0); + sliderValues.put("satMax", 255); + sliderValues.put("valMin", 0); + sliderValues.put("valMax", 255); + } +} diff --git a/src/main/java/com/chromascape/web/slider/SliderConfig.java b/src/main/java/com/chromascape/web/slider/SliderConfig.java index 94c5129..660c624 100644 --- a/src/main/java/com/chromascape/web/slider/SliderConfig.java +++ b/src/main/java/com/chromascape/web/slider/SliderConfig.java @@ -1,50 +1,50 @@ -package com.chromascape.web.slider; - -/** - * DTO representing an individual slider update sent from the frontend. - * - *

This class is used as the {@code @RequestBody} for slider update POST requests in the HSV - * filter tuning UI. - * - *

Each instance represents a single slider (e.g., "hueMin") and its corresponding value. - */ -public class SliderConfig { - - /** The name of the slider being adjusted (e.g., "hueMin", "satMax"). */ - private String sliderName; - - /** The updated value of the slider. */ - private int sliderValue; - - /** Default constructor required for JSON deserialization. */ - public SliderConfig() {} - - /** - * Constructs a {@code SliderConfig} with a name and value. - * - * @param sliderName the identifier of the slider - * @param sliderValue the new value of the slider - */ - public SliderConfig(final String sliderName, final int sliderValue) { - this.sliderName = sliderName; - this.sliderValue = sliderValue; - } - - /** - * Returns the slider's name (e.g., "hueMin"). - * - * @return the slider name - */ - public String getSliderName() { - return sliderName; - } - - /** - * Returns the new value of the slider. - * - * @return the slider value - */ - public int getSliderValue() { - return sliderValue; - } -} +package com.chromascape.web.slider; + +/** + * DTO representing an individual slider update sent from the frontend. + * + *

This class is used as the {@code @RequestBody} for slider update POST requests in the HSV + * filter tuning UI. + * + *

Each instance represents a single slider (e.g., "hueMin") and its corresponding value. + */ +public class SliderConfig { + + /** The name of the slider being adjusted (e.g., "hueMin", "satMax"). */ + private String sliderName; + + /** The updated value of the slider. */ + private int sliderValue; + + /** Default constructor required for JSON deserialization. */ + public SliderConfig() {} + + /** + * Constructs a {@code SliderConfig} with a name and value. + * + * @param sliderName the identifier of the slider + * @param sliderValue the new value of the slider + */ + public SliderConfig(final String sliderName, final int sliderValue) { + this.sliderName = sliderName; + this.sliderValue = sliderValue; + } + + /** + * Returns the slider's name (e.g., "hueMin"). + * + * @return the slider name + */ + public String getSliderName() { + return sliderName; + } + + /** + * Returns the new value of the slider. + * + * @return the slider value + */ + public int getSliderValue() { + return sliderValue; + } +} diff --git a/src/main/java/com/chromascape/web/slider/SliderController.java b/src/main/java/com/chromascape/web/slider/SliderController.java index 2619e2d..e1d90b6 100644 --- a/src/main/java/com/chromascape/web/slider/SliderController.java +++ b/src/main/java/com/chromascape/web/slider/SliderController.java @@ -1,68 +1,68 @@ -package com.chromascape.web.slider; - -import com.chromascape.web.image.ModifyImage; -import java.io.IOException; -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for handling slider input changes from the frontend UI. - * - *

This controller updates the internal HSV slider state based on user input and triggers - * downstream image modification logic in real time. - */ -@RestController -@RequestMapping("/api") -public class SliderController { - - /** Holds the current state of all slider values (e.g., hueMin, satMax). */ - private final CurrentSliderState sliderState; - - /** Applies visual changes based on updated HSV thresholds. */ - private final ModifyImage modifyImage; - - /** - * Constructs a new {@code SliderController}. - * - * @param sliderState shared singleton bean tracking live slider positions - * @param modifyImage service for applying image modifications - */ - public SliderController(CurrentSliderState sliderState, ModifyImage modifyImage) { - this.sliderState = sliderState; - this.modifyImage = modifyImage; - } - - /** - * Updates the server-side HSV slider state based on frontend input and applies the updated - * thresholds to the live image preview. - * - * @param config the updated slider configuration (name and value) - * @return HTTP 200 with success message upon update - * @throws IOException if image processing fails during application of changes - */ - @PostMapping("/slider") - public ResponseEntity updateSlider(@RequestBody SliderConfig config) throws IOException { - String id = config.getSliderName(); - int val = config.getSliderValue(); - - sliderState.set(id, val); - modifyImage.applySliderChanges(sliderState); - - return ResponseEntity.ok(Map.of("status", "success")); - } - - /** - * Retrieves the current configuration of all sliders. - * - * @return a map containing the current values for all HSV sliders. - */ - @GetMapping("/slider") - public ResponseEntity> getSliderState() { - return ResponseEntity.ok(sliderState.getAll()); - } -} +package com.chromascape.web.slider; + +import com.chromascape.web.image.ModifyImage; +import java.io.IOException; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for handling slider input changes from the frontend UI. + * + *

This controller updates the internal HSV slider state based on user input and triggers + * downstream image modification logic in real time. + */ +@RestController +@RequestMapping("/api") +public class SliderController { + + /** Holds the current state of all slider values (e.g., hueMin, satMax). */ + private final CurrentSliderState sliderState; + + /** Applies visual changes based on updated HSV thresholds. */ + private final ModifyImage modifyImage; + + /** + * Constructs a new {@code SliderController}. + * + * @param sliderState shared singleton bean tracking live slider positions + * @param modifyImage service for applying image modifications + */ + public SliderController(CurrentSliderState sliderState, ModifyImage modifyImage) { + this.sliderState = sliderState; + this.modifyImage = modifyImage; + } + + /** + * Updates the server-side HSV slider state based on frontend input and applies the updated + * thresholds to the live image preview. + * + * @param config the updated slider configuration (name and value) + * @return HTTP 200 with success message upon update + * @throws IOException if image processing fails during application of changes + */ + @PostMapping("/slider") + public ResponseEntity updateSlider(@RequestBody SliderConfig config) throws IOException { + String id = config.getSliderName(); + int val = config.getSliderValue(); + + sliderState.set(id, val); + modifyImage.applySliderChanges(sliderState); + + return ResponseEntity.ok(Map.of("status", "success")); + } + + /** + * Retrieves the current configuration of all sliders. + * + * @return a map containing the current values for all HSV sliders. + */ + @GetMapping("/slider") + public ResponseEntity> getSliderState() { + return ResponseEntity.ok(sliderState.getAll()); + } +} diff --git a/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java b/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java index 3c2e1a7..b30a50a 100644 --- a/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java @@ -1,88 +1,88 @@ -package com.chromascape.web.state; - -import com.chromascape.utils.core.state.BotState; -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler responsible for broadcasting the bot's current semantic state to all connected - * frontend clients. - * - *

This handler manages a thread-safe set of active WebSocket sessions. It listens for - * connections at {@code /ws/semantic-state} and provides methods to push state updates via JSON - * messages containing the state name, display label, and CSS styling class. - * - * @see com.chromascape.utils.core.state.BotState - * @see WebsocketBotStateListener - */ -@Component -public class SemanticWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(SemanticWebSocketHandler.class); - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** - * Registers a new WebSocket session when a client connects. - * - * @param session the new WebSocket session - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - } - - /** - * Removes a WebSocket session when the connection is closed. - * - * @param session the closed WebSocket session - * @param status the closure status - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - } - - /** - * Broadcasts the specified {@link BotState} to all currently connected WebSocket sessions. - * - *

The state is serialized into a JSON object with the following fields: - * - *

    - *
  • {@code state}: The enum name of the state. - *
  • {@code label}: The user-friendly display name. - *
  • {@code css}: The associated CSS class for styling UI elements. - *
- * - * @param state the new state to broadcast; must not be null - */ - public void broadcastState(BotState state) { - String json = - String.format( - "{\"state\": \"%s\", \"label\": \"%s\", \"css\": \"%s\"}", - state.name(), state.getDisplayName(), state.getCssClass()); - - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - synchronized (session) { - if (session.isOpen()) { - session.sendMessage(new TextMessage(json)); - } - } - } catch (IOException e) { - logger.warn("Failed to send state update: {}", e.getMessage()); - } - } - } - } -} +package com.chromascape.web.state; + +import com.chromascape.utils.core.state.BotState; +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler responsible for broadcasting the bot's current semantic state to all connected + * frontend clients. + * + *

This handler manages a thread-safe set of active WebSocket sessions. It listens for + * connections at {@code /ws/semantic-state} and provides methods to push state updates via JSON + * messages containing the state name, display label, and CSS styling class. + * + * @see com.chromascape.utils.core.state.BotState + * @see WebsocketBotStateListener + */ +@Component +public class SemanticWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(SemanticWebSocketHandler.class); + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** + * Registers a new WebSocket session when a client connects. + * + * @param session the new WebSocket session + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + } + + /** + * Removes a WebSocket session when the connection is closed. + * + * @param session the closed WebSocket session + * @param status the closure status + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + } + + /** + * Broadcasts the specified {@link BotState} to all currently connected WebSocket sessions. + * + *

The state is serialized into a JSON object with the following fields: + * + *

    + *
  • {@code state}: The enum name of the state. + *
  • {@code label}: The user-friendly display name. + *
  • {@code css}: The associated CSS class for styling UI elements. + *
+ * + * @param state the new state to broadcast; must not be null + */ + public void broadcastState(BotState state) { + String json = + String.format( + "{\"state\": \"%s\", \"label\": \"%s\", \"css\": \"%s\"}", + state.name(), state.getDisplayName(), state.getCssClass()); + + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + synchronized (session) { + if (session.isOpen()) { + session.sendMessage(new TextMessage(json)); + } + } + } catch (IOException e) { + logger.warn("Failed to send state update: {}", e.getMessage()); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java b/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java index 320471d..6f247cf 100644 --- a/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java +++ b/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java @@ -1,51 +1,51 @@ -package com.chromascape.web.state; - -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.BotStateListener; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -/** - * A Spring component that implements {@link BotStateListener} to act as a bridge between the core - * application state and the web layer. - * - *

This listener subscribes to state changes from the core {@link - * com.chromascape.utils.core.state.StateManager} (via the listener infrastructure) and forwards - * them to the {@link SemanticWebSocketHandler} for broadcast to connected web clients. - * - * @see SemanticWebSocketHandler - * @see BotStateListener - */ -@Component -public class WebsocketBotStateListener implements BotStateListener { - - /** - * The WebSocket handler responsible for managing connections and broadcasting messages to web - * clients. - */ - private final SemanticWebSocketHandler handler; - - /** - * Constructs a new {@code WebsocketBotStateListener} with the specified WebSocket handler. - * - * @param handler the {@link SemanticWebSocketHandler} used to broadcast state updates; must not - * be null - */ - @Autowired - public WebsocketBotStateListener(SemanticWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Invoked when the bot's state changes. - * - *

This method delegates the new state to the {@link SemanticWebSocketHandler} to be broadcast - * to all active WebSocket sessions. - * - * @param state the new {@link BotState} of the application - */ - @Override - public void onStateChange(BotState state) { - handler.broadcastState(state); - } -} +package com.chromascape.web.state; + +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.BotStateListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * A Spring component that implements {@link BotStateListener} to act as a bridge between the core + * application state and the web layer. + * + *

This listener subscribes to state changes from the core {@link + * com.chromascape.utils.core.state.StateManager} (via the listener infrastructure) and forwards + * them to the {@link SemanticWebSocketHandler} for broadcast to connected web clients. + * + * @see SemanticWebSocketHandler + * @see BotStateListener + */ +@Component +public class WebsocketBotStateListener implements BotStateListener { + + /** + * The WebSocket handler responsible for managing connections and broadcasting messages to web + * clients. + */ + private final SemanticWebSocketHandler handler; + + /** + * Constructs a new {@code WebsocketBotStateListener} with the specified WebSocket handler. + * + * @param handler the {@link SemanticWebSocketHandler} used to broadcast state updates; must not + * be null + */ + @Autowired + public WebsocketBotStateListener(SemanticWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Invoked when the bot's state changes. + * + *

This method delegates the new state to the {@link SemanticWebSocketHandler} to be broadcast + * to all active WebSocket sessions. + * + * @param state the new {@link BotState} of the application + */ + @Override + public void onStateChange(BotState state) { + handler.broadcastState(state); + } +} diff --git a/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java b/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java index d579dea..949a6c4 100644 --- a/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java +++ b/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java @@ -1,74 +1,74 @@ -package com.chromascape.web.stats; - -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * Component responsible for broadcasting application statistics to connected WebSocket clients. - * - *

This class executes a scheduled task every second to aggregate performance metrics from the - * {@link StatisticsManager} (such as uptime, CPU cycles, inputs, and object detections). These - * metrics are formatted into a JSON payload and sent to the {@link StatisticsWebSocketHandler}. - */ -@Component -public class StatisticsBroadcaster { - - private final StatisticsWebSocketHandler handler; - - /** - * Constructs a new broadcaster with the given WebSocket handler. - * - * @param handler the handler used to send messages to clients - */ - @Autowired - public StatisticsBroadcaster(StatisticsWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Periodically fetches the latest statistics and broadcasts them. - * - *

This method runs on a fixed schedule of 1000ms. It retrieves the following data: - * - *

    - *
  • Elapsed Time (formatted as HH:mm:ss) - *
  • Logic Cycles - *
  • Input Actions - *
  • Objects Detected - *
- * - *

The data is serialized into a simple JSON string before broadcast. - */ - @Scheduled(fixedRate = 1000) - public void pushStats() { - long elapsedTime = StatisticsManager.getElapsedTime(); - String duration = formatDuration(elapsedTime); - int cycles = StatisticsManager.getCycles(); - int inputs = StatisticsManager.getInputs(); - int objects = StatisticsManager.getObjectsDetected(); - - String json = - String.format( - "{\"time\": \"%s\", \"cycles\": %d, \"inputs\": %d, \"objects\": %d}", - duration, cycles, inputs, objects); - - handler.broadcast(json); - } - - /** - * Formats a duration in milliseconds into a readable string (HH:mm:ss). - * - * @param millis the duration in milliseconds - * @return a formatted time string - */ - private String formatDuration(long millis) { - Duration d = Duration.ofMillis(millis); - long hours = d.toHours(); - long minutes = d.toMinutesPart(); - long seconds = d.toSecondsPart(); - return String.format("%02d:%02d:%02d", hours, minutes, seconds); - } -} +package com.chromascape.web.stats; + +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Component responsible for broadcasting application statistics to connected WebSocket clients. + * + *

This class executes a scheduled task every second to aggregate performance metrics from the + * {@link StatisticsManager} (such as uptime, CPU cycles, inputs, and object detections). These + * metrics are formatted into a JSON payload and sent to the {@link StatisticsWebSocketHandler}. + */ +@Component +public class StatisticsBroadcaster { + + private final StatisticsWebSocketHandler handler; + + /** + * Constructs a new broadcaster with the given WebSocket handler. + * + * @param handler the handler used to send messages to clients + */ + @Autowired + public StatisticsBroadcaster(StatisticsWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Periodically fetches the latest statistics and broadcasts them. + * + *

This method runs on a fixed schedule of 1000ms. It retrieves the following data: + * + *

    + *
  • Elapsed Time (formatted as HH:mm:ss) + *
  • Logic Cycles + *
  • Input Actions + *
  • Objects Detected + *
+ * + *

The data is serialized into a simple JSON string before broadcast. + */ + @Scheduled(fixedRate = 1000) + public void pushStats() { + long elapsedTime = StatisticsManager.getElapsedTime(); + String duration = formatDuration(elapsedTime); + int cycles = StatisticsManager.getCycles(); + int inputs = StatisticsManager.getInputs(); + int objects = StatisticsManager.getObjectsDetected(); + + String json = + String.format( + "{\"time\": \"%s\", \"cycles\": %d, \"inputs\": %d, \"objects\": %d}", + duration, cycles, inputs, objects); + + handler.broadcast(json); + } + + /** + * Formats a duration in milliseconds into a readable string (HH:mm:ss). + * + * @param millis the duration in milliseconds + * @return a formatted time string + */ + private String formatDuration(long millis) { + Duration d = Duration.ofMillis(millis); + long hours = d.toHours(); + long minutes = d.toMinutesPart(); + long seconds = d.toSecondsPart(); + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } +} diff --git a/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java b/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java index 02e2767..32011f3 100644 --- a/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java @@ -1,91 +1,91 @@ -package com.chromascape.web.stats; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler that manages connections for the statistics endpoint. - * - *

Listens on {@code /ws/stats}. This handler maintains a registry of active sessions and - * provides a mechanism to broadcast statistical data updates to all connected clients in real-time. - */ -@Component -public class StatisticsWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatisticsWebSocketHandler.class); - - /** - * A thread-safe set of active WebSocket sessions. - * - *

{@link CopyOnWriteArraySet} is used here to ensure safe iteration during broadcasts while - * allowing concurrent additions and removals of sessions. - */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** - * Invoked after a WebSocket negotiation has succeeded and the WebSocket connection is opened and - * ready for use. - * - *

This implementation registers the new session in the tracking set to receive future - * broadcasts. - * - * @param session the new {@link WebSocketSession}; may be {@code null} if the framework passes a - * null session (though unlikely in standard Spring WebSocket flow) - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - if (session != null) { - sessions.add(session); - } - } - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - *

This implementation removes the session from the tracking set to prevent memory leaks and - * attempted writes to closed connections. - * - * @param session the {@link WebSocketSession} that was closed - * @param status the close status code and reason - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - if (session != null) { - sessions.remove(session); - } - } - - /** - * Broadcasts the provided statistics JSON string to all currently connected and open clients. - * - *

If a session is closed or encounters an I/O error during sending, the exception is logged, - * but the broadcast continues to other clients. - * - * @param statsJson The JSON string containing the current stats to be sent to clients. - */ - public void broadcast(String statsJson) { - if (sessions.isEmpty()) { - return; - } - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage(statsJson)); - } catch (IOException e) { - logger.warn("Failed to send stats update: {}", e.getMessage()); - } - } - } - } -} +package com.chromascape.web.stats; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler that manages connections for the statistics endpoint. + * + *

Listens on {@code /ws/stats}. This handler maintains a registry of active sessions and + * provides a mechanism to broadcast statistical data updates to all connected clients in real-time. + */ +@Component +public class StatisticsWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatisticsWebSocketHandler.class); + + /** + * A thread-safe set of active WebSocket sessions. + * + *

{@link CopyOnWriteArraySet} is used here to ensure safe iteration during broadcasts while + * allowing concurrent additions and removals of sessions. + */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** + * Invoked after a WebSocket negotiation has succeeded and the WebSocket connection is opened and + * ready for use. + * + *

This implementation registers the new session in the tracking set to receive future + * broadcasts. + * + * @param session the new {@link WebSocketSession}; may be {@code null} if the framework passes a + * null session (though unlikely in standard Spring WebSocket flow) + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + if (session != null) { + sessions.add(session); + } + } + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + *

This implementation removes the session from the tracking set to prevent memory leaks and + * attempted writes to closed connections. + * + * @param session the {@link WebSocketSession} that was closed + * @param status the close status code and reason + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + if (session != null) { + sessions.remove(session); + } + } + + /** + * Broadcasts the provided statistics JSON string to all currently connected and open clients. + * + *

If a session is closed or encounters an I/O error during sending, the exception is logged, + * but the broadcast continues to other clients. + * + * @param statsJson The JSON string containing the current stats to be sent to clients. + */ + public void broadcast(String statsJson) { + if (sessions.isEmpty()) { + return; + } + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + session.sendMessage(new TextMessage(statsJson)); + } catch (IOException e) { + logger.warn("Failed to send stats update: {}", e.getMessage()); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java b/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java index 64a4adf..dcd9023 100644 --- a/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java @@ -1,114 +1,114 @@ -package com.chromascape.web.viewport; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * A WebSocket handler specifically for the viewport endpoint. - * - *

This class manages the active WebSocket connections and provides functionality to broadcast - * image updates to all connected clients. - */ -@Component -public class ViewportWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(ViewportWebSocketHandler.class); - - /** A thread-safe set of active WebSocket sessions. */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** Executor service for sending messages asynchronously to avoid blocking. */ - private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - * @param session The session that was established. - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - if (session != null) { - logger.info("Viewport WebSocket client connected"); - } - } - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - * @param session The session that was closed. - * @param status The status code indicating why the session was closed. - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - if (session != null) { - logger.info("Viewport WebSocket client disconnected"); - } - } - - /** - * Invoked when an error occurs in the underlying communication channel. - * - * @param session The session where the error occurred. - * @param exception The exception that occurred. - * @throws Exception If handling the error fails. - */ - @Override - public void handleTransportError( - @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { - sessions.remove(session); - if (session != null) { - session.close(CloseStatus.SERVER_ERROR); - logger.error( - "Viewport WebSocket transport error: {}", - exception != null ? exception.getMessage() : "Unknown error"); - } - } - - /** - * Broadcasts a text message to all currently connected clients. - * - *

Broadcasting is done asynchronously for each client to prevent one slow client from blocking - * the update for others. - * - * @param message The message string to broadcast (e.g., a Data URI). - */ - public void broadcast(String message) { - if (sessions.isEmpty()) { - return; - } - for (WebSocketSession session : sessions) { - if (!session.isOpen()) { - sessions.remove(session); - continue; - } - wsExecutor.submit( - () -> { - try { - // Double check before sending - if (session.isOpen()) { - session.sendMessage(new TextMessage(message)); - } - } catch (IOException e) { - logger.warn("Failed to send viewport data to session: {}", e.getMessage()); - sessions.remove(session); - } - }); - } - } -} +package com.chromascape.web.viewport; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * A WebSocket handler specifically for the viewport endpoint. + * + *

This class manages the active WebSocket connections and provides functionality to broadcast + * image updates to all connected clients. + */ +@Component +public class ViewportWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(ViewportWebSocketHandler.class); + + /** A thread-safe set of active WebSocket sessions. */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** Executor service for sending messages asynchronously to avoid blocking. */ + private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + * @param session The session that was established. + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + if (session != null) { + logger.info("Viewport WebSocket client connected"); + } + } + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + * @param session The session that was closed. + * @param status The status code indicating why the session was closed. + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + if (session != null) { + logger.info("Viewport WebSocket client disconnected"); + } + } + + /** + * Invoked when an error occurs in the underlying communication channel. + * + * @param session The session where the error occurred. + * @param exception The exception that occurred. + * @throws Exception If handling the error fails. + */ + @Override + public void handleTransportError( + @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { + sessions.remove(session); + if (session != null) { + session.close(CloseStatus.SERVER_ERROR); + logger.error( + "Viewport WebSocket transport error: {}", + exception != null ? exception.getMessage() : "Unknown error"); + } + } + + /** + * Broadcasts a text message to all currently connected clients. + * + *

Broadcasting is done asynchronously for each client to prevent one slow client from blocking + * the update for others. + * + * @param message The message string to broadcast (e.g., a Data URI). + */ + public void broadcast(String message) { + if (sessions.isEmpty()) { + return; + } + for (WebSocketSession session : sessions) { + if (!session.isOpen()) { + sessions.remove(session); + continue; + } + wsExecutor.submit( + () -> { + try { + // Double check before sending + if (session.isOpen()) { + session.sendMessage(new TextMessage(message)); + } + } catch (IOException e) { + logger.warn("Failed to send viewport data to session: {}", e.getMessage()); + sessions.remove(session); + } + }); + } + } +} diff --git a/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java b/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java index b33869d..47be119 100644 --- a/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java +++ b/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java @@ -1,133 +1,133 @@ -package com.chromascape.web.viewport; - -import com.chromascape.utils.core.screen.viewport.Viewport; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Base64; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicReference; -import javax.imageio.ImageIO; -import org.bytedeco.javacv.Java2DFrameUtils; -import org.bytedeco.opencv.opencv_core.Mat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Component; - -/** - * A Websocket-based implementation of the {@link Viewport} interface. - * - *

This component is responsible for receiving visual updates from the bot, converting them to a - * web-friendly format (Base64 PNG), and broadcasting them to connected clients via the {@link - * ViewportWebSocketHandler}. - * - *

To ensure optimal performance, image conversion and network transmission are handled - * asynchronously on a separate thread, with frame dropping logic to prevent backpressure on the - * main bot loop. - */ -@Component -public class WebsocketViewport implements Viewport { - - /** logger for logging things :) . */ - private static final Logger logger = LoggerFactory.getLogger(WebsocketViewport.class); - - /** Websocket handler to broadcast messages. */ - private final ViewportWebSocketHandler handler; - - /** Holds the latest update to be processed, or null if empty. */ - private final AtomicReference pendingUpdate = new AtomicReference<>(); - - /** Executor service for running the background processing tasks. */ - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - /** Flag indicating whether the background worker is currently busy. */ - private volatile boolean isProcessing = false; - - /** - * Constructs a new WebsocketViewport. - * - * @param handler The websocket handler for broadcasting messages. Injected lazily. - */ - @Autowired - public WebsocketViewport(@Lazy ViewportWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Accepts a new image state from the bot. - * - *

If the worker thread is free, the {@link Mat} is converted to a {@link BufferedImage} and - * queued for processing. If the worker is busy, the frame is dropped to maintain performance. - * - * @param mat The raw OpenCV matrix representing the new state. - */ - @Override - public void updateState(Mat mat) { - // Optimization: Check if we are already processing a frame. - // If we are backlogged, DROP this frame immediately to save CPU. - // We only convert to BufferedImage if we actually plan to queue it. - if (isProcessing && pendingUpdate.get() != null) { - return; - } - - // Convert here. This cost is only incurred if we are NOT backlogged. - BufferedImage image = Java2DFrameUtils.toBufferedImage(mat); - - // Atomically set the latest update - pendingUpdate.set(image); - - // If not currently processing, trigger the worker - if (!isProcessing) { - executor.submit(this::processPendingUpdate); - } - } - - /** - * The background worker loop that processes and sends images. - * - *

It continues running as long as there are pending updates in the {@code pendingUpdate} - * reference. - */ - private void processPendingUpdate() { - isProcessing = true; - try { - // Keep processing as long as there is a pending update - BufferedImage image = pendingUpdate.getAndSet(null); - while (image != null) { - try { - String base64Image = encodeImageToBase64(image); - // Send raw data URI string directly - String message = "data:image/png;base64," + base64Image; - handler.broadcast(message); - } catch (IOException e) { - logger.error("Failed to encode image for Viewport: {}", e.getMessage()); - } - - // Check if a new update came in while we were processing - image = pendingUpdate.getAndSet(null); - } - } finally { - isProcessing = false; - // Double check race condition - if (pendingUpdate.get() != null) { - executor.submit(this::processPendingUpdate); - } - } - } - - /** - * Encodes a BufferedImage into a Base64 string representation of a PNG. - * - * @param image The image to encode. - * @return The Base64 encoded string. - * @throws IOException If writing the image fails. - */ - private String encodeImageToBase64(BufferedImage image) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ImageIO.write(image, "png", outputStream); - return Base64.getEncoder().encodeToString(outputStream.toByteArray()); - } -} +package com.chromascape.web.viewport; + +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.viewport.Viewport; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import javax.imageio.ImageIO; +import org.bytedeco.opencv.opencv_core.Mat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +/** + * A Websocket-based implementation of the {@link Viewport} interface. + * + *

This component is responsible for receiving visual updates from the bot, converting them to a + * web-friendly format (Base64 PNG), and broadcasting them to connected clients via the {@link + * ViewportWebSocketHandler}. + * + *

To ensure optimal performance, image conversion and network transmission are handled + * asynchronously on a separate thread, with frame dropping logic to prevent backpressure on the + * main bot loop. + */ +@Component +public class WebsocketViewport implements Viewport { + + /** logger for logging things :) . */ + private static final Logger logger = LoggerFactory.getLogger(WebsocketViewport.class); + + /** Websocket handler to broadcast messages. */ + private final ViewportWebSocketHandler handler; + + /** Holds the latest update to be processed, or null if empty. */ + private final AtomicReference pendingUpdate = new AtomicReference<>(); + + /** Executor service for running the background processing tasks. */ + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + /** Flag indicating whether the background worker is currently busy. */ + private volatile boolean isProcessing = false; + + /** + * Constructs a new WebsocketViewport. + * + * @param handler The websocket handler for broadcasting messages. Injected lazily. + */ + @Autowired + public WebsocketViewport(@Lazy ViewportWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Accepts a new image state from the bot. + * + *

If the worker thread is free, the {@link Mat} is converted to a {@link BufferedImage} and + * queued for processing. If the worker is busy, the frame is dropped to maintain performance. + * + * @param mat The raw OpenCV matrix representing the new state. + */ + @Override + public void updateState(Mat mat) { + // Optimization: Check if we are already processing a frame. + // If we are backlogged, DROP this frame immediately to save CPU. + // We only convert to BufferedImage if we actually plan to queue it. + if (isProcessing && pendingUpdate.get() != null) { + return; + } + + // Convert here. This cost is only incurred if we are NOT backlogged. + BufferedImage image = TemplateMatching.matToBufferedImage(mat); + + // Atomically set the latest update + pendingUpdate.set(image); + + // If not currently processing, trigger the worker + if (!isProcessing) { + executor.submit(this::processPendingUpdate); + } + } + + /** + * The background worker loop that processes and sends images. + * + *

It continues running as long as there are pending updates in the {@code pendingUpdate} + * reference. + */ + private void processPendingUpdate() { + isProcessing = true; + try { + // Keep processing as long as there is a pending update + BufferedImage image = pendingUpdate.getAndSet(null); + while (image != null) { + try { + String base64Image = encodeImageToBase64(image); + // Send raw data URI string directly + String message = "data:image/png;base64," + base64Image; + handler.broadcast(message); + } catch (IOException e) { + logger.error("Failed to encode image for Viewport: {}", e.getMessage()); + } + + // Check if a new update came in while we were processing + image = pendingUpdate.getAndSet(null); + } + } finally { + isProcessing = false; + // Double check race condition + if (pendingUpdate.get() != null) { + executor.submit(this::processPendingUpdate); + } + } + } + + /** + * Encodes a BufferedImage into a Base64 string representation of a PNG. + * + * @param image The image to encode. + * @return The Base64 encoded string. + * @throws IOException If writing the image fails. + */ + private String encodeImageToBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } +} diff --git a/src/test/java/com/chromascape/ChromaScapeApplicationTests.java b/src/test/java/com/chromascape/ChromaScapeApplicationTests.java index 186acc8..e51258d 100644 --- a/src/test/java/com/chromascape/ChromaScapeApplicationTests.java +++ b/src/test/java/com/chromascape/ChromaScapeApplicationTests.java @@ -1,11 +1,11 @@ -package com.chromascape; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest(classes = com.chromascape.web.ChromaScapeApplication.class) -class ChromaScapeApplicationTests { - - @Test - void contextLoads() {} -} +package com.chromascape; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = com.chromascape.web.ChromaScapeApplication.class) +class ChromaScapeApplicationTests { + + @Test + void contextLoads() {} +} From e7b8157ed160241d6e36ec5a977148d4001b90dd Mon Sep 17 00:00:00 2001 From: debajit gayen Date: Thu, 21 May 2026 18:52:37 +0100 Subject: [PATCH 2/2] fix: Mat to BufferedImage conversions, MouseOver reliability --- .gitattributes | 1 + src/main/java/com/chromascape/api/Dax.java | 138 ++-- .../chromascape/api/DiscordNotification.java | 156 ++-- .../java/com/chromascape/base/BaseScript.java | 320 ++++---- .../chromascape/controller/Controller.java | 310 ++++---- .../scripts/DemoAgilityScript.java | 440 +++++------ .../scripts/DemoFishingScript.java | 400 +++++----- .../chromascape/scripts/DemoMiningScript.java | 158 ++-- .../chromascape/scripts/DemoWineScript.java | 384 ++++----- .../chromascape/scripts/Screenshotter.java | 98 +-- .../com/chromascape/utils/actions/Idler.java | 122 +-- .../utils/actions/ItemDropper.java | 252 +++--- .../chromascape/utils/actions/Minimap.java | 182 ++--- .../chromascape/utils/actions/MouseOver.java | 424 +++++----- .../utils/actions/MovingObject.java | 350 ++++----- .../utils/actions/PointSelector.java | 506 ++++++------ .../input/distribution/ClickDistribution.java | 306 ++++---- .../input/keyboard/VirtualKeyboardUtils.java | 238 +++--- .../core/input/mouse/VirtualMouseUtils.java | 560 +++++++------- .../utils/core/input/mouse/WindMouse.java | 616 +++++++-------- .../core/input/remoteinput/ControlKey.java | 48 +- .../core/input/remoteinput/MouseButton.java | 26 +- .../core/input/remoteinput/RemoteInput.java | 666 ++++++++-------- .../remoteinput/RemoteInputInterface.java | 452 +++++------ .../runtime/exception/DaxAuthException.java | 26 +- .../core/runtime/exception/DaxException.java | 28 +- .../exception/DaxRateLimitException.java | 30 +- .../exception/ScriptStoppedException.java | 38 +- .../utils/core/runtime/profile/Profile.java | 46 +- .../runtime/profile/ProfileContainer.java | 30 +- .../core/runtime/profile/ProfileManager.java | 372 ++++----- .../utils/core/screen/DisplayImage.java | 86 +-- .../core/screen/colour/ColourInstances.java | 168 ++-- .../utils/core/screen/colour/ColourObj.java | 98 +-- .../utils/core/screen/topology/ChromaObj.java | 42 +- .../core/screen/topology/ColourContours.java | 400 +++++----- .../core/screen/topology/MatchResult.java | 36 +- .../screen/topology/TemplateMatching.java | 580 +++++++------- .../utils/core/screen/viewport/Viewport.java | 38 +- .../core/screen/viewport/ViewportManager.java | 116 +-- .../screen/window/LinuxProcessManager.java | 112 +-- .../core/screen/window/MacProcessManager.java | 38 +- .../core/screen/window/ProcessManager.java | 30 +- .../screen/window/ProcessManagerFactory.java | 54 +- .../core/screen/window/ScreenManager.java | 224 +++--- .../screen/window/WindowsProcessManager.java | 182 ++--- .../utils/core/state/BotState.java | 76 +- .../utils/core/state/BotStateListener.java | 24 +- .../utils/core/state/StateManager.java | 94 +-- .../core/statistics/StatisticsManager.java | 210 ++--- .../utils/domain/ocr/CharMatch.java | 24 +- .../com/chromascape/utils/domain/ocr/Ocr.java | 730 +++++++++--------- .../utils/domain/walker/Compass.java | 576 +++++++------- .../utils/domain/walker/DaxPath.java | 32 +- .../chromascape/utils/domain/walker/Tile.java | 24 +- .../utils/domain/walker/Walker.java | 632 +++++++-------- .../utils/domain/zones/MaskZones.java | 170 ++-- .../utils/domain/zones/SubZoneMapper.java | 388 +++++----- .../utils/domain/zones/ZoneManager.java | 432 +++++------ .../web/ChromaScapeApplication.java | 146 ++-- .../java/com/chromascape/web/ServePages.java | 66 +- .../web/config/StartupConfiguration.java | 108 +-- .../web/config/WebSocketConfig.java | 178 ++--- .../com/chromascape/web/image/AddColour.java | 86 +-- .../com/chromascape/web/image/ColourData.java | 164 ++-- .../web/image/ImageController.java | 142 ++-- .../com/chromascape/web/image/MaskImage.java | 130 ++-- .../chromascape/web/image/ModifyImage.java | 282 +++---- .../chromascape/web/image/SubmitColour.java | 144 ++-- .../chromascape/web/instance/RunConfig.java | 54 +- .../web/instance/ScriptControl.java | 190 ++--- .../web/instance/ScriptInstance.java | 190 ++--- .../web/instance/ScriptInstanceManager.java | 94 +-- .../chromascape/web/instance/SendScripts.java | 92 +-- .../web/instance/WebSocketStateHandler.java | 196 ++--- .../web/logs/LogWebSocketHandler.java | 242 +++--- .../web/logs/WebSocketLogAppender.java | 214 ++--- .../web/slider/CurrentSliderState.java | 168 ++-- .../chromascape/web/slider/SliderConfig.java | 100 +-- .../web/slider/SliderController.java | 136 ++-- .../web/state/SemanticWebSocketHandler.java | 176 ++--- .../web/state/WebsocketBotStateListener.java | 102 +-- .../web/stats/StatisticsBroadcaster.java | 148 ++-- .../web/stats/StatisticsWebSocketHandler.java | 182 ++--- .../viewport/ViewportWebSocketHandler.java | 228 +++--- .../web/viewport/WebsocketViewport.java | 266 +++---- .../ChromaScapeApplicationTests.java | 22 +- 87 files changed, 8793 insertions(+), 8792 deletions(-) diff --git a/.gitattributes b/.gitattributes index 8af972c..6331761 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /gradlew text eol=lf *.bat text eol=crlf *.jar binary +* text=auto diff --git a/src/main/java/com/chromascape/api/Dax.java b/src/main/java/com/chromascape/api/Dax.java index 57773e8..6357371 100644 --- a/src/main/java/com/chromascape/api/Dax.java +++ b/src/main/java/com/chromascape/api/Dax.java @@ -1,69 +1,69 @@ -package com.chromascape.api; - -import com.chromascape.utils.core.runtime.exception.DaxAuthException; -import com.chromascape.utils.core.runtime.exception.DaxException; -import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; -import java.awt.Point; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -/** - * Client wrapper for the DAX Walker REST API. Sends pathfinding requests and returns the raw JSON - * response representing the calculated path. - */ -public class Dax { - - private static final String WALKER_ENDPOINT = "https://walker.dax.cloud/walker/generatePath"; - - private final HttpClient client = - HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); - - /** - * Sends a pathfinding request to the DAX Walker API. - * - * @param start The starting tile coordinates. - * @param end The destination tile coordinates. - * @param members True if the player is a member; false otherwise. - * @return Raw JSON string representing the generated path. - * @throws IOException If an IO error occurs during the request. - * @throws InterruptedException If the thread is interrupted. - * @throws DaxRateLimitException If HTTP 429 is returned. - * @throws DaxAuthException If credentials or endpoint are invalid (400, 401, 404). - */ - public String generatePath(Point start, Point end, boolean members) - throws IOException, InterruptedException { - - String payload = - String.format( - """ - { - "start": {"x": %d, "y": %d, "z": 0}, - "end": {"x": %d, "y": %d, "z": 0}, - "player": {"members": %b} - } - """, - start.x, start.y, end.x, end.y, members); - - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(WALKER_ENDPOINT)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("key", "sub_DPjXXzL5DeSiPf") - .header("secret", "PUBLIC-KEY") - .POST(HttpRequest.BodyPublishers.ofString(payload)) - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - return switch (response.statusCode()) { - case 200 -> response.body(); - case 429 -> throw new DaxRateLimitException(); - case 400, 401, 404 -> throw new DaxAuthException(); - default -> throw new DaxException("Unexpected API error: " + response.statusCode()); - }; - } -} +package com.chromascape.api; + +import com.chromascape.utils.core.runtime.exception.DaxAuthException; +import com.chromascape.utils.core.runtime.exception.DaxException; +import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; +import java.awt.Point; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * Client wrapper for the DAX Walker REST API. Sends pathfinding requests and returns the raw JSON + * response representing the calculated path. + */ +public class Dax { + + private static final String WALKER_ENDPOINT = "https://walker.dax.cloud/walker/generatePath"; + + private final HttpClient client = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); + + /** + * Sends a pathfinding request to the DAX Walker API. + * + * @param start The starting tile coordinates. + * @param end The destination tile coordinates. + * @param members True if the player is a member; false otherwise. + * @return Raw JSON string representing the generated path. + * @throws IOException If an IO error occurs during the request. + * @throws InterruptedException If the thread is interrupted. + * @throws DaxRateLimitException If HTTP 429 is returned. + * @throws DaxAuthException If credentials or endpoint are invalid (400, 401, 404). + */ + public String generatePath(Point start, Point end, boolean members) + throws IOException, InterruptedException { + + String payload = + String.format( + """ + { + "start": {"x": %d, "y": %d, "z": 0}, + "end": {"x": %d, "y": %d, "z": 0}, + "player": {"members": %b} + } + """, + start.x, start.y, end.x, end.y, members); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(WALKER_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("key", "sub_DPjXXzL5DeSiPf") + .header("secret", "PUBLIC-KEY") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + return switch (response.statusCode()) { + case 200 -> response.body(); + case 429 -> throw new DaxRateLimitException(); + case 400, 401, 404 -> throw new DaxAuthException(); + default -> throw new DaxException("Unexpected API error: " + response.statusCode()); + }; + } +} diff --git a/src/main/java/com/chromascape/api/DiscordNotification.java b/src/main/java/com/chromascape/api/DiscordNotification.java index b6227e9..49d0d43 100644 --- a/src/main/java/com/chromascape/api/DiscordNotification.java +++ b/src/main/java/com/chromascape/api/DiscordNotification.java @@ -1,78 +1,78 @@ -package com.chromascape.api; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Properties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Provides functionality to send logs as notifications to yourself via Discord. - * - *

    - *
  • Loads the {@code secrets.properties} file in the root directory. - *
  • Saves the specified WebHook URL. - *
  • Sends a POST req to the endpoint with the user's desired notification. - *
- * - *

This is extremely useful if you aren't actively babysitting your bot or in the case of - * reaching a specified XP/GP goal. It's my personal advice to the reader for you to inform yourself - * upon catastrophic failure, promptly. - */ -public class DiscordNotification { - - private static final Logger logger = LoggerFactory.getLogger(DiscordNotification.class); - private static String webhookUrl; - - static { - try (InputStream input = new FileInputStream("secrets.properties")) { - Properties prop = new Properties(); - prop.load(input); - webhookUrl = prop.getProperty("discord.webhook.url"); - } catch (IOException ex) { - logger.info("Could not find secrets.properties in the project root."); - } - } - - /** - * Sends a user specified message to a Discord WebHook endpoint. Sets up a post request and - * expects a 204 response code for success. - * - * @param message User specified String to send to the endpoint. - */ - public static void send(String message) { - if (webhookUrl == null || webhookUrl.isEmpty()) { - return; - } - - String sanitizedMessage = message.replace("\"", "\\\"").replace("\n", "\\n"); - String jsonPayload = "{\"content\": \"" + sanitizedMessage + "\"}"; - - try { - URL url = new URL(webhookUrl); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setDoOutput(true); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json"); - conn.setRequestProperty("User-Agent", "Java-Discord-Webhook"); - - try (OutputStream os = conn.getOutputStream()) { - os.write(jsonPayload.getBytes(StandardCharsets.UTF_8)); - } - - // 204 means Success (No Content) - if (conn.getResponseCode() != 204) { - logger.error("Failed to send. Response code: {}", conn.getResponseCode()); - } - - conn.disconnect(); - } catch (Exception e) { - logger.error("Error sending Discord notification: {}", e.getMessage()); - } - } -} +package com.chromascape.api; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides functionality to send logs as notifications to yourself via Discord. + * + *

    + *
  • Loads the {@code secrets.properties} file in the root directory. + *
  • Saves the specified WebHook URL. + *
  • Sends a POST req to the endpoint with the user's desired notification. + *
+ * + *

This is extremely useful if you aren't actively babysitting your bot or in the case of + * reaching a specified XP/GP goal. It's my personal advice to the reader for you to inform yourself + * upon catastrophic failure, promptly. + */ +public class DiscordNotification { + + private static final Logger logger = LoggerFactory.getLogger(DiscordNotification.class); + private static String webhookUrl; + + static { + try (InputStream input = new FileInputStream("secrets.properties")) { + Properties prop = new Properties(); + prop.load(input); + webhookUrl = prop.getProperty("discord.webhook.url"); + } catch (IOException ex) { + logger.info("Could not find secrets.properties in the project root."); + } + } + + /** + * Sends a user specified message to a Discord WebHook endpoint. Sets up a post request and + * expects a 204 response code for success. + * + * @param message User specified String to send to the endpoint. + */ + public static void send(String message) { + if (webhookUrl == null || webhookUrl.isEmpty()) { + return; + } + + String sanitizedMessage = message.replace("\"", "\\\"").replace("\n", "\\n"); + String jsonPayload = "{\"content\": \"" + sanitizedMessage + "\"}"; + + try { + URL url = new URL(webhookUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "Java-Discord-Webhook"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(jsonPayload.getBytes(StandardCharsets.UTF_8)); + } + + // 204 means Success (No Content) + if (conn.getResponseCode() != 204) { + logger.error("Failed to send. Response code: {}", conn.getResponseCode()); + } + + conn.disconnect(); + } catch (Exception e) { + logger.error("Error sending Discord notification: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/chromascape/base/BaseScript.java b/src/main/java/com/chromascape/base/BaseScript.java index a3c75b1..a7c26b5 100644 --- a/src/main/java/com/chromascape/base/BaseScript.java +++ b/src/main/java/com/chromascape/base/BaseScript.java @@ -1,160 +1,160 @@ -package com.chromascape.base; - -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.runtime.exception.ScriptStoppedException; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.util.concurrent.ThreadLocalRandom; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Abstract base class representing a generic automation script with lifecycle management. - * - *

Provides a timed execution framework where the script runs cycles until the script is stopped - * externally. - * - *

Manages the underlying Controller instance. Subclasses should override {@link #cycle()} to - * define the script's main logic. - */ -public abstract class BaseScript { - private final Controller controller; - private static final Logger logger = LogManager.getLogger(BaseScript.class); - private volatile boolean running = true; - private Thread scriptThread; - - /** Constructs a BaseScript. */ - public BaseScript() { - controller = new Controller(); - } - - /** - * Runs the script lifecycle. - * - *

Initializes the controller, logs start and stop events, then continuously invokes the {@link - * #cycle()} method until the script is stopped. Checks for thread interruption and stops - * gracefully if detected. - * - *

This method blocks until completion. - */ - public final void run() { - scriptThread = Thread.currentThread(); - controller.init(); - StatisticsManager.reset(); - - try { - while (running) { - StatisticsManager.incrementCycles(); - if (Thread.currentThread().isInterrupted()) { - logger.info("Thread interrupted, exiting."); - break; - } - try { - cycle(); - } catch (ScriptStoppedException e) { - logger.error("Cycle interrupted: {}", e.getMessage()); - break; - } catch (Exception e) { - StateManager.setState(BotState.ERROR); - logger.error("Exception in cycle: {}, {}", e.getMessage(), e.getStackTrace()); - break; - } - } - } finally { - logger.info("Stopping and cleaning up."); - controller.shutdown(); - } - logger.info("Finished running script."); - } - - /** - * Stops the script execution by interrupting the script thread. - * - *

Can be called externally (e.g., via UI controls or programmatically) to request an immediate - * stop of the running script. If the script is already stopped, this method does nothing. - */ - public void stop() { - if (!running) { - return; - } - logger.info("Stop requested"); - running = false; - - // Interrupt the script thread instead of throwing exception - if (scriptThread != null) { - scriptThread.interrupt(); - } - } - - /** - * Pauses the current thread for the specified number of milliseconds. - * - *

If the sleep is interrupted, this method throws ScriptStoppedException to enable immediate - * stopping. - * - * @param ms the duration to sleep in milliseconds - * @throws ScriptStoppedException if the thread is interrupted during sleep - */ - public static void waitMillis(long ms) { - StateManager.setState(BotState.WAITING); - try { - Thread.sleep(ms); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // Restore interrupt status - throw new ScriptStoppedException(); - } - } - - /** - * Pauses the current thread for a random duration between {@code min} and {@code max} - * milliseconds (inclusive). - * - *

This method internally calls {@link #waitMillis(long)} with a randomly generated delay. - * - * @param min the minimum number of milliseconds to sleep (inclusive) - * @param max the maximum number of milliseconds to sleep (inclusive) - * @throws IllegalArgumentException if {@code min} is greater than {@code max} - * @throws ScriptStoppedException if the thread is interrupted during sleep - */ - public static void waitRandomMillis(long min, long max) throws ScriptStoppedException { - if (min > max) { - throw new IllegalArgumentException("min must be less than or equal to max"); - } - waitMillis(ThreadLocalRandom.current().nextLong(min, max + 1)); - } - - /** - * Checks if the current thread has been interrupted and throws ScriptStoppedException if so. Call - * this method frequently in your cycle implementation, especially in loops. - * - * @throws ScriptStoppedException if the thread has been interrupted - */ - public static void checkInterrupted() throws ScriptStoppedException { - if (Thread.currentThread().isInterrupted()) { - throw new ScriptStoppedException(); - } - } - - /** - * The core logic of the script. - * - *

This method is called repeatedly in a loop by {@link #run()} for the specified duration. - * Subclasses must override this method to implement their specific bot behavior. - * - *

Note: This method is called synchronously on the running thread. Use the provided sleep - * methods and call {@link #checkInterrupted()} frequently to enable immediate stopping. - */ - protected void cycle() { - // override this - } - - /** - * Exposes the local controller to children of this class. - * - * @return The controller object. - */ - public Controller controller() { - return controller; - } -} +package com.chromascape.base; + +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.runtime.exception.ScriptStoppedException; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Abstract base class representing a generic automation script with lifecycle management. + * + *

Provides a timed execution framework where the script runs cycles until the script is stopped + * externally. + * + *

Manages the underlying Controller instance. Subclasses should override {@link #cycle()} to + * define the script's main logic. + */ +public abstract class BaseScript { + private final Controller controller; + private static final Logger logger = LogManager.getLogger(BaseScript.class); + private volatile boolean running = true; + private Thread scriptThread; + + /** Constructs a BaseScript. */ + public BaseScript() { + controller = new Controller(); + } + + /** + * Runs the script lifecycle. + * + *

Initializes the controller, logs start and stop events, then continuously invokes the {@link + * #cycle()} method until the script is stopped. Checks for thread interruption and stops + * gracefully if detected. + * + *

This method blocks until completion. + */ + public final void run() { + scriptThread = Thread.currentThread(); + controller.init(); + StatisticsManager.reset(); + + try { + while (running) { + StatisticsManager.incrementCycles(); + if (Thread.currentThread().isInterrupted()) { + logger.info("Thread interrupted, exiting."); + break; + } + try { + cycle(); + } catch (ScriptStoppedException e) { + logger.error("Cycle interrupted: {}", e.getMessage()); + break; + } catch (Exception e) { + StateManager.setState(BotState.ERROR); + logger.error("Exception in cycle: {}, {}", e.getMessage(), e.getStackTrace()); + break; + } + } + } finally { + logger.info("Stopping and cleaning up."); + controller.shutdown(); + } + logger.info("Finished running script."); + } + + /** + * Stops the script execution by interrupting the script thread. + * + *

Can be called externally (e.g., via UI controls or programmatically) to request an immediate + * stop of the running script. If the script is already stopped, this method does nothing. + */ + public void stop() { + if (!running) { + return; + } + logger.info("Stop requested"); + running = false; + + // Interrupt the script thread instead of throwing exception + if (scriptThread != null) { + scriptThread.interrupt(); + } + } + + /** + * Pauses the current thread for the specified number of milliseconds. + * + *

If the sleep is interrupted, this method throws ScriptStoppedException to enable immediate + * stopping. + * + * @param ms the duration to sleep in milliseconds + * @throws ScriptStoppedException if the thread is interrupted during sleep + */ + public static void waitMillis(long ms) { + StateManager.setState(BotState.WAITING); + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore interrupt status + throw new ScriptStoppedException(); + } + } + + /** + * Pauses the current thread for a random duration between {@code min} and {@code max} + * milliseconds (inclusive). + * + *

This method internally calls {@link #waitMillis(long)} with a randomly generated delay. + * + * @param min the minimum number of milliseconds to sleep (inclusive) + * @param max the maximum number of milliseconds to sleep (inclusive) + * @throws IllegalArgumentException if {@code min} is greater than {@code max} + * @throws ScriptStoppedException if the thread is interrupted during sleep + */ + public static void waitRandomMillis(long min, long max) throws ScriptStoppedException { + if (min > max) { + throw new IllegalArgumentException("min must be less than or equal to max"); + } + waitMillis(ThreadLocalRandom.current().nextLong(min, max + 1)); + } + + /** + * Checks if the current thread has been interrupted and throws ScriptStoppedException if so. Call + * this method frequently in your cycle implementation, especially in loops. + * + * @throws ScriptStoppedException if the thread has been interrupted + */ + public static void checkInterrupted() throws ScriptStoppedException { + if (Thread.currentThread().isInterrupted()) { + throw new ScriptStoppedException(); + } + } + + /** + * The core logic of the script. + * + *

This method is called repeatedly in a loop by {@link #run()} for the specified duration. + * Subclasses must override this method to implement their specific bot behavior. + * + *

Note: This method is called synchronously on the running thread. Use the provided sleep + * methods and call {@link #checkInterrupted()} frequently to enable immediate stopping. + */ + protected void cycle() { + // override this + } + + /** + * Exposes the local controller to children of this class. + * + * @return The controller object. + */ + public Controller controller() { + return controller; + } +} diff --git a/src/main/java/com/chromascape/controller/Controller.java b/src/main/java/com/chromascape/controller/Controller.java index 3a3e24e..cc0deb2 100644 --- a/src/main/java/com/chromascape/controller/Controller.java +++ b/src/main/java/com/chromascape/controller/Controller.java @@ -1,155 +1,155 @@ -package com.chromascape.controller; - -import com.chromascape.utils.core.input.keyboard.VirtualKeyboardUtils; -import com.chromascape.utils.core.input.mouse.VirtualMouseUtils; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.screen.window.ProcessManagerFactory; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import com.chromascape.utils.domain.walker.Walker; -import com.chromascape.utils.domain.zones.ZoneManager; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * The central controller managing the lifecycle and access to core stateful utilities for input - * simulation, screen capture, zone management, and hotkey listening. - * - *

Responsible for initializing and shutting down resources and enforcing runtime state checks to - * prevent access to utilities when inactive. - * - *

This class abstracts and coordinates lower-level modules required for automation scripts. - */ -public class Controller { - - /** Represents the current running state of the controller. */ - private enum ControllerState { - STOPPED, - RUNNING - } - - private ControllerState state; - - private RemoteInput remoteInput; - private VirtualMouseUtils virtualMouseUtils; - private VirtualKeyboardUtils virtualKeyboardUtils; - private ZoneManager zoneManager; - private Walker walker; - private static final Logger logger = LogManager.getLogger(Controller.class); - - /** Constructs a new Controller instance. */ - public Controller() { - this.state = ControllerState.STOPPED; - } - - /** - * Initializes and starts the controller, setting up all core utilities needed for the bot to - * operate, including input devices, hotkey listener, screen capture, and zone management. - * - *

This method queries the target client window, configures input hooks, and prepares the - * internal state for running. - */ - public void init() { - logger.info("Setting up Font masks..."); - Ocr.loadFont("Plain 11"); - Ocr.loadFont("Plain 12"); - Ocr.loadFont("Bold 12"); - - logger.info("Setting up Remote Input Library..."); - // Obtain process ID of the target window to initialize input injection - remoteInput = new RemoteInput(ProcessManagerFactory.getProcessManager().getPid()); - // Give screen manager access to Remote Input to grab screen buffer - ScreenManager.setRemoteInput(remoteInput); - - // Initialize virtual input utilities with current window bounds and fullscreen status - logger.info("Initialising mouse and keyboard utils..."); - virtualMouseUtils = new VirtualMouseUtils(remoteInput); - virtualKeyboardUtils = new VirtualKeyboardUtils(remoteInput); - - logger.info("Pre-loading and instantiating zones..."); - // Initialize zone management with fixed mode option - zoneManager = new ZoneManager(); - // Initialise gameView instead of LazyLoading, to improve startup overhead - zoneManager.getGameView(); - - state = ControllerState.RUNNING; - - // Initialises a walker to provide the script with Walking functionality through the DAX API - walker = new Walker(this); - logger.info("Controller State: {}", state); - } - - /** - * Shuts down the controller and releases all resources. - * - *

This stops input injection, stops hotkey listening, and prevents further access to stateful - * utilities until re-initialized. - */ - public void shutdown() { - remoteInput.close(); - state = ControllerState.STOPPED; - logger.info("Shutting down"); - } - - /** - * Provides access to the virtual mouse utility. - * - * @return The virtual mouse utility for simulated mouse actions. - * @throws IllegalStateException if called while the controller is not running. - */ - public VirtualMouseUtils mouse() { - assertRunning("VirtualMouseUtils"); - return virtualMouseUtils; - } - - /** - * Provides access to the virtual keyboard utility. - * - * @return The virtual keyboard utility for simulated keyboard actions. - * @throws IllegalStateException if called while the controller is not running. - */ - public VirtualKeyboardUtils keyboard() { - assertRunning("VirtualKeyboardUtils"); - return virtualKeyboardUtils; - } - - /** - * Provides access to the zone manager utility. - * - *

The ZoneManager maintains mappings of UI sub-zones to support interaction with different - * client interface areas. - * - * @return The ZoneManager instance. - * @throws IllegalStateException if called while the controller is not running. - */ - public ZoneManager zones() { - assertRunning("ZoneManager"); - return zoneManager; - } - - /** - * Provides access to the walker domain utility. - * - * @return The walker utility, to be able to pathfind in-game. - */ - public Walker walker() { - assertRunning("Walker"); - return walker; - } - - /** - * Checks that the controller is currently running before allowing access to any stateful utility, - * logging and throwing an exception if not. - * - * @param component The name of the utility being accessed. - * @throws IllegalStateException if the controller is not running. - */ - private void assertRunning(String component) { - if (state != ControllerState.RUNNING) { - if (logger != null) { - logger.info("{} accessed while bot is not running.", component); - } - throw new IllegalStateException(component + " accessed while bot is not running."); - } - } -} +package com.chromascape.controller; + +import com.chromascape.utils.core.input.keyboard.VirtualKeyboardUtils; +import com.chromascape.utils.core.input.mouse.VirtualMouseUtils; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.screen.window.ProcessManagerFactory; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import com.chromascape.utils.domain.walker.Walker; +import com.chromascape.utils.domain.zones.ZoneManager; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The central controller managing the lifecycle and access to core stateful utilities for input + * simulation, screen capture, zone management, and hotkey listening. + * + *

Responsible for initializing and shutting down resources and enforcing runtime state checks to + * prevent access to utilities when inactive. + * + *

This class abstracts and coordinates lower-level modules required for automation scripts. + */ +public class Controller { + + /** Represents the current running state of the controller. */ + private enum ControllerState { + STOPPED, + RUNNING + } + + private ControllerState state; + + private RemoteInput remoteInput; + private VirtualMouseUtils virtualMouseUtils; + private VirtualKeyboardUtils virtualKeyboardUtils; + private ZoneManager zoneManager; + private Walker walker; + private static final Logger logger = LogManager.getLogger(Controller.class); + + /** Constructs a new Controller instance. */ + public Controller() { + this.state = ControllerState.STOPPED; + } + + /** + * Initializes and starts the controller, setting up all core utilities needed for the bot to + * operate, including input devices, hotkey listener, screen capture, and zone management. + * + *

This method queries the target client window, configures input hooks, and prepares the + * internal state for running. + */ + public void init() { + logger.info("Setting up Font masks..."); + Ocr.loadFont("Plain 11"); + Ocr.loadFont("Plain 12"); + Ocr.loadFont("Bold 12"); + + logger.info("Setting up Remote Input Library..."); + // Obtain process ID of the target window to initialize input injection + remoteInput = new RemoteInput(ProcessManagerFactory.getProcessManager().getPid()); + // Give screen manager access to Remote Input to grab screen buffer + ScreenManager.setRemoteInput(remoteInput); + + // Initialize virtual input utilities with current window bounds and fullscreen status + logger.info("Initialising mouse and keyboard utils..."); + virtualMouseUtils = new VirtualMouseUtils(remoteInput); + virtualKeyboardUtils = new VirtualKeyboardUtils(remoteInput); + + logger.info("Pre-loading and instantiating zones..."); + // Initialize zone management with fixed mode option + zoneManager = new ZoneManager(); + // Initialise gameView instead of LazyLoading, to improve startup overhead + zoneManager.getGameView(); + + state = ControllerState.RUNNING; + + // Initialises a walker to provide the script with Walking functionality through the DAX API + walker = new Walker(this); + logger.info("Controller State: {}", state); + } + + /** + * Shuts down the controller and releases all resources. + * + *

This stops input injection, stops hotkey listening, and prevents further access to stateful + * utilities until re-initialized. + */ + public void shutdown() { + remoteInput.close(); + state = ControllerState.STOPPED; + logger.info("Shutting down"); + } + + /** + * Provides access to the virtual mouse utility. + * + * @return The virtual mouse utility for simulated mouse actions. + * @throws IllegalStateException if called while the controller is not running. + */ + public VirtualMouseUtils mouse() { + assertRunning("VirtualMouseUtils"); + return virtualMouseUtils; + } + + /** + * Provides access to the virtual keyboard utility. + * + * @return The virtual keyboard utility for simulated keyboard actions. + * @throws IllegalStateException if called while the controller is not running. + */ + public VirtualKeyboardUtils keyboard() { + assertRunning("VirtualKeyboardUtils"); + return virtualKeyboardUtils; + } + + /** + * Provides access to the zone manager utility. + * + *

The ZoneManager maintains mappings of UI sub-zones to support interaction with different + * client interface areas. + * + * @return The ZoneManager instance. + * @throws IllegalStateException if called while the controller is not running. + */ + public ZoneManager zones() { + assertRunning("ZoneManager"); + return zoneManager; + } + + /** + * Provides access to the walker domain utility. + * + * @return The walker utility, to be able to pathfind in-game. + */ + public Walker walker() { + assertRunning("Walker"); + return walker; + } + + /** + * Checks that the controller is currently running before allowing access to any stateful utility, + * logging and throwing an exception if not. + * + * @param component The name of the utility being accessed. + * @throws IllegalStateException if the controller is not running. + */ + private void assertRunning(String component) { + if (state != ControllerState.RUNNING) { + if (logger != null) { + logger.info("{} accessed while bot is not running.", component); + } + throw new IllegalStateException(component + " accessed while bot is not running."); + } + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoAgilityScript.java b/src/main/java/com/chromascape/scripts/DemoAgilityScript.java index 837b1bc..a4b1621 100644 --- a/src/main/java/com/chromascape/scripts/DemoAgilityScript.java +++ b/src/main/java/com/chromascape/scripts/DemoAgilityScript.java @@ -1,220 +1,220 @@ -package com.chromascape.scripts; - -import com.chromascape.api.DiscordNotification; -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Minimap; -import com.chromascape.utils.actions.MovingObject; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import java.awt.Point; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; -import java.util.Random; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * An Agility script designed to be used with the ChromaScape RuneLite plugin configuration. - * - *

>>>TO USE THIS SCRIPT YOU MUST ENABLE THE PERMANENT XP BAR<<< - * - *

See instructions here: Instructions - * - *

The script relies on the improved agility plugin to show only the next visible obstacle or - * mark. - * - *

    - *
  • Green Highlight indicates the next obstacle is safe to click - *
  • Red Highlight (or absence of Green) indicates a Mark of Grace (next obstacle isn't - * highlighted green because of the plugin) - *
  • This script uses concurrency (multiple threads) to click the obstacle until a red click - * occurs. - *
- * - *

This implementation prioritizes Mark of Grace collection over course progression and includes - * fail-safe logic to prevent getting confused during the delay between looting and the plugin - * updating the obstacle highlights. - */ -public class DemoAgilityScript extends BaseScript { - - // Logger that appends to the Web UI - private static final Logger logger = LogManager.getLogger(DemoAgilityScript.class); - - // Preset tiles for specific rooftop courses - private static final Map ROOFTOP_RESET_TILES = - new HashMap<>() { - { - put("Draynor", new Point(3103, 3278)); - put("Varrock", new Point(3223, 3414)); - put("Canifis", new Point(3507, 3487)); - } - }; - - // Configuration Constants - - /** - * This is where you pick the reset tile for your script. e.g., if using Varrock, set the String - * below to "Varrock" - */ - private static final Point RESET_TILE = ROOFTOP_RESET_TILES.get("Canifis"); - - private static final int TIMEOUT_XP_CHANGE = 15; - private static final int TIMEOUT_OBSTACLE_APPEAR = 10; - - // Colour Definitions - // These are instantiated as final fields to prevent unnecessary memory allocation during cycles - private static final ColourObj OBSTACLE_COLOUR = - new ColourObj("green", new Scalar(59, 254, 254, 0), new Scalar(60, 255, 255, 0)); - private static final ColourObj MARK_COLOUR = - new ColourObj("red", new Scalar(0, 254, 254, 0), new Scalar(1, 255, 255, 0)); - - // Random used in randomising break times between obstacles - private final Random random = new Random(); - - /** - * The main execution loop of the script. - * - *

The cycle follows a priority order: - * - *

    - *
  • Check for the next obstacle highlight and mark of grace as a fallback - *
  • If present, click it and wait for xp or pickup - *
  • If neither is present, verify state and potentially walk to reset - *
  • 1% chance of taking a break after each obstacle click - *
- */ - @Override - protected void cycle() { - // Log the current XP before clicking obstacle for comparison later - // The idea is to click the obstacle then wait for XP change then loop - int previousXp = Minimap.getXp(this); - - // Make sure it's read properly - if (previousXp == -1) { - stop(); - DiscordNotification.send("Xp could not be read."); - } - - // Check the state of the course - if (!isObstacleVisible()) { - if (clickMarkOfGraceIfPresent()) { - waitForObstacleToAppear(); - } else { - recoverToResetTile(); - } - return; - } - - // Interact with the detected obstacle - // Clicking continuously until the Red X animation is detected - MovingObject.clickMovingObjectByColourObjUntilRedClick(OBSTACLE_COLOUR, this); - - // Wait for the action to complete via XP update - waitUntilXpChange(previousXp); - - // Humanizing sleep to mimic natural player behavior - // And to prevent overloading moving object logic - waitRandomMillis(650, 800); - - // 1% chance to take a break between 2 and 5 minutes after clicking an obstacle - if (random.nextInt(100) < 1) { - logger.info("Taking a break..."); - waitRandomMillis(120000, 300000); - } - } - - /** - * Manages the scenario when nothing is visible. Firstly, confirms that it's really lost, if so -> - * uses the walker to path back to the reset tile. Finally, waits for the player's animation to - * settle after reaching the true tile. - */ - private void recoverToResetTile() { - // Double check we are actually lost to protect against lag or rendering delays - waitRandomMillis(600, 800); - - if (!isObstacleVisible()) { - - int attempts = 0; - int allowedAttempts = 5; - - while (attempts < allowedAttempts) { - try { - logger.info("We are lost. Walking to reset tile."); - controller().walker().pathTo(RESET_TILE, true); - // wait for camera to stabilise and walking animation to finish at true tile. - waitRandomMillis(4000, 6000); - break; - - } catch (IOException e) { - // This exception refers to Timeout or transport error - logger.error("Walker error {}", e.getMessage()); - attempts++; - - } catch (InterruptedException e) { - // This error means that the thread was interrupted while calling Dax - DiscordNotification.send("Walker thread interrupted, catastrophic failure."); - logger.error("Walker thread interrupted, catastrophic failure."); - stop(); - } - } - } - } - - /** - * Scans the game view for the Red colour associated with a Mark of Grace and attempts to click - * it. - * - * @return true if the mouse action was taken, false if no mark was found - */ - private boolean clickMarkOfGraceIfPresent() { - BufferedImage gameView = controller().zones().getGameView(); - // You'll see that there's an extra parameter on the point selector - // This is "tightness", how closely grouped the click should be - // 15.0 or more works best for ground items, best to look from a higher camera angle - Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, MARK_COLOUR, 15, 15.0); - - if (clickLocation != null) { - controller().mouse().moveTo(clickLocation, "medium"); - controller().mouse().leftClick(); - return true; - } - return false; - } - - /** - * Blocks execution until the Total XP value changes or the timeout is reached. - * - * @param previousXp the XP value captured before the action started - */ - private void waitUntilXpChange(int previousXp) { - LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_XP_CHANGE); - // Ensure we do not hang if the initial OCR read failed and returned an empty string - while (previousXp == Minimap.getXp(this) && LocalDateTime.now().isBefore(endTime)) { - waitMillis(300); - } - } - - /** Blocks execution until the obstacle highlight appears or the timeout is reached. */ - private void waitForObstacleToAppear() { - LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_OBSTACLE_APPEAR); - while (!isObstacleVisible() && LocalDateTime.now().isBefore(endTime)) { - waitMillis(300); - } - } - - /** - * Checks if the obstacle highlight is currently present in the game view. - * - * @return true if the colour contours are detected, false otherwise - */ - private boolean isObstacleVisible() { - BufferedImage gameView = controller().zones().getGameView(); - return !ColourContours.getChromaObjsInColour(gameView, OBSTACLE_COLOUR).isEmpty(); - } -} +package com.chromascape.scripts; + +import com.chromascape.api.DiscordNotification; +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Minimap; +import com.chromascape.utils.actions.MovingObject; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import java.awt.Point; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * An Agility script designed to be used with the ChromaScape RuneLite plugin configuration. + * + *

>>>TO USE THIS SCRIPT YOU MUST ENABLE THE PERMANENT XP BAR<<< + * + *

See instructions here: Instructions + * + *

The script relies on the improved agility plugin to show only the next visible obstacle or + * mark. + * + *

    + *
  • Green Highlight indicates the next obstacle is safe to click + *
  • Red Highlight (or absence of Green) indicates a Mark of Grace (next obstacle isn't + * highlighted green because of the plugin) + *
  • This script uses concurrency (multiple threads) to click the obstacle until a red click + * occurs. + *
+ * + *

This implementation prioritizes Mark of Grace collection over course progression and includes + * fail-safe logic to prevent getting confused during the delay between looting and the plugin + * updating the obstacle highlights. + */ +public class DemoAgilityScript extends BaseScript { + + // Logger that appends to the Web UI + private static final Logger logger = LogManager.getLogger(DemoAgilityScript.class); + + // Preset tiles for specific rooftop courses + private static final Map ROOFTOP_RESET_TILES = + new HashMap<>() { + { + put("Draynor", new Point(3103, 3278)); + put("Varrock", new Point(3223, 3414)); + put("Canifis", new Point(3507, 3487)); + } + }; + + // Configuration Constants + + /** + * This is where you pick the reset tile for your script. e.g., if using Varrock, set the String + * below to "Varrock" + */ + private static final Point RESET_TILE = ROOFTOP_RESET_TILES.get("Canifis"); + + private static final int TIMEOUT_XP_CHANGE = 15; + private static final int TIMEOUT_OBSTACLE_APPEAR = 10; + + // Colour Definitions + // These are instantiated as final fields to prevent unnecessary memory allocation during cycles + private static final ColourObj OBSTACLE_COLOUR = + new ColourObj("green", new Scalar(59, 254, 254, 0), new Scalar(60, 255, 255, 0)); + private static final ColourObj MARK_COLOUR = + new ColourObj("red", new Scalar(0, 254, 254, 0), new Scalar(1, 255, 255, 0)); + + // Random used in randomising break times between obstacles + private final Random random = new Random(); + + /** + * The main execution loop of the script. + * + *

The cycle follows a priority order: + * + *

    + *
  • Check for the next obstacle highlight and mark of grace as a fallback + *
  • If present, click it and wait for xp or pickup + *
  • If neither is present, verify state and potentially walk to reset + *
  • 1% chance of taking a break after each obstacle click + *
+ */ + @Override + protected void cycle() { + // Log the current XP before clicking obstacle for comparison later + // The idea is to click the obstacle then wait for XP change then loop + int previousXp = Minimap.getXp(this); + + // Make sure it's read properly + if (previousXp == -1) { + stop(); + DiscordNotification.send("Xp could not be read."); + } + + // Check the state of the course + if (!isObstacleVisible()) { + if (clickMarkOfGraceIfPresent()) { + waitForObstacleToAppear(); + } else { + recoverToResetTile(); + } + return; + } + + // Interact with the detected obstacle + // Clicking continuously until the Red X animation is detected + MovingObject.clickMovingObjectByColourObjUntilRedClick(OBSTACLE_COLOUR, this); + + // Wait for the action to complete via XP update + waitUntilXpChange(previousXp); + + // Humanizing sleep to mimic natural player behavior + // And to prevent overloading moving object logic + waitRandomMillis(650, 800); + + // 1% chance to take a break between 2 and 5 minutes after clicking an obstacle + if (random.nextInt(100) < 1) { + logger.info("Taking a break..."); + waitRandomMillis(120000, 300000); + } + } + + /** + * Manages the scenario when nothing is visible. Firstly, confirms that it's really lost, if so -> + * uses the walker to path back to the reset tile. Finally, waits for the player's animation to + * settle after reaching the true tile. + */ + private void recoverToResetTile() { + // Double check we are actually lost to protect against lag or rendering delays + waitRandomMillis(600, 800); + + if (!isObstacleVisible()) { + + int attempts = 0; + int allowedAttempts = 5; + + while (attempts < allowedAttempts) { + try { + logger.info("We are lost. Walking to reset tile."); + controller().walker().pathTo(RESET_TILE, true); + // wait for camera to stabilise and walking animation to finish at true tile. + waitRandomMillis(4000, 6000); + break; + + } catch (IOException e) { + // This exception refers to Timeout or transport error + logger.error("Walker error {}", e.getMessage()); + attempts++; + + } catch (InterruptedException e) { + // This error means that the thread was interrupted while calling Dax + DiscordNotification.send("Walker thread interrupted, catastrophic failure."); + logger.error("Walker thread interrupted, catastrophic failure."); + stop(); + } + } + } + } + + /** + * Scans the game view for the Red colour associated with a Mark of Grace and attempts to click + * it. + * + * @return true if the mouse action was taken, false if no mark was found + */ + private boolean clickMarkOfGraceIfPresent() { + BufferedImage gameView = controller().zones().getGameView(); + // You'll see that there's an extra parameter on the point selector + // This is "tightness", how closely grouped the click should be + // 15.0 or more works best for ground items, best to look from a higher camera angle + Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, MARK_COLOUR, 15, 15.0); + + if (clickLocation != null) { + controller().mouse().moveTo(clickLocation, "medium"); + controller().mouse().leftClick(); + return true; + } + return false; + } + + /** + * Blocks execution until the Total XP value changes or the timeout is reached. + * + * @param previousXp the XP value captured before the action started + */ + private void waitUntilXpChange(int previousXp) { + LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_XP_CHANGE); + // Ensure we do not hang if the initial OCR read failed and returned an empty string + while (previousXp == Minimap.getXp(this) && LocalDateTime.now().isBefore(endTime)) { + waitMillis(300); + } + } + + /** Blocks execution until the obstacle highlight appears or the timeout is reached. */ + private void waitForObstacleToAppear() { + LocalDateTime endTime = LocalDateTime.now().plusSeconds(TIMEOUT_OBSTACLE_APPEAR); + while (!isObstacleVisible() && LocalDateTime.now().isBefore(endTime)) { + waitMillis(300); + } + } + + /** + * Checks if the obstacle highlight is currently present in the game view. + * + * @return true if the colour contours are detected, false otherwise + */ + private boolean isObstacleVisible() { + BufferedImage gameView = controller().zones().getGameView(); + return !ColourContours.getChromaObjsInColour(gameView, OBSTACLE_COLOUR).isEmpty(); + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoFishingScript.java b/src/main/java/com/chromascape/scripts/DemoFishingScript.java index 0f9636d..d8cfd34 100644 --- a/src/main/java/com/chromascape/scripts/DemoFishingScript.java +++ b/src/main/java/com/chromascape/scripts/DemoFishingScript.java @@ -1,200 +1,200 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Idler; -import com.chromascape.utils.actions.ItemDropper; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.time.LocalDateTime; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * A demo script. Created to show off how to use certain utilities, namely: - * - *
    - *
  • Ocr and how to read text in screen regions. - *
  • How to use the Idler - *
  • Dropping items in a human like manner using the mouse - *
  • Template matching, how to use the MatchResult, and how to search for images on-screen - *
  • Colour detection within the gameView - *
  • The use of the {@link PointSelector} actions utility - *
- * - *

This is a DEMO script. It's not intended to be used, but rather as a reference. ChromaScape is - * not liable for any damages incurred whilst using DEMO scripts. Images not included. - */ -public class DemoFishingScript extends BaseScript { - - private static final String flyFishingRod = "/images/user/Fly_fishing_rod.png"; - private static final String feather = "/images/user/Feather.png"; - - private static final Logger logger = LogManager.getLogger(DemoFishingScript.class); - - private static final int IDLE_TIMEOUT_SECONDS = 300; - private static final int WALK_TIMEOUT_SECONDS = 17; - - /** - * Overridden cycle. Repeats all tasks within, until stop() is called from either the Web UI, or - * from within the script. - */ - @Override - protected void cycle() { - if (!checkIfCorrectInventoryLayout()) { - logger.warn("Fly-fishing rod must be in inventory slot 27 / idx 26"); - logger.warn("Feathers must be in inventory slot 28 / idx 27"); - logger.info("The top of feather image should be cropped by 10 px"); - stop(); - } - - clickFishingSpot(); - - waitUntilStoppedMoving(); - - waitUntilStoppedFishing(); - logger.info("Is idle"); - - // If ran out of bait, stop - if (checkChatPopup("have")) { - logger.warn("Ran out of bait!"); - stop(); - } - - // If inventory full, drop all fish - if (checkChatPopup("carry")) { - logger.warn("Pop-up found"); - dropAllFish(); - } - } - - /** - * Queries {@link DemoFishingScript#getCurrentWorldPos()} every tick until the player has stopped - * moving or the WALK_TIMEOUT_SECONDS is reached. Blocks execution of the script until either - * condition is met. - */ - private void waitUntilStoppedMoving() { - LocalDateTime end = LocalDateTime.now().plusSeconds(WALK_TIMEOUT_SECONDS); - String currentTile = getCurrentWorldPos(); - while (LocalDateTime.now().isBefore(end)) { - waitMillis(650); - if (currentTile.equals(getCurrentWorldPos())) { - return; - } - currentTile = getCurrentWorldPos(); - } - } - - /** - * Uses Ocr on the Grid Info box's Tile subzone. - * - * @return the tile position as a String. - */ - private String getCurrentWorldPos() { - Rectangle zone = controller().zones().getGridInfo().get("Tile"); - ColourObj colour = ColourInstances.getByName("White"); - return Ocr.extractText(zone, "Plain 12", colour, true); - } - - /** - * Checks if the inventory layout is as expected. The inventory layout needs to be in a specific - * format to ensure that the dropping of items looks human. Will check for a fly-fishing rod in - * index 26 and feathers in index 27. - * - * @return {@code boolean} true if correct, false if not. - */ - private boolean checkIfCorrectInventoryLayout() { - logger.info("Checking if inventory layout is valid"); - - Rectangle invSlot27 = controller().zones().getInventorySlots().get(26); - Rectangle invSlot28 = controller().zones().getInventorySlots().get(27); - - BufferedImage invSlot27Image = ScreenManager.captureZone(invSlot27); - BufferedImage invSlot28Image = ScreenManager.captureZone(invSlot28); - - MatchResult slot27Match = TemplateMatching.match(flyFishingRod, invSlot27Image, 0.15); - MatchResult slot28Match = TemplateMatching.match(feather, invSlot28Image, 0.15); - - if (!slot27Match.success()) { - logger.error("Slot 27 / idx 26 does not contain a fly fishing rod."); - return false; - } - - if (!slot28Match.success()) { - logger.error("Slot 28 / idx 27 does not contain feathers."); - return false; - } - - return true; - } - - /** - * Drops all fish in the inventory in a human-like manner. Designed to only be called when the - * inventory is full, because it doesn't check whether there is an item in any specific slot. - */ - private void dropAllFish() { - logger.info("Dropping all fish"); - - int[] excludeSlots = {26, 27}; - ItemDropper.dropAll(this, ItemDropper.DropPattern.ZIGZAG, excludeSlots); - } - - /** - * Checks if the chat contains a specified phrase in the font {@code Quill 8}. Uses the Ocr module - * to look for the phrase in the {@code Chat} zone. - * - * @param phrase The phrase to look for in the chat. - * @return true if found, else false. - */ - private boolean checkChatPopup(String phrase) { - Rectangle chat = controller().zones().getChatTabs().get("Chat"); - ColourObj black = ColourInstances.getByName("Black"); - String extraction = Ocr.extractText(chat, "Quill 8", black, true); - return extraction.contains(phrase); - } - - /** - * Clicks the {@code Cyan} colour which denotes a fishing spot within the GameView {@link - * BufferedImage}. Generates a random click point to click within the contour of the found {@code - * Cyan} object. - */ - private void clickFishingSpot() { - logger.info("Clicking fishing spot"); - BufferedImage gameView = controller().zones().getGameView(); - - Point clickLocation = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); - if (clickLocation == null) { - logger.error("clickLocation is null!"); - stop(); - } - controller().mouse().moveTo(clickLocation, "medium"); - controller().mouse().leftClick(); - } - - /** - * Iterates over checking for idle, if the player can't carry any more fish, and if the player has - * run out of bait. Blocks the main thread until one of these events occurs or the TIMEOUT_SECONDS - * have elapsed. - */ - private void waitUntilStoppedFishing() { - LocalDateTime end = LocalDateTime.now().plusSeconds(IDLE_TIMEOUT_SECONDS); - while (LocalDateTime.now().isBefore(end)) { - if (Idler.waitUntilIdle(this, 3)) { - return; - } - if (checkChatPopup("carry")) { - return; - } - if (checkChatPopup("have")) { - return; - } - } - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Idler; +import com.chromascape.utils.actions.ItemDropper; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.time.LocalDateTime; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A demo script. Created to show off how to use certain utilities, namely: + * + *

    + *
  • Ocr and how to read text in screen regions. + *
  • How to use the Idler + *
  • Dropping items in a human like manner using the mouse + *
  • Template matching, how to use the MatchResult, and how to search for images on-screen + *
  • Colour detection within the gameView + *
  • The use of the {@link PointSelector} actions utility + *
+ * + *

This is a DEMO script. It's not intended to be used, but rather as a reference. ChromaScape is + * not liable for any damages incurred whilst using DEMO scripts. Images not included. + */ +public class DemoFishingScript extends BaseScript { + + private static final String flyFishingRod = "/images/user/Fly_fishing_rod.png"; + private static final String feather = "/images/user/Feather.png"; + + private static final Logger logger = LogManager.getLogger(DemoFishingScript.class); + + private static final int IDLE_TIMEOUT_SECONDS = 300; + private static final int WALK_TIMEOUT_SECONDS = 17; + + /** + * Overridden cycle. Repeats all tasks within, until stop() is called from either the Web UI, or + * from within the script. + */ + @Override + protected void cycle() { + if (!checkIfCorrectInventoryLayout()) { + logger.warn("Fly-fishing rod must be in inventory slot 27 / idx 26"); + logger.warn("Feathers must be in inventory slot 28 / idx 27"); + logger.info("The top of feather image should be cropped by 10 px"); + stop(); + } + + clickFishingSpot(); + + waitUntilStoppedMoving(); + + waitUntilStoppedFishing(); + logger.info("Is idle"); + + // If ran out of bait, stop + if (checkChatPopup("have")) { + logger.warn("Ran out of bait!"); + stop(); + } + + // If inventory full, drop all fish + if (checkChatPopup("carry")) { + logger.warn("Pop-up found"); + dropAllFish(); + } + } + + /** + * Queries {@link DemoFishingScript#getCurrentWorldPos()} every tick until the player has stopped + * moving or the WALK_TIMEOUT_SECONDS is reached. Blocks execution of the script until either + * condition is met. + */ + private void waitUntilStoppedMoving() { + LocalDateTime end = LocalDateTime.now().plusSeconds(WALK_TIMEOUT_SECONDS); + String currentTile = getCurrentWorldPos(); + while (LocalDateTime.now().isBefore(end)) { + waitMillis(650); + if (currentTile.equals(getCurrentWorldPos())) { + return; + } + currentTile = getCurrentWorldPos(); + } + } + + /** + * Uses Ocr on the Grid Info box's Tile subzone. + * + * @return the tile position as a String. + */ + private String getCurrentWorldPos() { + Rectangle zone = controller().zones().getGridInfo().get("Tile"); + ColourObj colour = ColourInstances.getByName("White"); + return Ocr.extractText(zone, "Plain 12", colour, true); + } + + /** + * Checks if the inventory layout is as expected. The inventory layout needs to be in a specific + * format to ensure that the dropping of items looks human. Will check for a fly-fishing rod in + * index 26 and feathers in index 27. + * + * @return {@code boolean} true if correct, false if not. + */ + private boolean checkIfCorrectInventoryLayout() { + logger.info("Checking if inventory layout is valid"); + + Rectangle invSlot27 = controller().zones().getInventorySlots().get(26); + Rectangle invSlot28 = controller().zones().getInventorySlots().get(27); + + BufferedImage invSlot27Image = ScreenManager.captureZone(invSlot27); + BufferedImage invSlot28Image = ScreenManager.captureZone(invSlot28); + + MatchResult slot27Match = TemplateMatching.match(flyFishingRod, invSlot27Image, 0.15); + MatchResult slot28Match = TemplateMatching.match(feather, invSlot28Image, 0.15); + + if (!slot27Match.success()) { + logger.error("Slot 27 / idx 26 does not contain a fly fishing rod."); + return false; + } + + if (!slot28Match.success()) { + logger.error("Slot 28 / idx 27 does not contain feathers."); + return false; + } + + return true; + } + + /** + * Drops all fish in the inventory in a human-like manner. Designed to only be called when the + * inventory is full, because it doesn't check whether there is an item in any specific slot. + */ + private void dropAllFish() { + logger.info("Dropping all fish"); + + int[] excludeSlots = {26, 27}; + ItemDropper.dropAll(this, ItemDropper.DropPattern.ZIGZAG, excludeSlots); + } + + /** + * Checks if the chat contains a specified phrase in the font {@code Quill 8}. Uses the Ocr module + * to look for the phrase in the {@code Chat} zone. + * + * @param phrase The phrase to look for in the chat. + * @return true if found, else false. + */ + private boolean checkChatPopup(String phrase) { + Rectangle chat = controller().zones().getChatTabs().get("Chat"); + ColourObj black = ColourInstances.getByName("Black"); + String extraction = Ocr.extractText(chat, "Quill 8", black, true); + return extraction.contains(phrase); + } + + /** + * Clicks the {@code Cyan} colour which denotes a fishing spot within the GameView {@link + * BufferedImage}. Generates a random click point to click within the contour of the found {@code + * Cyan} object. + */ + private void clickFishingSpot() { + logger.info("Clicking fishing spot"); + BufferedImage gameView = controller().zones().getGameView(); + + Point clickLocation = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); + if (clickLocation == null) { + logger.error("clickLocation is null!"); + stop(); + } + controller().mouse().moveTo(clickLocation, "medium"); + controller().mouse().leftClick(); + } + + /** + * Iterates over checking for idle, if the player can't carry any more fish, and if the player has + * run out of bait. Blocks the main thread until one of these events occurs or the TIMEOUT_SECONDS + * have elapsed. + */ + private void waitUntilStoppedFishing() { + LocalDateTime end = LocalDateTime.now().plusSeconds(IDLE_TIMEOUT_SECONDS); + while (LocalDateTime.now().isBefore(end)) { + if (Idler.waitUntilIdle(this, 3)) { + return; + } + if (checkChatPopup("carry")) { + return; + } + if (checkChatPopup("have")) { + return; + } + } + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoMiningScript.java b/src/main/java/com/chromascape/scripts/DemoMiningScript.java index b4e6862..e6acdbb 100644 --- a/src/main/java/com/chromascape/scripts/DemoMiningScript.java +++ b/src/main/java/com/chromascape/scripts/DemoMiningScript.java @@ -1,79 +1,79 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.Idler; -import com.chromascape.utils.actions.ItemDropper; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * A demo script that automates basic mining behavior in the client. - * - *

The script demonstrates simple bot actions such as: - * - *

    - *
  • Clicking on ore rocks to mine - *
  • Detecting when the inventory is full - *
  • Dropping ore using shift-click - *
  • Idling until "You are now idle!" message appears - *
- * - *

This is intended as an example implementation built on top of {@link BaseScript}. - */ -public class DemoMiningScript extends BaseScript { - - private static final Logger logger = LogManager.getLogger(DemoMiningScript.class); - private static final String ironOre = "/images/user/Iron_ore.png"; - - /** - * Executes one cycle of the script logic. - * - *

If the inventory is full, the script drops ore. Otherwise, it attempts to click an ore rock - * and mine it, then idles briefly before repeating. - */ - @Override - protected void cycle() { - if (isInventoryFull()) { - ItemDropper.dropAll(this); - } - clickOre(); - waitRandomMillis(800, 1000); - Idler.waitUntilIdle(this, 20); - } - - /** - * Attempts to locate and click on an ore rock in the game view. - * - *

If no suitable rock is found, the script stops. - */ - private void clickOre() { - BufferedImage gameView = controller().zones().getGameView(); - Point clickLoc = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); - if (clickLoc == null) { - logger.error("Click location is null"); - stop(); - return; - } - controller().mouse().moveTo(clickLoc, "medium"); - controller().mouse().leftClick(); - } - - /** - * Checks whether the player’s inventory is full by examining the final inventory slot for the - * presence of an iron ore image. - * - * @return {@code true} if the inventory is full, otherwise {@code false} - */ - private boolean isInventoryFull() { - Rectangle invSlot = controller().zones().getInventorySlots().get(27); - BufferedImage invSlotImg = ScreenManager.captureZone(invSlot); - Rectangle match = TemplateMatching.match(ironOre, invSlotImg, 0.05).bounds(); - return match != null; - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.Idler; +import com.chromascape.utils.actions.ItemDropper; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A demo script that automates basic mining behavior in the client. + * + *

The script demonstrates simple bot actions such as: + * + *

    + *
  • Clicking on ore rocks to mine + *
  • Detecting when the inventory is full + *
  • Dropping ore using shift-click + *
  • Idling until "You are now idle!" message appears + *
+ * + *

This is intended as an example implementation built on top of {@link BaseScript}. + */ +public class DemoMiningScript extends BaseScript { + + private static final Logger logger = LogManager.getLogger(DemoMiningScript.class); + private static final String ironOre = "/images/user/Iron_ore.png"; + + /** + * Executes one cycle of the script logic. + * + *

If the inventory is full, the script drops ore. Otherwise, it attempts to click an ore rock + * and mine it, then idles briefly before repeating. + */ + @Override + protected void cycle() { + if (isInventoryFull()) { + ItemDropper.dropAll(this); + } + clickOre(); + waitRandomMillis(800, 1000); + Idler.waitUntilIdle(this, 20); + } + + /** + * Attempts to locate and click on an ore rock in the game view. + * + *

If no suitable rock is found, the script stops. + */ + private void clickOre() { + BufferedImage gameView = controller().zones().getGameView(); + Point clickLoc = PointSelector.getRandomPointInColour(gameView, "Cyan", 15); + if (clickLoc == null) { + logger.error("Click location is null"); + stop(); + return; + } + controller().mouse().moveTo(clickLoc, "medium"); + controller().mouse().leftClick(); + } + + /** + * Checks whether the player’s inventory is full by examining the final inventory slot for the + * presence of an iron ore image. + * + * @return {@code true} if the inventory is full, otherwise {@code false} + */ + private boolean isInventoryFull() { + Rectangle invSlot = controller().zones().getInventorySlots().get(27); + BufferedImage invSlotImg = ScreenManager.captureZone(invSlot); + Rectangle match = TemplateMatching.match(ironOre, invSlotImg, 0.05).bounds(); + return match != null; + } +} diff --git a/src/main/java/com/chromascape/scripts/DemoWineScript.java b/src/main/java/com/chromascape/scripts/DemoWineScript.java index ce5edee..f04eb66 100644 --- a/src/main/java/com/chromascape/scripts/DemoWineScript.java +++ b/src/main/java/com/chromascape/scripts/DemoWineScript.java @@ -1,192 +1,192 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.actions.PointSelector; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.awt.image.BufferedImage; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * DemoWineScript serves as a tutorial and example script to demonstrate how to automate basic tasks - * using the ChromaScape framework. - * - *

Warning: This script is NOT intended for actual use or to be run at all! Running it may - * violate terms of service of the target application and result in a ban. - * - *

The script automates a simplified "wine making" task by interacting with a game UI through - * template matching, clicking, and keyboard inputs. - */ -public class DemoWineScript extends BaseScript { - - private final Logger logger = LogManager.getLogger(this.getClass()); - - private static final String grapes = "/images/user/Grapes.png"; - private static final String jugs = "/images/user/Jug_of_water.png"; - private static final String dumpBank = "/images/user/Dump_bank.png"; - private static final String unfermented = "/images/user/Unfermented_wine.png"; - - private static final int MAX_ATTEMPTS = 15; - private static final int INVENT_SLOT_GRAPES = 13; - private static final int INVENT_SLOT_JUGS = 14; - - private boolean bankFlag = true; - - /** - * The core logic of the script. This function will loop repeatedly until {@link #stop()} is - * called. You should avoid putting all your script logic directly inside this function, instead - * split it up into other functions as shown below. - */ - @Override - protected void cycle() { - if (bankFlag) { - clickBank(); // Open the bank once at the start of the script - waitRandomMillis(700, 900); - bankFlag = false; - // Cannot start in bank because UI needs to initialise - } - - clickImage(grapes, "fast", 0.07); // Take out grapes - waitRandomMillis(300, 600); - - clickImage(jugs, "slow", 0.065); // Take out water jugs - waitRandomMillis(400, 500); - - pressEscape(); // Exit bank UI - waitRandomMillis(600, 800); - - clickInvSlot(INVENT_SLOT_JUGS, "fast"); // Click the jugs of water in the inventory - waitRandomMillis(400, 500); - - clickInvSlot(INVENT_SLOT_GRAPES, "medium"); // Use the jugs on the grapes to start making wine - waitRandomMillis(800, 900); - - pressSpace(); // Accept the start button - waitRandomMillis(17000, 18000); // Wait for wines to combine - - clickBank(); // Open the bank to drop off items - waitRandomMillis(700, 900); - - clickImage(dumpBank, "medium", 0.055); // Put the fermenting wines in the bank to repeat - waitRandomMillis(650, 750); - - if (checkIfImageInvSlot1(unfermented, 0.055)) { // Repeating because bank is weird - controller().mouse().leftClick(); - waitRandomMillis(600, 800); - } - } - - /** - * Simulates pressing the Escape key by sending the key press and release events to the client - * keyboard controller. - */ - private void pressEscape() { - controller().keyboard().sendKeyDown(KeyEvent.VK_ESCAPE); - waitRandomMillis(80, 100); - controller().keyboard().sendKeyRelease(KeyEvent.VK_ESCAPE); - } - - /** - * Simulates pressing the Space key by sending the key press and release events to the client - * keyboard controller. - */ - private void pressSpace() { - controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); - waitRandomMillis(300, 500); - controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); - } - - /** - * Attempts to locate and click the purple bank object within the game view. It searches for - * purple contours, then clicks a randomly distributed point inside the contour bounding box, - * retrying up to a maximum number of attempts. Logs failures and stops the script if unable to - * click successfully. - */ - private void clickBank() { - Point clickLocation = - PointSelector.getRandomPointInColour( - controller().zones().getGameView(), "Cyan", MAX_ATTEMPTS); - - if (clickLocation == null) { - logger.error("clickBank click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, "medium"); - logger.info("Clicked on purple bank object at {}", clickLocation); - - controller().mouse().leftClick(); - } - - /** - * Searches for the provided image template within the current game view, then clicks a random - * point within the detected bounding box if the match exceeds the defined threshold. - * - * @param imagePath the BufferedImage template to locate and click within the game view - * @param speed the speed that the mouse moves to click the image - * @param threshold the openCV threshold to decide if a match exists - */ - private void clickImage(String imagePath, String speed, double threshold) { - BufferedImage gameView = controller().zones().getGameView(); - Point clickLocation = PointSelector.getRandomPointInImage(imagePath, gameView, threshold); - - if (clickLocation == null) { - logger.error("clickImage click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, speed); - - controller().mouse().leftClick(); - logger.info("Clicked on image at {}", clickLocation); - } - - /** - * Clicks a random point within the bounding box of a given inventory slot. - * - * @param slot the index of the inventory slot to click (0-27) - * @param speed the speed that the mouse moves to click the image - */ - private void clickInvSlot(int slot, String speed) { - Rectangle boundingBox = controller().zones().getInventorySlots().get(slot); - if (boundingBox == null || boundingBox.isEmpty()) { - logger.info("Inventory slot {} not found.", slot); - stop(); - return; - } - - Point clickLocation = ClickDistribution.generateRandomPoint(boundingBox); - - if (clickLocation == null) { - logger.error("clickInventSlot click location is null"); - stop(); - } - - controller().mouse().moveTo(clickLocation, speed); - - controller().mouse().leftClick(); - logger.info("Clicked inventory slot {} at {}", slot, clickLocation); - } - - /** - * Checks if an image exists in the first inventory slot. - * - * @param imagePath the path to the image being searched - * @param threshold the openCV threshold to decide if a match exists - * @return true if the image exists in the inventory slot 1, else false - */ - private boolean checkIfImageInvSlot1(String imagePath, double threshold) { - BufferedImage inventorySlot1 = - ScreenManager.captureZone(controller().zones().getInventorySlots().get(0)); - - MatchResult result = TemplateMatching.match(imagePath, inventorySlot1, threshold); - - return result.success(); - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.actions.PointSelector; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * DemoWineScript serves as a tutorial and example script to demonstrate how to automate basic tasks + * using the ChromaScape framework. + * + *

Warning: This script is NOT intended for actual use or to be run at all! Running it may + * violate terms of service of the target application and result in a ban. + * + *

The script automates a simplified "wine making" task by interacting with a game UI through + * template matching, clicking, and keyboard inputs. + */ +public class DemoWineScript extends BaseScript { + + private final Logger logger = LogManager.getLogger(this.getClass()); + + private static final String grapes = "/images/user/Grapes.png"; + private static final String jugs = "/images/user/Jug_of_water.png"; + private static final String dumpBank = "/images/user/Dump_bank.png"; + private static final String unfermented = "/images/user/Unfermented_wine.png"; + + private static final int MAX_ATTEMPTS = 15; + private static final int INVENT_SLOT_GRAPES = 13; + private static final int INVENT_SLOT_JUGS = 14; + + private boolean bankFlag = true; + + /** + * The core logic of the script. This function will loop repeatedly until {@link #stop()} is + * called. You should avoid putting all your script logic directly inside this function, instead + * split it up into other functions as shown below. + */ + @Override + protected void cycle() { + if (bankFlag) { + clickBank(); // Open the bank once at the start of the script + waitRandomMillis(700, 900); + bankFlag = false; + // Cannot start in bank because UI needs to initialise + } + + clickImage(grapes, "fast", 0.07); // Take out grapes + waitRandomMillis(300, 600); + + clickImage(jugs, "slow", 0.065); // Take out water jugs + waitRandomMillis(400, 500); + + pressEscape(); // Exit bank UI + waitRandomMillis(600, 800); + + clickInvSlot(INVENT_SLOT_JUGS, "fast"); // Click the jugs of water in the inventory + waitRandomMillis(400, 500); + + clickInvSlot(INVENT_SLOT_GRAPES, "medium"); // Use the jugs on the grapes to start making wine + waitRandomMillis(800, 900); + + pressSpace(); // Accept the start button + waitRandomMillis(17000, 18000); // Wait for wines to combine + + clickBank(); // Open the bank to drop off items + waitRandomMillis(700, 900); + + clickImage(dumpBank, "medium", 0.055); // Put the fermenting wines in the bank to repeat + waitRandomMillis(650, 750); + + if (checkIfImageInvSlot1(unfermented, 0.055)) { // Repeating because bank is weird + controller().mouse().leftClick(); + waitRandomMillis(600, 800); + } + } + + /** + * Simulates pressing the Escape key by sending the key press and release events to the client + * keyboard controller. + */ + private void pressEscape() { + controller().keyboard().sendKeyDown(KeyEvent.VK_ESCAPE); + waitRandomMillis(80, 100); + controller().keyboard().sendKeyRelease(KeyEvent.VK_ESCAPE); + } + + /** + * Simulates pressing the Space key by sending the key press and release events to the client + * keyboard controller. + */ + private void pressSpace() { + controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); + waitRandomMillis(300, 500); + controller().keyboard().sendKeyDown(KeyEvent.VK_SPACE); + } + + /** + * Attempts to locate and click the purple bank object within the game view. It searches for + * purple contours, then clicks a randomly distributed point inside the contour bounding box, + * retrying up to a maximum number of attempts. Logs failures and stops the script if unable to + * click successfully. + */ + private void clickBank() { + Point clickLocation = + PointSelector.getRandomPointInColour( + controller().zones().getGameView(), "Cyan", MAX_ATTEMPTS); + + if (clickLocation == null) { + logger.error("clickBank click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, "medium"); + logger.info("Clicked on purple bank object at {}", clickLocation); + + controller().mouse().leftClick(); + } + + /** + * Searches for the provided image template within the current game view, then clicks a random + * point within the detected bounding box if the match exceeds the defined threshold. + * + * @param imagePath the BufferedImage template to locate and click within the game view + * @param speed the speed that the mouse moves to click the image + * @param threshold the openCV threshold to decide if a match exists + */ + private void clickImage(String imagePath, String speed, double threshold) { + BufferedImage gameView = controller().zones().getGameView(); + Point clickLocation = PointSelector.getRandomPointInImage(imagePath, gameView, threshold); + + if (clickLocation == null) { + logger.error("clickImage click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, speed); + + controller().mouse().leftClick(); + logger.info("Clicked on image at {}", clickLocation); + } + + /** + * Clicks a random point within the bounding box of a given inventory slot. + * + * @param slot the index of the inventory slot to click (0-27) + * @param speed the speed that the mouse moves to click the image + */ + private void clickInvSlot(int slot, String speed) { + Rectangle boundingBox = controller().zones().getInventorySlots().get(slot); + if (boundingBox == null || boundingBox.isEmpty()) { + logger.info("Inventory slot {} not found.", slot); + stop(); + return; + } + + Point clickLocation = ClickDistribution.generateRandomPoint(boundingBox); + + if (clickLocation == null) { + logger.error("clickInventSlot click location is null"); + stop(); + } + + controller().mouse().moveTo(clickLocation, speed); + + controller().mouse().leftClick(); + logger.info("Clicked inventory slot {} at {}", slot, clickLocation); + } + + /** + * Checks if an image exists in the first inventory slot. + * + * @param imagePath the path to the image being searched + * @param threshold the openCV threshold to decide if a match exists + * @return true if the image exists in the inventory slot 1, else false + */ + private boolean checkIfImageInvSlot1(String imagePath, double threshold) { + BufferedImage inventorySlot1 = + ScreenManager.captureZone(controller().zones().getInventorySlots().get(0)); + + MatchResult result = TemplateMatching.match(imagePath, inventorySlot1, threshold); + + return result.success(); + } +} diff --git a/src/main/java/com/chromascape/scripts/Screenshotter.java b/src/main/java/com/chromascape/scripts/Screenshotter.java index ad51b15..3617923 100644 --- a/src/main/java/com/chromascape/scripts/Screenshotter.java +++ b/src/main/java/com/chromascape/scripts/Screenshotter.java @@ -1,49 +1,49 @@ -package com.chromascape.scripts; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.image.BufferedImage; -import java.io.File; -import javax.imageio.ImageIO; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Creates screenshots of the client and stores it in an external folder "/output". Screenshots are - * saved as "original.png". Screenshots taken by this class are used by the colour picker in the web - * UI. This is a single cycle program that exits out immediately after completion. Served as a "user - * script" in the web UI, however started via a dedicated button, rather than started as a normal - * script. - */ -public class Screenshotter extends BaseScript { - - /** - * The logger is specially initialised to be used in this program. This is exactly how you should - * access it in a user script. - */ - private final Logger logger = LogManager.getLogger(Screenshotter.class); - - public static final String ORIGINAL_IMAGE_PATH = "output/original.png"; - - /** Same constructor as super (BaseScript). */ - public Screenshotter() { - super(); - } - - /** - * Takes a screenshot and saves it in the "/output" directory on the same level as /src. Although - * in the BaseScript - this function is repeated until the specified time duration is met - Here, - * because this is a one time task it exits out early by calling "stop()". - */ - @Override - protected void cycle() { - BufferedImage sc = ScreenManager.captureWindow(); - System.out.println("Screenshotter cycle"); - try { - ImageIO.write(sc, "png", new File(ORIGINAL_IMAGE_PATH)); - } catch (Exception e) { - logger.error(e.getMessage()); - } - stop(); - } -} +package com.chromascape.scripts; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Creates screenshots of the client and stores it in an external folder "/output". Screenshots are + * saved as "original.png". Screenshots taken by this class are used by the colour picker in the web + * UI. This is a single cycle program that exits out immediately after completion. Served as a "user + * script" in the web UI, however started via a dedicated button, rather than started as a normal + * script. + */ +public class Screenshotter extends BaseScript { + + /** + * The logger is specially initialised to be used in this program. This is exactly how you should + * access it in a user script. + */ + private final Logger logger = LogManager.getLogger(Screenshotter.class); + + public static final String ORIGINAL_IMAGE_PATH = "output/original.png"; + + /** Same constructor as super (BaseScript). */ + public Screenshotter() { + super(); + } + + /** + * Takes a screenshot and saves it in the "/output" directory on the same level as /src. Although + * in the BaseScript - this function is repeated until the specified time duration is met - Here, + * because this is a one time task it exits out early by calling "stop()". + */ + @Override + protected void cycle() { + BufferedImage sc = ScreenManager.captureWindow(); + System.out.println("Screenshotter cycle"); + try { + ImageIO.write(sc, "png", new File(ORIGINAL_IMAGE_PATH)); + } catch (Exception e) { + logger.error(e.getMessage()); + } + stop(); + } +} diff --git a/src/main/java/com/chromascape/utils/actions/Idler.java b/src/main/java/com/chromascape/utils/actions/Idler.java index 55a61e4..a6165cf 100644 --- a/src/main/java/com/chromascape/utils/actions/Idler.java +++ b/src/main/java/com/chromascape/utils/actions/Idler.java @@ -1,61 +1,61 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import java.time.Duration; -import java.time.Instant; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Utility class for handling idle behavior in scripts. - * - *

This class provides functionality to pause execution for a given amount of time, or until the - * game client indicates the player has become idle again through a chat message. - */ -public class Idler { - - private static final Logger logger = LogManager.getLogger(Idler.class); - private static volatile String lastMessage = ""; - - private static final ColourObj black = - new ColourObj("black", new Scalar(0, 0, 0, 0), new Scalar(0, 0, 0, 0)); - private static final ColourObj chatRed = - new ColourObj("chatRed", new Scalar(177, 229, 239, 0), new Scalar(179, 240, 240, 0)); - - /** - * Waits until either the specified timeout has elapsed or until the client chatbox reports that - * the player is idle. - * - *

Specifically, this method monitors the "Latest Message" zone in the chatbox for a red - * message containing the substring {@code "idle"} or {@code "moving"}, which typically appears - * when using the Idle Notifier plugin - * - * @param base the active {@link BaseScript} instance, usually passed as {@code this} - * @param timeoutSeconds the maximum number of seconds to remain idle before continuing - * @return {@code true} if the idle message was found, {@code false} if the timeout was reached - */ - public static boolean waitUntilIdle(BaseScript base, int timeoutSeconds) { - // Initial wait to prevent race condition to previous idle message. - BaseScript.waitMillis(600); - BaseScript.checkInterrupted(); - Instant start = Instant.now(); - Instant deadline = start.plus(Duration.ofSeconds(timeoutSeconds)); - while (Instant.now().isBefore(deadline)) { - // Throttle wait to reduce lag, this is enough. - BaseScript.waitMillis(300); - Rectangle latestMessage = base.controller().zones().getChatTabs().get("Latest Message"); - String idleText = Ocr.extractText(latestMessage, "Plain 12", chatRed, true); - String timeStamp = Ocr.extractText(latestMessage, "Plain 12", black, true); - if ((idleText.contains("moving") || idleText.contains("idle")) - && !timeStamp.equals(lastMessage)) { - lastMessage = timeStamp; - return true; - } - } - return false; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import java.time.Duration; +import java.time.Instant; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Utility class for handling idle behavior in scripts. + * + *

This class provides functionality to pause execution for a given amount of time, or until the + * game client indicates the player has become idle again through a chat message. + */ +public class Idler { + + private static final Logger logger = LogManager.getLogger(Idler.class); + private static volatile String lastMessage = ""; + + private static final ColourObj black = + new ColourObj("black", new Scalar(0, 0, 0, 0), new Scalar(0, 0, 0, 0)); + private static final ColourObj chatRed = + new ColourObj("chatRed", new Scalar(177, 229, 239, 0), new Scalar(179, 240, 240, 0)); + + /** + * Waits until either the specified timeout has elapsed or until the client chatbox reports that + * the player is idle. + * + *

Specifically, this method monitors the "Latest Message" zone in the chatbox for a red + * message containing the substring {@code "idle"} or {@code "moving"}, which typically appears + * when using the Idle Notifier plugin + * + * @param base the active {@link BaseScript} instance, usually passed as {@code this} + * @param timeoutSeconds the maximum number of seconds to remain idle before continuing + * @return {@code true} if the idle message was found, {@code false} if the timeout was reached + */ + public static boolean waitUntilIdle(BaseScript base, int timeoutSeconds) { + // Initial wait to prevent race condition to previous idle message. + BaseScript.waitMillis(600); + BaseScript.checkInterrupted(); + Instant start = Instant.now(); + Instant deadline = start.plus(Duration.ofSeconds(timeoutSeconds)); + while (Instant.now().isBefore(deadline)) { + // Throttle wait to reduce lag, this is enough. + BaseScript.waitMillis(300); + Rectangle latestMessage = base.controller().zones().getChatTabs().get("Latest Message"); + String idleText = Ocr.extractText(latestMessage, "Plain 12", chatRed, true); + String timeStamp = Ocr.extractText(latestMessage, "Plain 12", black, true); + if ((idleText.contains("moving") || idleText.contains("idle")) + && !timeStamp.equals(lastMessage)) { + lastMessage = timeStamp; + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/ItemDropper.java b/src/main/java/com/chromascape/utils/actions/ItemDropper.java index cceb2da..b416e3b 100644 --- a/src/main/java/com/chromascape/utils/actions/ItemDropper.java +++ b/src/main/java/com/chromascape/utils/actions/ItemDropper.java @@ -1,126 +1,126 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * An actions utility - a utility that does commonly repeated tasks found in bot scripts. This - * utility provides functionality for dropping items using human-like patterns. - */ -public class ItemDropper { - - private static final Logger logger = LogManager.getLogger(ItemDropper.class); - private static final int INVENTORY_SIZE = 28; - - /** Defines the order in which items should be dropped. */ - public enum DropPattern { - - /** Drops items left-to-right, top-to-bottom (0, 1, 2...). */ - STANDARD, - - /** - * Drops items using a "2-Row Vertical Strip" logic. - * - *

Drops pairs of items vertically (e.g., 0 then 4, 1 then 5) moving across, then moves to - * the next set of two rows, imo this looks most human. - */ - ZIGZAG - } - - /** - * Drops all items in the inventory using the default ZigZag (2-Row Strip) pattern. - * - * @param baseScript The script that's running (Keyword: {@code this}). - */ - public static void dropAll(BaseScript baseScript) { - dropAll(baseScript, DropPattern.ZIGZAG, new int[0]); - } - - /** - * Drops all items in the inventory using a specified pattern. - * - * @param baseScript The script that's running (Keyword: {@code this}). - * @param pattern The {@link DropPattern} to use for index generation. - * @param exclude An int array with indexes NOT to be dropped. - */ - public static void dropAll(BaseScript baseScript, DropPattern pattern, int[] exclude) { - if (baseScript.controller() == null) { - logger.error("Controller is null, cannot drop items."); - return; - } - - logger.info("Dropping all items using pattern: {}", pattern); - - List slotsToDrop = generateSlotIndices(pattern); - - // Start Shift-Drop - baseScript.controller().keyboard().sendKeyDown(KeyEvent.VK_SHIFT); - BaseScript.waitRandomMillis(400, 850); - - try { - for (int slotIndex : slotsToDrop) { - if (slotIndex >= baseScript.controller().zones().getInventorySlots().size()) { - continue; - } - - if (Arrays.stream(exclude).anyMatch(x -> x == slotIndex)) { - continue; - } - - Rectangle slotZone = baseScript.controller().zones().getInventorySlots().get(slotIndex); - Point clickPoint = ClickDistribution.generateRandomPoint(slotZone); - - baseScript.controller().mouse().moveTo(clickPoint, "fast"); - baseScript.controller().mouse().leftClick(); - BaseScript.waitRandomMillis(40, 90); - } - } finally { - BaseScript.waitRandomMillis(100, 200); - baseScript.controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT); - } - } - - /** - * Generates a list of inventory slot indices based on the selected pattern. - * - * @param pattern The pattern strategy. - * @return A list of integers representing the order of slots to click. - */ - private static List generateSlotIndices(DropPattern pattern) { - List indices = new ArrayList<>(); - - switch (pattern) { - case ZIGZAG: - // Process rows 0-1, then 2-3, then 4-5 in vertical pairs - for (int rowGroup = 0; rowGroup < 3; rowGroup++) { - int baseRowStart = rowGroup * 8; // 0, 8, 16 - for (int col = 0; col < 4; col++) { - indices.add(baseRowStart + col); // Top of pair (e.g., 0) - indices.add(baseRowStart + col + 4); // Bottom of pair (e.g., 4) - } - } - // Handle the last row (6) linearly - indices.add(24); - indices.add(25); - indices.add(26); - indices.add(27); - break; - - case STANDARD: - default: - indices = IntStream.range(0, INVENTORY_SIZE).boxed().collect(Collectors.toList()); - break; - } - return indices; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * An actions utility - a utility that does commonly repeated tasks found in bot scripts. This + * utility provides functionality for dropping items using human-like patterns. + */ +public class ItemDropper { + + private static final Logger logger = LogManager.getLogger(ItemDropper.class); + private static final int INVENTORY_SIZE = 28; + + /** Defines the order in which items should be dropped. */ + public enum DropPattern { + + /** Drops items left-to-right, top-to-bottom (0, 1, 2...). */ + STANDARD, + + /** + * Drops items using a "2-Row Vertical Strip" logic. + * + *

Drops pairs of items vertically (e.g., 0 then 4, 1 then 5) moving across, then moves to + * the next set of two rows, imo this looks most human. + */ + ZIGZAG + } + + /** + * Drops all items in the inventory using the default ZigZag (2-Row Strip) pattern. + * + * @param baseScript The script that's running (Keyword: {@code this}). + */ + public static void dropAll(BaseScript baseScript) { + dropAll(baseScript, DropPattern.ZIGZAG, new int[0]); + } + + /** + * Drops all items in the inventory using a specified pattern. + * + * @param baseScript The script that's running (Keyword: {@code this}). + * @param pattern The {@link DropPattern} to use for index generation. + * @param exclude An int array with indexes NOT to be dropped. + */ + public static void dropAll(BaseScript baseScript, DropPattern pattern, int[] exclude) { + if (baseScript.controller() == null) { + logger.error("Controller is null, cannot drop items."); + return; + } + + logger.info("Dropping all items using pattern: {}", pattern); + + List slotsToDrop = generateSlotIndices(pattern); + + // Start Shift-Drop + baseScript.controller().keyboard().sendKeyDown(KeyEvent.VK_SHIFT); + BaseScript.waitRandomMillis(400, 850); + + try { + for (int slotIndex : slotsToDrop) { + if (slotIndex >= baseScript.controller().zones().getInventorySlots().size()) { + continue; + } + + if (Arrays.stream(exclude).anyMatch(x -> x == slotIndex)) { + continue; + } + + Rectangle slotZone = baseScript.controller().zones().getInventorySlots().get(slotIndex); + Point clickPoint = ClickDistribution.generateRandomPoint(slotZone); + + baseScript.controller().mouse().moveTo(clickPoint, "fast"); + baseScript.controller().mouse().leftClick(); + BaseScript.waitRandomMillis(40, 90); + } + } finally { + BaseScript.waitRandomMillis(100, 200); + baseScript.controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT); + } + } + + /** + * Generates a list of inventory slot indices based on the selected pattern. + * + * @param pattern The pattern strategy. + * @return A list of integers representing the order of slots to click. + */ + private static List generateSlotIndices(DropPattern pattern) { + List indices = new ArrayList<>(); + + switch (pattern) { + case ZIGZAG: + // Process rows 0-1, then 2-3, then 4-5 in vertical pairs + for (int rowGroup = 0; rowGroup < 3; rowGroup++) { + int baseRowStart = rowGroup * 8; // 0, 8, 16 + for (int col = 0; col < 4; col++) { + indices.add(baseRowStart + col); // Top of pair (e.g., 0) + indices.add(baseRowStart + col + 4); // Bottom of pair (e.g., 4) + } + } + // Handle the last row (6) linearly + indices.add(24); + indices.add(25); + indices.add(26); + indices.add(27); + break; + + case STANDARD: + default: + indices = IntStream.range(0, INVENTORY_SIZE).boxed().collect(Collectors.toList()); + break; + } + return indices; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/Minimap.java b/src/main/java/com/chromascape/utils/actions/Minimap.java index 18633e9..b7558ca 100644 --- a/src/main/java/com/chromascape/utils/actions/Minimap.java +++ b/src/main/java/com/chromascape/utils/actions/Minimap.java @@ -1,91 +1,91 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Class for retrieving information from the minimap area, such as orb data (HP, prayer, run, and - * spec) and XP data. - */ -public class Minimap { - - private static final ColourObj textColour = - new ColourObj("green", new Scalar(0, 254, 254, 0), new Scalar(60, 255, 255, 0)); - - private static final ColourObj white = - new ColourObj("White", new Scalar(0, 0, 255, 0), new Scalar(0, 0, 255, 0)); - - /** - * Returns the character's current hitpoints, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getHp(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("hpText"); - String hpText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (hpText.isEmpty()) { - return -1; - } - return Integer.parseInt(hpText); - } - - /** - * Returns the character's current prayer, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getPrayer(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("prayerText"); - String prayerText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (prayerText.isEmpty()) { - return -1; - } - return Integer.parseInt(prayerText); - } - - /** - * Returns the character's current run energy, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getRun(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("runText"); - String runText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (runText.isEmpty()) { - return -1; - } - return Integer.parseInt(runText); - } - - /** - * Returns the character's current special attack energy, or -1 if not found. - * - * @param script The current running script (typically pass {@code this}) - */ - public static int getSpec(BaseScript script) { - Rectangle textArea = script.controller().zones().getMinimap().get("specText"); - String specText = Ocr.extractText(textArea, "Plain 11", textColour, true); - if (specText.isEmpty()) { - return -1; - } - return Integer.parseInt(specText); - } - - /** - * Retrieves the current XP from beside the minimap UI element. - * - *

It is highly recommended to set the XP bar to permanent, as seen here: see Requirements - * - * @param script The current running script (typically pass {@code this}) - * @return the XP integer, or empty if not found - */ - public static int getXp(BaseScript script) { - Rectangle xpZone = script.controller().zones().getMinimap().get("totalXP"); - String xpText = Ocr.extractText(xpZone, "Plain 12", white, true); - return Integer.parseInt(xpText.trim().replace(",", "")); - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Class for retrieving information from the minimap area, such as orb data (HP, prayer, run, and + * spec) and XP data. + */ +public class Minimap { + + private static final ColourObj textColour = + new ColourObj("green", new Scalar(0, 254, 254, 0), new Scalar(60, 255, 255, 0)); + + private static final ColourObj white = + new ColourObj("White", new Scalar(0, 0, 255, 0), new Scalar(0, 0, 255, 0)); + + /** + * Returns the character's current hitpoints, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getHp(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("hpText"); + String hpText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (hpText.isEmpty()) { + return -1; + } + return Integer.parseInt(hpText); + } + + /** + * Returns the character's current prayer, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getPrayer(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("prayerText"); + String prayerText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (prayerText.isEmpty()) { + return -1; + } + return Integer.parseInt(prayerText); + } + + /** + * Returns the character's current run energy, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getRun(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("runText"); + String runText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (runText.isEmpty()) { + return -1; + } + return Integer.parseInt(runText); + } + + /** + * Returns the character's current special attack energy, or -1 if not found. + * + * @param script The current running script (typically pass {@code this}) + */ + public static int getSpec(BaseScript script) { + Rectangle textArea = script.controller().zones().getMinimap().get("specText"); + String specText = Ocr.extractText(textArea, "Plain 11", textColour, true); + if (specText.isEmpty()) { + return -1; + } + return Integer.parseInt(specText); + } + + /** + * Retrieves the current XP from beside the minimap UI element. + * + *

It is highly recommended to set the XP bar to permanent, as seen here: see Requirements + * + * @param script The current running script (typically pass {@code this}) + * @return the XP integer, or empty if not found + */ + public static int getXp(BaseScript script) { + Rectangle xpZone = script.controller().zones().getMinimap().get("totalXP"); + String xpText = Ocr.extractText(xpZone, "Plain 12", white, true); + return Integer.parseInt(xpText.trim().replace(",", "")); + } +} diff --git a/src/main/java/com/chromascape/utils/actions/MouseOver.java b/src/main/java/com/chromascape/utils/actions/MouseOver.java index 1ecc875..b0384e6 100644 --- a/src/main/java/com/chromascape/utils/actions/MouseOver.java +++ b/src/main/java/com/chromascape/utils/actions/MouseOver.java @@ -1,212 +1,212 @@ -package com.chromascape.utils.actions; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; -import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.ocr.Ocr; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.bytedeco.javacpp.indexer.UByteIndexer; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * An actions utility to provide a high level API for MouseOverText. Allows the user to get the text - * of the MouseOverText zone regardless of colour. - * - *

Allows the user to grab the MouseOverText as a string, excluding spaces. - */ -public class MouseOver { - - // Higher -> brighter pixels are considered shadows - private static final int SHADOW_THRESHOLD = 120; - - // Lower -> stricter tolerance on similar colours - private static final int COLOUR_TOLERANCE = 20; - - private static final ColourObj ORANGE = - new ColourObj("Orange", new Scalar(16, 224, 255, 0), new Scalar(16, 224, 255, 0)); - - private static final Mat OLIVE = new Mat(1, 1, CV_8UC3, new Scalar(17, 73, 73, 0)); - - /** - * Extracts text from the MouseOverText zone. Does not include spaces. - * - * @param baseScript {@code this} the script that the user is running. - * @return a String of all text found. - */ - public static String getText(BaseScript baseScript) { - // Get image of MouseOverText - Rectangle zone = baseScript.controller().zones().getMouseOver(); - BufferedImage capture = ScreenManager.captureZone(zone); - // BGR image of the zone - Mat image = TemplateMatching.bufferedImageToMat(capture); - // Replace orange to olive to exclude interface colours near zone - Mat orangeMask = ColourContours.extractColours(image, ORANGE); - image.setTo(OLIVE, orangeMask); - // Release mask - orangeMask.release(); - // Extract possible character colours based on shadow - Set textColours = extractTextColour(image); - Object[] uniqueTextColours = removeDuplicatesWithinRange(textColours); - // Set non character pixels to black - Mat mask = maskText(uniqueTextColours, image); - // For testing - // DisplayImage.display(TemplateMatching.matToBufferedImage(mask)); - return Ocr.extractTextFromMask(mask, "Bold 12", true); - } - - /** - * Eliminates any colours that can be considered similar enough within the colour threshold. - * - * @param textColours a list of packed RGB integers. - * @return Unique colours that can't be considered similar. - */ - private static Object[] removeDuplicatesWithinRange(Set textColours) { - List palette = new ArrayList<>(); - - for (Integer newColour : textColours) { - boolean found = false; - - for (Integer existingColour : palette) { - - if (isInThreshold(existingColour, newColour)) { - found = true; - break; - } - } - - if (!found) { - palette.add(newColour); - } - } - return palette.toArray(); - } - - /** - * Compares each pixel in an image against a palette of unique colours that represent text colours - * in an image. Creates a mask with white pixels where text should be and the background black. - * - * @param colours a list of unique colours not within the colour threshold of each other. - * @param image the image containing text to mask. - * @return a CV_8UC1 mask with white pixels where text should be and the rest black. - */ - private static Mat maskText(Object[] colours, Mat image) { - Mat mask = new Mat(image.rows(), image.cols(), CV_8UC1, new Scalar(0)); - UByteIndexer img = image.createIndexer(); - UByteIndexer out = mask.createIndexer(); - - for (int y = 0; y < image.rows(); y++) { - for (int x = 0; x < image.cols(); x++) { - int b = img.get(y, x, 0) & 0xFF; - int g = img.get(y, x, 1) & 0xFF; - int r = img.get(y, x, 2) & 0xFF; - int packed = (r << 16) | (g << 8) | b; - - boolean isWhite = false; - - for (Object colour : colours) { - Integer colourInt = (Integer) colour; - - if (isInThreshold(colourInt, packed)) { - isWhite = true; - break; - } - } - - out.put(y, x, isWhite ? 255 : 0); - } - } - - img.release(); - out.release(); - return mask; - } - - /** - * Compares the Euclidean distance between the RGB values of two pixels against a static tolerance - * value to gauge if they are similar enough. - * - * @param colour1 one of the colours to compare. - * @param colour2 one of the colours to compare. - * @return whether the colours are similar enough to consider part of the same text colour. - */ - private static boolean isInThreshold(int colour1, int colour2) { - // Unpack first colour - int r = (colour1 >> 16) & 0xFF; - int g = (colour1 >> 8) & 0xFF; - int b = colour1 & 0xFF; - // Unpack second - int r2 = (colour2 >> 16) & 0xFF; - int g2 = (colour2 >> 8) & 0xFF; - int b2 = colour2 & 0xFF; - // Calculate difference - int dr = r - r2; - int dg = g - g2; - int db = b - b2; - // Calculate distance^2 - int dist2 = dr * dr + dg * dg + db * db; - return dist2 <= COLOUR_TOLERANCE * COLOUR_TOLERANCE; - } - - private static boolean isShadow(int r, int g, int b) { - return ((r + g + b) / 3 < SHADOW_THRESHOLD) - && (r < SHADOW_THRESHOLD * 2) - && (g < SHADOW_THRESHOLD * 2) - && (b < SHADOW_THRESHOLD * 2); - } - - /** - * Iterates through an image's pixels, determines whether a pixel is a shadow of text based on - * brightness, and then saves the pixel top-left of it. Returns a set of unique pixels that are - * considered to be part of text. - * - * @param image the input image which may contain text. - * @return a unique set of packed RGB integers representing pixels belonging to text. - */ - private static Set extractTextColour(Mat image) { - int width = image.cols(); - int height = image.rows(); - - UByteIndexer indexer = image.createIndexer(); - - Set textColours = new HashSet<>(); - - for (int y = 1; y < height; y++) { - for (int x = 1; x < width; x++) { - - int blue = indexer.get(y, x, 0) & 0xFF; - int green = indexer.get(y, x, 1) & 0xFF; - int red = indexer.get(y, x, 2) & 0xFF; - - if (isShadow(red, green, blue)) { - int matchBlue = indexer.get(y - 1, x - 1, 0) & 0xFF; - int matchGreen = indexer.get(y - 1, x - 1, 1) & 0xFF; - int matchRed = indexer.get(y - 1, x - 1, 2) & 0xFF; - - if (isShadow(matchRed, matchGreen, matchBlue)) { - continue; - } - - int r = matchRed & 0xFF; - int g = matchGreen & 0xFF; - int b = matchBlue & 0xFF; - - int packed = (r << 16) | (g << 8) | b; - - textColours.add(packed); - } - } - } - return textColours; - } -} +package com.chromascape.utils.actions; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.ocr.Ocr; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.bytedeco.javacpp.indexer.UByteIndexer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * An actions utility to provide a high level API for MouseOverText. Allows the user to get the text + * of the MouseOverText zone regardless of colour. + * + *

Allows the user to grab the MouseOverText as a string, excluding spaces. + */ +public class MouseOver { + + // Higher -> brighter pixels are considered shadows + private static final int SHADOW_THRESHOLD = 120; + + // Lower -> stricter tolerance on similar colours + private static final int COLOUR_TOLERANCE = 20; + + private static final ColourObj ORANGE = + new ColourObj("Orange", new Scalar(16, 224, 255, 0), new Scalar(16, 224, 255, 0)); + + private static final Mat OLIVE = new Mat(1, 1, CV_8UC3, new Scalar(17, 73, 73, 0)); + + /** + * Extracts text from the MouseOverText zone. Does not include spaces. + * + * @param baseScript {@code this} the script that the user is running. + * @return a String of all text found. + */ + public static String getText(BaseScript baseScript) { + // Get image of MouseOverText + Rectangle zone = baseScript.controller().zones().getMouseOver(); + BufferedImage capture = ScreenManager.captureZone(zone); + // BGR image of the zone + Mat image = TemplateMatching.bufferedImageToMat(capture); + // Replace orange to olive to exclude interface colours near zone + Mat orangeMask = ColourContours.extractColours(image, ORANGE); + image.setTo(OLIVE, orangeMask); + // Release mask + orangeMask.release(); + // Extract possible character colours based on shadow + Set textColours = extractTextColour(image); + Object[] uniqueTextColours = removeDuplicatesWithinRange(textColours); + // Set non character pixels to black + Mat mask = maskText(uniqueTextColours, image); + // For testing + // DisplayImage.display(TemplateMatching.matToBufferedImage(mask)); + return Ocr.extractTextFromMask(mask, "Bold 12", true); + } + + /** + * Eliminates any colours that can be considered similar enough within the colour threshold. + * + * @param textColours a list of packed RGB integers. + * @return Unique colours that can't be considered similar. + */ + private static Object[] removeDuplicatesWithinRange(Set textColours) { + List palette = new ArrayList<>(); + + for (Integer newColour : textColours) { + boolean found = false; + + for (Integer existingColour : palette) { + + if (isInThreshold(existingColour, newColour)) { + found = true; + break; + } + } + + if (!found) { + palette.add(newColour); + } + } + return palette.toArray(); + } + + /** + * Compares each pixel in an image against a palette of unique colours that represent text colours + * in an image. Creates a mask with white pixels where text should be and the background black. + * + * @param colours a list of unique colours not within the colour threshold of each other. + * @param image the image containing text to mask. + * @return a CV_8UC1 mask with white pixels where text should be and the rest black. + */ + private static Mat maskText(Object[] colours, Mat image) { + Mat mask = new Mat(image.rows(), image.cols(), CV_8UC1, new Scalar(0)); + UByteIndexer img = image.createIndexer(); + UByteIndexer out = mask.createIndexer(); + + for (int y = 0; y < image.rows(); y++) { + for (int x = 0; x < image.cols(); x++) { + int b = img.get(y, x, 0) & 0xFF; + int g = img.get(y, x, 1) & 0xFF; + int r = img.get(y, x, 2) & 0xFF; + int packed = (r << 16) | (g << 8) | b; + + boolean isWhite = false; + + for (Object colour : colours) { + Integer colourInt = (Integer) colour; + + if (isInThreshold(colourInt, packed)) { + isWhite = true; + break; + } + } + + out.put(y, x, isWhite ? 255 : 0); + } + } + + img.release(); + out.release(); + return mask; + } + + /** + * Compares the Euclidean distance between the RGB values of two pixels against a static tolerance + * value to gauge if they are similar enough. + * + * @param colour1 one of the colours to compare. + * @param colour2 one of the colours to compare. + * @return whether the colours are similar enough to consider part of the same text colour. + */ + private static boolean isInThreshold(int colour1, int colour2) { + // Unpack first colour + int r = (colour1 >> 16) & 0xFF; + int g = (colour1 >> 8) & 0xFF; + int b = colour1 & 0xFF; + // Unpack second + int r2 = (colour2 >> 16) & 0xFF; + int g2 = (colour2 >> 8) & 0xFF; + int b2 = colour2 & 0xFF; + // Calculate difference + int dr = r - r2; + int dg = g - g2; + int db = b - b2; + // Calculate distance^2 + int dist2 = dr * dr + dg * dg + db * db; + return dist2 <= COLOUR_TOLERANCE * COLOUR_TOLERANCE; + } + + private static boolean isShadow(int r, int g, int b) { + return ((r + g + b) / 3 < SHADOW_THRESHOLD) + && (r < SHADOW_THRESHOLD * 2) + && (g < SHADOW_THRESHOLD * 2) + && (b < SHADOW_THRESHOLD * 2); + } + + /** + * Iterates through an image's pixels, determines whether a pixel is a shadow of text based on + * brightness, and then saves the pixel top-left of it. Returns a set of unique pixels that are + * considered to be part of text. + * + * @param image the input image which may contain text. + * @return a unique set of packed RGB integers representing pixels belonging to text. + */ + private static Set extractTextColour(Mat image) { + int width = image.cols(); + int height = image.rows(); + + UByteIndexer indexer = image.createIndexer(); + + Set textColours = new HashSet<>(); + + for (int y = 1; y < height; y++) { + for (int x = 1; x < width; x++) { + + int blue = indexer.get(y, x, 0) & 0xFF; + int green = indexer.get(y, x, 1) & 0xFF; + int red = indexer.get(y, x, 2) & 0xFF; + + if (isShadow(red, green, blue)) { + int matchBlue = indexer.get(y - 1, x - 1, 0) & 0xFF; + int matchGreen = indexer.get(y - 1, x - 1, 1) & 0xFF; + int matchRed = indexer.get(y - 1, x - 1, 2) & 0xFF; + + if (isShadow(matchRed, matchGreen, matchBlue)) { + continue; + } + + int r = matchRed & 0xFF; + int g = matchGreen & 0xFF; + int b = matchBlue & 0xFF; + + int packed = (r << 16) | (g << 8) | b; + + textColours.add(packed); + } + } + } + return textColours; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/MovingObject.java b/src/main/java/com/chromascape/utils/actions/MovingObject.java index 1bbfdb6..6ba717c 100644 --- a/src/main/java/com/chromascape/utils/actions/MovingObject.java +++ b/src/main/java/com/chromascape/utils/actions/MovingObject.java @@ -1,175 +1,175 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.concurrent.CompletableFuture; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Provides interaction for moving entities such as Agility obstacles or NPCs. - * - *

This class utilizes the red click sprite's appearance delay to asynchronously pre-calculate - * the next retry point. This ensures that if verification fails, the backup point is ready - * instantly but calculated with fresh screen data to minimize stale click locations. - * - *

Async Verification Pipeline unlike static clicking, this implementation clicks a target - * and validates success by scanning for the red X click sprite. It uses background threads to - * ensure zero downtime between a failed click and the subsequent retry. - */ -public class MovingObject { - - /** - * Half-width of the detection box. A padding of 7 creates a 14x14px capture region which is - * optimized for the 11x11px Red X sprite. Inspired by OSBC. - */ - private static final int PADDING = 7; - - /** Logger that appends to the Web UI. */ - private static final Logger logger = LogManager.getLogger(MovingObject.class); - - private static final String[] RED_CLICK_IMAGES = { - "/images/mouse_clicks/red_1.png", - "/images/mouse_clicks/red_2.png", - "/images/mouse_clicks/red_3.png", - "/images/mouse_clicks/red_4.png" - }; - - /** - * Overload for the primary click method that accepts a colour name string. It performs a lookup - * of the ColourObj from the ColourInstances. - * - * @param colour The unique name of the colour such as Agility_Green - * @param baseScript The active script instance for accessing the Controller - * @return true if a Red X was detected or false if the colour was not found or max retries were - * exceeded - * @throws InterruptedException When mouse movement is interrupted. - */ - public static boolean clickMovingObjectInColourUntilRedClick(String colour, BaseScript baseScript) - throws InterruptedException { - return clickMovingObjectByColourObjUntilRedClick(ColourInstances.getByName(colour), baseScript); - } - - /** - * Attempts to click a moving target defined by a ColourObj and verifies the action by looking for - * a red click. - * - *

    - *
  • Finds a random point within the current screen position of the colour - *
  • Clicks the point and immediately starts a background task to find the next location - *
  • Waits for the game to render the Red X interaction sprite - *
  • Captures a small region around the click and checks for the sprite - *
  • If verification fails, it retrieves the pre-calculated point and retries instantly - *
- * - * @param colour The colour of the moving object - * @param baseScript The active script instance - * @return true if the interaction was verified with a Red X or false otherwise - */ - public static boolean clickMovingObjectByColourObjUntilRedClick( - ColourObj colour, BaseScript baseScript) { - BaseScript.checkInterrupted(); - // Initial Calculation and Click - BufferedImage gameView = baseScript.controller().zones().getGameView(); - Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, colour, 15); - - if (clickLocation == null) { - return false; - } - - baseScript.controller().mouse().moveTo(clickLocation, "fast"); - baseScript.controller().mouse().leftClick(); - - int attempts = 10; - int safetyCounter = 0; - - while (safetyCounter < attempts) { - - // Start calculating the NEXT point immediately - // Running this in the background during the wait below - CompletableFuture nextPointFuture = - CompletableFuture.supplyAsync( - () -> { - BufferedImage futureView = baseScript.controller().zones().getGameView(); - // Increased scan radius for retries to catch moving targets - return PointSelector.getRandomPointByColourObj(futureView, colour, 15); - }); - - // Wait for Red X to appear due to game delay - // This 120ms covers the computation time of nextPointFuture - BaseScript.waitMillis(120); - - // Update the click image - BufferedImage clickImage = getClickImage(clickLocation); - - // Verify click - if (clickImageContainsRedClick(clickImage)) { - // Success so cancel the backup calculation - nextPointFuture.cancel(true); - return true; - } - - // Failure detected so retrieve the backup point - // This should return almost instantly - clickLocation = nextPointFuture.join(); - - if (clickLocation == null) { - logger.warn("Could not find fallback point for colour {}", colour.name()); - break; - } - - // Instant Retry - baseScript.controller().mouse().moveTo(clickLocation, "fast"); - baseScript.controller().mouse().leftClick(); - safetyCounter++; - } - - logger.error("Failed to verify red click on {} after {} attempts", colour.name(), attempts); - return false; - } - - /** - * Captures a screenshot centered on the last click location. - * - *

The region size is calculated using the PADDING constant. By default, it is large enough to - * contain the 11px interaction sprite even with minor rendering offsets. - * - * @param clickLocation The screen coordinate where the mouse last clicked - * @return A BufferedImage of the immediate area around the cursor or null if location is invalid - */ - private static BufferedImage getClickImage(Point clickLocation) { - if (clickLocation == null) { - return null; - } - - Rectangle clickRect = - new Rectangle( - clickLocation.x - PADDING, clickLocation.y - PADDING, PADDING * 2, PADDING * 2); - return ScreenManager.captureZone(clickRect); - } - - /** - * Scans the captured click image for any frame of the Red X animation. - * - *

Iterates through the preloaded red click images using template matching. - * - * @param clickImage The screenshot from the getClickImage method - * @return true if any frame of the rec click animation is present, false otherwise - */ - private static boolean clickImageContainsRedClick(BufferedImage clickImage) { - if (clickImage == null) { - return false; - } - - for (String redClickImage : RED_CLICK_IMAGES) { - return TemplateMatching.match(redClickImage, clickImage, 0.15).success(); - } - return false; - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.concurrent.CompletableFuture; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Provides interaction for moving entities such as Agility obstacles or NPCs. + * + *

This class utilizes the red click sprite's appearance delay to asynchronously pre-calculate + * the next retry point. This ensures that if verification fails, the backup point is ready + * instantly but calculated with fresh screen data to minimize stale click locations. + * + *

Async Verification Pipeline unlike static clicking, this implementation clicks a target + * and validates success by scanning for the red X click sprite. It uses background threads to + * ensure zero downtime between a failed click and the subsequent retry. + */ +public class MovingObject { + + /** + * Half-width of the detection box. A padding of 7 creates a 14x14px capture region which is + * optimized for the 11x11px Red X sprite. Inspired by OSBC. + */ + private static final int PADDING = 7; + + /** Logger that appends to the Web UI. */ + private static final Logger logger = LogManager.getLogger(MovingObject.class); + + private static final String[] RED_CLICK_IMAGES = { + "/images/mouse_clicks/red_1.png", + "/images/mouse_clicks/red_2.png", + "/images/mouse_clicks/red_3.png", + "/images/mouse_clicks/red_4.png" + }; + + /** + * Overload for the primary click method that accepts a colour name string. It performs a lookup + * of the ColourObj from the ColourInstances. + * + * @param colour The unique name of the colour such as Agility_Green + * @param baseScript The active script instance for accessing the Controller + * @return true if a Red X was detected or false if the colour was not found or max retries were + * exceeded + * @throws InterruptedException When mouse movement is interrupted. + */ + public static boolean clickMovingObjectInColourUntilRedClick(String colour, BaseScript baseScript) + throws InterruptedException { + return clickMovingObjectByColourObjUntilRedClick(ColourInstances.getByName(colour), baseScript); + } + + /** + * Attempts to click a moving target defined by a ColourObj and verifies the action by looking for + * a red click. + * + *

    + *
  • Finds a random point within the current screen position of the colour + *
  • Clicks the point and immediately starts a background task to find the next location + *
  • Waits for the game to render the Red X interaction sprite + *
  • Captures a small region around the click and checks for the sprite + *
  • If verification fails, it retrieves the pre-calculated point and retries instantly + *
+ * + * @param colour The colour of the moving object + * @param baseScript The active script instance + * @return true if the interaction was verified with a Red X or false otherwise + */ + public static boolean clickMovingObjectByColourObjUntilRedClick( + ColourObj colour, BaseScript baseScript) { + BaseScript.checkInterrupted(); + // Initial Calculation and Click + BufferedImage gameView = baseScript.controller().zones().getGameView(); + Point clickLocation = PointSelector.getRandomPointByColourObj(gameView, colour, 15); + + if (clickLocation == null) { + return false; + } + + baseScript.controller().mouse().moveTo(clickLocation, "fast"); + baseScript.controller().mouse().leftClick(); + + int attempts = 10; + int safetyCounter = 0; + + while (safetyCounter < attempts) { + + // Start calculating the NEXT point immediately + // Running this in the background during the wait below + CompletableFuture nextPointFuture = + CompletableFuture.supplyAsync( + () -> { + BufferedImage futureView = baseScript.controller().zones().getGameView(); + // Increased scan radius for retries to catch moving targets + return PointSelector.getRandomPointByColourObj(futureView, colour, 15); + }); + + // Wait for Red X to appear due to game delay + // This 120ms covers the computation time of nextPointFuture + BaseScript.waitMillis(120); + + // Update the click image + BufferedImage clickImage = getClickImage(clickLocation); + + // Verify click + if (clickImageContainsRedClick(clickImage)) { + // Success so cancel the backup calculation + nextPointFuture.cancel(true); + return true; + } + + // Failure detected so retrieve the backup point + // This should return almost instantly + clickLocation = nextPointFuture.join(); + + if (clickLocation == null) { + logger.warn("Could not find fallback point for colour {}", colour.name()); + break; + } + + // Instant Retry + baseScript.controller().mouse().moveTo(clickLocation, "fast"); + baseScript.controller().mouse().leftClick(); + safetyCounter++; + } + + logger.error("Failed to verify red click on {} after {} attempts", colour.name(), attempts); + return false; + } + + /** + * Captures a screenshot centered on the last click location. + * + *

The region size is calculated using the PADDING constant. By default, it is large enough to + * contain the 11px interaction sprite even with minor rendering offsets. + * + * @param clickLocation The screen coordinate where the mouse last clicked + * @return A BufferedImage of the immediate area around the cursor or null if location is invalid + */ + private static BufferedImage getClickImage(Point clickLocation) { + if (clickLocation == null) { + return null; + } + + Rectangle clickRect = + new Rectangle( + clickLocation.x - PADDING, clickLocation.y - PADDING, PADDING * 2, PADDING * 2); + return ScreenManager.captureZone(clickRect); + } + + /** + * Scans the captured click image for any frame of the Red X animation. + * + *

Iterates through the preloaded red click images using template matching. + * + * @param clickImage The screenshot from the getClickImage method + * @return true if any frame of the rec click animation is present, false otherwise + */ + private static boolean clickImageContainsRedClick(BufferedImage clickImage) { + if (clickImage == null) { + return false; + } + + for (String redClickImage : RED_CLICK_IMAGES) { + return TemplateMatching.match(redClickImage, clickImage, 0.15).success(); + } + return false; + } +} diff --git a/src/main/java/com/chromascape/utils/actions/PointSelector.java b/src/main/java/com/chromascape/utils/actions/PointSelector.java index 90abeab..e9d3cf8 100644 --- a/src/main/java/com/chromascape/utils/actions/PointSelector.java +++ b/src/main/java/com/chromascape/utils/actions/PointSelector.java @@ -1,253 +1,253 @@ -package com.chromascape.utils.actions; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.distribution.ClickDistribution; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ChromaObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.List; -import java.util.function.Function; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * The {@code PointSelector} class provides utility methods for selecting random points within zones - * and colours. These methods are designed to reduce code duplication and focus common actions when - * automating interactions with graphical objects like colours or images. - * - *

Features: - * - *

    - *
  • Finds a random point within the bounding box of a detected image template. - *
  • Finds a random point inside the contour of the first detected object of a specified colour. - *
  • Supports both heuristic-based distributions (dynamic sizing) and explicit - * tightness control. - *
- * - *

These utilities are commonly reused across scripts. The class does not perform any input - * actions (such as clicking), but provides the coordinates needed for it. - * - *

Typical Usage: - * - *

- * // Default heuristic distribution
- * Point imgPoint = PointSelector.getRandomPointInImage(templatePath, gameView, 0.15);
- * // Custom tightness (maybe clicking a ground item)
- * Point colorPoint = PointSelector.getRandomPointInColour(gameView, "Purple", 5, 15.0);
- * 
- * - *

All methods are static and thread-safe. - */ -public class PointSelector { - - private static final Logger logger = LogManager.getLogger(PointSelector.class); - - /** - * Searches for the provided image template within a larger image, then returns a random point - * within the detected bounding box if the match exceeds the defined threshold. Relative to the - * larger image. - * - *

This method uses the default {@link ClickDistribution} heuristic, which dynamically adjusts - * distribution spread based on the size of the found target. - * - * @param templatePath the BufferedImage template to locate within the larger image - * @param image the larger image to search inside (e.g. game view) - * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection - * valid - * @return a valid {@link Point} within the detected region, or {@code null} if no match is found - */ - public static Point getRandomPointInImage( - String templatePath, BufferedImage image, double threshold) { - // Defines which function to apply onto the rectangle found - return findPointInTemplate( - templatePath, image, threshold, ClickDistribution::generateRandomPoint); - } - - /** - * Searches for the provided image template within a larger and returns a random point with a - * specific Gaussian distribution tightness. Relative to the larger image. - * - *

This overload allows for control over where the click lands. Higher tightness values force - * the point closer to the center of the image, while lower values spread it towards the edges. - * - * @param templatePath the BufferedImage template to search for within the larger image view - * @param image the larger image to search inside (e.g. game view) - * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection - * valid - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a valid {@link Point} within the detected region, or {@code null} if no match is found - */ - public static Point getRandomPointInImage( - String templatePath, BufferedImage image, double threshold, double tightness) { - // Defines which function to apply onto the rectangle found - return findPointInTemplate( - templatePath, - image, - threshold, - rect -> ClickDistribution.generateRandomPoint(rect, tightness)); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified colour - * using the default distribution heuristic. - * - *

This is an overload for {@link #getRandomPointByColourObj(BufferedImage, ColourObj, int)}. - * It looks up the colour by name from {@link ColourInstances} at runtime. - * - * @param image the image to search in (e.g. game view) - * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. - * "Purple") - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointInColour( - BufferedImage image, String colourName, int maxAttempts) { - // Calls the public API after grabbing the colour - return getRandomPointByColourObj(image, ColourInstances.getByName(colourName), maxAttempts); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified colour - * using a specific Gaussian tightness. - * - * @param image the image to search in (e.g. game view) - * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. - * "Purple") - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointInColour( - BufferedImage image, String colourName, int maxAttempts, double tightness) { - // Call the public API after grabbing the colour - return getRandomPointByColourObj( - image, ColourInstances.getByName(colourName), maxAttempts, tightness); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified {@link - * ColourObj}. - * - *

Uses {@link ColourContours} to mask the image and extract contours. It generates a random - * point within the bounding box of the detected {@link ChromaObj} and verifies if the point lies - * within the actual contour polygon. - * - * @param image the image to search in - * @param colour the specific {@link ColourObj} to detect - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointByColourObj( - BufferedImage image, ColourObj colour, int maxAttempts) { - // Defines which function to apply onto the rectangle found - return findPointInColourInternal( - image, colour, maxAttempts, ClickDistribution::generateRandomPoint); - } - - /** - * Attempts to find a random point inside the contour of the first object of the specified {@link - * ColourObj} using a specific Gaussian tightness. - * - * @param image the image to search in - * @param colour the specific {@link ColourObj} to detect - * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the - * irregular contour - * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter - * cluster around the center - * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts - * exceeded - */ - public static Point getRandomPointByColourObj( - BufferedImage image, ColourObj colour, int maxAttempts, double tightness) { - // Defines which function to apply onto the rectangle found - return findPointInColourInternal( - image, colour, maxAttempts, rect -> ClickDistribution.generateRandomPoint(rect, tightness)); - } - - /** - * Internal abstraction for template matching logic. Executes the match and applies the provided - * point generation strategy. - */ - private static Point findPointInTemplate( - String templatePath, - BufferedImage image, - double threshold, - Function pointGenerator) { - BaseScript.checkInterrupted(); - MatchResult result = TemplateMatching.match(templatePath, image, threshold); - - if (!result.success()) { - logger.error("getRandomPointInImage failed: {}", result.message()); - return null; - } - // Applying the desired function parameter onto the bounding box and returning it - return pointGenerator.apply(result.bounds()); - } - - /** - * Internal abstraction for colour contour logic. Handles object detection, contour validation - * loops, and memory cleanup. - */ - private static Point findPointInColourInternal( - BufferedImage image, - ColourObj colour, - int maxAttempts, - Function pointGenerator) { - - List objs; - try { - objs = ColourContours.getChromaObjsInColour(image, colour); - } catch (Exception e) { - logger.error(e.getMessage()); - logger.error(e.getStackTrace()); - return null; - } - - if (objs.isEmpty()) { - logger.error("No objects found for colour: {}", colour); - return null; - } - - // Use the closest object to screen centre since only one object is desired - ChromaObj obj = ColourContours.getChromaObjClosestToCentre(objs); - try { - int attempts = 0; - // Generate initial point using the function provided (Heuristic or Tightness) - Point p = pointGenerator.apply(obj.boundingBox()); - - // Resample if the point is outside the actual pixel contour - while (!ColourContours.isPointInContour(p, obj.contour()) && attempts < maxAttempts) { - BaseScript.checkInterrupted(); - // Apply the desired function on the bounding box - p = pointGenerator.apply(obj.boundingBox()); - attempts++; - } - - if (attempts >= maxAttempts) { - logger.error( - "Failed to find a valid point in {} contour after {} attempts.", colour, maxAttempts); - return null; - } - return p; - } finally { - // Release Mat contours to free memory. - for (ChromaObj chromaObj : objs) { - chromaObj.release(); - } - } - } -} +package com.chromascape.utils.actions; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.distribution.ClickDistribution; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ChromaObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The {@code PointSelector} class provides utility methods for selecting random points within zones + * and colours. These methods are designed to reduce code duplication and focus common actions when + * automating interactions with graphical objects like colours or images. + * + *

Features: + * + *

    + *
  • Finds a random point within the bounding box of a detected image template. + *
  • Finds a random point inside the contour of the first detected object of a specified colour. + *
  • Supports both heuristic-based distributions (dynamic sizing) and explicit + * tightness control. + *
+ * + *

These utilities are commonly reused across scripts. The class does not perform any input + * actions (such as clicking), but provides the coordinates needed for it. + * + *

Typical Usage: + * + *

+ * // Default heuristic distribution
+ * Point imgPoint = PointSelector.getRandomPointInImage(templatePath, gameView, 0.15);
+ * // Custom tightness (maybe clicking a ground item)
+ * Point colorPoint = PointSelector.getRandomPointInColour(gameView, "Purple", 5, 15.0);
+ * 
+ * + *

All methods are static and thread-safe. + */ +public class PointSelector { + + private static final Logger logger = LogManager.getLogger(PointSelector.class); + + /** + * Searches for the provided image template within a larger image, then returns a random point + * within the detected bounding box if the match exceeds the defined threshold. Relative to the + * larger image. + * + *

This method uses the default {@link ClickDistribution} heuristic, which dynamically adjusts + * distribution spread based on the size of the found target. + * + * @param templatePath the BufferedImage template to locate within the larger image + * @param image the larger image to search inside (e.g. game view) + * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection + * valid + * @return a valid {@link Point} within the detected region, or {@code null} if no match is found + */ + public static Point getRandomPointInImage( + String templatePath, BufferedImage image, double threshold) { + // Defines which function to apply onto the rectangle found + return findPointInTemplate( + templatePath, image, threshold, ClickDistribution::generateRandomPoint); + } + + /** + * Searches for the provided image template within a larger and returns a random point with a + * specific Gaussian distribution tightness. Relative to the larger image. + * + *

This overload allows for control over where the click lands. Higher tightness values force + * the point closer to the center of the image, while lower values spread it towards the edges. + * + * @param templatePath the BufferedImage template to search for within the larger image view + * @param image the larger image to search inside (e.g. game view) + * @param threshold the match confidence threshold (0.0 to 1.0) required to consider a detection + * valid + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a valid {@link Point} within the detected region, or {@code null} if no match is found + */ + public static Point getRandomPointInImage( + String templatePath, BufferedImage image, double threshold, double tightness) { + // Defines which function to apply onto the rectangle found + return findPointInTemplate( + templatePath, + image, + threshold, + rect -> ClickDistribution.generateRandomPoint(rect, tightness)); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified colour + * using the default distribution heuristic. + * + *

This is an overload for {@link #getRandomPointByColourObj(BufferedImage, ColourObj, int)}. + * It looks up the colour by name from {@link ColourInstances} at runtime. + * + * @param image the image to search in (e.g. game view) + * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. + * "Purple") + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointInColour( + BufferedImage image, String colourName, int maxAttempts) { + // Calls the public API after grabbing the colour + return getRandomPointByColourObj(image, ColourInstances.getByName(colourName), maxAttempts); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified colour + * using a specific Gaussian tightness. + * + * @param image the image to search in (e.g. game view) + * @param colourName the name of the colour (must match a {@link ColourInstances} key, e.g. + * "Purple") + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointInColour( + BufferedImage image, String colourName, int maxAttempts, double tightness) { + // Call the public API after grabbing the colour + return getRandomPointByColourObj( + image, ColourInstances.getByName(colourName), maxAttempts, tightness); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified {@link + * ColourObj}. + * + *

Uses {@link ColourContours} to mask the image and extract contours. It generates a random + * point within the bounding box of the detected {@link ChromaObj} and verifies if the point lies + * within the actual contour polygon. + * + * @param image the image to search in + * @param colour the specific {@link ColourObj} to detect + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointByColourObj( + BufferedImage image, ColourObj colour, int maxAttempts) { + // Defines which function to apply onto the rectangle found + return findPointInColourInternal( + image, colour, maxAttempts, ClickDistribution::generateRandomPoint); + } + + /** + * Attempts to find a random point inside the contour of the first object of the specified {@link + * ColourObj} using a specific Gaussian tightness. + * + * @param image the image to search in + * @param colour the specific {@link ColourObj} to detect + * @param maxAttempts maximum number of re-rolls to find a point that falls exactly inside the + * irregular contour + * @param tightness the distribution divisor. Higher values (e.g., 15.0) result in a tighter + * cluster around the center + * @return a random {@link Point} inside the contour, or {@code null} if not found or max attempts + * exceeded + */ + public static Point getRandomPointByColourObj( + BufferedImage image, ColourObj colour, int maxAttempts, double tightness) { + // Defines which function to apply onto the rectangle found + return findPointInColourInternal( + image, colour, maxAttempts, rect -> ClickDistribution.generateRandomPoint(rect, tightness)); + } + + /** + * Internal abstraction for template matching logic. Executes the match and applies the provided + * point generation strategy. + */ + private static Point findPointInTemplate( + String templatePath, + BufferedImage image, + double threshold, + Function pointGenerator) { + BaseScript.checkInterrupted(); + MatchResult result = TemplateMatching.match(templatePath, image, threshold); + + if (!result.success()) { + logger.error("getRandomPointInImage failed: {}", result.message()); + return null; + } + // Applying the desired function parameter onto the bounding box and returning it + return pointGenerator.apply(result.bounds()); + } + + /** + * Internal abstraction for colour contour logic. Handles object detection, contour validation + * loops, and memory cleanup. + */ + private static Point findPointInColourInternal( + BufferedImage image, + ColourObj colour, + int maxAttempts, + Function pointGenerator) { + + List objs; + try { + objs = ColourContours.getChromaObjsInColour(image, colour); + } catch (Exception e) { + logger.error(e.getMessage()); + logger.error(e.getStackTrace()); + return null; + } + + if (objs.isEmpty()) { + logger.error("No objects found for colour: {}", colour); + return null; + } + + // Use the closest object to screen centre since only one object is desired + ChromaObj obj = ColourContours.getChromaObjClosestToCentre(objs); + try { + int attempts = 0; + // Generate initial point using the function provided (Heuristic or Tightness) + Point p = pointGenerator.apply(obj.boundingBox()); + + // Resample if the point is outside the actual pixel contour + while (!ColourContours.isPointInContour(p, obj.contour()) && attempts < maxAttempts) { + BaseScript.checkInterrupted(); + // Apply the desired function on the bounding box + p = pointGenerator.apply(obj.boundingBox()); + attempts++; + } + + if (attempts >= maxAttempts) { + logger.error( + "Failed to find a valid point in {} contour after {} attempts.", colour, maxAttempts); + return null; + } + return p; + } finally { + // Release Mat contours to free memory. + for (ChromaObj chromaObj : objs) { + chromaObj.release(); + } + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java b/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java index ef8128f..c2bd703 100644 --- a/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java +++ b/src/main/java/com/chromascape/utils/core/input/distribution/ClickDistribution.java @@ -1,153 +1,153 @@ -package com.chromascape.utils.core.input.distribution; - -import java.awt.Point; -import java.awt.Rectangle; -import java.security.SecureRandom; -import org.apache.commons.math3.distribution.MultivariateNormalDistribution; -import org.apache.commons.math3.random.MersenneTwister; -import org.apache.commons.math3.random.RandomGenerator; - -/** - * Utility class for generating biased, Gaussian-distributed click points within a rectangular UI - * region. - * - *

Instead of uniformly sampling click coordinates, this utility uses a {@link - * MultivariateNormalDistribution} centered within the given rectangle. This approach simulates - * human-like behavior by favoring points near the center while still allowing edge hits. - */ -public class ClickDistribution { - - /** Shared random generator with a secure, non-deterministic seed. */ - private static final RandomGenerator rng = new MersenneTwister(new SecureRandom().nextLong()); - - /** - * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D - * normal (Gaussian) distribution biased toward the center using internal heuristics. - * - *

The standard deviation is dynamically adjusted based on the rectangle's size to prevent - * excessive out-of-bounds sampling on small targets. - * - * @param rect the rectangular region to sample from - * @return a valid Point within {@code rect} with center-biased Gaussian randomness - */ - public static Point generateRandomPoint(Rectangle rect) { - if (isTooSmall(rect)) { - return getCenter(rect); - } - - // Calculate sigma based on internal heuristic - double stdDevX = rect.width / deviation(rect.getWidth()); - double stdDevY = rect.height / deviation(rect.getHeight()); - - return samplePoint(rect, stdDevX, stdDevY); - } - - /** - * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D - * normal (Gaussian) distribution with a custom tightness factor. - * - *

The {@code tightness} parameter controls the spread of the distribution. It acts as the - * divisor for the rectangle's dimensions when calculating standard deviation. - * - *

    - *
  • High Tightness (> 15.0): Very focused in the center. Useful for Ground items. - *
  • Low Tightness (< 3.0): Broad spread. High probability of points near edges. - *
- * - * @param rect the rectangular region to sample from - * @param tightness the factor by which to divide the dimension to get sigma. Must be positive. - * @return a valid Point within {@code rect} - * @throws IllegalArgumentException if tightness is less than or equal to zero - */ - public static Point generateRandomPoint(Rectangle rect, double tightness) { - if (tightness <= 0) { - throw new IllegalArgumentException("Tightness factor must be greater than 0"); - } - - if (isTooSmall(rect)) { - return getCenter(rect); - } - - double stdDevX = rect.width / tightness; - double stdDevY = rect.height / tightness; - - return samplePoint(rect, stdDevX, stdDevY); - } - - /** Internal helper to execute the sampling logic given specific standard deviations. */ - private static Point samplePoint(Rectangle rect, double stdDevX, double stdDevY) { - MultivariateNormalDistribution mnd = getMultivariateNormalDistribution(rect, stdDevX, stdDevY); - - Point randomPoint; - do { - double[] sample = mnd.sample(); - randomPoint = new Point((int) Math.round(sample[0]), (int) Math.round(sample[1])); - } while (!rect.contains(randomPoint)); // Resample until within bounds - - return randomPoint; - } - - /** - * Constructs a {@link MultivariateNormalDistribution} centered within the given rectangle using - * explicit standard deviations. - * - * @param rect the rectangle to derive center from - * @param stdDevX the standard deviation for the X axis - * @param stdDevY the standard deviation for the Y axis - * @return a 2D normal distribution configured with the provided spread - */ - private static MultivariateNormalDistribution getMultivariateNormalDistribution( - Rectangle rect, double stdDevX, double stdDevY) { - - double meanX = rect.getX() + rect.getWidth() / 2.0; - double meanY = rect.getY() + rect.getHeight() / 2.0; - double[] mean = {meanX, meanY}; - - double[][] covariance = { - {stdDevX * stdDevX, 0}, // No correlation between X and Y - {0, stdDevY * stdDevY} - }; - - return new MultivariateNormalDistribution(ClickDistribution.rng, mean, covariance); - } - - /** - * Heuristic used to adjust the spread of the Gaussian distribution based on rectangle size. - * - *

This prevents excessive sampling outside of bounds by reducing standard deviation for small - * targets. - * - * @param length the width or height (in pixels) of a side of the rectangle - * @return a divisor used to calculate standard deviation - */ - private static double deviation(double length) { - if (length >= 50) { - return 4.0; - } else if (length >= 25) { - return 7.0; - } else if (length >= 15) { - return 8.0; - } - return 9.0; - } - - /** - * Helper method to justify whether a rectangle is too small to conduct sampling. - * - * @param rect Rectangle to test. - * @return {@code true} if too small, else {@code false}. - */ - private static boolean isTooSmall(Rectangle rect) { - return rect.width < 5 || rect.height < 5; - } - - /** - * Helper method to return the center of a given Rectangle. - * - * @param rect The rectangle to return the center of. - * @return The {@link Point} center of the given Rectangle. - */ - private static Point getCenter(Rectangle rect) { - return new Point((int) rect.getCenterX(), (int) rect.getCenterY()); - } -} +package com.chromascape.utils.core.input.distribution; + +import java.awt.Point; +import java.awt.Rectangle; +import java.security.SecureRandom; +import org.apache.commons.math3.distribution.MultivariateNormalDistribution; +import org.apache.commons.math3.random.MersenneTwister; +import org.apache.commons.math3.random.RandomGenerator; + +/** + * Utility class for generating biased, Gaussian-distributed click points within a rectangular UI + * region. + * + *

Instead of uniformly sampling click coordinates, this utility uses a {@link + * MultivariateNormalDistribution} centered within the given rectangle. This approach simulates + * human-like behavior by favoring points near the center while still allowing edge hits. + */ +public class ClickDistribution { + + /** Shared random generator with a secure, non-deterministic seed. */ + private static final RandomGenerator rng = new MersenneTwister(new SecureRandom().nextLong()); + + /** + * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D + * normal (Gaussian) distribution biased toward the center using internal heuristics. + * + *

The standard deviation is dynamically adjusted based on the rectangle's size to prevent + * excessive out-of-bounds sampling on small targets. + * + * @param rect the rectangular region to sample from + * @return a valid Point within {@code rect} with center-biased Gaussian randomness + */ + public static Point generateRandomPoint(Rectangle rect) { + if (isTooSmall(rect)) { + return getCenter(rect); + } + + // Calculate sigma based on internal heuristic + double stdDevX = rect.width / deviation(rect.getWidth()); + double stdDevY = rect.height / deviation(rect.getHeight()); + + return samplePoint(rect, stdDevX, stdDevY); + } + + /** + * Generates a pseudo-random {@link Point} within the specified {@link Rectangle}, following a 2D + * normal (Gaussian) distribution with a custom tightness factor. + * + *

The {@code tightness} parameter controls the spread of the distribution. It acts as the + * divisor for the rectangle's dimensions when calculating standard deviation. + * + *

    + *
  • High Tightness (> 15.0): Very focused in the center. Useful for Ground items. + *
  • Low Tightness (< 3.0): Broad spread. High probability of points near edges. + *
+ * + * @param rect the rectangular region to sample from + * @param tightness the factor by which to divide the dimension to get sigma. Must be positive. + * @return a valid Point within {@code rect} + * @throws IllegalArgumentException if tightness is less than or equal to zero + */ + public static Point generateRandomPoint(Rectangle rect, double tightness) { + if (tightness <= 0) { + throw new IllegalArgumentException("Tightness factor must be greater than 0"); + } + + if (isTooSmall(rect)) { + return getCenter(rect); + } + + double stdDevX = rect.width / tightness; + double stdDevY = rect.height / tightness; + + return samplePoint(rect, stdDevX, stdDevY); + } + + /** Internal helper to execute the sampling logic given specific standard deviations. */ + private static Point samplePoint(Rectangle rect, double stdDevX, double stdDevY) { + MultivariateNormalDistribution mnd = getMultivariateNormalDistribution(rect, stdDevX, stdDevY); + + Point randomPoint; + do { + double[] sample = mnd.sample(); + randomPoint = new Point((int) Math.round(sample[0]), (int) Math.round(sample[1])); + } while (!rect.contains(randomPoint)); // Resample until within bounds + + return randomPoint; + } + + /** + * Constructs a {@link MultivariateNormalDistribution} centered within the given rectangle using + * explicit standard deviations. + * + * @param rect the rectangle to derive center from + * @param stdDevX the standard deviation for the X axis + * @param stdDevY the standard deviation for the Y axis + * @return a 2D normal distribution configured with the provided spread + */ + private static MultivariateNormalDistribution getMultivariateNormalDistribution( + Rectangle rect, double stdDevX, double stdDevY) { + + double meanX = rect.getX() + rect.getWidth() / 2.0; + double meanY = rect.getY() + rect.getHeight() / 2.0; + double[] mean = {meanX, meanY}; + + double[][] covariance = { + {stdDevX * stdDevX, 0}, // No correlation between X and Y + {0, stdDevY * stdDevY} + }; + + return new MultivariateNormalDistribution(ClickDistribution.rng, mean, covariance); + } + + /** + * Heuristic used to adjust the spread of the Gaussian distribution based on rectangle size. + * + *

This prevents excessive sampling outside of bounds by reducing standard deviation for small + * targets. + * + * @param length the width or height (in pixels) of a side of the rectangle + * @return a divisor used to calculate standard deviation + */ + private static double deviation(double length) { + if (length >= 50) { + return 4.0; + } else if (length >= 25) { + return 7.0; + } else if (length >= 15) { + return 8.0; + } + return 9.0; + } + + /** + * Helper method to justify whether a rectangle is too small to conduct sampling. + * + * @param rect Rectangle to test. + * @return {@code true} if too small, else {@code false}. + */ + private static boolean isTooSmall(Rectangle rect) { + return rect.width < 5 || rect.height < 5; + } + + /** + * Helper method to return the center of a given Rectangle. + * + * @param rect The rectangle to return the center of. + * @return The {@link Point} center of the given Rectangle. + */ + private static Point getCenter(Rectangle rect) { + return new Point((int) rect.getCenterX(), (int) rect.getCenterY()); + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java b/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java index dcfc8f0..a9fffc0 100644 --- a/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java +++ b/src/main/java/com/chromascape/utils/core/input/keyboard/VirtualKeyboardUtils.java @@ -1,119 +1,119 @@ -package com.chromascape.utils.core.input.keyboard; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.event.KeyEvent; -import java.util.Random; - -/** - * Provides high-level methods for simulating keyboard input using the RemoteInput API. The user can - * use {@link KeyEvent} objects to dictate exactly what to press and for how long. There is - * functionality to type out a string, compensating for modifier keys where necessary. - */ -public class VirtualKeyboardUtils { - - private final RemoteInput input; - - private static final Random RANDOM = new Random(); - - /** - * Constructs a VirtualKeyboardUtils instance that wraps a RemoteInput instance. - * - * @param input The RemoteInput object that can operate IO - */ - public VirtualKeyboardUtils(RemoteInput input) { - this.input = input; - } - - /** - * Updates the state of the bot for the {@link BaseScript}'s stop() function and updates the - * BotState for the UI. - */ - private void prepareInput() { - BaseScript.checkInterrupted(); - StateManager.setState(BotState.ACTING); - StatisticsManager.incrementInputs(); - } - - /** - * Sends a key down, given that it isn't already held. As this function requires an int keycode, - * please use {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your - * IDE of choice, showing you available keys which are mapped to integer keycodes. You may opt to - * look for Java VK keycodes online, however this is the best approach. - * - *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} - * - * @param javaKeyCode the {@link KeyEvent} key to hold - */ - public void sendKeyDown(int javaKeyCode) { - prepareInput(); - if (!input.isKeyHeld(javaKeyCode)) { - input.holdKey(javaKeyCode); - } - } - - /** - * Releases a key, given that it is held. As this function requires an int keycode, please use - * {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your IDE of - * choice, showing you available keys which are mapped to integer keycodes. You may opt to look - * for Java VK keycodes online, however this is the best approach. - * - *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} - * - * @param javaKeyCode the {@link KeyEvent} key to release - */ - public void sendKeyRelease(int javaKeyCode) { - prepareInput(); - if (input.isKeyHeld(javaKeyCode)) { - input.releaseKey(javaKeyCode); - } - } - - /** - * Checks whether a key is currently being held. - * - * @param javaKeyCode the {@link KeyEvent} key to check - * @return Whether the key is currently being held or not - */ - public boolean isKeyHeld(int javaKeyCode) { - return input.isKeyHeld(javaKeyCode); - } - - /** - * Types out a given string to the client window using heuristics to mimic a human. Uses default - * heuristic settings for convenience. - * - * @param string The String of characters to type out in a human like fashion - */ - public synchronized void sendString(String string) { - prepareInput(); - for (char c : string.toCharArray()) { - int keyWait = RANDOM.nextInt(30, 60); - int keyModWait = RANDOM.nextInt(30, 60); - int keyPressWait = RANDOM.nextInt(40, 85); - input.sendString(String.valueOf(c), keyWait, keyModWait); - BaseScript.waitMillis(keyPressWait); - } - } - - /** - * Types out a given string to the client window using heuristics to mimic a human. Internally - * randomises between 1x - 1.1x the given modifier value. - * - * @param string The String of characters to type out in a human like fashion - * @param keyWait The amount of time to hold a key down - * @param keyModWait The amount of time to hold a modifier key down (e.g., shift) - * @param keyPressWait The amount of time to wait between pressing keys - */ - public synchronized void sendString( - String string, int keyWait, int keyModWait, int keyPressWait) { - prepareInput(); - for (char c : string.toCharArray()) { - input.sendString(String.valueOf(c), keyWait, keyModWait); - BaseScript.waitMillis(keyPressWait); - } - } -} +package com.chromascape.utils.core.input.keyboard; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.event.KeyEvent; +import java.util.Random; + +/** + * Provides high-level methods for simulating keyboard input using the RemoteInput API. The user can + * use {@link KeyEvent} objects to dictate exactly what to press and for how long. There is + * functionality to type out a string, compensating for modifier keys where necessary. + */ +public class VirtualKeyboardUtils { + + private final RemoteInput input; + + private static final Random RANDOM = new Random(); + + /** + * Constructs a VirtualKeyboardUtils instance that wraps a RemoteInput instance. + * + * @param input The RemoteInput object that can operate IO + */ + public VirtualKeyboardUtils(RemoteInput input) { + this.input = input; + } + + /** + * Updates the state of the bot for the {@link BaseScript}'s stop() function and updates the + * BotState for the UI. + */ + private void prepareInput() { + BaseScript.checkInterrupted(); + StateManager.setState(BotState.ACTING); + StatisticsManager.incrementInputs(); + } + + /** + * Sends a key down, given that it isn't already held. As this function requires an int keycode, + * please use {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your + * IDE of choice, showing you available keys which are mapped to integer keycodes. You may opt to + * look for Java VK keycodes online, however this is the best approach. + * + *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} + * + * @param javaKeyCode the {@link KeyEvent} key to hold + */ + public void sendKeyDown(int javaKeyCode) { + prepareInput(); + if (!input.isKeyHeld(javaKeyCode)) { + input.holdKey(javaKeyCode); + } + } + + /** + * Releases a key, given that it is held. As this function requires an int keycode, please use + * {@link KeyEvent}. Typing {@code KeyEvent.VK_} should show contextual actions in your IDE of + * choice, showing you available keys which are mapped to integer keycodes. You may opt to look + * for Java VK keycodes online, however this is the best approach. + * + *

Example usage: {@code controller().keyboard().sendKeyRelease(KeyEvent.VK_SHIFT);} + * + * @param javaKeyCode the {@link KeyEvent} key to release + */ + public void sendKeyRelease(int javaKeyCode) { + prepareInput(); + if (input.isKeyHeld(javaKeyCode)) { + input.releaseKey(javaKeyCode); + } + } + + /** + * Checks whether a key is currently being held. + * + * @param javaKeyCode the {@link KeyEvent} key to check + * @return Whether the key is currently being held or not + */ + public boolean isKeyHeld(int javaKeyCode) { + return input.isKeyHeld(javaKeyCode); + } + + /** + * Types out a given string to the client window using heuristics to mimic a human. Uses default + * heuristic settings for convenience. + * + * @param string The String of characters to type out in a human like fashion + */ + public synchronized void sendString(String string) { + prepareInput(); + for (char c : string.toCharArray()) { + int keyWait = RANDOM.nextInt(30, 60); + int keyModWait = RANDOM.nextInt(30, 60); + int keyPressWait = RANDOM.nextInt(40, 85); + input.sendString(String.valueOf(c), keyWait, keyModWait); + BaseScript.waitMillis(keyPressWait); + } + } + + /** + * Types out a given string to the client window using heuristics to mimic a human. Internally + * randomises between 1x - 1.1x the given modifier value. + * + * @param string The String of characters to type out in a human like fashion + * @param keyWait The amount of time to hold a key down + * @param keyModWait The amount of time to hold a modifier key down (e.g., shift) + * @param keyPressWait The amount of time to wait between pressing keys + */ + public synchronized void sendString( + String string, int keyWait, int keyModWait, int keyPressWait) { + prepareInput(); + for (char c : string.toCharArray()) { + input.sendString(String.valueOf(c), keyWait, keyModWait); + BaseScript.waitMillis(keyPressWait); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java b/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java index ed9d873..3889cbf 100644 --- a/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java +++ b/src/main/java/com/chromascape/utils/core/input/mouse/VirtualMouseUtils.java @@ -1,280 +1,280 @@ -package com.chromascape.utils.core.input.mouse; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.input.remoteinput.MouseButton; -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.util.Objects; -import java.util.Random; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -/** - * High level abstraction for a user to handle mouse IO through the {@link - * com.chromascape.controller.Controller}. Orchestrator of both the mouse movement physics - * calculation and dispatching of IO via the JNA layer. Uses a producer -> consumer threading model - * to prevent a mouse movement from lagging if the IO fails or stutters. Provides IO capabilities - * such as mouse movement, clicking, and holding of {@link MouseButton}s. - */ -public class VirtualMouseUtils { - - /** The current virtual mouse position. */ - private Point currentPosition; - - /** Interface for injecting low-level mouse events independently of system mouse. */ - private final RemoteInput input; - - /** Humanised mouse movement service. */ - private final WindMouse windMouse; - - private final Random random = new Random(); - - /** The latest point generated by the physics engine, waiting to be consumed by input. */ - private final AtomicReference pendingInputPoint = new AtomicReference<>(); - - /** Flag to indicate if the virtual mouse is currently performing a movement path. */ - private final AtomicBoolean isMoving = new AtomicBoolean(false); - - /** - * Lock object to ensure input is accessed by only one thread at a time. - * - *

Required because the underlying WebSocket/Connection in input is not thread-safe and cannot - * handle simultaneous write operations. - */ - private final Object inputLock = new Object(); - - /** - * Constructs the VMU class. Initialises the mouse overlay. Gives the mouse cursor a random start - * position. Starts the input consumer thread. - * - * @param input A hardware RemoteInput capable object - */ - public VirtualMouseUtils(RemoteInput input) { - this.input = input; - windMouse = new WindMouse(); - // Randomize starting position within the client window - randomiseStartPos(); - // Initialize atomic reference to prevent null pointer in consumer - pendingInputPoint.set(currentPosition); - // Start the background Input Consumer thread - startInputConsumerThread(); - } - - /** Starts the input consumer thread to prepare for IO. */ - private void startInputConsumerThread() { - Thread inputConsumerThread = new Thread(this::consumeInputLoop, "VirtualMouse-Input-Consumer"); - inputConsumerThread.setDaemon(true); // Ensure thread dies when JVM shuts down - inputConsumerThread.start(); - } - - /** - * Randomises the start position of the cursor within the client's bounds. Only used at startup. - * If the bot ran before and has a persisting cursor, it will default to that instead. - */ - private void randomiseStartPos() { - if (Objects.equals(input.getMousePosition(), new Point(0, 0)) - || input.getMousePosition() == null) { - - Rectangle bounds = input.getTargetDimensions(); - int startX = bounds.x + random.nextInt(bounds.width); - int startY = bounds.y + random.nextInt(bounds.height); - currentPosition = new Point(startX, startY); - } else { - currentPosition = input.getMousePosition(); - } - currentPosition = input.getMousePosition(); - // Initialize atomic reference to prevent null pointer in consumer - pendingInputPoint.set(currentPosition); - } - - /** - * Used alongside the producer thread to reliably move the mouse. While the flag is set to true, - * consumes the latest snapshot of the mouse movement simulation and sends a synchronised call to - * the IO layer to move the mouse. Discards duplicates. - */ - private void consumeInputLoop() { - Point lastSentPoint = null; - - while (true) { - if (isMoving.get()) { - // Practically final value, will not change during execution - Point target = pendingInputPoint.get(); - - // Only send input if the point is new - if (target != null && !target.equals(lastSentPoint)) { - synchronized (inputLock) { - input.moveMouse(target); - } - lastSentPoint = target; - } - } else { - // If not moving, sleep longer to save resources - BaseScript.waitMillis(5); - } - } - } - - /** - * Intended to be called before IO execution. Checks whether the script has been stopped to - * reliably prevent execution. Sets the bot state to acting and increments inputs for the web UI's - * statistics. - */ - private void prepareInput() { - BaseScript.checkInterrupted(); - StateManager.setState(BotState.ACTING); - StatisticsManager.incrementInputs(); - } - - /** - * Moves the mouse to a target destination point onscreen at a given speed using a humanised mouse - * movement algorithm. - * - * @param target The {@link Point} location to travel to - * @param speed How fast the mouse should travel, "slow", "medium" or "fast" - */ - public void moveTo(final Point target, final String speed) { - prepareInput(); - - // Flag start of movement for the Consumer thread - isMoving.set(true); - - try { - // WindMouse's physics loop will run on the current thread, producer - // The hardware IO execution (plus overlay/current state) is the consumer thread - windMouse.move(currentPosition, target, speed, this::moveMouseImpl); - } finally { - finaliseMovement(target); - } - } - - /** - * Callback for the WindMouse algorithm, where it used to execute the mouse movement action. This - * function only updates the state for the consumer thread, however serves as a nod to the source. - * - * @param p The point generated by WindMouse. - */ - private void moveMouseImpl(Point p) { - pendingInputPoint.set(p); - currentPosition = p; - } - - /** - * Disables the flag for the consumer thread, signaling there is no more work to do. Forces the - * destination point for the internal state, remote input, and overlay; to ensure scripts do not - * fail. - * - * @param target The final mouse destination. - */ - private void finaliseMovement(Point target) { - // Movement finished. - isMoving.set(false); - // Force the final position update to ensure exact accuracy. - currentPosition = target; - pendingInputPoint.set(target); - // Snap to final position to force script safety - // synchronized to prevent race conditions with other IO - synchronized (inputLock) { - input.moveMouse(target); - } - } - - /** - * Executes a mouse button press and release in a human-like fashion, given which button to press. - * Synchronised as not to collide with other IO. - * - * @param button the mouse button to press - */ - private void click(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - input.holdMouse(button); - BaseScript.waitRandomMillis(50, 80); - input.releaseMouse(button); - } - } - - /** Left clicks at the current mouse position. */ - public void leftClick() { - click(MouseButton.left); - } - - /** Right clicks at the current mouse position. */ - public void rightClick() { - click(MouseButton.right); - } - - /** Middle clicks at the current mouse position. */ - public void middleClick() { - click(MouseButton.middle); - } - - /** - * Holds down a Mouse button given which button to hold. - * - * @param button the {@link MouseButton} to hold. - */ - public void holdMouseButton(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - if (!input.isMouseHeld(button)) { - input.holdMouse(button); - } - } - } - - /** - * Queries whether a {@link MouseButton} is currently being held. - * - * @param button The {@link MouseButton} to check - * @return Whether it's currently being held - */ - public boolean isMouseButtonHeld(MouseButton button) { - return input.isMouseHeld(button); - } - - /** - * Releases a Mouse button given which button to release. - * - * @param button the {@link MouseButton} to release. - */ - public void releaseMouseButton(MouseButton button) { - prepareInput(); - synchronized (inputLock) { - if (input.isMouseHeld(button)) { - input.releaseMouse(button); - } - } - } - - /** - * Scrolls the mouse given the direction and amount. The mouse must have moved in the particular - * session for scrolling to take effect. - * - * @param totalNotches Amount of notches to scroll - * @param down true if scrolling down, false if scrolling up - */ - public void scrollMouse(int totalNotches, boolean down) { - int notchesSent = 0; - int k = 1; - - int step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); - - while (notchesSent < totalNotches) { - input.scrollMouse(down ? 1 : -1); - notchesSent++; - - if (k % step == 0) { - step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); - BaseScript.waitRandomMillis(215, 410); - k = 0; - } else { - BaseScript.waitRandomMillis(25, 46); - } - k++; - } - } -} +package com.chromascape.utils.core.input.mouse; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.input.remoteinput.MouseButton; +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.util.Objects; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * High level abstraction for a user to handle mouse IO through the {@link + * com.chromascape.controller.Controller}. Orchestrator of both the mouse movement physics + * calculation and dispatching of IO via the JNA layer. Uses a producer -> consumer threading model + * to prevent a mouse movement from lagging if the IO fails or stutters. Provides IO capabilities + * such as mouse movement, clicking, and holding of {@link MouseButton}s. + */ +public class VirtualMouseUtils { + + /** The current virtual mouse position. */ + private Point currentPosition; + + /** Interface for injecting low-level mouse events independently of system mouse. */ + private final RemoteInput input; + + /** Humanised mouse movement service. */ + private final WindMouse windMouse; + + private final Random random = new Random(); + + /** The latest point generated by the physics engine, waiting to be consumed by input. */ + private final AtomicReference pendingInputPoint = new AtomicReference<>(); + + /** Flag to indicate if the virtual mouse is currently performing a movement path. */ + private final AtomicBoolean isMoving = new AtomicBoolean(false); + + /** + * Lock object to ensure input is accessed by only one thread at a time. + * + *

Required because the underlying WebSocket/Connection in input is not thread-safe and cannot + * handle simultaneous write operations. + */ + private final Object inputLock = new Object(); + + /** + * Constructs the VMU class. Initialises the mouse overlay. Gives the mouse cursor a random start + * position. Starts the input consumer thread. + * + * @param input A hardware RemoteInput capable object + */ + public VirtualMouseUtils(RemoteInput input) { + this.input = input; + windMouse = new WindMouse(); + // Randomize starting position within the client window + randomiseStartPos(); + // Initialize atomic reference to prevent null pointer in consumer + pendingInputPoint.set(currentPosition); + // Start the background Input Consumer thread + startInputConsumerThread(); + } + + /** Starts the input consumer thread to prepare for IO. */ + private void startInputConsumerThread() { + Thread inputConsumerThread = new Thread(this::consumeInputLoop, "VirtualMouse-Input-Consumer"); + inputConsumerThread.setDaemon(true); // Ensure thread dies when JVM shuts down + inputConsumerThread.start(); + } + + /** + * Randomises the start position of the cursor within the client's bounds. Only used at startup. + * If the bot ran before and has a persisting cursor, it will default to that instead. + */ + private void randomiseStartPos() { + if (Objects.equals(input.getMousePosition(), new Point(0, 0)) + || input.getMousePosition() == null) { + + Rectangle bounds = input.getTargetDimensions(); + int startX = bounds.x + random.nextInt(bounds.width); + int startY = bounds.y + random.nextInt(bounds.height); + currentPosition = new Point(startX, startY); + } else { + currentPosition = input.getMousePosition(); + } + currentPosition = input.getMousePosition(); + // Initialize atomic reference to prevent null pointer in consumer + pendingInputPoint.set(currentPosition); + } + + /** + * Used alongside the producer thread to reliably move the mouse. While the flag is set to true, + * consumes the latest snapshot of the mouse movement simulation and sends a synchronised call to + * the IO layer to move the mouse. Discards duplicates. + */ + private void consumeInputLoop() { + Point lastSentPoint = null; + + while (true) { + if (isMoving.get()) { + // Practically final value, will not change during execution + Point target = pendingInputPoint.get(); + + // Only send input if the point is new + if (target != null && !target.equals(lastSentPoint)) { + synchronized (inputLock) { + input.moveMouse(target); + } + lastSentPoint = target; + } + } else { + // If not moving, sleep longer to save resources + BaseScript.waitMillis(5); + } + } + } + + /** + * Intended to be called before IO execution. Checks whether the script has been stopped to + * reliably prevent execution. Sets the bot state to acting and increments inputs for the web UI's + * statistics. + */ + private void prepareInput() { + BaseScript.checkInterrupted(); + StateManager.setState(BotState.ACTING); + StatisticsManager.incrementInputs(); + } + + /** + * Moves the mouse to a target destination point onscreen at a given speed using a humanised mouse + * movement algorithm. + * + * @param target The {@link Point} location to travel to + * @param speed How fast the mouse should travel, "slow", "medium" or "fast" + */ + public void moveTo(final Point target, final String speed) { + prepareInput(); + + // Flag start of movement for the Consumer thread + isMoving.set(true); + + try { + // WindMouse's physics loop will run on the current thread, producer + // The hardware IO execution (plus overlay/current state) is the consumer thread + windMouse.move(currentPosition, target, speed, this::moveMouseImpl); + } finally { + finaliseMovement(target); + } + } + + /** + * Callback for the WindMouse algorithm, where it used to execute the mouse movement action. This + * function only updates the state for the consumer thread, however serves as a nod to the source. + * + * @param p The point generated by WindMouse. + */ + private void moveMouseImpl(Point p) { + pendingInputPoint.set(p); + currentPosition = p; + } + + /** + * Disables the flag for the consumer thread, signaling there is no more work to do. Forces the + * destination point for the internal state, remote input, and overlay; to ensure scripts do not + * fail. + * + * @param target The final mouse destination. + */ + private void finaliseMovement(Point target) { + // Movement finished. + isMoving.set(false); + // Force the final position update to ensure exact accuracy. + currentPosition = target; + pendingInputPoint.set(target); + // Snap to final position to force script safety + // synchronized to prevent race conditions with other IO + synchronized (inputLock) { + input.moveMouse(target); + } + } + + /** + * Executes a mouse button press and release in a human-like fashion, given which button to press. + * Synchronised as not to collide with other IO. + * + * @param button the mouse button to press + */ + private void click(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + input.holdMouse(button); + BaseScript.waitRandomMillis(50, 80); + input.releaseMouse(button); + } + } + + /** Left clicks at the current mouse position. */ + public void leftClick() { + click(MouseButton.left); + } + + /** Right clicks at the current mouse position. */ + public void rightClick() { + click(MouseButton.right); + } + + /** Middle clicks at the current mouse position. */ + public void middleClick() { + click(MouseButton.middle); + } + + /** + * Holds down a Mouse button given which button to hold. + * + * @param button the {@link MouseButton} to hold. + */ + public void holdMouseButton(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + if (!input.isMouseHeld(button)) { + input.holdMouse(button); + } + } + } + + /** + * Queries whether a {@link MouseButton} is currently being held. + * + * @param button The {@link MouseButton} to check + * @return Whether it's currently being held + */ + public boolean isMouseButtonHeld(MouseButton button) { + return input.isMouseHeld(button); + } + + /** + * Releases a Mouse button given which button to release. + * + * @param button the {@link MouseButton} to release. + */ + public void releaseMouseButton(MouseButton button) { + prepareInput(); + synchronized (inputLock) { + if (input.isMouseHeld(button)) { + input.releaseMouse(button); + } + } + } + + /** + * Scrolls the mouse given the direction and amount. The mouse must have moved in the particular + * session for scrolling to take effect. + * + * @param totalNotches Amount of notches to scroll + * @param down true if scrolling down, false if scrolling up + */ + public void scrollMouse(int totalNotches, boolean down) { + int notchesSent = 0; + int k = 1; + + int step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); + + while (notchesSent < totalNotches) { + input.scrollMouse(down ? 1 : -1); + notchesSent++; + + if (k % step == 0) { + step = (int) Math.round(random.nextGaussian() * 0.6 + 6.0); + BaseScript.waitRandomMillis(215, 410); + k = 0; + } else { + BaseScript.waitRandomMillis(25, 46); + } + k++; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java b/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java index 9935ea0..9d0d28f 100644 --- a/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java +++ b/src/main/java/com/chromascape/utils/core/input/mouse/WindMouse.java @@ -1,308 +1,308 @@ -/** - * Copyright 2006-2013 by Benjamin J. Land (a.k.a. BenLand100) - * - *

This file is part of the SMART Minimizing Autoing Resource Thing (SMART) - * - *

SMART is free software: you can redistribute it and/or modify it under the terms of the GNU - * General Public License as published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - *

SMART is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without - * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - *

You should have received a copy of the GNU General Public License along with SMART. If not, - * see . - */ -package com.chromascape.utils.core.input.mouse; - -import static java.util.concurrent.locks.LockSupport.parkNanos; - -import java.awt.Point; -import java.util.Random; -import java.util.function.Consumer; - -/** - * - * - *

- * - * "The WindMouse algorithm is inspired by highschool physics that me-of-fifteen-years-ago was just - * getting interested in. The cursor is modeled as an object with some inertia (mass) that is acted - * on by two forces: - * - *
    - *
  • Gravity, which is constant in magnitude (a configurable parameter) and always points - * towards the final destination. - *
  • Wind, which exerts a random force in a random direction, and smoothly changes in both - * magnitude and direction over time." - BenLand100 - *
- * - *
- * - *

This modified impl has been tuned to 60hz as opposed to 30 - * - *

Original Algorithm by BenLand100. Tweaked - * "WindMouse2" implementation by holic. - * Adapted for ChromaScape. - */ -public class WindMouse { - - private final Random random = new Random(); - - /** - * Moves the mouse from a starting point to a destination using the WindMouse physics model. - * - *

This method selects a speed profile and delegates to the internal physics engine. - * - * @param start The current coordinates of the mouse cursor. - * @param target The destination coordinates. - * @param speedProfile A string constant determining movement characteristics ("slow", "medium", - * "fast"). Defaults to "medium" if the profile is unrecognized. - * @param moveMouseImpl A {@link Consumer} that accepts a {@link Point} for every step of the - * path. This is typically used to trigger the actual hardware or robot input. - */ - public void move(Point start, Point target, String speedProfile, Consumer moveMouseImpl) { - double mouseSpeed = 30; - double mouseGravity = 4.5; - double mouseWind = 1.5; - - switch (speedProfile.toLowerCase()) { - case "slow" -> { - mouseSpeed = 20; - mouseGravity = 5.0; - mouseWind = 1.0; - } - case "fast" -> { - mouseSpeed = 50; - mouseGravity = 6.0; - mouseWind = 2.0; - } - case "medium", "default" -> { - // Keeps defaults - } - } - - windMouse2(start, target, mouseGravity, mouseWind, mouseSpeed, moveMouseImpl); - } - - /** - * Moves the mouse from the current position to the specified position. Approximates human - * movement in a way where smoothness and accuracy are relative to speed, as it should be. - * - *

Algorithm by BenLand100, modified by holic and later ChromaScape. - * - * @param start The starting point. - * @param target The final destination. - * @param gravity The gravitational pull towards the target. - * @param wind The magnitude of random perturbations. - * @param speed The timing speed factor. - * @param moveMouseImpl The callback for cursor updates. - */ - private void windMouse2( - Point start, - Point target, - double gravity, - double wind, - double speed, - Consumer moveMouseImpl) { - - Point intermediate = - (distance(target, start) > 250 && random.nextInt(2) == 1) - ? randomPoint(target, start) - : null; - - if (intermediate != null) { - windMouseImpl( - start.x, - start.y, - intermediate.x, - intermediate.y, - gravity, - wind, - speed, - random.nextInt(10, 25), - moveMouseImpl); - - // Small pause between each movement - sleepPrecise(random.nextInt(1, 150)); - start = intermediate; // Continue from intermediate - } - - // Move to final target - windMouseImpl( - start.x, - start.y, - target.x, - target.y, - gravity, - wind, - speed, - random.nextInt(10, 25), - moveMouseImpl); - } - - /** - * Internal mouse movement algorithm. Do not use this without credit to either Benjamin J. Land or - * BenLand100. This is synchronized to prevent multiple motions and bannage. - * - * @param xs The x start - * @param ys The y start - * @param xe The x destination - * @param ye The y destination - * @param gravity Strength pulling the position towards the destination - * @param wind Strength pulling the position in random directions - * @param speed Influences the rate of sleeps, speeding up or slowing down the routine - * @param targetArea Radius of area around the destination that should trigger slowing, prevents - * spiraling - */ - private void windMouseImpl( - double xs, - double ys, - double xe, - double ye, - double gravity, - double wind, - double speed, - double targetArea, - Consumer onMove) { - - double dist, veloX = 0, veloY = 0, windX = 0, windY = 0; - - double sqrt2 = Math.sqrt(2); - double sqrt3 = Math.sqrt(3); - double sqrt5 = Math.sqrt(5); - - int tDist = (int) distance(new Point((int) xs, (int) ys), new Point((int) xe, (int) ye)); - long t = System.currentTimeMillis() + 10000; // 10-second timeout safety - - while ((dist = Math.hypot((xs - xe), (ys - ye))) >= 3) { - if (System.currentTimeMillis() > t) break; - - wind = Math.min(wind, dist); - - long d = (Math.round((Math.round(((double) (tDist))) * 0.3)) / 7); - if (d > 20) d = 20; - if (d < 5) d = 5; - - if (random.nextInt(6) == 0) { - d = 2; - } - - double maxStep = (Math.min(d, Math.round(dist))) * 1.5; - - if (dist >= targetArea) { - // Apply normal wind - int windRange = (int) (Math.round(wind) * 2) + 1; - windX = (windX / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); - windY = (windY / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); - } else { - - windX = (windX / sqrt2); - windY = (windY / sqrt2); - - veloX *= 0.64; - veloY *= 0.64; - } - - veloX += windX + gravity * (xe - xs) / dist; - veloY += windY + gravity * (ye - ys) / dist; - - if (Math.hypot(veloX, veloY) > maxStep) { - maxStep = ((maxStep / 2) < 1) ? 2 : maxStep; - double randomDist = (maxStep / 2) + random.nextInt((int) (Math.round(maxStep) / 2)); - double veloMag = Math.sqrt(((veloX * veloX) + (veloY * veloY))); - veloX = (veloX / veloMag) * randomDist; - veloY = (veloY / veloMag) * randomDist; - } - - int lastX = ((int) (Math.round(xs))); - int lastY = ((int) (Math.round(ys))); - xs += veloX; - ys += veloY; - - if ((lastX != Math.round(xs)) || (lastY != Math.round(ys))) { - Point newP = new Point((int) Math.round(xs), (int) Math.round(ys)); - if (onMove != null) onMove.accept(newP); - } - - int w = random.nextInt((int) (Math.round(100.0 / speed))) * 12; - if (w < 10) { - w = 10; - } - - w = (int) Math.round(w * 0.9); - sleepPrecise(w); - } - - if ((Math.round(xe) != Math.round(xs)) || (Math.round(ye) != Math.round(ys))) { - Point finalP = new Point((int) Math.round(xe), (int) Math.round(ye)); - if (onMove != null) onMove.accept(finalP); - } - } - - /** - * Precisely sleeps for a given length of time, as other approaches aren't as accurate. - * - * @param millis The duration to sleep in milliseconds. - */ - private void sleepPrecise(long millis) { - long end = System.nanoTime() + millis * 1_000_000L; - long timeLeft = end - System.nanoTime(); - while (timeLeft > 2_000_000L) { - parkNanos(timeLeft - 1_000_000L); - timeLeft = end - System.nanoTime(); - } - - while (System.nanoTime() < end) { - try { - java.lang.Thread.onSpinWait(); - } catch (NoSuchMethodError e) { - // Fallback for older JDKs (implicitly just busy-waits) - } - } - } - - /** - * Calculates distance between 2 points. - * - * @param p1 The first point. - * @param p2 The second point. - * @return The distance. - */ - private double distance(Point p1, Point p2) { - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - } - - /** - * Get a random point between 2 points. - * - * @param p1 The first point. - * @param p2 The second point. - * @return The random point. - */ - private Point randomPoint(Point p1, Point p2) { - int randomX = (int) randomPointBetween(p1.x, p2.x); - int randomY = (int) randomPointBetween(p1.y, p2.y); - return new Point(randomX, randomY); - } - - /** - * Generates a random floating-point value between two bounds. - * - * @param corner1 The first boundary (e.g., the starting coordinate). - * @param corner2 The second boundary (e.g., the target coordinate). - * @return A random float value falling between {@code corner1} and {@code corner2}. If both - * bounds are equal, returns that value immediately to avoid processing. - */ - private float randomPointBetween(float corner1, float corner2) { - if (corner1 == corner2) { - return corner1; - } - float delta = corner2 - corner1; - float offset = random.nextFloat() * delta; - return corner1 + offset; - } -} +/** + * Copyright 2006-2013 by Benjamin J. Land (a.k.a. BenLand100) + * + *

This file is part of the SMART Minimizing Autoing Resource Thing (SMART) + * + *

SMART is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + *

SMART is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + *

You should have received a copy of the GNU General Public License along with SMART. If not, + * see . + */ +package com.chromascape.utils.core.input.mouse; + +import static java.util.concurrent.locks.LockSupport.parkNanos; + +import java.awt.Point; +import java.util.Random; +import java.util.function.Consumer; + +/** + * + * + *

+ * + * "The WindMouse algorithm is inspired by highschool physics that me-of-fifteen-years-ago was just + * getting interested in. The cursor is modeled as an object with some inertia (mass) that is acted + * on by two forces: + * + *
    + *
  • Gravity, which is constant in magnitude (a configurable parameter) and always points + * towards the final destination. + *
  • Wind, which exerts a random force in a random direction, and smoothly changes in both + * magnitude and direction over time." - BenLand100 + *
+ * + *
+ * + *

This modified impl has been tuned to 60hz as opposed to 30 + * + *

Original Algorithm by BenLand100. Tweaked + * "WindMouse2" implementation by holic. + * Adapted for ChromaScape. + */ +public class WindMouse { + + private final Random random = new Random(); + + /** + * Moves the mouse from a starting point to a destination using the WindMouse physics model. + * + *

This method selects a speed profile and delegates to the internal physics engine. + * + * @param start The current coordinates of the mouse cursor. + * @param target The destination coordinates. + * @param speedProfile A string constant determining movement characteristics ("slow", "medium", + * "fast"). Defaults to "medium" if the profile is unrecognized. + * @param moveMouseImpl A {@link Consumer} that accepts a {@link Point} for every step of the + * path. This is typically used to trigger the actual hardware or robot input. + */ + public void move(Point start, Point target, String speedProfile, Consumer moveMouseImpl) { + double mouseSpeed = 30; + double mouseGravity = 4.5; + double mouseWind = 1.5; + + switch (speedProfile.toLowerCase()) { + case "slow" -> { + mouseSpeed = 20; + mouseGravity = 5.0; + mouseWind = 1.0; + } + case "fast" -> { + mouseSpeed = 50; + mouseGravity = 6.0; + mouseWind = 2.0; + } + case "medium", "default" -> { + // Keeps defaults + } + } + + windMouse2(start, target, mouseGravity, mouseWind, mouseSpeed, moveMouseImpl); + } + + /** + * Moves the mouse from the current position to the specified position. Approximates human + * movement in a way where smoothness and accuracy are relative to speed, as it should be. + * + *

Algorithm by BenLand100, modified by holic and later ChromaScape. + * + * @param start The starting point. + * @param target The final destination. + * @param gravity The gravitational pull towards the target. + * @param wind The magnitude of random perturbations. + * @param speed The timing speed factor. + * @param moveMouseImpl The callback for cursor updates. + */ + private void windMouse2( + Point start, + Point target, + double gravity, + double wind, + double speed, + Consumer moveMouseImpl) { + + Point intermediate = + (distance(target, start) > 250 && random.nextInt(2) == 1) + ? randomPoint(target, start) + : null; + + if (intermediate != null) { + windMouseImpl( + start.x, + start.y, + intermediate.x, + intermediate.y, + gravity, + wind, + speed, + random.nextInt(10, 25), + moveMouseImpl); + + // Small pause between each movement + sleepPrecise(random.nextInt(1, 150)); + start = intermediate; // Continue from intermediate + } + + // Move to final target + windMouseImpl( + start.x, + start.y, + target.x, + target.y, + gravity, + wind, + speed, + random.nextInt(10, 25), + moveMouseImpl); + } + + /** + * Internal mouse movement algorithm. Do not use this without credit to either Benjamin J. Land or + * BenLand100. This is synchronized to prevent multiple motions and bannage. + * + * @param xs The x start + * @param ys The y start + * @param xe The x destination + * @param ye The y destination + * @param gravity Strength pulling the position towards the destination + * @param wind Strength pulling the position in random directions + * @param speed Influences the rate of sleeps, speeding up or slowing down the routine + * @param targetArea Radius of area around the destination that should trigger slowing, prevents + * spiraling + */ + private void windMouseImpl( + double xs, + double ys, + double xe, + double ye, + double gravity, + double wind, + double speed, + double targetArea, + Consumer onMove) { + + double dist, veloX = 0, veloY = 0, windX = 0, windY = 0; + + double sqrt2 = Math.sqrt(2); + double sqrt3 = Math.sqrt(3); + double sqrt5 = Math.sqrt(5); + + int tDist = (int) distance(new Point((int) xs, (int) ys), new Point((int) xe, (int) ye)); + long t = System.currentTimeMillis() + 10000; // 10-second timeout safety + + while ((dist = Math.hypot((xs - xe), (ys - ye))) >= 3) { + if (System.currentTimeMillis() > t) break; + + wind = Math.min(wind, dist); + + long d = (Math.round((Math.round(((double) (tDist))) * 0.3)) / 7); + if (d > 20) d = 20; + if (d < 5) d = 5; + + if (random.nextInt(6) == 0) { + d = 2; + } + + double maxStep = (Math.min(d, Math.round(dist))) * 1.5; + + if (dist >= targetArea) { + // Apply normal wind + int windRange = (int) (Math.round(wind) * 2) + 1; + windX = (windX / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); + windY = (windY / sqrt3) + ((random.nextInt(windRange) - wind) / sqrt5); + } else { + + windX = (windX / sqrt2); + windY = (windY / sqrt2); + + veloX *= 0.64; + veloY *= 0.64; + } + + veloX += windX + gravity * (xe - xs) / dist; + veloY += windY + gravity * (ye - ys) / dist; + + if (Math.hypot(veloX, veloY) > maxStep) { + maxStep = ((maxStep / 2) < 1) ? 2 : maxStep; + double randomDist = (maxStep / 2) + random.nextInt((int) (Math.round(maxStep) / 2)); + double veloMag = Math.sqrt(((veloX * veloX) + (veloY * veloY))); + veloX = (veloX / veloMag) * randomDist; + veloY = (veloY / veloMag) * randomDist; + } + + int lastX = ((int) (Math.round(xs))); + int lastY = ((int) (Math.round(ys))); + xs += veloX; + ys += veloY; + + if ((lastX != Math.round(xs)) || (lastY != Math.round(ys))) { + Point newP = new Point((int) Math.round(xs), (int) Math.round(ys)); + if (onMove != null) onMove.accept(newP); + } + + int w = random.nextInt((int) (Math.round(100.0 / speed))) * 12; + if (w < 10) { + w = 10; + } + + w = (int) Math.round(w * 0.9); + sleepPrecise(w); + } + + if ((Math.round(xe) != Math.round(xs)) || (Math.round(ye) != Math.round(ys))) { + Point finalP = new Point((int) Math.round(xe), (int) Math.round(ye)); + if (onMove != null) onMove.accept(finalP); + } + } + + /** + * Precisely sleeps for a given length of time, as other approaches aren't as accurate. + * + * @param millis The duration to sleep in milliseconds. + */ + private void sleepPrecise(long millis) { + long end = System.nanoTime() + millis * 1_000_000L; + long timeLeft = end - System.nanoTime(); + while (timeLeft > 2_000_000L) { + parkNanos(timeLeft - 1_000_000L); + timeLeft = end - System.nanoTime(); + } + + while (System.nanoTime() < end) { + try { + java.lang.Thread.onSpinWait(); + } catch (NoSuchMethodError e) { + // Fallback for older JDKs (implicitly just busy-waits) + } + } + } + + /** + * Calculates distance between 2 points. + * + * @param p1 The first point. + * @param p2 The second point. + * @return The distance. + */ + private double distance(Point p1, Point p2) { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + } + + /** + * Get a random point between 2 points. + * + * @param p1 The first point. + * @param p2 The second point. + * @return The random point. + */ + private Point randomPoint(Point p1, Point p2) { + int randomX = (int) randomPointBetween(p1.x, p2.x); + int randomY = (int) randomPointBetween(p1.y, p2.y); + return new Point(randomX, randomY); + } + + /** + * Generates a random floating-point value between two bounds. + * + * @param corner1 The first boundary (e.g., the starting coordinate). + * @param corner2 The second boundary (e.g., the target coordinate). + * @return A random float value falling between {@code corner1} and {@code corner2}. If both + * bounds are equal, returns that value immediately to avoid processing. + */ + private float randomPointBetween(float corner1, float corner2) { + if (corner1 == corner2) { + return corner1; + } + float delta = corner2 - corner1; + float offset = random.nextFloat() * delta; + return corner1 + offset; + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java index f8d1870..1bc349f 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/ControlKey.java @@ -1,24 +1,24 @@ -package com.chromascape.utils.core.input.remoteinput; - -/** - * Key value pair enum containing keys and their Java key code. Used to define the preferred Java - * keycode for RemoteInput. e.g. enter is typically 10, we want 13. - */ -public enum ControlKey { - VK_LEFT_CONTROL(162), - VK_LEFT_ALT(164), - VK_RIGHT_ALT(165), - VK_LEFT_WINDOWS(91), - VK_RETURN(13); - - public final int nativeCode; - - /** - * Constructs the enum with an extra value, which contains the java keycode. - * - * @param nativeCode the Java keycode - */ - ControlKey(int nativeCode) { - this.nativeCode = nativeCode; - } -} +package com.chromascape.utils.core.input.remoteinput; + +/** + * Key value pair enum containing keys and their Java key code. Used to define the preferred Java + * keycode for RemoteInput. e.g. enter is typically 10, we want 13. + */ +public enum ControlKey { + VK_LEFT_CONTROL(162), + VK_LEFT_ALT(164), + VK_RIGHT_ALT(165), + VK_LEFT_WINDOWS(91), + VK_RETURN(13); + + public final int nativeCode; + + /** + * Constructs the enum with an extra value, which contains the java keycode. + * + * @param nativeCode the Java keycode + */ + ControlKey(int nativeCode) { + this.nativeCode = nativeCode; + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java index f45db04..e62b0fa 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/MouseButton.java @@ -1,13 +1,13 @@ -package com.chromascape.utils.core.input.remoteinput; - -/** - * Enum containing the available mouse buttons for use with RemoteInput. Namely, the functions - * {@link RemoteInput#holdMouse(MouseButton)}, {@link RemoteInput#releaseMouse(MouseButton)}, and - * {@link RemoteInput#isMouseHeld(MouseButton)}. The ordinal of each mouse button refers to the - * integer value required by RI. - */ -public enum MouseButton { - right, - left, - middle -} +package com.chromascape.utils.core.input.remoteinput; + +/** + * Enum containing the available mouse buttons for use with RemoteInput. Namely, the functions + * {@link RemoteInput#holdMouse(MouseButton)}, {@link RemoteInput#releaseMouse(MouseButton)}, and + * {@link RemoteInput#isMouseHeld(MouseButton)}. The ordinal of each mouse button refers to the + * integer value required by RI. + */ +public enum MouseButton { + right, + left, + middle +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java index 9e526e7..b682edd 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInput.java @@ -1,333 +1,333 @@ -package com.chromascape.utils.core.input.remoteinput; - -import com.sun.jna.Native; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.IntByReference; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.event.KeyEvent; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * RemoteInput allows ChromaScape to send Java AWT signals to a target Java app, to simulate IO. - * This approach as opposed to sending OS signals, allows the user to fully minimise and or cover - * the target app. It also allows the user to keep using their computer as they wish, as if - * ChromaScape was never running. This class provides functionality to load a RemoteInput binary - * regardless of operating system, provide IO to the target application, and receive the most - * up-to-date snapshot of the application's Java canvas (updated whenever they draw a new frame). - */ -public class RemoteInput implements AutoCloseable { - - private static final String COMPILED_BINARY_FILENAME = "libRemoteInput" + getExtension(); - - private final int pid; - - /** JNA needs to load the binary as an interface, the interface acts as the exported headers. */ - private final RemoteInputInterface remoteInput; - - /** - * RemoteInput returns a Pointer which acts as a reference to a specific client/target. A single - * instance of RI can support several targets. RI requests this pointer when performing IO, to - * specify which target to use. - */ - private Pointer target; - - /** - * Constructs the RemoteInput class. - * - * @param pid The process ID of the target Java application - */ - public RemoteInput(int pid) { - this.pid = pid; - this.remoteInput = loadRemoteInput(); - initialise(); - } - - /** - * The RI binary can be compiled on Linux, Mac and Windows. This function detects OS and applies - * the corresponding filetype. - * - * @return OS specific filetype - */ - private static String getExtension() { - String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win")) { - return ".dll"; - } - if (os.contains("mac")) { - return ".dylib"; - } - return ".so"; - } - - /** - * RemoteInput expects specific Java keycodes for several keys, compared to generic keycodes. - * e.g., enter is typically 10, we want 13. - * - * @param javaKeyCode The {@link KeyEvent} Java keycode - * @return RemoteInput's preferred keycode - */ - private int toNativeCode(int javaKeyCode) { - return switch (javaKeyCode) { - case KeyEvent.VK_ENTER -> ControlKey.VK_RETURN.nativeCode; - case KeyEvent.VK_CONTROL -> ControlKey.VK_LEFT_CONTROL.nativeCode; - case KeyEvent.VK_ALT -> ControlKey.VK_LEFT_ALT.nativeCode; - case KeyEvent.VK_ALT_GRAPH -> ControlKey.VK_RIGHT_ALT.nativeCode; - case KeyEvent.VK_WINDOWS -> ControlKey.VK_LEFT_WINDOWS.nativeCode; - default -> javaKeyCode; - }; - } - - /** - * Checks if the target application is already a registered client. If not, will inject RI into - * the application. Finally, it pairs with the target - */ - private void initialise() { - if (!isInjectedClient()) { - remoteInput.EIOS_Inject_PID(pid); - } - pairClient(); - } - - /** - * Checks if the target application is already registered within RI's internal list of connected - * clients. A registered client is one that already has RI injected into it. - * - * @return Whether the target application is registered - */ - private boolean isInjectedClient() { - long totalClients = remoteInput.EIOS_GetClients(false); - - for (long i = 0; i < totalClients; i++) { - if (remoteInput.EIOS_GetClientPID(i) == pid) { - return true; - } - } - return false; - } - - /** - * Grants IO operation over a registered client by returning a pointer to reference a specific - * client. - * - * @see #target <- The returned pointer - */ - private void pairClient() { - target = remoteInput.EIOS_RequestTarget(String.valueOf(pid)); - if (target == null) { - throw new RuntimeException("Target Not Found with pid: " + pid); - } - } - - /** - * Loads the RemoteInput binary as a {@link RemoteInputInterface} object to allow Java to - * communicate directly to the native binary. Will first check if a user compiled binary exists, - * if not, uses a provided pre-compiled binary. - * - * @return An interface that acts as a bridge to talk to the binary in Java, used within this - * class to provide IO operations - */ - private static RemoteInputInterface loadRemoteInput() { - Path binaryFile = - Paths.get("third-party", "RemoteInput", "cmake-build-release", COMPILED_BINARY_FILENAME); - if (!Files.exists(binaryFile)) { - binaryFile = Paths.get("third-party", "RemoteInput", "precompiled", COMPILED_BINARY_FILENAME); - } - - try { - return Native.load(binaryFile.toString(), RemoteInputInterface.class); - } catch (UnsatisfiedLinkError e) { - throw new RuntimeException("Unable to load RemoteInput binary from path", e); - } - } - - /** - * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel - * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of - * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native - * hooks in the target application whenever a frame is rendered. - * - * @return A pointer to the start of the BGRA pixel array - */ - public synchronized Pointer getImageBuffer() { - Pointer p = remoteInput.EIOS_GetImageBuffer(target); - if (p == null) { - throw new RuntimeException("Image Buffer Not Found with pid: " + pid); - } - return p; - } - - /** - * Retrieves a memory pointer similarly to {@link #getImageBuffer()}. This method however will - * contain the mouse pointer and any objects drawn onto the canvas. - * - * @return A pointer to the start of the BGRA pixel array - */ - public synchronized Pointer getDebugImageBuffer() { - Pointer p = remoteInput.EIOS_GetDebugImageBuffer(target); - if (p == null) { - throw new RuntimeException("Image Buffer Not Found with pid: " + pid); - } - return p; - } - - /** Will get focus of the client if in an unfocused state, necessary for mouse input. */ - private void getFocusIfNotFocused() { - if (!remoteInput.EIOS_HasFocus(target)) { - remoteInput.EIOS_GainFocus(target); - } - } - - /** Will enable keyboard input if it's currently disabled, necessary for keyboard input. */ - private void setKeyboardInputIfDisabled() { - if (!remoteInput.EIOS_IsKeyboardInputEnabled(target)) { - remoteInput.EIOS_SetKeyboardInputEnabled(target, true); - } - } - - /** - * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. - * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless - * RemoteInput is pairing for the first time, where it'll randomise mouse position. - * - * @return A {@link Point} referring to the client relative mouse position - */ - public synchronized Point getMousePosition() { - IntByReference x = new IntByReference(); - IntByReference y = new IntByReference(); - remoteInput.EIOS_GetMousePosition(target, x, y); - return new Point(x.getValue(), y.getValue()); - } - - /** - * Gets the target app's window dimensions. Due to all IO being performed in client relative - * space, the user can assume the origin as 0,0. - * - * @return A rectangle defining the origin and bounds of the target application - */ - public synchronized Rectangle getTargetDimensions() { - IntByReference x = new IntByReference(); - IntByReference y = new IntByReference(); - remoteInput.EIOS_GetTargetDimensions(target, x, y); - return new Rectangle(0, 0, x.getValue(), y.getValue()); - } - - /** - * Sends a key down in the target java app. To get the keycode, use a {@link - * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. - * - * @param javaKeyCode The Java keycode corresponding to the key being pressed - */ - public synchronized void holdKey(int javaKeyCode) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_HoldKey(target, toNativeCode(javaKeyCode)); - } - - /** - * Queries whether a key is currently being held. - * - * @param javaKeyCode The Java keycode of the key in question - * @return Whether the key is currently being held - */ - public synchronized boolean isKeyHeld(int javaKeyCode) { - return remoteInput.EIOS_IsKeyHeld(target, toNativeCode(javaKeyCode)); - } - - /** - * Sends a key release event to the target Java app. This should be used in conjunction with - * {@link #holdKey(int)} to simulate a full key press. - * - * @param javaKeyCode The Java keycode corresponding to the key being released - */ - public synchronized void releaseKey(int javaKeyCode) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_ReleaseKey(target, toNativeCode(javaKeyCode)); - } - - /** - * Sends a mouse button down in the target Java app. - * - * @param button The {@link MouseButton} button to hold - */ - public synchronized void holdMouse(MouseButton button) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_HoldMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); - } - - /** - * Queries whether a {@link MouseButton} is currently held. - * - * @param button The {@link MouseButton} to check - * @return If the mouse button is currently being held - */ - public synchronized boolean isMouseHeld(MouseButton button) { - return remoteInput.EIOS_IsMouseHeld(target, button.ordinal()); - } - - /** - * Releases a mouse button at the designated client local co-ordinates. - * - * @param button The {@link MouseButton} to release - */ - public synchronized void releaseMouse(MouseButton button) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_ReleaseMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); - } - - /** - * Moves a mouse to a designated client local co-ordinate. - * - * @param location The {@link Point} location to snap the mouse to - */ - public synchronized void moveMouse(Point location) { - getFocusIfNotFocused(); - remoteInput.EIOS_MoveMouse(target, location.x, location.y); - } - - /** - * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds - * a small float value to this, to simulate human imperfection - * - * @param notches The number of mouse notches to scroll, down is positive, up is negative - */ - public synchronized void scrollMouse(int notches) { - getFocusIfNotFocused(); - Point mousePosition = getMousePosition(); - remoteInput.EIOS_ScrollMouse(target, mousePosition.x, mousePosition.y, notches); - } - - /** - * Types out a string of characters whilst compensating for the need of modifier keys. Useful when - * typing something to a dialogue box, will compensate for special characters, however lacks delay - * between keypresses. - * - * @param string The text to be typed - * @param keyWait The time in milliseconds to hold down a key - * @param keyModWait The time in milliseconds to hold down modifier keys - */ - public synchronized void sendString(String string, int keyWait, int keyModWait) { - setKeyboardInputIfDisabled(); - getFocusIfNotFocused(); - remoteInput.EIOS_SendString(target, string, keyWait, keyModWait); - } - - /** - * Since this class implements the {@link AutoCloseable} interface, it must be closed to relieve - * native memory. This method will release the target, effectively shutting down RemoteInput for - * the particular ChromaScape instance. However, this does not delete the injected part of RI in - * the target, simply shuts down control over it. - */ - @Override - public void close() { - if (target != null) { - remoteInput.EIOS_ReleaseTarget(target); - target = null; - } - } -} +package com.chromascape.utils.core.input.remoteinput; + +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * RemoteInput allows ChromaScape to send Java AWT signals to a target Java app, to simulate IO. + * This approach as opposed to sending OS signals, allows the user to fully minimise and or cover + * the target app. It also allows the user to keep using their computer as they wish, as if + * ChromaScape was never running. This class provides functionality to load a RemoteInput binary + * regardless of operating system, provide IO to the target application, and receive the most + * up-to-date snapshot of the application's Java canvas (updated whenever they draw a new frame). + */ +public class RemoteInput implements AutoCloseable { + + private static final String COMPILED_BINARY_FILENAME = "libRemoteInput" + getExtension(); + + private final int pid; + + /** JNA needs to load the binary as an interface, the interface acts as the exported headers. */ + private final RemoteInputInterface remoteInput; + + /** + * RemoteInput returns a Pointer which acts as a reference to a specific client/target. A single + * instance of RI can support several targets. RI requests this pointer when performing IO, to + * specify which target to use. + */ + private Pointer target; + + /** + * Constructs the RemoteInput class. + * + * @param pid The process ID of the target Java application + */ + public RemoteInput(int pid) { + this.pid = pid; + this.remoteInput = loadRemoteInput(); + initialise(); + } + + /** + * The RI binary can be compiled on Linux, Mac and Windows. This function detects OS and applies + * the corresponding filetype. + * + * @return OS specific filetype + */ + private static String getExtension() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + return ".dll"; + } + if (os.contains("mac")) { + return ".dylib"; + } + return ".so"; + } + + /** + * RemoteInput expects specific Java keycodes for several keys, compared to generic keycodes. + * e.g., enter is typically 10, we want 13. + * + * @param javaKeyCode The {@link KeyEvent} Java keycode + * @return RemoteInput's preferred keycode + */ + private int toNativeCode(int javaKeyCode) { + return switch (javaKeyCode) { + case KeyEvent.VK_ENTER -> ControlKey.VK_RETURN.nativeCode; + case KeyEvent.VK_CONTROL -> ControlKey.VK_LEFT_CONTROL.nativeCode; + case KeyEvent.VK_ALT -> ControlKey.VK_LEFT_ALT.nativeCode; + case KeyEvent.VK_ALT_GRAPH -> ControlKey.VK_RIGHT_ALT.nativeCode; + case KeyEvent.VK_WINDOWS -> ControlKey.VK_LEFT_WINDOWS.nativeCode; + default -> javaKeyCode; + }; + } + + /** + * Checks if the target application is already a registered client. If not, will inject RI into + * the application. Finally, it pairs with the target + */ + private void initialise() { + if (!isInjectedClient()) { + remoteInput.EIOS_Inject_PID(pid); + } + pairClient(); + } + + /** + * Checks if the target application is already registered within RI's internal list of connected + * clients. A registered client is one that already has RI injected into it. + * + * @return Whether the target application is registered + */ + private boolean isInjectedClient() { + long totalClients = remoteInput.EIOS_GetClients(false); + + for (long i = 0; i < totalClients; i++) { + if (remoteInput.EIOS_GetClientPID(i) == pid) { + return true; + } + } + return false; + } + + /** + * Grants IO operation over a registered client by returning a pointer to reference a specific + * client. + * + * @see #target <- The returned pointer + */ + private void pairClient() { + target = remoteInput.EIOS_RequestTarget(String.valueOf(pid)); + if (target == null) { + throw new RuntimeException("Target Not Found with pid: " + pid); + } + } + + /** + * Loads the RemoteInput binary as a {@link RemoteInputInterface} object to allow Java to + * communicate directly to the native binary. Will first check if a user compiled binary exists, + * if not, uses a provided pre-compiled binary. + * + * @return An interface that acts as a bridge to talk to the binary in Java, used within this + * class to provide IO operations + */ + private static RemoteInputInterface loadRemoteInput() { + Path binaryFile = + Paths.get("third-party", "RemoteInput", "cmake-build-release", COMPILED_BINARY_FILENAME); + if (!Files.exists(binaryFile)) { + binaryFile = Paths.get("third-party", "RemoteInput", "precompiled", COMPILED_BINARY_FILENAME); + } + + try { + return Native.load(binaryFile.toString(), RemoteInputInterface.class); + } catch (UnsatisfiedLinkError e) { + throw new RuntimeException("Unable to load RemoteInput binary from path", e); + } + } + + /** + * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel + * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of + * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native + * hooks in the target application whenever a frame is rendered. + * + * @return A pointer to the start of the BGRA pixel array + */ + public synchronized Pointer getImageBuffer() { + Pointer p = remoteInput.EIOS_GetImageBuffer(target); + if (p == null) { + throw new RuntimeException("Image Buffer Not Found with pid: " + pid); + } + return p; + } + + /** + * Retrieves a memory pointer similarly to {@link #getImageBuffer()}. This method however will + * contain the mouse pointer and any objects drawn onto the canvas. + * + * @return A pointer to the start of the BGRA pixel array + */ + public synchronized Pointer getDebugImageBuffer() { + Pointer p = remoteInput.EIOS_GetDebugImageBuffer(target); + if (p == null) { + throw new RuntimeException("Image Buffer Not Found with pid: " + pid); + } + return p; + } + + /** Will get focus of the client if in an unfocused state, necessary for mouse input. */ + private void getFocusIfNotFocused() { + if (!remoteInput.EIOS_HasFocus(target)) { + remoteInput.EIOS_GainFocus(target); + } + } + + /** Will enable keyboard input if it's currently disabled, necessary for keyboard input. */ + private void setKeyboardInputIfDisabled() { + if (!remoteInput.EIOS_IsKeyboardInputEnabled(target)) { + remoteInput.EIOS_SetKeyboardInputEnabled(target, true); + } + } + + /** + * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. + * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless + * RemoteInput is pairing for the first time, where it'll randomise mouse position. + * + * @return A {@link Point} referring to the client relative mouse position + */ + public synchronized Point getMousePosition() { + IntByReference x = new IntByReference(); + IntByReference y = new IntByReference(); + remoteInput.EIOS_GetMousePosition(target, x, y); + return new Point(x.getValue(), y.getValue()); + } + + /** + * Gets the target app's window dimensions. Due to all IO being performed in client relative + * space, the user can assume the origin as 0,0. + * + * @return A rectangle defining the origin and bounds of the target application + */ + public synchronized Rectangle getTargetDimensions() { + IntByReference x = new IntByReference(); + IntByReference y = new IntByReference(); + remoteInput.EIOS_GetTargetDimensions(target, x, y); + return new Rectangle(0, 0, x.getValue(), y.getValue()); + } + + /** + * Sends a key down in the target java app. To get the keycode, use a {@link + * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. + * + * @param javaKeyCode The Java keycode corresponding to the key being pressed + */ + public synchronized void holdKey(int javaKeyCode) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_HoldKey(target, toNativeCode(javaKeyCode)); + } + + /** + * Queries whether a key is currently being held. + * + * @param javaKeyCode The Java keycode of the key in question + * @return Whether the key is currently being held + */ + public synchronized boolean isKeyHeld(int javaKeyCode) { + return remoteInput.EIOS_IsKeyHeld(target, toNativeCode(javaKeyCode)); + } + + /** + * Sends a key release event to the target Java app. This should be used in conjunction with + * {@link #holdKey(int)} to simulate a full key press. + * + * @param javaKeyCode The Java keycode corresponding to the key being released + */ + public synchronized void releaseKey(int javaKeyCode) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_ReleaseKey(target, toNativeCode(javaKeyCode)); + } + + /** + * Sends a mouse button down in the target Java app. + * + * @param button The {@link MouseButton} button to hold + */ + public synchronized void holdMouse(MouseButton button) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_HoldMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); + } + + /** + * Queries whether a {@link MouseButton} is currently held. + * + * @param button The {@link MouseButton} to check + * @return If the mouse button is currently being held + */ + public synchronized boolean isMouseHeld(MouseButton button) { + return remoteInput.EIOS_IsMouseHeld(target, button.ordinal()); + } + + /** + * Releases a mouse button at the designated client local co-ordinates. + * + * @param button The {@link MouseButton} to release + */ + public synchronized void releaseMouse(MouseButton button) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_ReleaseMouse(target, mousePosition.x, mousePosition.y, button.ordinal()); + } + + /** + * Moves a mouse to a designated client local co-ordinate. + * + * @param location The {@link Point} location to snap the mouse to + */ + public synchronized void moveMouse(Point location) { + getFocusIfNotFocused(); + remoteInput.EIOS_MoveMouse(target, location.x, location.y); + } + + /** + * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds + * a small float value to this, to simulate human imperfection + * + * @param notches The number of mouse notches to scroll, down is positive, up is negative + */ + public synchronized void scrollMouse(int notches) { + getFocusIfNotFocused(); + Point mousePosition = getMousePosition(); + remoteInput.EIOS_ScrollMouse(target, mousePosition.x, mousePosition.y, notches); + } + + /** + * Types out a string of characters whilst compensating for the need of modifier keys. Useful when + * typing something to a dialogue box, will compensate for special characters, however lacks delay + * between keypresses. + * + * @param string The text to be typed + * @param keyWait The time in milliseconds to hold down a key + * @param keyModWait The time in milliseconds to hold down modifier keys + */ + public synchronized void sendString(String string, int keyWait, int keyModWait) { + setKeyboardInputIfDisabled(); + getFocusIfNotFocused(); + remoteInput.EIOS_SendString(target, string, keyWait, keyModWait); + } + + /** + * Since this class implements the {@link AutoCloseable} interface, it must be closed to relieve + * native memory. This method will release the target, effectively shutting down RemoteInput for + * the particular ChromaScape instance. However, this does not delete the injected part of RI in + * the target, simply shuts down control over it. + */ + @Override + public void close() { + if (target != null) { + remoteInput.EIOS_ReleaseTarget(target); + target = null; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java index 798127d..7cff1bc 100644 --- a/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java +++ b/src/main/java/com/chromascape/utils/core/input/remoteinput/RemoteInputInterface.java @@ -1,226 +1,226 @@ -package com.chromascape.utils.core.input.remoteinput; - -import com.sun.jna.Library; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.IntByReference; - -/** - * JNA interface to load the RemoteInput DLL as a {@link RemoteInput} object. Contains the available - * exported headers available for ChromaScape to use. - */ -public interface RemoteInputInterface extends Library { - - /** - * Injects part of the RemoteInput Process into the target Java app. RI will only work after this - * pairing process has been performed. - * - * @param pid The OS process ID of which to inject into. - */ - void EIOS_Inject_PID(int pid); - - /** - * Requests to control a specific target app after it's been registered using {@link - * #EIOS_Inject_PID(int)}. Many targets can be controlled using a single instance of RemoteInput. - * - * @param initargs The String value of the target Java app's process ID (PID) - * @return A pointer reference to the target's EIOS object. This object is used in IO operations - * as a reference to which target should receive IO - */ - Pointer EIOS_RequestTarget(String initargs); - - /** - * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel - * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of - * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native - * hooks in the target application whenever a frame is rendered. - * - * @param eios The pointer to the paired EIOS target instance - * @return A pointer to the start of the BGRA pixel array - */ - Pointer EIOS_GetImageBuffer(Pointer eios); - - /** - * Retrieves a memory pointer similarly to {@link #EIOS_GetImageBuffer(Pointer)}. This method - * however will contain the mouse pointer and any objects drawn onto the canvas. - * - * @param eios The pointer to the paired EIOS target instance - * @return A pointer to the start of the BGRA pixel array - */ - Pointer EIOS_GetDebugImageBuffer(Pointer eios); - - /** - * Checks if the target application has keyboard input enabled. This is useful when using {@link - * #EIOS_HoldKey(Pointer, int)}, {@link #EIOS_ReleaseKey(Pointer, int)} and or {@link - * #EIOS_SendString(Pointer, String, int, int)}. - * - * @param eios The pointer to the paired EIOS target instance - * @return If the target has keyboard input enabled - */ - boolean EIOS_IsKeyboardInputEnabled(Pointer eios); - - /** - * Sets the keyboard input to either enabled or disabled, useful when conducting keyboard input. - * - * @param eios The pointer to the paired EIOS target instance - * @param enabled Whether you want keyboard input to be enabled - */ - void EIOS_SetKeyboardInputEnabled(Pointer eios, boolean enabled); - - /** - * Gets the total number of clients that currently have RemoteInput injected into them. - * - * @param unpaired_only Whether to count only unpaired targets. - * @return The total number of connected targets. - */ - long EIOS_GetClients(boolean unpaired_only); - - /** - * Fetches the process ID of a client given the index of the client in RemoteInput's internal - * client array. - * - * @param index The index position of the client - * @return The process ID - */ - int EIOS_GetClientPID(long index); - - /** - * Whether the host machine has focus over the target Java app. Focus is required when sending - * inputs to the client, it's recommended to use {@link #EIOS_GainFocus(Pointer)} to do so. - * - * @param eios The pointer to the paired EIOS target instance - * @return Whether the host has focus over the client - */ - boolean EIOS_HasFocus(Pointer eios); - - /** - * Used to gain focus over the target app. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_GainFocus(Pointer eios); - - /** - * Used to lose focus over the target app, might be useful for antiban possibly. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_LoseFocus(Pointer eios); - - /** - * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. - * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless - * RemoteInput is pairing for the first time, where it'll randomise mouse position. - * - * @param eios The pointer to the paired EIOS target instance - * @param x An {@link IntByReference} which gets mutated with the x value - * @param y An {@link IntByReference} which gets mutated with the y value - */ - void EIOS_GetMousePosition(Pointer eios, IntByReference x, IntByReference y); - - /** - * Gets the target app's window dimensions. Due to all IO being performed in client relative - * space, the user can assume the origin as 0,0. - * - * @param eios The pointer to the paired EIOS target instance - * @param width An {@link IntByReference} which gets mutated with the width's value in pixels - * @param height An {@link IntByReference} which gets mutated with the height's value in pixels - */ - void EIOS_GetTargetDimensions(Pointer eios, IntByReference width, IntByReference height); - - /** - * Sends a key down in the target java app. To get the keycode, use a {@link - * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode corresponding to the key being pressed - */ - void EIOS_HoldKey(Pointer eios, int key); - - /** - * Sends a mouse button down in the target java app. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param button The {@link MouseButton} to use - */ - void EIOS_HoldMouse(Pointer eios, int x, int y, int button); - - /** - * Queries whether a key is currently held. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode of the key - * @return If the key is currently being held - */ - boolean EIOS_IsKeyHeld(Pointer eios, int key); - - /** - * Queries whether a {@link MouseButton} is currently held. - * - * @param eios The pointer to the paired EIOS target instance - * @param button The {@link MouseButton} to check - * @return If the mouse button is currently being held - */ - boolean EIOS_IsMouseHeld(Pointer eios, int button); - - /** - * Moves a mouse to a designated client local co-ordinate. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - */ - void EIOS_MoveMouse(Pointer eios, int x, int y); - - /** - * Sends a key release event to the target Java app. This should be used in conjunction with - * {@link #EIOS_HoldKey(Pointer, int)} to simulate a full key press. - * - * @param eios The pointer to the paired EIOS target instance - * @param key The Java keycode corresponding to the key being released - */ - void EIOS_ReleaseKey(Pointer eios, int key); - - /** - * Releases a mouse button at the designated client local co-ordinates. - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param button The {@link MouseButton} to release - */ - void EIOS_ReleaseMouse(Pointer eios, int x, int y, int button); - - /** - * Severs the connection to the target EIOS object and releases native resources. This should be - * called when the script stops or the controller shuts down to avoid memory leaks in the target - * app. - * - * @param eios The pointer to the paired EIOS target instance - */ - void EIOS_ReleaseTarget(Pointer eios); - - /** - * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds - * a small float value to this, to simulate human imperfection - * - * @param eios The pointer to the paired EIOS target instance - * @param x The x co-ordinate of the input - * @param y The y co-ordinate of the input - * @param lines The number of notches to scroll; positive for down, negative for up - */ - void EIOS_ScrollMouse(Pointer eios, int x, int y, int lines); - - /** - * Types out a string of characters whilst compensating for the need of modifier keys. Useful when - * typing something to a dialogue box, will compensate for special characters, however lacks delay - * between keypresses. - * - * @param eios The pointer to the paired EIOS target instance - * @param string The text to be typed - * @param keywait The time in milliseconds to hold down a key - * @param keymodwait The time in milliseconds to hold down modifier keys - */ - void EIOS_SendString(Pointer eios, String string, int keywait, int keymodwait); -} +package com.chromascape.utils.core.input.remoteinput; + +import com.sun.jna.Library; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; + +/** + * JNA interface to load the RemoteInput DLL as a {@link RemoteInput} object. Contains the available + * exported headers available for ChromaScape to use. + */ +public interface RemoteInputInterface extends Library { + + /** + * Injects part of the RemoteInput Process into the target Java app. RI will only work after this + * pairing process has been performed. + * + * @param pid The OS process ID of which to inject into. + */ + void EIOS_Inject_PID(int pid); + + /** + * Requests to control a specific target app after it's been registered using {@link + * #EIOS_Inject_PID(int)}. Many targets can be controlled using a single instance of RemoteInput. + * + * @param initargs The String value of the target Java app's process ID (PID) + * @return A pointer reference to the target's EIOS object. This object is used in IO operations + * as a reference to which target should receive IO + */ + Pointer EIOS_RequestTarget(String initargs); + + /** + * Retrieves the memory pointer to the target's current image buffer. The buffer contains pixel + * data in BGRA (Blue, Green, Red, Alpha) format. Each pixel occupies 4 bytes. The total size of + * the buffer is {@code width * height * 4} bytes. The data is updated automatically by the native + * hooks in the target application whenever a frame is rendered. + * + * @param eios The pointer to the paired EIOS target instance + * @return A pointer to the start of the BGRA pixel array + */ + Pointer EIOS_GetImageBuffer(Pointer eios); + + /** + * Retrieves a memory pointer similarly to {@link #EIOS_GetImageBuffer(Pointer)}. This method + * however will contain the mouse pointer and any objects drawn onto the canvas. + * + * @param eios The pointer to the paired EIOS target instance + * @return A pointer to the start of the BGRA pixel array + */ + Pointer EIOS_GetDebugImageBuffer(Pointer eios); + + /** + * Checks if the target application has keyboard input enabled. This is useful when using {@link + * #EIOS_HoldKey(Pointer, int)}, {@link #EIOS_ReleaseKey(Pointer, int)} and or {@link + * #EIOS_SendString(Pointer, String, int, int)}. + * + * @param eios The pointer to the paired EIOS target instance + * @return If the target has keyboard input enabled + */ + boolean EIOS_IsKeyboardInputEnabled(Pointer eios); + + /** + * Sets the keyboard input to either enabled or disabled, useful when conducting keyboard input. + * + * @param eios The pointer to the paired EIOS target instance + * @param enabled Whether you want keyboard input to be enabled + */ + void EIOS_SetKeyboardInputEnabled(Pointer eios, boolean enabled); + + /** + * Gets the total number of clients that currently have RemoteInput injected into them. + * + * @param unpaired_only Whether to count only unpaired targets. + * @return The total number of connected targets. + */ + long EIOS_GetClients(boolean unpaired_only); + + /** + * Fetches the process ID of a client given the index of the client in RemoteInput's internal + * client array. + * + * @param index The index position of the client + * @return The process ID + */ + int EIOS_GetClientPID(long index); + + /** + * Whether the host machine has focus over the target Java app. Focus is required when sending + * inputs to the client, it's recommended to use {@link #EIOS_GainFocus(Pointer)} to do so. + * + * @param eios The pointer to the paired EIOS target instance + * @return Whether the host has focus over the client + */ + boolean EIOS_HasFocus(Pointer eios); + + /** + * Used to gain focus over the target app. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_GainFocus(Pointer eios); + + /** + * Used to lose focus over the target app, might be useful for antiban possibly. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_LoseFocus(Pointer eios); + + /** + * RemoteInput stores an internal mouse position that stays persistent after ChromaScape restarts. + * This will return that mouse position. VirtualMouseUtils automatically syncs to this unless + * RemoteInput is pairing for the first time, where it'll randomise mouse position. + * + * @param eios The pointer to the paired EIOS target instance + * @param x An {@link IntByReference} which gets mutated with the x value + * @param y An {@link IntByReference} which gets mutated with the y value + */ + void EIOS_GetMousePosition(Pointer eios, IntByReference x, IntByReference y); + + /** + * Gets the target app's window dimensions. Due to all IO being performed in client relative + * space, the user can assume the origin as 0,0. + * + * @param eios The pointer to the paired EIOS target instance + * @param width An {@link IntByReference} which gets mutated with the width's value in pixels + * @param height An {@link IntByReference} which gets mutated with the height's value in pixels + */ + void EIOS_GetTargetDimensions(Pointer eios, IntByReference width, IntByReference height); + + /** + * Sends a key down in the target java app. To get the keycode, use a {@link + * java.awt.event.KeyEvent} object. Example: {@code KeyEvent.VK_ENTER}. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode corresponding to the key being pressed + */ + void EIOS_HoldKey(Pointer eios, int key); + + /** + * Sends a mouse button down in the target java app. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param button The {@link MouseButton} to use + */ + void EIOS_HoldMouse(Pointer eios, int x, int y, int button); + + /** + * Queries whether a key is currently held. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode of the key + * @return If the key is currently being held + */ + boolean EIOS_IsKeyHeld(Pointer eios, int key); + + /** + * Queries whether a {@link MouseButton} is currently held. + * + * @param eios The pointer to the paired EIOS target instance + * @param button The {@link MouseButton} to check + * @return If the mouse button is currently being held + */ + boolean EIOS_IsMouseHeld(Pointer eios, int button); + + /** + * Moves a mouse to a designated client local co-ordinate. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + */ + void EIOS_MoveMouse(Pointer eios, int x, int y); + + /** + * Sends a key release event to the target Java app. This should be used in conjunction with + * {@link #EIOS_HoldKey(Pointer, int)} to simulate a full key press. + * + * @param eios The pointer to the paired EIOS target instance + * @param key The Java keycode corresponding to the key being released + */ + void EIOS_ReleaseKey(Pointer eios, int key); + + /** + * Releases a mouse button at the designated client local co-ordinates. + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param button The {@link MouseButton} to release + */ + void EIOS_ReleaseMouse(Pointer eios, int x, int y, int button); + + /** + * Severs the connection to the target EIOS object and releases native resources. This should be + * called when the script stops or the controller shuts down to avoid memory leaks in the target + * app. + * + * @param eios The pointer to the paired EIOS target instance + */ + void EIOS_ReleaseTarget(Pointer eios); + + /** + * Performs a mouse wheel scroll at the current virtual mouse position. RemoteInput natively adds + * a small float value to this, to simulate human imperfection + * + * @param eios The pointer to the paired EIOS target instance + * @param x The x co-ordinate of the input + * @param y The y co-ordinate of the input + * @param lines The number of notches to scroll; positive for down, negative for up + */ + void EIOS_ScrollMouse(Pointer eios, int x, int y, int lines); + + /** + * Types out a string of characters whilst compensating for the need of modifier keys. Useful when + * typing something to a dialogue box, will compensate for special characters, however lacks delay + * between keypresses. + * + * @param eios The pointer to the paired EIOS target instance + * @param string The text to be typed + * @param keywait The time in milliseconds to hold down a key + * @param keymodwait The time in milliseconds to hold down modifier keys + */ + void EIOS_SendString(Pointer eios, String string, int keywait, int keymodwait); +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java index 996542f..4c4886e 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxAuthException.java @@ -1,13 +1,13 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This error is thrown when the API key used to authenticate with the Dax API does not work. - * Contact a developer to inquire about the service's availability and or a change of Public key. - */ -public class DaxAuthException extends DaxException { - - /** Constructs a new DaxAuthException with a default message. */ - public DaxAuthException() { - super("Invalid credentials"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This error is thrown when the API key used to authenticate with the Dax API does not work. + * Contact a developer to inquire about the service's availability and or a change of Public key. + */ +public class DaxAuthException extends DaxException { + + /** Constructs a new DaxAuthException with a default message. */ + public DaxAuthException() { + super("Invalid credentials"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java index 8fcb257..1cbb5ed 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxException.java @@ -1,14 +1,14 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This is the parent of all the Dax API related exceptions. If this is the only error thrown, it - * signifies that it was an unexpected error that is unaccounted for, contact a developer. If this - * error is the parent of an error thrown, that error will contain more details. - */ -public class DaxException extends RuntimeException { - - /** Constructs a new DaxException with a default message. */ - public DaxException(String message) { - super(message); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This is the parent of all the Dax API related exceptions. If this is the only error thrown, it + * signifies that it was an unexpected error that is unaccounted for, contact a developer. If this + * error is the parent of an error thrown, that error will contain more details. + */ +public class DaxException extends RuntimeException { + + /** Constructs a new DaxException with a default message. */ + public DaxException(String message) { + super(message); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java index 7f5470c..a070138 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/DaxRateLimitException.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * This exception is thrown when the Publicly available endpoint for the Dax API is overloaded. When - * the service exceeds a certain threshold in a given timeframe, they stop taking more requests. The - * user experiencing this exception should implement a fallback to wait for the API, or do something - * else. - */ -public class DaxRateLimitException extends DaxException { - - /** Constructs a new DaxRateLimitException with a default message. */ - public DaxRateLimitException() { - super("Rate limit exceeded"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * This exception is thrown when the Publicly available endpoint for the Dax API is overloaded. When + * the service exceeds a certain threshold in a given timeframe, they stop taking more requests. The + * user experiencing this exception should implement a fallback to wait for the API, or do something + * else. + */ +public class DaxRateLimitException extends DaxException { + + /** Constructs a new DaxRateLimitException with a default message. */ + public DaxRateLimitException() { + super("Rate limit exceeded"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java b/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java index a509460..80e9697 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java +++ b/src/main/java/com/chromascape/utils/core/runtime/exception/ScriptStoppedException.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.runtime.exception; - -/** - * Exception used to indicate that a running script has been requested to stop. - * - *

This unchecked exception is thrown internally to signal that the script execution should be - * terminated gracefully. It can be caught by the script runner to halt execution without treating - * the stop as an error. - * - *

Typically, this exception is thrown by calling {@code stop()} methods in the script lifecycle - * to immediately exit the current execution cycle. - */ -public class ScriptStoppedException extends RuntimeException { - - /** Constructs a new ScriptStoppedException with a default message. */ - public ScriptStoppedException() { - super("Script stopped"); - } -} +package com.chromascape.utils.core.runtime.exception; + +/** + * Exception used to indicate that a running script has been requested to stop. + * + *

This unchecked exception is thrown internally to signal that the script execution should be + * terminated gracefully. It can be caught by the script runner to halt execution without treating + * the stop as an error. + * + *

Typically, this exception is thrown by calling {@code stop()} methods in the script lifecycle + * to immediately exit the current execution cycle. + */ +public class ScriptStoppedException extends RuntimeException { + + /** Constructs a new ScriptStoppedException with a default message. */ + public ScriptStoppedException() { + super("Script stopped"); + } +} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java b/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java index 5eb71fd..94fce47 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/Profile.java @@ -1,23 +1,23 @@ -package com.chromascape.utils.core.runtime.profile; - -/** - * Represents a RuneLite profile configuration. - * - *

Each profile corresponds to a {@code .properties} file in the RuneLite profiles directory and - * an entry in {@code profiles.json}. This record stores the metadata RuneLite uses to identify and - * manage the profile. - * - *

Fields: - * - *

    - *
  • {@code id} – unique identifier for the profile (used in the filename) - *
  • {@code name} – display name of the profile - *
  • {@code sync} – whether this profile is synced via RuneLite cloud - *
  • {@code active} – whether this profile is currently selected - *
  • {@code rev} – revision number for internal tracking - *
  • {@code defaultForRsProfiles} – array of RuneLite internal profile IDs that this profile is - * the default for - *
- */ -public record Profile( - long id, String name, boolean sync, boolean active, int rev, String[] defaultForRsProfiles) {} +package com.chromascape.utils.core.runtime.profile; + +/** + * Represents a RuneLite profile configuration. + * + *

Each profile corresponds to a {@code .properties} file in the RuneLite profiles directory and + * an entry in {@code profiles.json}. This record stores the metadata RuneLite uses to identify and + * manage the profile. + * + *

Fields: + * + *

    + *
  • {@code id} – unique identifier for the profile (used in the filename) + *
  • {@code name} – display name of the profile + *
  • {@code sync} – whether this profile is synced via RuneLite cloud + *
  • {@code active} – whether this profile is currently selected + *
  • {@code rev} – revision number for internal tracking + *
  • {@code defaultForRsProfiles} – array of RuneLite internal profile IDs that this profile is + * the default for + *
+ */ +public record Profile( + long id, String name, boolean sync, boolean active, int rev, String[] defaultForRsProfiles) {} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java index 435f3a0..aba62ce 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileContainer.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.runtime.profile; - -import java.util.List; - -/** - * A container for multiple {@link Profile} objects. - * - *

This class is primarily used for JSON serialization and deserialization of RuneLite profiles. - * The {@code profiles.json} file is mapped to a {@code ProfileContainer}, which holds a list of - * individual {@link Profile} records. - * - *

Each {@link Profile} in the list represents one profile configuration, including its metadata - * and settings. - */ -public record ProfileContainer(List profiles) {} +package com.chromascape.utils.core.runtime.profile; + +import java.util.List; + +/** + * A container for multiple {@link Profile} objects. + * + *

This class is primarily used for JSON serialization and deserialization of RuneLite profiles. + * The {@code profiles.json} file is mapped to a {@code ProfileContainer}, which holds a list of + * individual {@link Profile} records. + * + *

Each {@link Profile} in the list represents one profile configuration, including its metadata + * and settings. + */ +public record ProfileContainer(List profiles) {} diff --git a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java index 485ff35..b094d0f 100644 --- a/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java +++ b/src/main/java/com/chromascape/utils/core/runtime/profile/ProfileManager.java @@ -1,186 +1,186 @@ -package com.chromascape.utils.core.runtime.profile; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.List; -import java.util.Objects; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Manages RuneLite profile configuration for ChromaScape. - * - *

This class is responsible for loading existing RuneLite profiles, checking whether a - * ChromaScape-specific profile already exists, and creating one if necessary. A template profile is - * bundled with the project resources and copied into RuneLite's profile directory when required. - * - *

Profiles are tracked in two ways: - * - *

    - *
  • The {@code profiles.json} file maintained by RuneLite - *
  • A corresponding {@code .properties} file for each profile - *
- * - *

The ChromaScape profile is added only if it is missing. The profile data is then saved back to - * {@code profiles.json}. - */ -@SuppressWarnings("checkstyle:SummaryJavadoc") -public class ProfileManager { - /** Path to the current user's home directory. */ - private final String userHome = System.getProperty("user.home"); - - /** RuneLite profile directory. On Linux with native Bolt, uses the Bolt data path. */ - private final Path profileDir = resolveProfileDir(); - - /** List of all loaded RuneLite profiles from profiles.json. */ - private List profiles = null; - - /** JSON mapper for serializing and deserializing profile data. */ - private final ObjectMapper mapper; - - /** Logger for status and diagnostic messages. */ - private static final Logger logger = LogManager.getLogger(ProfileManager.class); - - /** Creates a new {@code ProfileManager} with a default Jackson ObjectMapper. */ - public ProfileManager() { - mapper = new ObjectMapper(); - } - - /** - * Resolves the RuneLite profile directory for the current operating system. - * - *

On Windows, returns the standard {@code %APPDATA%\Local\RuneLite\profiles2} path. - * - *

On Linux, checks for a native Bolt installation first ({@code - * ~/.var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2}), falling back to the - * standard {@code ~/.runelite/profiles2} path if Bolt is not detected. - * - *

On macOS, returns {@code ~/Library/Application Support/RuneLite/profiles2}. - * - * @return The resolved profile directory path - */ - private Path resolveProfileDir() { - String home = System.getProperty("user.home"); - String os = System.getProperty("os.name").toLowerCase(); - - if (os.contains("win")) { - return Path.of(home, "AppData", "Local", "RuneLite", "profiles2"); - } - - if (os.contains("linux")) { - Path boltPath = - Path.of(home, ".var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2"); - if (Files.exists(boltPath.getParent())) { - logger.info("ProfileManager: using Bolt RuneLite path: {}", boltPath); - return boltPath; - } - } - - if (os.contains("mac")) { - return Path.of(home, "Library/Application Support/RuneLite/profiles2"); - } - - // Fallback (Linux standard or unknown OS) - return Path.of(home, ".runelite/profiles2"); - } - - /** - * Ensures that the ChromaScape profile exists in the RuneLite configuration. - * - *

If the profile is already present, no changes are made. Otherwise: - * - *

    - *
  1. The bundled template properties file is copied into the profile directory - *
  2. A new entry is added to the in-memory profile list - *
  3. The updated profiles.json is written to disk - *
- * - * @throws IOException if profile data cannot be read or written - */ - public void loadBotProfile() throws IOException { - loadProfileInfoFromDisk(); - if (hasChromaScapeProfile()) { - logger.info("ChromaScape RuneLite profile already loaded"); - return; - } - logger.info("ChromaScape RuneLite profile doesn't exist, loading profile..."); - addProfile(); - saveProfileInfoToDisk(); - } - - /** - * Loads the current RuneLite profile information from {@code profiles.json}. - * - * @throws IOException if the file cannot be read - */ - private void loadProfileInfoFromDisk() throws IOException { - try (InputStream in = Files.newInputStream(profileDir.resolve("profiles.json"))) { - profiles = mapper.readValue(in, ProfileContainer.class).profiles(); - } - } - - /** - * Saves the current in-memory profile list back to {@code profiles.json}. - * - * @throws IOException if the file cannot be written - */ - private void saveProfileInfoToDisk() throws IOException { - ProfileContainer profileContainer = new ProfileContainer(profiles); - mapper.writeValue(profileDir.resolve("profiles.json").toFile(), profileContainer); - } - - /** - * Checks whether a ChromaScape profile is already defined. - * - * @return {@code true} if a profile with name "ChromaScape" exists, {@code false} otherwise - */ - private boolean hasChromaScapeProfile() { - for (Profile profile : profiles) { - if (Objects.equals(profile.name(), "ChromaScape")) { - return true; - } - } - return false; - } - - /** - * Adds a new ChromaScape profile by: - * - *
    - *
  • Generating a unique identifier for the profile - *
  • Copying the template properties file from resources to the RuneLite directory - *
  • Appending a new {@link Profile} entry to the in-memory list - *
- * - * @throws IOException if the template file cannot be found or written - */ - private void addProfile() throws IOException { - // Generate unique ID - long id = System.currentTimeMillis(); - for (Profile profile : profiles) { - if (profile.id() == id) { - id = System.currentTimeMillis(); - } - } - // Copy profile to the directory and rename it using the ID - try (InputStream savedProfile = - this.getClass().getResourceAsStream("/profiles/ChromaScape.properties")) { - if (savedProfile != null) { - Files.copy( - savedProfile, - profileDir.resolve("ChromaScape-" + id + ".properties"), - StandardCopyOption.REPLACE_EXISTING); - } else { - throw new FileNotFoundException("Resource not found: /profiles/ChromaScape.properties"); - } - } - // Add new profile to the locally saved copy - Profile botProfile = new Profile(id, "ChromaScape", false, false, -1, new String[0]); - profiles.add(botProfile); - } -} +package com.chromascape.utils.core.runtime.profile; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Objects; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Manages RuneLite profile configuration for ChromaScape. + * + *

This class is responsible for loading existing RuneLite profiles, checking whether a + * ChromaScape-specific profile already exists, and creating one if necessary. A template profile is + * bundled with the project resources and copied into RuneLite's profile directory when required. + * + *

Profiles are tracked in two ways: + * + *

    + *
  • The {@code profiles.json} file maintained by RuneLite + *
  • A corresponding {@code .properties} file for each profile + *
+ * + *

The ChromaScape profile is added only if it is missing. The profile data is then saved back to + * {@code profiles.json}. + */ +@SuppressWarnings("checkstyle:SummaryJavadoc") +public class ProfileManager { + /** Path to the current user's home directory. */ + private final String userHome = System.getProperty("user.home"); + + /** RuneLite profile directory. On Linux with native Bolt, uses the Bolt data path. */ + private final Path profileDir = resolveProfileDir(); + + /** List of all loaded RuneLite profiles from profiles.json. */ + private List profiles = null; + + /** JSON mapper for serializing and deserializing profile data. */ + private final ObjectMapper mapper; + + /** Logger for status and diagnostic messages. */ + private static final Logger logger = LogManager.getLogger(ProfileManager.class); + + /** Creates a new {@code ProfileManager} with a default Jackson ObjectMapper. */ + public ProfileManager() { + mapper = new ObjectMapper(); + } + + /** + * Resolves the RuneLite profile directory for the current operating system. + * + *

On Windows, returns the standard {@code %APPDATA%\Local\RuneLite\profiles2} path. + * + *

On Linux, checks for a native Bolt installation first ({@code + * ~/.var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2}), falling back to the + * standard {@code ~/.runelite/profiles2} path if Bolt is not detected. + * + *

On macOS, returns {@code ~/Library/Application Support/RuneLite/profiles2}. + * + * @return The resolved profile directory path + */ + private Path resolveProfileDir() { + String home = System.getProperty("user.home"); + String os = System.getProperty("os.name").toLowerCase(); + + if (os.contains("win")) { + return Path.of(home, "AppData", "Local", "RuneLite", "profiles2"); + } + + if (os.contains("linux")) { + Path boltPath = + Path.of(home, ".var/app/com.adamcake.Bolt/data/bolt-launcher/.runelite/profiles2"); + if (Files.exists(boltPath.getParent())) { + logger.info("ProfileManager: using Bolt RuneLite path: {}", boltPath); + return boltPath; + } + } + + if (os.contains("mac")) { + return Path.of(home, "Library/Application Support/RuneLite/profiles2"); + } + + // Fallback (Linux standard or unknown OS) + return Path.of(home, ".runelite/profiles2"); + } + + /** + * Ensures that the ChromaScape profile exists in the RuneLite configuration. + * + *

If the profile is already present, no changes are made. Otherwise: + * + *

    + *
  1. The bundled template properties file is copied into the profile directory + *
  2. A new entry is added to the in-memory profile list + *
  3. The updated profiles.json is written to disk + *
+ * + * @throws IOException if profile data cannot be read or written + */ + public void loadBotProfile() throws IOException { + loadProfileInfoFromDisk(); + if (hasChromaScapeProfile()) { + logger.info("ChromaScape RuneLite profile already loaded"); + return; + } + logger.info("ChromaScape RuneLite profile doesn't exist, loading profile..."); + addProfile(); + saveProfileInfoToDisk(); + } + + /** + * Loads the current RuneLite profile information from {@code profiles.json}. + * + * @throws IOException if the file cannot be read + */ + private void loadProfileInfoFromDisk() throws IOException { + try (InputStream in = Files.newInputStream(profileDir.resolve("profiles.json"))) { + profiles = mapper.readValue(in, ProfileContainer.class).profiles(); + } + } + + /** + * Saves the current in-memory profile list back to {@code profiles.json}. + * + * @throws IOException if the file cannot be written + */ + private void saveProfileInfoToDisk() throws IOException { + ProfileContainer profileContainer = new ProfileContainer(profiles); + mapper.writeValue(profileDir.resolve("profiles.json").toFile(), profileContainer); + } + + /** + * Checks whether a ChromaScape profile is already defined. + * + * @return {@code true} if a profile with name "ChromaScape" exists, {@code false} otherwise + */ + private boolean hasChromaScapeProfile() { + for (Profile profile : profiles) { + if (Objects.equals(profile.name(), "ChromaScape")) { + return true; + } + } + return false; + } + + /** + * Adds a new ChromaScape profile by: + * + *
    + *
  • Generating a unique identifier for the profile + *
  • Copying the template properties file from resources to the RuneLite directory + *
  • Appending a new {@link Profile} entry to the in-memory list + *
+ * + * @throws IOException if the template file cannot be found or written + */ + private void addProfile() throws IOException { + // Generate unique ID + long id = System.currentTimeMillis(); + for (Profile profile : profiles) { + if (profile.id() == id) { + id = System.currentTimeMillis(); + } + } + // Copy profile to the directory and rename it using the ID + try (InputStream savedProfile = + this.getClass().getResourceAsStream("/profiles/ChromaScape.properties")) { + if (savedProfile != null) { + Files.copy( + savedProfile, + profileDir.resolve("ChromaScape-" + id + ".properties"), + StandardCopyOption.REPLACE_EXISTING); + } else { + throw new FileNotFoundException("Resource not found: /profiles/ChromaScape.properties"); + } + } + // Add new profile to the locally saved copy + Profile botProfile = new Profile(id, "ChromaScape", false, false, -1, new String[0]); + profiles.add(botProfile); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java b/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java index af3fc9b..b41a0f8 100644 --- a/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java +++ b/src/main/java/com/chromascape/utils/core/screen/DisplayImage.java @@ -1,43 +1,43 @@ -package com.chromascape.utils.core.screen; - -import java.awt.BorderLayout; -import java.awt.image.BufferedImage; -import javax.swing.ImageIcon; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.WindowConstants; - -/** - * Utility class for temporarily displaying {@link BufferedImage} instances in a Swing window for - * debugging and visualization purposes (e.g., testing masks or OpenCV Mats). - */ -public class DisplayImage { - - private static JFrame frame; - private static JLabel label; - - /** - * Displays the provided {@link BufferedImage} in a simple Swing window. - * - *

If the display window has not been created yet, it will be initialized and shown. Otherwise, - * the image inside the existing window will be updated. - * - *

This method is primarily intended for testing and debugging purposes during development. - * - * @param image The image to display. If the source is an OpenCV {@code Mat}, convert it first - * using {@code TemplateMatching.matToBufferedImage(Mat)}. - */ - public static void display(BufferedImage image) { - if (frame == null) { - frame = new JFrame("ScreenShot"); - frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); - label = new JLabel(new ImageIcon(image)); - frame.getContentPane().add(label, BorderLayout.CENTER); - frame.pack(); - frame.setLocationRelativeTo(null); - frame.setVisible(true); - } else { - label.setIcon(new ImageIcon(image)); - } - } -} +package com.chromascape.utils.core.screen; + +import java.awt.BorderLayout; +import java.awt.image.BufferedImage; +import javax.swing.ImageIcon; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.WindowConstants; + +/** + * Utility class for temporarily displaying {@link BufferedImage} instances in a Swing window for + * debugging and visualization purposes (e.g., testing masks or OpenCV Mats). + */ +public class DisplayImage { + + private static JFrame frame; + private static JLabel label; + + /** + * Displays the provided {@link BufferedImage} in a simple Swing window. + * + *

If the display window has not been created yet, it will be initialized and shown. Otherwise, + * the image inside the existing window will be updated. + * + *

This method is primarily intended for testing and debugging purposes during development. + * + * @param image The image to display. If the source is an OpenCV {@code Mat}, convert it first + * using {@code TemplateMatching.matToBufferedImage(Mat)}. + */ + public static void display(BufferedImage image) { + if (frame == null) { + frame = new JFrame("ScreenShot"); + frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + label = new JLabel(new ImageIcon(image)); + frame.getContentPane().add(label, BorderLayout.CENTER); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } else { + label.setIcon(new ImageIcon(image)); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java b/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java index 55e10ec..e610b56 100644 --- a/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java +++ b/src/main/java/com/chromascape/utils/core/screen/colour/ColourInstances.java @@ -1,84 +1,84 @@ -package com.chromascape.utils.core.screen.colour; - -import com.chromascape.web.image.ColourData; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * A utility class for loading and accessing named colour definitions used for screen detection. - * - *

Colour data is loaded once at class initialization from a {@code colours.json} file located - * relative to the current working directory (usually the project root). Each colour definition - * includes a name and a min-max HSV range, which is used to construct {@link ColourObj} instances. - * - *

Note: The fourth component of the {@link Scalar} is always zero due to JavaCV's Scalar - * structure, so only the first three channels (H, S, V) are meaningful. - */ -public class ColourInstances { - - private static final Logger logger = LogManager.getLogger(ColourInstances.class); - - /** The cached list of all colour definitions loaded from the configuration file. */ - private static List COLOURS; - - /** - * Path to the colours JSON file, relative to working directory. Adjust this path if your file - * location changes. - */ - private static final String COLOURS_JSON_PATH = "colours/colours.json"; - - // Static block to load colour data once at class load time - static { - try (InputStream is = Files.newInputStream(Path.of(COLOURS_JSON_PATH))) { - ObjectMapper mapper = new ObjectMapper(); - - List colourDataList = mapper.readValue(is, new TypeReference<>() {}); - - COLOURS = - colourDataList.stream() - .map( - data -> - new ColourObj( - data.getName(), - new Scalar( - data.getMin()[0], - data.getMin()[1], - data.getMin()[2], - data.getMin()[3]), - new Scalar( - data.getMax()[0], - data.getMax()[1], - data.getMax()[2], - data.getMax()[3]))) - .toList(); - - } catch (IOException e) { - logger.error( - "Could not load colours.json from path '" + COLOURS_JSON_PATH + "': {}", e.getMessage()); - COLOURS = List.of(); // Initialize as empty list to avoid null pointer issues - } - } - - /** - * Retrieves a {@link ColourObj} by its name. - * - * @param name The name of the colour to retrieve. - * @return The {@link ColourObj} matching the given name, or {@code null} if not found. - */ - public static ColourObj getByName(String name) { - for (ColourObj colour : COLOURS) { - if (colour.name().equals(name)) { - return colour; - } - } - return null; - } -} +package com.chromascape.utils.core.screen.colour; + +import com.chromascape.web.image.ColourData; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * A utility class for loading and accessing named colour definitions used for screen detection. + * + *

Colour data is loaded once at class initialization from a {@code colours.json} file located + * relative to the current working directory (usually the project root). Each colour definition + * includes a name and a min-max HSV range, which is used to construct {@link ColourObj} instances. + * + *

Note: The fourth component of the {@link Scalar} is always zero due to JavaCV's Scalar + * structure, so only the first three channels (H, S, V) are meaningful. + */ +public class ColourInstances { + + private static final Logger logger = LogManager.getLogger(ColourInstances.class); + + /** The cached list of all colour definitions loaded from the configuration file. */ + private static List COLOURS; + + /** + * Path to the colours JSON file, relative to working directory. Adjust this path if your file + * location changes. + */ + private static final String COLOURS_JSON_PATH = "colours/colours.json"; + + // Static block to load colour data once at class load time + static { + try (InputStream is = Files.newInputStream(Path.of(COLOURS_JSON_PATH))) { + ObjectMapper mapper = new ObjectMapper(); + + List colourDataList = mapper.readValue(is, new TypeReference<>() {}); + + COLOURS = + colourDataList.stream() + .map( + data -> + new ColourObj( + data.getName(), + new Scalar( + data.getMin()[0], + data.getMin()[1], + data.getMin()[2], + data.getMin()[3]), + new Scalar( + data.getMax()[0], + data.getMax()[1], + data.getMax()[2], + data.getMax()[3]))) + .toList(); + + } catch (IOException e) { + logger.error( + "Could not load colours.json from path '" + COLOURS_JSON_PATH + "': {}", e.getMessage()); + COLOURS = List.of(); // Initialize as empty list to avoid null pointer issues + } + } + + /** + * Retrieves a {@link ColourObj} by its name. + * + * @param name The name of the colour to retrieve. + * @return The {@link ColourObj} matching the given name, or {@code null} if not found. + */ + public static ColourObj getByName(String name) { + for (ColourObj colour : COLOURS) { + if (colour.name().equals(name)) { + return colour; + } + } + return null; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java b/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java index cc13243..a19c250 100644 --- a/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java +++ b/src/main/java/com/chromascape/utils/core/screen/colour/ColourObj.java @@ -1,49 +1,49 @@ -package com.chromascape.utils.core.screen.colour; - -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * This record class stores the name, min threshold, and max threshold of an HSV colour. Note: The - * fourth channel (alpha) is always zero and unused due to how JavaCV handles Scalar. - * - * @param name Name of the colour. - * @param hsvMin Minimum HSV threshold; alpha channel is ignored (always zero). - * @param hsvMax Maximum HSV threshold; alpha channel is ignored (always zero). - */ -public record ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { - - /** - * Constructs a ColourObj with copies of the provided HSV scalar bounds. This ensures immutability - * by duplicating the passed Scalar objects. The fourth (alpha) channel is preserved from input - * but unused in HSV processing. - * - * @param name The name identifier for the colour. - * @param hsvMin The lower HSV bound (inclusive). - * @param hsvMax The upper HSV bound (inclusive). - */ - public ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { - this.name = name; - this.hsvMin = new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); - this.hsvMax = new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); - } - - /** - * Fetches the minimum HSV values of this colour. - * - * @return A copy of the internal hsvMin to avoid mutability. The 4th channel is always zero. - */ - @Override - public Scalar hsvMin() { - return new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); - } - - /** - * Fetches the maximum HSV values of this colour. - * - * @return A copy of the internal hsvMax to avoid mutability. The 4th channel is always zero. - */ - @Override - public Scalar hsvMax() { - return new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); - } -} +package com.chromascape.utils.core.screen.colour; + +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * This record class stores the name, min threshold, and max threshold of an HSV colour. Note: The + * fourth channel (alpha) is always zero and unused due to how JavaCV handles Scalar. + * + * @param name Name of the colour. + * @param hsvMin Minimum HSV threshold; alpha channel is ignored (always zero). + * @param hsvMax Maximum HSV threshold; alpha channel is ignored (always zero). + */ +public record ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { + + /** + * Constructs a ColourObj with copies of the provided HSV scalar bounds. This ensures immutability + * by duplicating the passed Scalar objects. The fourth (alpha) channel is preserved from input + * but unused in HSV processing. + * + * @param name The name identifier for the colour. + * @param hsvMin The lower HSV bound (inclusive). + * @param hsvMax The upper HSV bound (inclusive). + */ + public ColourObj(String name, Scalar hsvMin, Scalar hsvMax) { + this.name = name; + this.hsvMin = new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); + this.hsvMax = new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); + } + + /** + * Fetches the minimum HSV values of this colour. + * + * @return A copy of the internal hsvMin to avoid mutability. The 4th channel is always zero. + */ + @Override + public Scalar hsvMin() { + return new Scalar(hsvMin.get(0), hsvMin.get(1), hsvMin.get(2), hsvMin.get(3)); + } + + /** + * Fetches the maximum HSV values of this colour. + * + * @return A copy of the internal hsvMax to avoid mutability. The 4th channel is always zero. + */ + @Override + public Scalar hsvMax() { + return new Scalar(hsvMax.get(0), hsvMax.get(1), hsvMax.get(2), hsvMax.get(3)); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java b/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java index 73af7ad..a4106fa 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/ChromaObj.java @@ -1,21 +1,21 @@ -package com.chromascape.utils.core.screen.topology; - -import java.awt.Rectangle; -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * Represents a detected object in the ChromaScape pipeline with a unique ID, its contour as an - * OpenCV {@link Mat}, and a bounding box for interaction. - * - * @param id A unique identifier assigned based on the object's index among detected contours. - * @param contour The OpenCV matrix representing the object's contour. - * @param boundingBox The bounding rectangle used to sample interaction points. - */ -public record ChromaObj(int id, Mat contour, Rectangle boundingBox) { - /** Releases the native OpenCV memory associated with the contour. */ - public void release() { - if (contour != null) { - contour.release(); - } - } -} +package com.chromascape.utils.core.screen.topology; + +import java.awt.Rectangle; +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * Represents a detected object in the ChromaScape pipeline with a unique ID, its contour as an + * OpenCV {@link Mat}, and a bounding box for interaction. + * + * @param id A unique identifier assigned based on the object's index among detected contours. + * @param contour The OpenCV matrix representing the object's contour. + * @param boundingBox The bounding rectangle used to sample interaction points. + */ +public record ChromaObj(int id, Mat contour, Rectangle boundingBox) { + /** Releases the native OpenCV memory associated with the contour. */ + public void release() { + if (contour != null) { + contour.release(); + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java b/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java index fa3ea64..8bf69ac 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/ColourContours.java @@ -1,200 +1,200 @@ -package com.chromascape.utils.core.screen.topology; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; -import static org.bytedeco.opencv.global.opencv_core.inRange; -import static org.bytedeco.opencv.global.opencv_imgproc.*; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.List; -import org.bytedeco.opencv.opencv_core.*; - -/** - * Utility class for extracting and processing colour-based contours from images. Uses OpenCV to - * convert images to HSV, extract colours within HSV ranges, find contours, and create ChromaObj - * objects representing these contours. - */ -public class ColourContours { - - private static final Mat DILATE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); - private static final Mat ERODE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); - private static final Scalar COLOUR_WHITE = new Scalar(255); - private static final Mat EMPTY_HIERARCHY = new Mat(); - private static final org.bytedeco.opencv.opencv_core.Point OFFSET_ZERO = - new org.bytedeco.opencv.opencv_core.Point(0, 0); - - /** - * Finds and returns a list of ChromaObj instances representing contours in the given image that - * match the specified colour range. - * - * @param image the BufferedImage to process - * @param colourObj the ColourObj specifying the HSV colour range to extract - * @return a list of ChromaObj objects representing detected contours of the specified colour - */ - public static List getChromaObjsInColour(BufferedImage image, ColourObj colourObj) { - Mat mask = extractColours(image, colourObj); - morphClose(mask); - ViewportManager.getInstance().updateState(mask); - MatVector contours = extractContours(mask); - mask.release(); - return createChromaObjects(contours); - } - - /** - * Iterates over a list of ChromaObjs to calculate and return whichever is closest to the - * player/screen centre. Useful in a wide range of activities and preferred over arbitrary choice - * by detection. - * - * @param chromaObjs {@code List} of which to iterate over. - * @return a single {@link ChromaObj} which is closest to the player. - */ - public static ChromaObj getChromaObjClosestToCentre(List chromaObjs) { - if (chromaObjs == null || chromaObjs.isEmpty()) { - return null; - } - - Point screenCentre = - new Point( - (int) ScreenManager.getWindowBounds().getCenterX(), - (int) ScreenManager.getWindowBounds().getCenterY()); - - double minDistance = Double.MAX_VALUE; - ChromaObj closestChromaObj = null; - - for (ChromaObj chromaObj : chromaObjs) { - Point objCentre = - new Point( - (int) chromaObj.boundingBox().getCenterX(), - (int) chromaObj.boundingBox().getCenterY()); - - double currentDistance = objCentre.distance(screenCentre); - - if (currentDistance < minDistance) { - minDistance = currentDistance; - closestChromaObj = chromaObj; - } - } - - return closestChromaObj; - } - - /** - * Converts the input image to HSV colour space and extracts a binary mask where pixels within the - * HSV range specified by the colourObj are white (255), and others are black (0). - * - * @param image the BufferedImage to convert and threshold - * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds - * @return a Mat binary mask with pixels in range set to 255, others 0 - */ - public static Mat extractColours(BufferedImage image, ColourObj colourObj) { - // Convert BufferedImage to Mat explicitly - try (Mat hsvImage = TemplateMatching.bufferedImageToMat(image)) { - return extractColours(hsvImage, colourObj); - } - } - - /** - * Converts the input Mat to HSV colour space and extracts a binary mask. - * - * @param inputMat the source image Mat (BGR) - * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds - * @return a Mat binary mask with pixels in range set to 255, others 0 - */ - public static Mat extractColours(Mat inputMat, ColourObj colourObj) { - StateManager.setState(com.chromascape.utils.core.state.BotState.SEARCHING); - Mat hsvImage = inputMat.clone(); - cvtColor(hsvImage, hsvImage, COLOR_BGR2HSV); - Mat result = new Mat(hsvImage.size(), CV_8UC1); - Mat hsvMin = new Mat(colourObj.hsvMin()); - Mat hsvMax = new Mat(colourObj.hsvMax()); - inRange(hsvImage, hsvMin, hsvMax, result); - hsvImage.release(); - hsvMin.release(); - hsvMax.release(); - - return result; - } - - /** - * Uses Morphological Closing via dilation and erosion, to ensure that no breaks appear in the - * contour. Fills object's contours to ensure consistency and to reduce duplicate contours. - * Mutates the given Mat object rather than assigning separate objects. - * - * @param result The 8UC1 {@link Mat} mask which to mutate. - */ - public static void morphClose(Mat result) { - // Dilate the contour to fix breaks e.g., C should become O - morphologyEx(result, result, MORPH_DILATE, DILATE_KERNEL); - - // Completely fill internal space with white - // For consistency and improved contour calculation - try (MatVector contours = new MatVector()) { - findContours(result, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); - // Using static constants for reused variables to reduce CPU allocation fatigue - drawContours( - result, - contours, - -1, - COLOUR_WHITE, - -1, - LINE_8, - EMPTY_HIERARCHY, - Integer.MAX_VALUE, - OFFSET_ZERO); - } - - // Restore original size through erosion whilst closing contour breaks - morphologyEx(result, result, MORPH_ERODE, ERODE_KERNEL); - } - - /** - * Finds contours in a binary mask image. - * - * @param binaryMask a binary Mat mask where contours are to be found - * @return a MatVector containing all detected contours - */ - public static MatVector extractContours(Mat binaryMask) { - MatVector contours = new MatVector(); - findContours(binaryMask, contours, CV_RETR_LIST, CHAIN_APPROX_SIMPLE); - return contours; - } - - /** - * Creates a list of ChromaObj objects from the given contours. Each ChromaObj contains the - * contour index, the contour Mat itself, and its bounding rectangle as a Java AWT Rectangle. - * - * @param contours MatVector containing contours detected in the image - * @return list of ChromaObj objects representing each contour with bounding box - */ - public static List createChromaObjects(MatVector contours) { - List chromaObjects = new ArrayList<>(); - for (int i = 0; i < contours.size(); i++) { - Mat contour = contours.get(i); - Rect rect = boundingRect(contour); - Rectangle contourBounds = new Rectangle(rect.x(), rect.y(), rect.width(), rect.height()); - chromaObjects.add(new ChromaObj(i, contour, contourBounds)); - StatisticsManager.incrementObjectsDetected(); - } - return chromaObjects; - } - - /** - * Checks whether a given point lies inside a specified contour. - * - * @param point the Point to test - * @param contour the Mat representing the contour to test against - * @return true if the point lies inside the contour; false otherwise - */ - public static boolean isPointInContour(Point point, Mat contour) { - try (Point2f point2f = new Point2f(point.x, point.y)) { - return pointPolygonTest(contour, point2f, false) > 0; - } - } -} +package com.chromascape.utils.core.screen.topology; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.inRange; +import static org.bytedeco.opencv.global.opencv_imgproc.*; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import org.bytedeco.opencv.opencv_core.*; + +/** + * Utility class for extracting and processing colour-based contours from images. Uses OpenCV to + * convert images to HSV, extract colours within HSV ranges, find contours, and create ChromaObj + * objects representing these contours. + */ +public class ColourContours { + + private static final Mat DILATE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); + private static final Mat ERODE_KERNEL = getStructuringElement(MORPH_ELLIPSE, new Size(20, 20)); + private static final Scalar COLOUR_WHITE = new Scalar(255); + private static final Mat EMPTY_HIERARCHY = new Mat(); + private static final org.bytedeco.opencv.opencv_core.Point OFFSET_ZERO = + new org.bytedeco.opencv.opencv_core.Point(0, 0); + + /** + * Finds and returns a list of ChromaObj instances representing contours in the given image that + * match the specified colour range. + * + * @param image the BufferedImage to process + * @param colourObj the ColourObj specifying the HSV colour range to extract + * @return a list of ChromaObj objects representing detected contours of the specified colour + */ + public static List getChromaObjsInColour(BufferedImage image, ColourObj colourObj) { + Mat mask = extractColours(image, colourObj); + morphClose(mask); + ViewportManager.getInstance().updateState(mask); + MatVector contours = extractContours(mask); + mask.release(); + return createChromaObjects(contours); + } + + /** + * Iterates over a list of ChromaObjs to calculate and return whichever is closest to the + * player/screen centre. Useful in a wide range of activities and preferred over arbitrary choice + * by detection. + * + * @param chromaObjs {@code List} of which to iterate over. + * @return a single {@link ChromaObj} which is closest to the player. + */ + public static ChromaObj getChromaObjClosestToCentre(List chromaObjs) { + if (chromaObjs == null || chromaObjs.isEmpty()) { + return null; + } + + Point screenCentre = + new Point( + (int) ScreenManager.getWindowBounds().getCenterX(), + (int) ScreenManager.getWindowBounds().getCenterY()); + + double minDistance = Double.MAX_VALUE; + ChromaObj closestChromaObj = null; + + for (ChromaObj chromaObj : chromaObjs) { + Point objCentre = + new Point( + (int) chromaObj.boundingBox().getCenterX(), + (int) chromaObj.boundingBox().getCenterY()); + + double currentDistance = objCentre.distance(screenCentre); + + if (currentDistance < minDistance) { + minDistance = currentDistance; + closestChromaObj = chromaObj; + } + } + + return closestChromaObj; + } + + /** + * Converts the input image to HSV colour space and extracts a binary mask where pixels within the + * HSV range specified by the colourObj are white (255), and others are black (0). + * + * @param image the BufferedImage to convert and threshold + * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds + * @return a Mat binary mask with pixels in range set to 255, others 0 + */ + public static Mat extractColours(BufferedImage image, ColourObj colourObj) { + // Convert BufferedImage to Mat explicitly + try (Mat hsvImage = TemplateMatching.bufferedImageToMat(image)) { + return extractColours(hsvImage, colourObj); + } + } + + /** + * Converts the input Mat to HSV colour space and extracts a binary mask. + * + * @param inputMat the source image Mat (BGR) + * @param colourObj the ColourObj specifying the HSV minimum and maximum bounds + * @return a Mat binary mask with pixels in range set to 255, others 0 + */ + public static Mat extractColours(Mat inputMat, ColourObj colourObj) { + StateManager.setState(com.chromascape.utils.core.state.BotState.SEARCHING); + Mat hsvImage = inputMat.clone(); + cvtColor(hsvImage, hsvImage, COLOR_BGR2HSV); + Mat result = new Mat(hsvImage.size(), CV_8UC1); + Mat hsvMin = new Mat(colourObj.hsvMin()); + Mat hsvMax = new Mat(colourObj.hsvMax()); + inRange(hsvImage, hsvMin, hsvMax, result); + hsvImage.release(); + hsvMin.release(); + hsvMax.release(); + + return result; + } + + /** + * Uses Morphological Closing via dilation and erosion, to ensure that no breaks appear in the + * contour. Fills object's contours to ensure consistency and to reduce duplicate contours. + * Mutates the given Mat object rather than assigning separate objects. + * + * @param result The 8UC1 {@link Mat} mask which to mutate. + */ + public static void morphClose(Mat result) { + // Dilate the contour to fix breaks e.g., C should become O + morphologyEx(result, result, MORPH_DILATE, DILATE_KERNEL); + + // Completely fill internal space with white + // For consistency and improved contour calculation + try (MatVector contours = new MatVector()) { + findContours(result, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); + // Using static constants for reused variables to reduce CPU allocation fatigue + drawContours( + result, + contours, + -1, + COLOUR_WHITE, + -1, + LINE_8, + EMPTY_HIERARCHY, + Integer.MAX_VALUE, + OFFSET_ZERO); + } + + // Restore original size through erosion whilst closing contour breaks + morphologyEx(result, result, MORPH_ERODE, ERODE_KERNEL); + } + + /** + * Finds contours in a binary mask image. + * + * @param binaryMask a binary Mat mask where contours are to be found + * @return a MatVector containing all detected contours + */ + public static MatVector extractContours(Mat binaryMask) { + MatVector contours = new MatVector(); + findContours(binaryMask, contours, CV_RETR_LIST, CHAIN_APPROX_SIMPLE); + return contours; + } + + /** + * Creates a list of ChromaObj objects from the given contours. Each ChromaObj contains the + * contour index, the contour Mat itself, and its bounding rectangle as a Java AWT Rectangle. + * + * @param contours MatVector containing contours detected in the image + * @return list of ChromaObj objects representing each contour with bounding box + */ + public static List createChromaObjects(MatVector contours) { + List chromaObjects = new ArrayList<>(); + for (int i = 0; i < contours.size(); i++) { + Mat contour = contours.get(i); + Rect rect = boundingRect(contour); + Rectangle contourBounds = new Rectangle(rect.x(), rect.y(), rect.width(), rect.height()); + chromaObjects.add(new ChromaObj(i, contour, contourBounds)); + StatisticsManager.incrementObjectsDetected(); + } + return chromaObjects; + } + + /** + * Checks whether a given point lies inside a specified contour. + * + * @param point the Point to test + * @param contour the Mat representing the contour to test against + * @return true if the point lies inside the contour; false otherwise + */ + public static boolean isPointInContour(Point point, Mat contour) { + try (Point2f point2f = new Point2f(point.x, point.y)) { + return pointPolygonTest(contour, point2f, false) > 0; + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java b/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java index a86a251..ee13f4b 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/MatchResult.java @@ -1,18 +1,18 @@ -package com.chromascape.utils.core.screen.topology; - -import java.awt.Rectangle; - -/** - * The object returned by {@link TemplateMatching}. Contains all the necessary information to react - * to the state of the template image. It is the consumer's responsibility to act on the result. - * There are no errors thrown. - * - * @param bounds The bounding box and location of the detected image. If success is false/match not - * found, this value is {@code null}. - * @param score The minVal/threshold/confidence at which the image correlated to the base (lower = - * better). If success is false/match not found, this value is {@code Double.MAX_VALUE()}. - * @param success Whether the template was successfully found within the base image. {@code boolean} - * @param message A message associated to state, in case there is no match, this will contain - * further information. - */ -public record MatchResult(Rectangle bounds, double score, boolean success, String message) {} +package com.chromascape.utils.core.screen.topology; + +import java.awt.Rectangle; + +/** + * The object returned by {@link TemplateMatching}. Contains all the necessary information to react + * to the state of the template image. It is the consumer's responsibility to act on the result. + * There are no errors thrown. + * + * @param bounds The bounding box and location of the detected image. If success is false/match not + * found, this value is {@code null}. + * @param score The minVal/threshold/confidence at which the image correlated to the base (lower = + * better). If success is false/match not found, this value is {@code Double.MAX_VALUE()}. + * @param success Whether the template was successfully found within the base image. {@code boolean} + * @param message A message associated to state, in case there is no match, this will contain + * further information. + */ +public record MatchResult(Rectangle bounds, double score, boolean success, String message) {} diff --git a/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java b/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java index eb7de88..d2bf510 100644 --- a/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java +++ b/src/main/java/com/chromascape/utils/core/screen/topology/TemplateMatching.java @@ -1,290 +1,290 @@ -package com.chromascape.utils.core.screen.topology; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; -import static org.bytedeco.opencv.global.opencv_core.extractChannel; -import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; -import static org.bytedeco.opencv.global.opencv_imgproc.*; -import static org.opencv.imgproc.Imgproc.TM_SQDIFF_NORMED; - -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.awt.Graphics2D; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.awt.image.DataBufferByte; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import org.bytedeco.javacpp.DoublePointer; -import org.bytedeco.opencv.global.opencv_imgcodecs; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point; - -/** - * Utility class for performing alpha-aware template matching using OpenCV and JavaCV. - * - *

This class provides a single static method, {@link #match}, which uses the TM_SQDIFF_NORMED - * algorithm to locate a template image within a larger base image. It uses an alpha mask to ignore - * transparent pixels in the template. - * - *

This is commonly to locate UI elements or sprites in the client window, based on screen - * captures and template assets. - */ -public class TemplateMatching { - - /** - * Performs template matching to locate a smaller image (template) within a larger image (base), - * using normalised squared difference matching with an alpha channel mask to ignore transparent - * pixels. - * - *

The method returns the bounding rectangle of the best match if its matching score is below - * the given threshold. If no match satisfies the threshold, the method returns {@code null}. - * - * @param templateImg The template image (smaller), expected as a {@link BufferedImage}. - * @param baseImg The base image (larger) where the template is searched, expected as a {@link - * BufferedImage} in RGB format. - * @param threshold The maximum allowed normalised squared difference score for a valid match. - * Lower values mean better matches. - * @return A {@link MatchResult} representing the position and size of the matching area in the - * base image, or {@code null} if no match meets the threshold criteria. - */ - public static MatchResult match(String templateImg, BufferedImage baseImg, double threshold) { - - // Update bot's semantic state - StateManager.setState(BotState.SEARCHING); - - Mat template = null; - Mat base = null; - Mat convolution = null; - Mat alpha = null; - Mat mask = null; - - try { - // Read template image from disk and load it as a Mat - try { - template = loadMatFromResource(templateImg); - } catch (IOException e) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); - } - - // Prepare a mat in RGB to send to the viewport - Mat view = new Mat(); - // Use the template as source and view as destination. - // This handles data copying/conversion safely without modifying template. - if (template.channels() == 4) { - cvtColor(template, view, COLOR_BGRA2RGB); - } else { - cvtColor(template, view, COLOR_BGR2RGB); - } - ViewportManager.getInstance().updateState(template); - // Release the view Mat immediately as ViewportManager handles the data. - view.release(); - - if (template.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); - } - - // Internally swaps channels to from RGBA to BGRA or RGB to BGR - base = bufferedImageToMat(baseImg); - - if (base.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Base image is empty"); - } - - if (template.channels() != 4) { - cvtColor(template, template, COLOR_BGR2BGRA); - } - - if (base.channels() != 4) { - cvtColor(base, base, COLOR_BGR2BGRA); - } - - if (template.cols() > base.cols() || template.rows() > base.rows()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Template is larger than base image"); - } - - int convRows = base.rows() - template.rows() + 1; - int convCols = base.cols() - template.cols() + 1; - - alpha = new Mat(); - extractChannel(template, alpha, 3); - - convolution = new Mat(convRows, convCols); - - matchTemplate(base, template, convolution, TM_SQDIFF_NORMED, alpha); - - if (convolution.empty()) { - return new MatchResult(null, Double.MAX_VALUE, false, "Convolution matrix is empty"); - } - - DoublePointer minVal = new DoublePointer(1); - DoublePointer maxVal = new DoublePointer(1); - Point minLoc = new Point(); - Point maxLoc = new Point(); - - mask = new Mat(); - minMaxLoc(convolution, minVal, maxVal, minLoc, maxLoc, mask); - - if (minVal.get() > threshold) { - return new MatchResult(null, minVal.get(), false, "MinVal greater than threshold"); - } - - Rectangle match = new Rectangle(minLoc.x(), minLoc.y(), template.cols(), template.rows()); - - // Update singleton state manager to update stats in UI - StatisticsManager.incrementObjectsDetected(); - - return new MatchResult(match, minVal.get(), true, "Match found"); - } finally { - - // Release native memory - if (template != null && !template.isNull()) { - template.release(); - } - if (base != null && !base.isNull()) { - base.release(); - } - if (convolution != null && !convolution.isNull()) { - convolution.release(); - } - if (alpha != null && !alpha.isNull()) { - alpha.release(); - } - if (mask != null && !mask.isNull()) { - mask.release(); - } - } - } - - /** - * Loads an image as a Mat from a resource path, preserving alpha channel. - * - * @param resourcePath path to image resource, e.g. "/images/user/myTemplate.png" (first "/" is - * necessary) - * @return Mat with image data including alpha - * @throws IOException if resource not found or temp file write fails - */ - public static Mat loadMatFromResource(String resourcePath) throws IOException { - // Get resource as stream from classpath - InputStream is = TemplateMatching.class.getResourceAsStream(resourcePath); - if (is == null) { - throw new IllegalArgumentException("Resource not found: " + resourcePath); - } - - // Create a temp file to write the resource contents - Path tempFile = Files.createTempFile("opencv-temp-", ".png"); - tempFile.toFile().deleteOnExit(); - - // Copy resource stream to temp file - Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - - // Load with imread and IMREAD_UNCHANGED to keep alpha - Mat mat = opencv_imgcodecs.imread(tempFile.toString(), opencv_imgcodecs.IMREAD_UNCHANGED); - - if (mat.empty()) { - throw new IllegalStateException("Failed to load Mat from resource: " + resourcePath); - } - - return mat; - } - - /** - * Converts a Java {@link BufferedImage} into an OpenCV {@link Mat} object. To ensure - * compatibility with standard OpenCV processing pipeline expectations, this method forces a - * standardisation step. It draws the input image onto a fresh canvas explicitly formatted as - * {@code BufferedImage.TYPE_3BYTE_BGR}. - * - * @param image the source {@code BufferedImage} to convert - * @return a {@code Mat} containing the BGR pixel data of the image, or an empty {@code Mat} if - * the input image is null - */ - public static Mat bufferedImageToMat(BufferedImage image) { - if (image == null) { - return new Mat(); - } - // Convert ARGB/GRAY images to BGR for standardisation - BufferedImage bgrImage = - new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_3BYTE_BGR); - Graphics2D g = bgrImage.createGraphics(); - g.drawImage(image, 0, 0, null); - g.dispose(); - // Extract data - byte[] cleanData = ((DataBufferByte) bgrImage.getRaster().getDataBuffer()).getData(); - // Create mat of correct format and size - Mat mat = new Mat(bgrImage.getHeight(), bgrImage.getWidth(), CV_8UC3); - // Put data into mat - mat.data().put(cleanData); - return mat; - } - - /** - * Converts an OpenCV {@link Mat} object into a Java {@link BufferedImage}. - * - *

This method dynamically uses the number of {@code sourceMat.channels()} to create output - * mats: - * - *

    - *
  • 1 Channel: Maps directly to {@code BufferedImage.TYPE_BYTE_GRAY} (Grayscale). - *
  • 3 Channels: Maps directly to {@code BufferedImage.TYPE_3BYTE_BGR} (Standard BGR - * Colour). - *
  • 4 Channels: Maps to {@code BufferedImage.TYPE_4BYTE_ABGR}, manually reordering the - * byte alignment from OpenCV's BGRA format to Java's expected ABGR format. - *
- * - * @param mat the source OpenCV matrix to convert; may be uncontinuous, but must not be null or - * empty - * @return a {@code BufferedImage} matching the dimensions and colour depth of the input, or - * {@code null} if the input matrix is null or empty - * @throws IllegalArgumentException if the matrix has an unsupported number of channels (e.g., 2 - * channels) - */ - public static BufferedImage matToBufferedImage(Mat mat) { - if (mat == null || mat.empty()) { - return null; - } - - Mat sourceMat = mat.isContinuous() ? mat : mat.clone(); - - byte[] sourcePixels = new byte[sourceMat.cols() * sourceMat.rows() * sourceMat.channels()]; - sourceMat.data().get(sourcePixels); - - BufferedImage image; - byte[] targetPixels; - - if (sourceMat.channels() == 3) { - image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_3BYTE_BGR); - targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); - System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); - - } else if (sourceMat.channels() == 4) { - - image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_4BYTE_ABGR); - targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); - - for (int i = 0; i < sourcePixels.length; i += 4) { - targetPixels[i] = sourcePixels[i + 3]; - targetPixels[i + 1] = sourcePixels[i]; - targetPixels[i + 2] = sourcePixels[i + 1]; - targetPixels[i + 3] = sourcePixels[i + 2]; - } - - } else if (sourceMat.channels() == 1) { - image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_BYTE_GRAY); - targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); - System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); - - } else { - throw new IllegalArgumentException("Unsupported channel count: " + sourceMat.channels()); - } - - if (!sourceMat.isContinuous() && sourceMat != mat) { - sourceMat.release(); - } - - return image; - } -} +package com.chromascape.utils.core.screen.topology; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; +import static org.bytedeco.opencv.global.opencv_core.extractChannel; +import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; +import static org.bytedeco.opencv.global.opencv_imgproc.*; +import static org.opencv.imgproc.Imgproc.TM_SQDIFF_NORMED; + +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferByte; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.bytedeco.javacpp.DoublePointer; +import org.bytedeco.opencv.global.opencv_imgcodecs; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point; + +/** + * Utility class for performing alpha-aware template matching using OpenCV and JavaCV. + * + *

This class provides a single static method, {@link #match}, which uses the TM_SQDIFF_NORMED + * algorithm to locate a template image within a larger base image. It uses an alpha mask to ignore + * transparent pixels in the template. + * + *

This is commonly to locate UI elements or sprites in the client window, based on screen + * captures and template assets. + */ +public class TemplateMatching { + + /** + * Performs template matching to locate a smaller image (template) within a larger image (base), + * using normalised squared difference matching with an alpha channel mask to ignore transparent + * pixels. + * + *

The method returns the bounding rectangle of the best match if its matching score is below + * the given threshold. If no match satisfies the threshold, the method returns {@code null}. + * + * @param templateImg The template image (smaller), expected as a {@link BufferedImage}. + * @param baseImg The base image (larger) where the template is searched, expected as a {@link + * BufferedImage} in RGB format. + * @param threshold The maximum allowed normalised squared difference score for a valid match. + * Lower values mean better matches. + * @return A {@link MatchResult} representing the position and size of the matching area in the + * base image, or {@code null} if no match meets the threshold criteria. + */ + public static MatchResult match(String templateImg, BufferedImage baseImg, double threshold) { + + // Update bot's semantic state + StateManager.setState(BotState.SEARCHING); + + Mat template = null; + Mat base = null; + Mat convolution = null; + Mat alpha = null; + Mat mask = null; + + try { + // Read template image from disk and load it as a Mat + try { + template = loadMatFromResource(templateImg); + } catch (IOException e) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); + } + + // Prepare a mat in RGB to send to the viewport + Mat view = new Mat(); + // Use the template as source and view as destination. + // This handles data copying/conversion safely without modifying template. + if (template.channels() == 4) { + cvtColor(template, view, COLOR_BGRA2RGB); + } else { + cvtColor(template, view, COLOR_BGR2RGB); + } + ViewportManager.getInstance().updateState(template); + // Release the view Mat immediately as ViewportManager handles the data. + view.release(); + + if (template.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template image is empty"); + } + + // Internally swaps channels to from RGBA to BGRA or RGB to BGR + base = bufferedImageToMat(baseImg); + + if (base.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Base image is empty"); + } + + if (template.channels() != 4) { + cvtColor(template, template, COLOR_BGR2BGRA); + } + + if (base.channels() != 4) { + cvtColor(base, base, COLOR_BGR2BGRA); + } + + if (template.cols() > base.cols() || template.rows() > base.rows()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Template is larger than base image"); + } + + int convRows = base.rows() - template.rows() + 1; + int convCols = base.cols() - template.cols() + 1; + + alpha = new Mat(); + extractChannel(template, alpha, 3); + + convolution = new Mat(convRows, convCols); + + matchTemplate(base, template, convolution, TM_SQDIFF_NORMED, alpha); + + if (convolution.empty()) { + return new MatchResult(null, Double.MAX_VALUE, false, "Convolution matrix is empty"); + } + + DoublePointer minVal = new DoublePointer(1); + DoublePointer maxVal = new DoublePointer(1); + Point minLoc = new Point(); + Point maxLoc = new Point(); + + mask = new Mat(); + minMaxLoc(convolution, minVal, maxVal, minLoc, maxLoc, mask); + + if (minVal.get() > threshold) { + return new MatchResult(null, minVal.get(), false, "MinVal greater than threshold"); + } + + Rectangle match = new Rectangle(minLoc.x(), minLoc.y(), template.cols(), template.rows()); + + // Update singleton state manager to update stats in UI + StatisticsManager.incrementObjectsDetected(); + + return new MatchResult(match, minVal.get(), true, "Match found"); + } finally { + + // Release native memory + if (template != null && !template.isNull()) { + template.release(); + } + if (base != null && !base.isNull()) { + base.release(); + } + if (convolution != null && !convolution.isNull()) { + convolution.release(); + } + if (alpha != null && !alpha.isNull()) { + alpha.release(); + } + if (mask != null && !mask.isNull()) { + mask.release(); + } + } + } + + /** + * Loads an image as a Mat from a resource path, preserving alpha channel. + * + * @param resourcePath path to image resource, e.g. "/images/user/myTemplate.png" (first "/" is + * necessary) + * @return Mat with image data including alpha + * @throws IOException if resource not found or temp file write fails + */ + public static Mat loadMatFromResource(String resourcePath) throws IOException { + // Get resource as stream from classpath + InputStream is = TemplateMatching.class.getResourceAsStream(resourcePath); + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + + // Create a temp file to write the resource contents + Path tempFile = Files.createTempFile("opencv-temp-", ".png"); + tempFile.toFile().deleteOnExit(); + + // Copy resource stream to temp file + Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); + + // Load with imread and IMREAD_UNCHANGED to keep alpha + Mat mat = opencv_imgcodecs.imread(tempFile.toString(), opencv_imgcodecs.IMREAD_UNCHANGED); + + if (mat.empty()) { + throw new IllegalStateException("Failed to load Mat from resource: " + resourcePath); + } + + return mat; + } + + /** + * Converts a Java {@link BufferedImage} into an OpenCV {@link Mat} object. To ensure + * compatibility with standard OpenCV processing pipeline expectations, this method forces a + * standardisation step. It draws the input image onto a fresh canvas explicitly formatted as + * {@code BufferedImage.TYPE_3BYTE_BGR}. + * + * @param image the source {@code BufferedImage} to convert + * @return a {@code Mat} containing the BGR pixel data of the image, or an empty {@code Mat} if + * the input image is null + */ + public static Mat bufferedImageToMat(BufferedImage image) { + if (image == null) { + return new Mat(); + } + // Convert ARGB/GRAY images to BGR for standardisation + BufferedImage bgrImage = + new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_3BYTE_BGR); + Graphics2D g = bgrImage.createGraphics(); + g.drawImage(image, 0, 0, null); + g.dispose(); + // Extract data + byte[] cleanData = ((DataBufferByte) bgrImage.getRaster().getDataBuffer()).getData(); + // Create mat of correct format and size + Mat mat = new Mat(bgrImage.getHeight(), bgrImage.getWidth(), CV_8UC3); + // Put data into mat + mat.data().put(cleanData); + return mat; + } + + /** + * Converts an OpenCV {@link Mat} object into a Java {@link BufferedImage}. + * + *

This method dynamically uses the number of {@code sourceMat.channels()} to create output + * mats: + * + *

    + *
  • 1 Channel: Maps directly to {@code BufferedImage.TYPE_BYTE_GRAY} (Grayscale). + *
  • 3 Channels: Maps directly to {@code BufferedImage.TYPE_3BYTE_BGR} (Standard BGR + * Colour). + *
  • 4 Channels: Maps to {@code BufferedImage.TYPE_4BYTE_ABGR}, manually reordering the + * byte alignment from OpenCV's BGRA format to Java's expected ABGR format. + *
+ * + * @param mat the source OpenCV matrix to convert; may be uncontinuous, but must not be null or + * empty + * @return a {@code BufferedImage} matching the dimensions and colour depth of the input, or + * {@code null} if the input matrix is null or empty + * @throws IllegalArgumentException if the matrix has an unsupported number of channels (e.g., 2 + * channels) + */ + public static BufferedImage matToBufferedImage(Mat mat) { + if (mat == null || mat.empty()) { + return null; + } + + Mat sourceMat = mat.isContinuous() ? mat : mat.clone(); + + byte[] sourcePixels = new byte[sourceMat.cols() * sourceMat.rows() * sourceMat.channels()]; + sourceMat.data().get(sourcePixels); + + BufferedImage image; + byte[] targetPixels; + + if (sourceMat.channels() == 3) { + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_3BYTE_BGR); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); + + } else if (sourceMat.channels() == 4) { + + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_4BYTE_ABGR); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + + for (int i = 0; i < sourcePixels.length; i += 4) { + targetPixels[i] = sourcePixels[i + 3]; + targetPixels[i + 1] = sourcePixels[i]; + targetPixels[i + 2] = sourcePixels[i + 1]; + targetPixels[i + 3] = sourcePixels[i + 2]; + } + + } else if (sourceMat.channels() == 1) { + image = new BufferedImage(sourceMat.cols(), sourceMat.rows(), BufferedImage.TYPE_BYTE_GRAY); + targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData(); + System.arraycopy(sourcePixels, 0, targetPixels, 0, sourcePixels.length); + + } else { + throw new IllegalArgumentException("Unsupported channel count: " + sourceMat.channels()); + } + + if (!sourceMat.isContinuous() && sourceMat != mat) { + sourceMat.release(); + } + + return image; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java b/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java index 4fcecf8..ef4cce8 100644 --- a/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java +++ b/src/main/java/com/chromascape/utils/core/screen/viewport/Viewport.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.screen.viewport; - -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * Interface that defines the contract for a viewport implementation. - * - *

A viewport is responsible for visualising the bot's sensor data (such as masks or templates) - * to an external observer, usually via a web interface. - */ -public interface Viewport { - - /** - * Updates the visual state of the viewport with a new image. - * - * @param image The matrix (image) to be displayed in the viewport. - */ - void updateState(Mat image); -} +package com.chromascape.utils.core.screen.viewport; + +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * Interface that defines the contract for a viewport implementation. + * + *

A viewport is responsible for visualising the bot's sensor data (such as masks or templates) + * to an external observer, usually via a web interface. + */ +public interface Viewport { + + /** + * Updates the visual state of the viewport with a new image. + * + * @param image The matrix (image) to be displayed in the viewport. + */ + void updateState(Mat image); +} diff --git a/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java b/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java index 7380e80..7ac115a 100644 --- a/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/viewport/ViewportManager.java @@ -1,58 +1,58 @@ -package com.chromascape.utils.core.screen.viewport; - -import org.bytedeco.opencv.opencv_core.Mat; - -/** - * A singleton manager that holds the active {@link Viewport} instance. - * - *

This class ensures that core utilities can send visual data without knowing the specific - * implementation details (e.g. whether it's running headless or via websockets). - */ -public class ViewportManager { - - /** The current active viewport instance. Defaults to a no-op implementation. */ - private static Viewport instance = new NoOpViewport(); - - /** Private constructor to prevent instantiation. */ - private ViewportManager() {} - - /** - * Retrieves the current viewport instance. - * - * @return The active {@link Viewport}. - */ - public static Viewport getInstance() { - return instance; - } - - /** - * Sets the active viewport instance. - * - *

This is typically called by the Spring application startup to inject the websocket-based - * implementation. - * - * @param viewport The new {@link Viewport} implementation to use. - */ - public static void setInstance(Viewport viewport) { - instance = viewport; - } - - /** - * A default no-operation implementation of the Viewport interface. - * - *

This is used when the application is running in headless mode or otherwise has no mechanism - * to display visual data. It simply discards updates to prevent errors and overhead. - */ - private static class NoOpViewport implements Viewport { - - /** - * Discards the update as this is a no-op implementation. - * - * @param image The image to discard. - */ - @Override - public void updateState(Mat image) { - // Do nothing - } - } -} +package com.chromascape.utils.core.screen.viewport; + +import org.bytedeco.opencv.opencv_core.Mat; + +/** + * A singleton manager that holds the active {@link Viewport} instance. + * + *

This class ensures that core utilities can send visual data without knowing the specific + * implementation details (e.g. whether it's running headless or via websockets). + */ +public class ViewportManager { + + /** The current active viewport instance. Defaults to a no-op implementation. */ + private static Viewport instance = new NoOpViewport(); + + /** Private constructor to prevent instantiation. */ + private ViewportManager() {} + + /** + * Retrieves the current viewport instance. + * + * @return The active {@link Viewport}. + */ + public static Viewport getInstance() { + return instance; + } + + /** + * Sets the active viewport instance. + * + *

This is typically called by the Spring application startup to inject the websocket-based + * implementation. + * + * @param viewport The new {@link Viewport} implementation to use. + */ + public static void setInstance(Viewport viewport) { + instance = viewport; + } + + /** + * A default no-operation implementation of the Viewport interface. + * + *

This is used when the application is running in headless mode or otherwise has no mechanism + * to display visual data. It simply discards updates to prevent errors and overhead. + */ + private static class NoOpViewport implements Viewport { + + /** + * Discards the update as this is a no-op implementation. + * + * @param image The image to discard. + */ + @Override + public void updateState(Mat image) { + // Do nothing + } + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java index 3cc6dfc..babd127 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/LinuxProcessManager.java @@ -1,56 +1,56 @@ -package com.chromascape.utils.core.screen.window; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Utility class for locating and identifying a specific native process (e.g., "RuneLite") on the - * Linux operating system by scanning {@code /proc/[pid]/cmdline} entries. - * - *

Assumes a shared PID namespace — {@code /proc} must be visible. - */ -public class LinuxProcessManager implements ProcessManager { - - private static final Logger logger = LogManager.getLogger(LinuxProcessManager.class); - private static final String RUNELITE_MAIN_CLASS = "net.runelite.client.RuneLite"; - - /** - * Returns the Process ID of RuneLite. Scans {@code /proc/[pid]/cmdline} entries for the RuneLite - * main class ({@code net.runelite.client.RuneLite}). - * - * @return The integer process ID of RuneLite, or {@code -1} if not found - */ - @Override - public int getPid() { - Path proc = Paths.get("/proc"); - try (DirectoryStream stream = Files.newDirectoryStream(proc, "[0-9]*")) { - for (Path pidDir : stream) { - Path cmdlinePath = pidDir.resolve("cmdline"); - try { - byte[] bytes = Files.readAllBytes(cmdlinePath); - // /proc/[pid]/cmdline is null-byte delimited — replace for string matching - String cmdline = new String(bytes, StandardCharsets.UTF_8).replace('\0', ' ').trim(); - if (cmdline.contains(RUNELITE_MAIN_CLASS)) { - return Integer.parseInt(pidDir.getFileName().toString()); - } - } catch (NoSuchFileException ignored) { - // Process exited between directory listing and read — skip silently - } catch (NumberFormatException ignored) { - // pidDir name is not a valid integer — skip - } catch (IOException e) { - logger.debug("Failed to read cmdline for {}: {}", pidDir, e.getMessage()); - } - } - } catch (IOException e) { - logger.error("Failed to iterate /proc: {}", e.getMessage()); - } - return -1; // May be -1 if not found - } -} +package com.chromascape.utils.core.screen.window; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Utility class for locating and identifying a specific native process (e.g., "RuneLite") on the + * Linux operating system by scanning {@code /proc/[pid]/cmdline} entries. + * + *

Assumes a shared PID namespace — {@code /proc} must be visible. + */ +public class LinuxProcessManager implements ProcessManager { + + private static final Logger logger = LogManager.getLogger(LinuxProcessManager.class); + private static final String RUNELITE_MAIN_CLASS = "net.runelite.client.RuneLite"; + + /** + * Returns the Process ID of RuneLite. Scans {@code /proc/[pid]/cmdline} entries for the RuneLite + * main class ({@code net.runelite.client.RuneLite}). + * + * @return The integer process ID of RuneLite, or {@code -1} if not found + */ + @Override + public int getPid() { + Path proc = Paths.get("/proc"); + try (DirectoryStream stream = Files.newDirectoryStream(proc, "[0-9]*")) { + for (Path pidDir : stream) { + Path cmdlinePath = pidDir.resolve("cmdline"); + try { + byte[] bytes = Files.readAllBytes(cmdlinePath); + // /proc/[pid]/cmdline is null-byte delimited — replace for string matching + String cmdline = new String(bytes, StandardCharsets.UTF_8).replace('\0', ' ').trim(); + if (cmdline.contains(RUNELITE_MAIN_CLASS)) { + return Integer.parseInt(pidDir.getFileName().toString()); + } + } catch (NoSuchFileException ignored) { + // Process exited between directory listing and read — skip silently + } catch (NumberFormatException ignored) { + // pidDir name is not a valid integer — skip + } catch (IOException e) { + logger.debug("Failed to read cmdline for {}: {}", pidDir, e.getMessage()); + } + } + } catch (IOException e) { + logger.error("Failed to iterate /proc: {}", e.getMessage()); + } + return -1; // May be -1 if not found + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java index a37d18a..e36367e 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/MacProcessManager.java @@ -1,19 +1,19 @@ -package com.chromascape.utils.core.screen.window; - -/** - * A class whose sole responsibility is to provide a native macOS implementation to return the - * process ID of RuneLite. - */ -public class MacProcessManager implements ProcessManager { - - /** - * To provide a macOS native way of grabbing and returning the Process ID of RuneLite. This is to - * be used by RemoteInput. - * - * @return An integer Process ID - */ - @Override - public int getPid() { - return -1; - } -} +package com.chromascape.utils.core.screen.window; + +/** + * A class whose sole responsibility is to provide a native macOS implementation to return the + * process ID of RuneLite. + */ +public class MacProcessManager implements ProcessManager { + + /** + * To provide a macOS native way of grabbing and returning the Process ID of RuneLite. This is to + * be used by RemoteInput. + * + * @return An integer Process ID + */ + @Override + public int getPid() { + return -1; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java index 37b0eec..4a1f02b 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManager.java @@ -1,15 +1,15 @@ -package com.chromascape.utils.core.screen.window; - -/** - * An interface to be implemented by Windows, Mac and Linux implementors. The sole responsibility of - * each implementor is to provide an OS native method to return the Process ID of RuneLite. - */ -public interface ProcessManager { - /** - * To provide an OS native way of grabbing and returning the Process ID of RuneLite. This is to be - * used by RemoteInput. - * - * @return An integer Process ID - */ - int getPid(); -} +package com.chromascape.utils.core.screen.window; + +/** + * An interface to be implemented by Windows, Mac and Linux implementors. The sole responsibility of + * each implementor is to provide an OS native method to return the Process ID of RuneLite. + */ +public interface ProcessManager { + /** + * To provide an OS native way of grabbing and returning the Process ID of RuneLite. This is to be + * used by RemoteInput. + * + * @return An integer Process ID + */ + int getPid(); +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java index 340e489..ee22cd8 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ProcessManagerFactory.java @@ -1,27 +1,27 @@ -package com.chromascape.utils.core.screen.window; - -/** - * Factory class to return the OS specific implementation of a {@link ProcessManager}. Detects OS by - * OS-name and returns the native implementor of the PM interface. - */ -public class ProcessManagerFactory { - - /** - * A factory that creates an OS specific {@link ProcessManager}. - * - * @return The OS specific ProcessManager implementor, used to extract Process ID. - */ - public static ProcessManager getProcessManager() { - String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win")) { - return new WindowsProcessManager(); - } - if (os.contains("mac")) { - return new MacProcessManager(); - } - if (os.contains("linux")) { - return new LinuxProcessManager(); - } - throw new UnsupportedOperationException("Unsupported OS: " + System.getProperty("os.name")); - } -} +package com.chromascape.utils.core.screen.window; + +/** + * Factory class to return the OS specific implementation of a {@link ProcessManager}. Detects OS by + * OS-name and returns the native implementor of the PM interface. + */ +public class ProcessManagerFactory { + + /** + * A factory that creates an OS specific {@link ProcessManager}. + * + * @return The OS specific ProcessManager implementor, used to extract Process ID. + */ + public static ProcessManager getProcessManager() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + return new WindowsProcessManager(); + } + if (os.contains("mac")) { + return new MacProcessManager(); + } + if (os.contains("linux")) { + return new LinuxProcessManager(); + } + throw new UnsupportedOperationException("Unsupported OS: " + System.getProperty("os.name")); + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java b/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java index 09d6df7..f92d768 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/ScreenManager.java @@ -1,112 +1,112 @@ -package com.chromascape.utils.core.screen.window; - -import com.chromascape.utils.core.input.remoteinput.RemoteInput; -import com.sun.jna.Pointer; -import java.awt.Rectangle; -import java.awt.Transparency; -import java.awt.color.ColorSpace; -import java.awt.image.BufferedImage; -import java.awt.image.ColorModel; -import java.awt.image.ComponentColorModel; -import java.awt.image.DataBuffer; -import java.awt.image.DataBufferByte; -import java.awt.image.Raster; -import java.awt.image.WritableRaster; - -/** - * Utility class for capturing screen regions and retrieving window bounds. Screen capture utilities - * are intended to be used with colour contour extraction and template matching. - */ -public class ScreenManager { - - private static RemoteInput remoteInput; - - /** - * Captures a {@link Rectangle} region on the client screen, intended to be used when - * screenshotting zones for template matching and or colour extraction. - * - * @param zone The rectangle area in client relative screen co-ordinates - * @return A {@link BufferedImage} of the captured area - */ - public static BufferedImage captureZone(Rectangle zone) { - BufferedImage screen = captureWindow(); - if (screen == null) { - throw new RuntimeException("Screen could not be captured"); - } - return screen.getSubimage(zone.x, zone.y, zone.width, zone.height); - } - - /** - * Grabs the latest rendered frame of the target application, regardless of if the client is - * maximised, minimised, partially or fully covered. This is to be used with template matching and - * {@link com.chromascape.utils.core.screen.topology.ChromaObj} detection. - * - * @return A {@link BufferedImage} of the client's screen - */ - public static synchronized BufferedImage captureWindow() { - Rectangle dims = remoteInput.getTargetDimensions(); - int width = dims.width; - int height = dims.height; - - if (width <= 0 || height <= 0) { - return null; - } - - Pointer currentScreenBuffer = remoteInput.getImageBuffer(); - if (currentScreenBuffer == null) { - return null; - } - - int bufferSize = width * height * 4; - byte[] data = currentScreenBuffer.getByteArray(0, bufferSize); - - return createBufferedImage(data, width, height); - } - - /** - * Internal helper to create a buffered image from a C++ style byte array of pixels in BGRA - * format. - * - * @param pixels The byte array of pixel data in [B, G, R, A] format - * @param width The width of the client in pixels - * @param height The height of the client in pixels - * @return A {@link BufferedImage} representing the image - */ - private static BufferedImage createBufferedImage(byte[] pixels, int width, int height) { - DataBufferByte buffer = new DataBufferByte(pixels, pixels.length); - WritableRaster raster = - Raster.createInterleavedRaster( - buffer, width, height, width * 4, 4, new int[] {2, 1, 0}, null); - - ColorModel cm = - new ComponentColorModel( - ColorSpace.getInstance(ColorSpace.CS_sRGB), - new int[] {8, 8, 8}, - false, - false, - Transparency.OPAQUE, - DataBuffer.TYPE_BYTE); - - return new BufferedImage(cm, raster, false, null); - } - - /** - * Gets the bounds of the (game view) RuneLite AWT Canvas object. - * - * @return A {@link Rectangle} representing the size of RuneLite's client area, excluding possible - * window borders, title or scrollbars. - */ - public static Rectangle getWindowBounds() { - return remoteInput.getTargetDimensions(); - } - - /** - * Sets the RemoteInput object in the ScreenManager, allowing it to access to the client's screen - * buffer. - * - * @param remoteInput The {@link RemoteInput} object - */ - public static void setRemoteInput(RemoteInput remoteInput) { - ScreenManager.remoteInput = remoteInput; - } -} +package com.chromascape.utils.core.screen.window; + +import com.chromascape.utils.core.input.remoteinput.RemoteInput; +import com.sun.jna.Pointer; +import java.awt.Rectangle; +import java.awt.Transparency; +import java.awt.color.ColorSpace; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.ComponentColorModel; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferByte; +import java.awt.image.Raster; +import java.awt.image.WritableRaster; + +/** + * Utility class for capturing screen regions and retrieving window bounds. Screen capture utilities + * are intended to be used with colour contour extraction and template matching. + */ +public class ScreenManager { + + private static RemoteInput remoteInput; + + /** + * Captures a {@link Rectangle} region on the client screen, intended to be used when + * screenshotting zones for template matching and or colour extraction. + * + * @param zone The rectangle area in client relative screen co-ordinates + * @return A {@link BufferedImage} of the captured area + */ + public static BufferedImage captureZone(Rectangle zone) { + BufferedImage screen = captureWindow(); + if (screen == null) { + throw new RuntimeException("Screen could not be captured"); + } + return screen.getSubimage(zone.x, zone.y, zone.width, zone.height); + } + + /** + * Grabs the latest rendered frame of the target application, regardless of if the client is + * maximised, minimised, partially or fully covered. This is to be used with template matching and + * {@link com.chromascape.utils.core.screen.topology.ChromaObj} detection. + * + * @return A {@link BufferedImage} of the client's screen + */ + public static synchronized BufferedImage captureWindow() { + Rectangle dims = remoteInput.getTargetDimensions(); + int width = dims.width; + int height = dims.height; + + if (width <= 0 || height <= 0) { + return null; + } + + Pointer currentScreenBuffer = remoteInput.getImageBuffer(); + if (currentScreenBuffer == null) { + return null; + } + + int bufferSize = width * height * 4; + byte[] data = currentScreenBuffer.getByteArray(0, bufferSize); + + return createBufferedImage(data, width, height); + } + + /** + * Internal helper to create a buffered image from a C++ style byte array of pixels in BGRA + * format. + * + * @param pixels The byte array of pixel data in [B, G, R, A] format + * @param width The width of the client in pixels + * @param height The height of the client in pixels + * @return A {@link BufferedImage} representing the image + */ + private static BufferedImage createBufferedImage(byte[] pixels, int width, int height) { + DataBufferByte buffer = new DataBufferByte(pixels, pixels.length); + WritableRaster raster = + Raster.createInterleavedRaster( + buffer, width, height, width * 4, 4, new int[] {2, 1, 0}, null); + + ColorModel cm = + new ComponentColorModel( + ColorSpace.getInstance(ColorSpace.CS_sRGB), + new int[] {8, 8, 8}, + false, + false, + Transparency.OPAQUE, + DataBuffer.TYPE_BYTE); + + return new BufferedImage(cm, raster, false, null); + } + + /** + * Gets the bounds of the (game view) RuneLite AWT Canvas object. + * + * @return A {@link Rectangle} representing the size of RuneLite's client area, excluding possible + * window borders, title or scrollbars. + */ + public static Rectangle getWindowBounds() { + return remoteInput.getTargetDimensions(); + } + + /** + * Sets the RemoteInput object in the ScreenManager, allowing it to access to the client's screen + * buffer. + * + * @param remoteInput The {@link RemoteInput} object + */ + public static void setRemoteInput(RemoteInput remoteInput) { + ScreenManager.remoteInput = remoteInput; + } +} diff --git a/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java b/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java index 0ebe78c..fb3f3e1 100644 --- a/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java +++ b/src/main/java/com/chromascape/utils/core/screen/window/WindowsProcessManager.java @@ -1,91 +1,91 @@ -package com.chromascape.utils.core.screen.window; - -import com.sun.jna.Native; -import com.sun.jna.Pointer; -import com.sun.jna.platform.win32.WinDef.HWND; -import com.sun.jna.platform.win32.WinUser.WNDENUMPROC; -import com.sun.jna.ptr.IntByReference; -import com.sun.jna.win32.StdCallLibrary; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Utility class for locating and identifying a specific native window (e.g., "RuneLite") on the - * Windows operating system using JNA and Win32 APIs. - */ -public class WindowsProcessManager implements ProcessManager { - - private static final String WINDOW_NAME = "RuneLite"; - - /** - * JNA interface for accessing low-level Win32 User32 functions that are not provided by the - * default JNA platform mappings. - */ - public interface User32 extends StdCallLibrary { - User32 INSTANCE = Native.load("user32", User32.class); - - /** - * Enumerates all top-level windows on the screen by invoking the provided callback. - * - * @param lpEnumFunc The callback to be called for each window. - * @param arg A user-defined value passed to the callback (usually null). - */ - void EnumWindows(WNDENUMPROC lpEnumFunc, Pointer arg); - - /** - * Retrieves the title text of the specified window. - * - * @param hwnd Handle to the window. - * @param lpString Buffer that receives the window title. - * @param maxCount Maximum number of characters to copy. - */ - void GetWindowTextA(HWND hwnd, byte[] lpString, int maxCount); - - /** - * Retrieves the process identifier (PID) for the specified window. - * - * @param hwnd Handle to the window. - * @param lpDword Receives the process ID. - */ - void GetWindowThreadProcessId(HWND hwnd, IntByReference lpDword); - } - - /** - * Attempts to locate the window whose title matches the {@code WINDOW_NAME}. - * - * @return The {@link HWND} handle of the target window, or {@code null} if not found. - */ - public static HWND getTargetWindow() { - AtomicReference targetHwnd = new AtomicReference<>(); - User32 user32 = User32.INSTANCE; - - user32.EnumWindows( - (hwnd, arg) -> { - byte[] buffer = new byte[512]; - user32.GetWindowTextA(hwnd, buffer, 512); - String title = Native.toString(buffer); - - if (title.trim().equals(WINDOW_NAME)) { - targetHwnd.set(hwnd); - return false; // stop enumeration - } - return true; - }, - null); - - return targetHwnd.get(); // May be null if not found - } - - /** - * To return the Process ID of RuneLite. Avoids non-ChromaScape related instances by searching for - * "RuneLite" only as opposed to "RuneLite - {UserName}" - * - * @return The integer process ID of RuneLite loaded with the ChromaScape profile - */ - @Override - public int getPid() { - HWND windowHandle = getTargetWindow(); - IntByReference pid = new IntByReference(); - User32.INSTANCE.GetWindowThreadProcessId(windowHandle, pid); - return pid.getValue(); - } -} +package com.chromascape.utils.core.screen.window; + +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.win32.WinDef.HWND; +import com.sun.jna.platform.win32.WinUser.WNDENUMPROC; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.win32.StdCallLibrary; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Utility class for locating and identifying a specific native window (e.g., "RuneLite") on the + * Windows operating system using JNA and Win32 APIs. + */ +public class WindowsProcessManager implements ProcessManager { + + private static final String WINDOW_NAME = "RuneLite"; + + /** + * JNA interface for accessing low-level Win32 User32 functions that are not provided by the + * default JNA platform mappings. + */ + public interface User32 extends StdCallLibrary { + User32 INSTANCE = Native.load("user32", User32.class); + + /** + * Enumerates all top-level windows on the screen by invoking the provided callback. + * + * @param lpEnumFunc The callback to be called for each window. + * @param arg A user-defined value passed to the callback (usually null). + */ + void EnumWindows(WNDENUMPROC lpEnumFunc, Pointer arg); + + /** + * Retrieves the title text of the specified window. + * + * @param hwnd Handle to the window. + * @param lpString Buffer that receives the window title. + * @param maxCount Maximum number of characters to copy. + */ + void GetWindowTextA(HWND hwnd, byte[] lpString, int maxCount); + + /** + * Retrieves the process identifier (PID) for the specified window. + * + * @param hwnd Handle to the window. + * @param lpDword Receives the process ID. + */ + void GetWindowThreadProcessId(HWND hwnd, IntByReference lpDword); + } + + /** + * Attempts to locate the window whose title matches the {@code WINDOW_NAME}. + * + * @return The {@link HWND} handle of the target window, or {@code null} if not found. + */ + public static HWND getTargetWindow() { + AtomicReference targetHwnd = new AtomicReference<>(); + User32 user32 = User32.INSTANCE; + + user32.EnumWindows( + (hwnd, arg) -> { + byte[] buffer = new byte[512]; + user32.GetWindowTextA(hwnd, buffer, 512); + String title = Native.toString(buffer); + + if (title.trim().equals(WINDOW_NAME)) { + targetHwnd.set(hwnd); + return false; // stop enumeration + } + return true; + }, + null); + + return targetHwnd.get(); // May be null if not found + } + + /** + * To return the Process ID of RuneLite. Avoids non-ChromaScape related instances by searching for + * "RuneLite" only as opposed to "RuneLite - {UserName}" + * + * @return The integer process ID of RuneLite loaded with the ChromaScape profile + */ + @Override + public int getPid() { + HWND windowHandle = getTargetWindow(); + IntByReference pid = new IntByReference(); + User32.INSTANCE.GetWindowThreadProcessId(windowHandle, pid); + return pid.getValue(); + } +} diff --git a/src/main/java/com/chromascape/utils/core/state/BotState.java b/src/main/java/com/chromascape/utils/core/state/BotState.java index 134396c..77d2457 100644 --- a/src/main/java/com/chromascape/utils/core/state/BotState.java +++ b/src/main/java/com/chromascape/utils/core/state/BotState.java @@ -1,38 +1,38 @@ -package com.chromascape.utils.core.state; - -/** - * definitions of the various high-level semantic states the bot can be in. - * - *

These states are used for visualization on the frontend to give the user insight into what the - * bot is currently "thinking" or doing. - */ -public enum BotState { - - /** The bot is actively scanning the screen for targets (e.g. finding colours). */ - SEARCHING("Searching", "primary"), - - /** The bot is performing an input action (e.g. clicking, typing). */ - ACTING("Acting", "success"), - - /** The bot is waiting or idle (e.g. sleeping between actions). */ - WAITING("Waiting", "warning"), - - /** The bot has encountered an error or exception. */ - ERROR("Error", "danger"); - - private final String displayName; - private final String cssClass; - - BotState(String displayName, String cssClass) { - this.displayName = displayName; - this.cssClass = cssClass; - } - - public String getDisplayName() { - return displayName; - } - - public String getCssClass() { - return cssClass; - } -} +package com.chromascape.utils.core.state; + +/** + * definitions of the various high-level semantic states the bot can be in. + * + *

These states are used for visualization on the frontend to give the user insight into what the + * bot is currently "thinking" or doing. + */ +public enum BotState { + + /** The bot is actively scanning the screen for targets (e.g. finding colours). */ + SEARCHING("Searching", "primary"), + + /** The bot is performing an input action (e.g. clicking, typing). */ + ACTING("Acting", "success"), + + /** The bot is waiting or idle (e.g. sleeping between actions). */ + WAITING("Waiting", "warning"), + + /** The bot has encountered an error or exception. */ + ERROR("Error", "danger"); + + private final String displayName; + private final String cssClass; + + BotState(String displayName, String cssClass) { + this.displayName = displayName; + this.cssClass = cssClass; + } + + public String getDisplayName() { + return displayName; + } + + public String getCssClass() { + return cssClass; + } +} diff --git a/src/main/java/com/chromascape/utils/core/state/BotStateListener.java b/src/main/java/com/chromascape/utils/core/state/BotStateListener.java index 08435e6..a62285f 100644 --- a/src/main/java/com/chromascape/utils/core/state/BotStateListener.java +++ b/src/main/java/com/chromascape/utils/core/state/BotStateListener.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.core.state; - -/** Interface for listening to changes in the bot's semantic state. */ -public interface BotStateListener { - - /** - * Called when the bot transitions to a new state. - * - * @param state The new {@link BotState}. - */ - void onStateChange(BotState state); -} +package com.chromascape.utils.core.state; + +/** Interface for listening to changes in the bot's semantic state. */ +public interface BotStateListener { + + /** + * Called when the bot transitions to a new state. + * + * @param state The new {@link BotState}. + */ + void onStateChange(BotState state); +} diff --git a/src/main/java/com/chromascape/utils/core/state/StateManager.java b/src/main/java/com/chromascape/utils/core/state/StateManager.java index f147fc0..5e236c6 100644 --- a/src/main/java/com/chromascape/utils/core/state/StateManager.java +++ b/src/main/java/com/chromascape/utils/core/state/StateManager.java @@ -1,47 +1,47 @@ -package com.chromascape.utils.core.state; - -/** - * Singleton manager for tracking and broadcasting the bot's semantic state. - * - *

This class serves as the bridge between core bot logic (which triggers state changes) and the - * presentation layer (which listens for them), without introducing direct dependencies. - */ -public class StateManager { - - private static BotStateListener listener = state -> {}; // Default No-Op - private static BotState currentState = BotState.WAITING; - - private StateManager() {} - - /** - * Sets the listener that will receive state change updates. - * - * @param newListener The listener implementation (e.g. a websocket bridge). - */ - public static void setListener(BotStateListener newListener) { - listener = newListener; - } - - /** - * Transitions the bot to a new semantic state. - * - *

If the new state is different from the current state, the registered listener is notified. - * - * @param newState The state to transition to. - */ - public static void setState(BotState newState) { - if (currentState != newState) { - currentState = newState; - listener.onStateChange(newState); - } - } - - /** - * Gets the current state of the bot. - * - * @return The active {@link BotState}. - */ - public static BotState getState() { - return currentState; - } -} +package com.chromascape.utils.core.state; + +/** + * Singleton manager for tracking and broadcasting the bot's semantic state. + * + *

This class serves as the bridge between core bot logic (which triggers state changes) and the + * presentation layer (which listens for them), without introducing direct dependencies. + */ +public class StateManager { + + private static BotStateListener listener = state -> {}; // Default No-Op + private static BotState currentState = BotState.WAITING; + + private StateManager() {} + + /** + * Sets the listener that will receive state change updates. + * + * @param newListener The listener implementation (e.g. a websocket bridge). + */ + public static void setListener(BotStateListener newListener) { + listener = newListener; + } + + /** + * Transitions the bot to a new semantic state. + * + *

If the new state is different from the current state, the registered listener is notified. + * + * @param newState The state to transition to. + */ + public static void setState(BotState newState) { + if (currentState != newState) { + currentState = newState; + listener.onStateChange(newState); + } + } + + /** + * Gets the current state of the bot. + * + * @return The active {@link BotState}. + */ + public static BotState getState() { + return currentState; + } +} diff --git a/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java b/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java index 7803bed..e575155 100644 --- a/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java +++ b/src/main/java/com/chromascape/utils/core/statistics/StatisticsManager.java @@ -1,105 +1,105 @@ -package com.chromascape.utils.core.statistics; - -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Singleton manager for tracking bot statistics such as runtime, cycles, inputs, and objects - * detected. - * - *

Uses thread-safe atomic variables to allow concurrent updates from different parts of the bot - * (e.g. input thread, vision thread, main loop) without blocking. - */ -public class StatisticsManager { - - private static final AtomicLong startTime = new AtomicLong(0); - private static final AtomicLong endTime = new AtomicLong(0); - private static volatile boolean running = false; - - private static final AtomicInteger cycles = new AtomicInteger(0); - private static final AtomicInteger inputs = new AtomicInteger(0); - private static final AtomicInteger objectsDetected = new AtomicInteger(0); - - private StatisticsManager() {} - - /** - * Resets all statistics to zero and sets the start time to the current system time. - * - *

Also resets the {@code endTime} and sets {@code running} to true. - */ - public static void reset() { - startTime.set(System.currentTimeMillis()); - endTime.set(0); - running = true; - cycles.set(0); - inputs.set(0); - objectsDetected.set(0); - } - - /** - * Stops the statistics tracking, freezing the elapsed time. - * - *

Sets {@code running} to false and records the current time as {@code endTime}. This ensures - * {@link #getElapsedTime()} returns a static duration after stopping. - */ - public static void stop() { - running = false; - endTime.set(System.currentTimeMillis()); - } - - /** Increments the cycle count by one. */ - public static void incrementCycles() { - cycles.incrementAndGet(); - } - - /** Increments the total input count by one. */ - public static void incrementInputs() { - inputs.incrementAndGet(); - } - - /** Increments the total objects detected count by one. */ - public static void incrementObjectsDetected() { - objectsDetected.incrementAndGet(); - } - - // Getters - - public static long getStartTime() { - return startTime.get(); - } - - public static int getCycles() { - return cycles.get(); - } - - public static int getInputs() { - return inputs.get(); - } - - public static int getObjectsDetected() { - return objectsDetected.get(); - } - - /** - * Calculates the elapsed time in milliseconds. - * - *

If the bot is running, returns {@code now - startTime}. If the bot is stopped, returns - * {@code endTime - startTime}. - * - * @return runtime in ms, or 0 if not started. - */ - public static long getElapsedTime() { - long start = startTime.get(); - if (start == 0) { - return 0; - } - if (running) { - return System.currentTimeMillis() - start; - } else { - long end = endTime.get(); - // If end is somehow invalid or 0 (shouldn't happen if stop called), return 0 or - // current diff - return end > start ? end - start : 0; - } - } -} +package com.chromascape.utils.core.statistics; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Singleton manager for tracking bot statistics such as runtime, cycles, inputs, and objects + * detected. + * + *

Uses thread-safe atomic variables to allow concurrent updates from different parts of the bot + * (e.g. input thread, vision thread, main loop) without blocking. + */ +public class StatisticsManager { + + private static final AtomicLong startTime = new AtomicLong(0); + private static final AtomicLong endTime = new AtomicLong(0); + private static volatile boolean running = false; + + private static final AtomicInteger cycles = new AtomicInteger(0); + private static final AtomicInteger inputs = new AtomicInteger(0); + private static final AtomicInteger objectsDetected = new AtomicInteger(0); + + private StatisticsManager() {} + + /** + * Resets all statistics to zero and sets the start time to the current system time. + * + *

Also resets the {@code endTime} and sets {@code running} to true. + */ + public static void reset() { + startTime.set(System.currentTimeMillis()); + endTime.set(0); + running = true; + cycles.set(0); + inputs.set(0); + objectsDetected.set(0); + } + + /** + * Stops the statistics tracking, freezing the elapsed time. + * + *

Sets {@code running} to false and records the current time as {@code endTime}. This ensures + * {@link #getElapsedTime()} returns a static duration after stopping. + */ + public static void stop() { + running = false; + endTime.set(System.currentTimeMillis()); + } + + /** Increments the cycle count by one. */ + public static void incrementCycles() { + cycles.incrementAndGet(); + } + + /** Increments the total input count by one. */ + public static void incrementInputs() { + inputs.incrementAndGet(); + } + + /** Increments the total objects detected count by one. */ + public static void incrementObjectsDetected() { + objectsDetected.incrementAndGet(); + } + + // Getters + + public static long getStartTime() { + return startTime.get(); + } + + public static int getCycles() { + return cycles.get(); + } + + public static int getInputs() { + return inputs.get(); + } + + public static int getObjectsDetected() { + return objectsDetected.get(); + } + + /** + * Calculates the elapsed time in milliseconds. + * + *

If the bot is running, returns {@code now - startTime}. If the bot is stopped, returns + * {@code endTime - startTime}. + * + * @return runtime in ms, or 0 if not started. + */ + public static long getElapsedTime() { + long start = startTime.get(); + if (start == 0) { + return 0; + } + if (running) { + return System.currentTimeMillis() - start; + } else { + long end = endTime.get(); + // If end is somehow invalid or 0 (shouldn't happen if stop called), return 0 or + // current diff + return end > start ? end - start : 0; + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java b/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java index 9220ffc..7d1069e 100644 --- a/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java +++ b/src/main/java/com/chromascape/utils/domain/ocr/CharMatch.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.domain.ocr; - -/** - * Objects to store Ocr match information. - * - * @param character The character found. - * @param x Top left X co-ordinate. - * @param y Top left Y co-ordinate. - * @param width Width of the character's image. - * @param height Height of the character's image. - */ -public record CharMatch(String character, int x, int y, int width, int height) {} +package com.chromascape.utils.domain.ocr; + +/** + * Objects to store Ocr match information. + * + * @param character The character found. + * @param x Top left X co-ordinate. + * @param y Top left Y co-ordinate. + * @param width Width of the character's image. + * @param height Height of the character's image. + */ +public record CharMatch(String character, int x, int y, int width, int height) {} diff --git a/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java b/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java index e7e425e..5b36be7 100644 --- a/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java +++ b/src/main/java/com/chromascape/utils/domain/ocr/Ocr.java @@ -1,365 +1,365 @@ -package com.chromascape.utils.domain.ocr; - -import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; -import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; -import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2GRAY; -import static org.bytedeco.opencv.global.opencv_imgproc.FILLED; -import static org.bytedeco.opencv.global.opencv_imgproc.LINE_8; -import static org.bytedeco.opencv.global.opencv_imgproc.TM_CCOEFF_NORMED; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; -import static org.bytedeco.opencv.global.opencv_imgproc.matchTemplate; -import static org.bytedeco.opencv.global.opencv_imgproc.rectangle; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import com.chromascape.utils.domain.zones.MaskZones; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.io.BufferedReader; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.UncheckedIOException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import javax.imageio.ImageIO; -import org.bytedeco.javacpp.DoublePointer; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point; -import org.bytedeco.opencv.opencv_core.Rect; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Provides Ocr (Optical Character Recognition) functionality using JavaCV/OpenCV. Allows for - * font-based glyph matching in screen-captured images to extract text. - */ -public class Ocr { - - /** Stores successful character matches during Ocr extraction. */ - private static final List matches = new ArrayList<>(); - - /** Cached zero scalar to prevent CPU allocation fatigue. */ - private static final Scalar ZERO_SCALAR = new Scalar(0); - - /** Cache for loaded fonts to prevent disk I/O on every OCR call. */ - private static final Map> fontCache = new HashMap<>(); - - /** - * Allowed characters for OCR to remove runtime overhead for unnecessary glyphs. Most common - * characters found. - */ - private static final String ALLOWED_CHARS = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()[],&-:/*'_\"?<>"; - - /** - * Loads a font glyph set from disk, converts each glyph to grayscale, and stores in a map. Uses - * an internal cache to avoid repeated disk I/O. Only allows whitelisted glyphs ( please add if - * necessary). - * - * @param font Name of the font folder inside resources. - * @return A map from character string to Mat (glyph image). - */ - public static synchronized Map loadFont(String font) { - // computeIfAbsent allows for the calculation of a value if it doesn't exist in a Map - return fontCache.computeIfAbsent( - font, - f -> { - Map fontMap = new HashMap<>(); - String basePath = "/fonts/" + f + "/"; - String indexPath = basePath + f + ".index"; - - try (InputStream indexStream = Ocr.class.getResourceAsStream(indexPath)) { - if (indexStream == null) { - // Throw runtime unchecked exception to fail if fonts are downloaded incorrectly and - // are unavailable - throw new UncheckedIOException(new IOException("Font index not found: " + indexPath)); - } - - // Stream the index file and load the files listed into the fontMap - try (BufferedReader reader = new BufferedReader(new InputStreamReader(indexStream))) { - String fontFileName; - while ((fontFileName = reader.readLine()) != null) { - processFontFile(basePath, fontFileName, fontMap); - } - } - } catch (IOException e) { - // It's necessary for the project files to exist, so fail fast - throw new RuntimeException( - "Failed to load font library " - + f - + " essential for runtime execution with error: " - + e); - } - return fontMap; - }); - } - - /** - * Private helper for loading a specified font bitmap into a font library. Mutates the given map, - * does not return anything, intended to be called in a loop. Expects the bitmap to be named as - * ascii codepoints and to be stored as resources. Loads each glyph as a greyscale mat with its - * corresponding character in String form. - * - * @param path {@link String} path of the font bitmap inside resources - * @param fileName the name of the file including type (e.g., 68.bmp) - * @param map the map to mutate and add the name + Mat object to - * @throws IOException In the case that a glyph fails to load - */ - private static void processFontFile(String path, String fileName, Map map) - throws IOException { - // Get the name ASCII codepoint from the filename - String cleanName = fileName.replace(".bmp", ""); - int codePoint = Integer.parseInt(cleanName); - String character = Character.toString(codePoint); - - if (!ALLOWED_CHARS.contains(character)) { - return; - } - - try (InputStream is = Ocr.class.getResourceAsStream(path + fileName)) { - if (is == null) { - // It's necessary for the project files to exist, so fail fast - throw new FileNotFoundException("Font file not found: " + fileName); - } - - // Add the character and Mat to the map - Mat img = TemplateMatching.bufferedImageToMat(ImageIO.read(is)); - cvtColor(img, img, COLOR_BGR2GRAY); - map.put(character, img); - } - } - - /** - * Extracts a string of text from a screen region ({@link Rectangle} zone) by template-matching - * glyphs from a font. Note: this will not include any spaces. - * - * @param zone Rectangle on screen to extract text from. - * @param font Font name to use for glyph matching. - * @param colour ColourObj specifying the color to isolate. - * @param clean Whether to clear internal match storage after use. - * @return The extracted text string from the zone. - */ - public static String extractText(Rectangle zone, String font, ColourObj colour, boolean clean) { - Map fontMap = loadFont(font); - matches.clear(); - BufferedImage zoneImage = ScreenManager.captureZone(zone); - Mat zoneMat = ColourContours.extractColours(zoneImage, colour); - return extraction(fontMap, zoneMat, font, clean); - } - - /** - * Extracts a string of text from a screen region by template-matching glyphs from a font. Note: - * this will not include any spaces. - * - * @param mask Mat CU81 mask to extract text from - * @param font Font name to use for glyph matching. - * @param clean Whether to clear internal match storage after use. - * @return The extracted text string from the zone. - */ - public static String extractTextFromMask(Mat mask, String font, boolean clean) { - Map fontMap = loadFont(font); - matches.clear(); - return extraction(fontMap, mask.clone(), font, clean); - } - - /** - * Internal function to perform Template matched OCR. Iterates over a font map, zeroing out the - * convolution as it goes. - * - * @param fontMap List of glyphs, string character & Mat bitmap. - * @param zoneMat Mat image of the source being searched within. - * @param font Type of font. - * @param clean Delete matches? - * @return String of extracted letters, no spaces. - */ - private static String extraction( - Map fontMap, Mat zoneMat, String font, boolean clean) { - double threshold = 0.99; - // Supports (CV_8UC1) binary greyscale. - // Holds pointers and correlation as reusable memory allocation to avoid JNI overhead - try (DoublePointer minVal = new DoublePointer(1); - DoublePointer maxVal = new DoublePointer(1); - Point minLoc = new Point(); - Point maxLoc = new Point(); - Mat correlation = new Mat()) { - // Template match each glyph in the font to the zoneMat. - for (String glyph : fontMap.keySet()) { - // These are to store the glyph sizes outside of try with resources scope. - int glyphImgRows; - int glyphImgCols; - - // We are trimming the font images and template matching - - // Based on the font type and how the image is stored. - int ycropModifier = getCropModifierForFont(font); - - try (Rect roi = - new Rect( - 0, - ycropModifier, - fontMap.get(glyph).arrayWidth(), - fontMap.get(glyph).arrayHeight() - ycropModifier); - Mat croppedGlyph = new Mat(fontMap.get(glyph), roi)) { - // Match template with cropped glyph and store size outside try-with-resources. - matchTemplate(zoneMat, croppedGlyph, correlation, TM_CCOEFF_NORMED); - glyphImgRows = croppedGlyph.rows(); - glyphImgCols = croppedGlyph.cols(); - } - // Call minMaxLoc repeatedly, zero out the area based on glyph size, save locations as - // CharMatch objs. - while (true) { // Loop breaks when threshold is not met. - minMaxLoc(correlation, minVal, maxVal, minLoc, maxLoc, null); - - if (maxVal.get() < threshold) { - break; - } - - Rectangle matchLocation = - new Rectangle(maxLoc.x(), maxLoc.y(), glyphImgCols, glyphImgRows); - matches.add( - new CharMatch(glyph, matchLocation.x, matchLocation.y, glyphImgCols, glyphImgRows)); - - zeroOutRegion(correlation, matchLocation); - - Mat oldZoneMat = zoneMat; - zoneMat = MaskZones.maskZonesMat(zoneMat.clone(), matchLocation); - oldZoneMat.release(); - } - } - } finally { - zoneMat.release(); - } - - // Sort CharMatch objects based on left-most positions. - matches.sort(Comparator.comparingInt(CharMatch::y).thenComparingInt(CharMatch::x)); - - StringBuilder result = new StringBuilder(); - for (CharMatch match : matches) { - result.append(match.character()); - } - - if (clean) { - matches.clear(); - } - - return result.toString(); - } - - /** - * Returns a BufferedImage mask representing matched glyph positions within a screen region. This - * is useful for clicking text. You are intended to extract contours from this and use it as a - * ChromaObj. - * - * @param zone Rectangle on screen to perform Ocr in. - * @param font Font name to use for glyph matching. - * @param text Expected string result; skips mask generation if mismatched. - * @param colour ColourObj specifying the color to isolate. - * @return A BufferedImage mask of the matched character zones, or null if text doesn't match. - */ - public static BufferedImage extractTextLocationMask( - Rectangle zone, String font, String text, ColourObj colour) { - // Get the full window bounds (this must match the screen capture bounds) - Rectangle window = ScreenManager.getWindowBounds(); - - // Create a black mask matching the window size - Mat fullScreenMask = new Mat(window.height, window.width, CV_8UC1, new Scalar(0)); - - // Early exit: text doesn't match expected - if (!extractText(zone, font, colour, false).equals(text)) { - return null; - } - - // Create a zone-sized mask where matched characters will be drawn - Mat zoneMask = new Mat(zone.height, zone.width, CV_8UC1, new Scalar(0)); - - // Draw rectangles for matched characters - for (CharMatch match : matches) { - rectangle( - zoneMask, - new Point(match.x(), match.y()), - new Point(match.x() + match.width(), match.y() + match.height()), - new Scalar(255), - FILLED, - LINE_8, - 0); - } - - // Convert screen-relative zone to window-relative position - Mat roiMat = getMat(zone, window, fullScreenMask); - zoneMask.copyTo(roiMat); - - // Release temporary mats - zoneMask.release(); - roiMat.release(); - - return TemplateMatching.matToBufferedImage(fullScreenMask); - } - - /** - * Converts a zone-relative rectangle to a window-relative Mat region for masking. - * - * @param zone Ocr region. - * @param window Full window bounds from capture. - * @param fullScreenMask The full-screen output mask. - * @return A Mat region of interest inside the full screen mask. - * @throws IllegalArgumentException if the zone is outside the screen bounds. - */ - private static Mat getMat(Rectangle zone, Rectangle window, Mat fullScreenMask) { - int relX = zone.x - window.x; - int relY = zone.y - window.y; - - // Validate bounds to avoid OpenCV crash - if (relX < 0 - || relY < 0 - || relX + zone.width > window.width - || relY + zone.height > window.height) { - throw new IllegalArgumentException( - "Zone is outside the window bounds: zone=" + zone + ", window=" + window); - } - - // Create region of interest in the full mask and copy the zone mask into it - Rect roi = new Rect(relX, relY, zone.width, zone.height); - return new Mat(fullScreenMask, roi); - } - - /** - * Sets all values in a rectangular region of a correlation matrix to zero. This prevents repeated - * template matches in the same area. - * - * @param correlation The template match result matrix. - * @param match The rectangle area to zero out. - */ - public static void zeroOutRegion(Mat correlation, Rectangle match) { - // Make sure the rectangle is within bounds of the correlation Mat - int x = Math.max(match.x, 0); - int y = Math.max(match.y, 0); - int width = Math.min(match.width, correlation.cols() - x); - int height = Math.min(match.height, correlation.rows() - y); - - if (width <= 0 || height <= 0) { - return; - } - - Rect roi = new Rect(x, y, width, height); - Mat subMat = new Mat(correlation, roi); - // Set all pixels in this region to 0 (lowest confidence) - subMat.setTo(new Mat(ZERO_SCALAR)); - subMat.release(); - } - - /** - * Returns a vertical crop offset used when slicing glyph images, depending on font type. - * - * @param font Font name. - * @return Crop offset in pixels. - */ - private static int getCropModifierForFont(String font) { - return Objects.equals(font, "Plain 12") ? 2 : 1; - } -} +package com.chromascape.utils.domain.ocr; + +import static org.bytedeco.opencv.global.opencv_core.CV_8UC1; +import static org.bytedeco.opencv.global.opencv_core.minMaxLoc; +import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2GRAY; +import static org.bytedeco.opencv.global.opencv_imgproc.FILLED; +import static org.bytedeco.opencv.global.opencv_imgproc.LINE_8; +import static org.bytedeco.opencv.global.opencv_imgproc.TM_CCOEFF_NORMED; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; +import static org.bytedeco.opencv.global.opencv_imgproc.matchTemplate; +import static org.bytedeco.opencv.global.opencv_imgproc.rectangle; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import com.chromascape.utils.domain.zones.MaskZones; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.imageio.ImageIO; +import org.bytedeco.javacpp.DoublePointer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point; +import org.bytedeco.opencv.opencv_core.Rect; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Provides Ocr (Optical Character Recognition) functionality using JavaCV/OpenCV. Allows for + * font-based glyph matching in screen-captured images to extract text. + */ +public class Ocr { + + /** Stores successful character matches during Ocr extraction. */ + private static final List matches = new ArrayList<>(); + + /** Cached zero scalar to prevent CPU allocation fatigue. */ + private static final Scalar ZERO_SCALAR = new Scalar(0); + + /** Cache for loaded fonts to prevent disk I/O on every OCR call. */ + private static final Map> fontCache = new HashMap<>(); + + /** + * Allowed characters for OCR to remove runtime overhead for unnecessary glyphs. Most common + * characters found. + */ + private static final String ALLOWED_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()[],&-:/*'_\"?<>"; + + /** + * Loads a font glyph set from disk, converts each glyph to grayscale, and stores in a map. Uses + * an internal cache to avoid repeated disk I/O. Only allows whitelisted glyphs ( please add if + * necessary). + * + * @param font Name of the font folder inside resources. + * @return A map from character string to Mat (glyph image). + */ + public static synchronized Map loadFont(String font) { + // computeIfAbsent allows for the calculation of a value if it doesn't exist in a Map + return fontCache.computeIfAbsent( + font, + f -> { + Map fontMap = new HashMap<>(); + String basePath = "/fonts/" + f + "/"; + String indexPath = basePath + f + ".index"; + + try (InputStream indexStream = Ocr.class.getResourceAsStream(indexPath)) { + if (indexStream == null) { + // Throw runtime unchecked exception to fail if fonts are downloaded incorrectly and + // are unavailable + throw new UncheckedIOException(new IOException("Font index not found: " + indexPath)); + } + + // Stream the index file and load the files listed into the fontMap + try (BufferedReader reader = new BufferedReader(new InputStreamReader(indexStream))) { + String fontFileName; + while ((fontFileName = reader.readLine()) != null) { + processFontFile(basePath, fontFileName, fontMap); + } + } + } catch (IOException e) { + // It's necessary for the project files to exist, so fail fast + throw new RuntimeException( + "Failed to load font library " + + f + + " essential for runtime execution with error: " + + e); + } + return fontMap; + }); + } + + /** + * Private helper for loading a specified font bitmap into a font library. Mutates the given map, + * does not return anything, intended to be called in a loop. Expects the bitmap to be named as + * ascii codepoints and to be stored as resources. Loads each glyph as a greyscale mat with its + * corresponding character in String form. + * + * @param path {@link String} path of the font bitmap inside resources + * @param fileName the name of the file including type (e.g., 68.bmp) + * @param map the map to mutate and add the name + Mat object to + * @throws IOException In the case that a glyph fails to load + */ + private static void processFontFile(String path, String fileName, Map map) + throws IOException { + // Get the name ASCII codepoint from the filename + String cleanName = fileName.replace(".bmp", ""); + int codePoint = Integer.parseInt(cleanName); + String character = Character.toString(codePoint); + + if (!ALLOWED_CHARS.contains(character)) { + return; + } + + try (InputStream is = Ocr.class.getResourceAsStream(path + fileName)) { + if (is == null) { + // It's necessary for the project files to exist, so fail fast + throw new FileNotFoundException("Font file not found: " + fileName); + } + + // Add the character and Mat to the map + Mat img = TemplateMatching.bufferedImageToMat(ImageIO.read(is)); + cvtColor(img, img, COLOR_BGR2GRAY); + map.put(character, img); + } + } + + /** + * Extracts a string of text from a screen region ({@link Rectangle} zone) by template-matching + * glyphs from a font. Note: this will not include any spaces. + * + * @param zone Rectangle on screen to extract text from. + * @param font Font name to use for glyph matching. + * @param colour ColourObj specifying the color to isolate. + * @param clean Whether to clear internal match storage after use. + * @return The extracted text string from the zone. + */ + public static String extractText(Rectangle zone, String font, ColourObj colour, boolean clean) { + Map fontMap = loadFont(font); + matches.clear(); + BufferedImage zoneImage = ScreenManager.captureZone(zone); + Mat zoneMat = ColourContours.extractColours(zoneImage, colour); + return extraction(fontMap, zoneMat, font, clean); + } + + /** + * Extracts a string of text from a screen region by template-matching glyphs from a font. Note: + * this will not include any spaces. + * + * @param mask Mat CU81 mask to extract text from + * @param font Font name to use for glyph matching. + * @param clean Whether to clear internal match storage after use. + * @return The extracted text string from the zone. + */ + public static String extractTextFromMask(Mat mask, String font, boolean clean) { + Map fontMap = loadFont(font); + matches.clear(); + return extraction(fontMap, mask.clone(), font, clean); + } + + /** + * Internal function to perform Template matched OCR. Iterates over a font map, zeroing out the + * convolution as it goes. + * + * @param fontMap List of glyphs, string character & Mat bitmap. + * @param zoneMat Mat image of the source being searched within. + * @param font Type of font. + * @param clean Delete matches? + * @return String of extracted letters, no spaces. + */ + private static String extraction( + Map fontMap, Mat zoneMat, String font, boolean clean) { + double threshold = 0.99; + // Supports (CV_8UC1) binary greyscale. + // Holds pointers and correlation as reusable memory allocation to avoid JNI overhead + try (DoublePointer minVal = new DoublePointer(1); + DoublePointer maxVal = new DoublePointer(1); + Point minLoc = new Point(); + Point maxLoc = new Point(); + Mat correlation = new Mat()) { + // Template match each glyph in the font to the zoneMat. + for (String glyph : fontMap.keySet()) { + // These are to store the glyph sizes outside of try with resources scope. + int glyphImgRows; + int glyphImgCols; + + // We are trimming the font images and template matching - + // Based on the font type and how the image is stored. + int ycropModifier = getCropModifierForFont(font); + + try (Rect roi = + new Rect( + 0, + ycropModifier, + fontMap.get(glyph).arrayWidth(), + fontMap.get(glyph).arrayHeight() - ycropModifier); + Mat croppedGlyph = new Mat(fontMap.get(glyph), roi)) { + // Match template with cropped glyph and store size outside try-with-resources. + matchTemplate(zoneMat, croppedGlyph, correlation, TM_CCOEFF_NORMED); + glyphImgRows = croppedGlyph.rows(); + glyphImgCols = croppedGlyph.cols(); + } + // Call minMaxLoc repeatedly, zero out the area based on glyph size, save locations as + // CharMatch objs. + while (true) { // Loop breaks when threshold is not met. + minMaxLoc(correlation, minVal, maxVal, minLoc, maxLoc, null); + + if (maxVal.get() < threshold) { + break; + } + + Rectangle matchLocation = + new Rectangle(maxLoc.x(), maxLoc.y(), glyphImgCols, glyphImgRows); + matches.add( + new CharMatch(glyph, matchLocation.x, matchLocation.y, glyphImgCols, glyphImgRows)); + + zeroOutRegion(correlation, matchLocation); + + Mat oldZoneMat = zoneMat; + zoneMat = MaskZones.maskZonesMat(zoneMat.clone(), matchLocation); + oldZoneMat.release(); + } + } + } finally { + zoneMat.release(); + } + + // Sort CharMatch objects based on left-most positions. + matches.sort(Comparator.comparingInt(CharMatch::y).thenComparingInt(CharMatch::x)); + + StringBuilder result = new StringBuilder(); + for (CharMatch match : matches) { + result.append(match.character()); + } + + if (clean) { + matches.clear(); + } + + return result.toString(); + } + + /** + * Returns a BufferedImage mask representing matched glyph positions within a screen region. This + * is useful for clicking text. You are intended to extract contours from this and use it as a + * ChromaObj. + * + * @param zone Rectangle on screen to perform Ocr in. + * @param font Font name to use for glyph matching. + * @param text Expected string result; skips mask generation if mismatched. + * @param colour ColourObj specifying the color to isolate. + * @return A BufferedImage mask of the matched character zones, or null if text doesn't match. + */ + public static BufferedImage extractTextLocationMask( + Rectangle zone, String font, String text, ColourObj colour) { + // Get the full window bounds (this must match the screen capture bounds) + Rectangle window = ScreenManager.getWindowBounds(); + + // Create a black mask matching the window size + Mat fullScreenMask = new Mat(window.height, window.width, CV_8UC1, new Scalar(0)); + + // Early exit: text doesn't match expected + if (!extractText(zone, font, colour, false).equals(text)) { + return null; + } + + // Create a zone-sized mask where matched characters will be drawn + Mat zoneMask = new Mat(zone.height, zone.width, CV_8UC1, new Scalar(0)); + + // Draw rectangles for matched characters + for (CharMatch match : matches) { + rectangle( + zoneMask, + new Point(match.x(), match.y()), + new Point(match.x() + match.width(), match.y() + match.height()), + new Scalar(255), + FILLED, + LINE_8, + 0); + } + + // Convert screen-relative zone to window-relative position + Mat roiMat = getMat(zone, window, fullScreenMask); + zoneMask.copyTo(roiMat); + + // Release temporary mats + zoneMask.release(); + roiMat.release(); + + return TemplateMatching.matToBufferedImage(fullScreenMask); + } + + /** + * Converts a zone-relative rectangle to a window-relative Mat region for masking. + * + * @param zone Ocr region. + * @param window Full window bounds from capture. + * @param fullScreenMask The full-screen output mask. + * @return A Mat region of interest inside the full screen mask. + * @throws IllegalArgumentException if the zone is outside the screen bounds. + */ + private static Mat getMat(Rectangle zone, Rectangle window, Mat fullScreenMask) { + int relX = zone.x - window.x; + int relY = zone.y - window.y; + + // Validate bounds to avoid OpenCV crash + if (relX < 0 + || relY < 0 + || relX + zone.width > window.width + || relY + zone.height > window.height) { + throw new IllegalArgumentException( + "Zone is outside the window bounds: zone=" + zone + ", window=" + window); + } + + // Create region of interest in the full mask and copy the zone mask into it + Rect roi = new Rect(relX, relY, zone.width, zone.height); + return new Mat(fullScreenMask, roi); + } + + /** + * Sets all values in a rectangular region of a correlation matrix to zero. This prevents repeated + * template matches in the same area. + * + * @param correlation The template match result matrix. + * @param match The rectangle area to zero out. + */ + public static void zeroOutRegion(Mat correlation, Rectangle match) { + // Make sure the rectangle is within bounds of the correlation Mat + int x = Math.max(match.x, 0); + int y = Math.max(match.y, 0); + int width = Math.min(match.width, correlation.cols() - x); + int height = Math.min(match.height, correlation.rows() - y); + + if (width <= 0 || height <= 0) { + return; + } + + Rect roi = new Rect(x, y, width, height); + Mat subMat = new Mat(correlation, roi); + // Set all pixels in this region to 0 (lowest confidence) + subMat.setTo(new Mat(ZERO_SCALAR)); + subMat.release(); + } + + /** + * Returns a vertical crop offset used when slicing glyph images, depending on font type. + * + * @param font Font name. + * @return Crop offset in pixels. + */ + private static int getCropModifierForFont(String font) { + return Objects.equals(font, "Plain 12") ? 2 : 1; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Compass.java b/src/main/java/com/chromascape/utils/domain/walker/Compass.java index 6b81cec..cdb3dd4 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Compass.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Compass.java @@ -1,288 +1,288 @@ -package com.chromascape.utils.domain.walker; - -import static org.bytedeco.opencv.global.opencv_core.fastAtan2; -import static org.bytedeco.opencv.global.opencv_core.inRange; -import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2HSV; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; - -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.List; -import org.bytedeco.javacpp.PointerScope; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Point2f; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Handles detection of the in-game compass orientation by calculating the bearing, based on the - * cardinal markers within the compass (East, West and South). - * - *

This class enables angle-based transformations, such as rotating click positions on the - * minimap to match the player's camera orientation. - */ -public class Compass { - - // Minimum length from centre that the outermost cardinal compass marker pixels should be - private static final double MIN_MARKER_RADIUS = 12.0; - // Minimum length from centre that the outermost cardinal compass marker pixels should be - private static final double MAX_MARKER_RADIUS = 17.0; - // Proximity of pixels to define a cluster (within 4 px? -> part of the same cluster) - private static final int CLUSTER_PROXIMITY_THRESHOLD = 4; - double[] cardinals = {0.0, 90.0, 180.0, 270.0, 360.0}; - // How close to the cardinal an angle should be to snap to it - double cardinalSnapThreshold = 3.0; - - private final Controller controller; - - // Colour of the outer cardinal markers within the compass - private final ColourObj compassRed = - new ColourObj("CompassRed", new Scalar(0, 200, 140, 0), new Scalar(20, 255, 200, 0)); - - /** - * Constructs the Compass class. Uses the BaseScript's {@link Controller} object to access zones. - * - * @param controller the BaseScript's controller object. - */ - public Compass(Controller controller) { - this.controller = controller; - } - - /** - * Calculates the current compass angle by using the 3 red markers that denote East, South and - * West. Dependant that compassRed is accurate in the user's environment. Releases native memory - * related to JavaCV. Heavily inspired by SRL. Thank you. - * - * @return The detected angle in degrees (0-359.9). - */ - public double getCompassAngle() { - try (PointerScope ignored = new PointerScope()) { - - // Mask out the compass image by compassRed - Mat mask = captureRedMarkerMask(); - // Keep only the outermost pixels (to erase the compass needle) - // Convert them to Points for clustering - List cardinalPoints = extractCardinalPoints(mask); - // If the pixels are within 4 pixels of each other, class them as the same cluster - List> clusters = clusterPoints(cardinalPoints); - // There should be exactly 3 clusters (E, S, W) - if (clusters.size() < 3) { - return 0.0; - } - // Average each cluster into a single point (weight) - Point2f[] markers = getClusterWeights(clusters); - // Move the south cluster to index 0 by judging the longest chord (between E and W) - sortClusterWeights(markers); - // Sort the array into S, E, W by comparing the predicted south vs real south - identifyEastAndWest(markers); - // Calculate the final bearing using E and W - double degrees = fastAtan2(markers[1].y() - markers[2].y(), markers[1].x() - markers[2].x()); - // Snap to a cardinal angle if within the threshold - for (double cardinal : cardinals) { - // We use deltaAngle to handle the 359 -> 0 wrap-around - if (Math.abs(deltaAngle((float) degrees, (float) cardinal)) <= cardinalSnapThreshold) { - return (cardinal == 360.0) ? 0.0 : cardinal; - } - } - - return degrees; - } - } - - /** - * Compares predicted south to true south to sort the clusters into [South, East, West]. Uses arc - * tangents to compare the relationship between the E/S vector and S, Pivot vector. Flips the East - * and West value to sort the array. - * - * @param sortedClusterWeights an array of cluster weights sorted to [South, East, West]. - */ - private void identifyEastAndWest(Point2f[] sortedClusterWeights) { - float eastOrWestAngle = - fastAtan2( - sortedClusterWeights[1].y() - getPivot().y(), - sortedClusterWeights[1].x() - getPivot().x()); - float southAngle = - fastAtan2( - sortedClusterWeights[0].y() - getPivot().y(), - sortedClusterWeights[0].x() - getPivot().x()); - if (Math.abs(deltaAngle(eastOrWestAngle + 90, southAngle)) > 90) { - Point2f temp = sortedClusterWeights[1]; - sortedClusterWeights[1] = sortedClusterWeights[2]; - sortedClusterWeights[2] = temp; - } - } - - /** - * Finds the shortest angle between two angles. Wraps around the circle correctly as opposed to a - * traditional modulus. - * - * @param a1 The first angle. - * @param a2 The second angle. - * @return the smallest angle between the two given values. - */ - public static float deltaAngle(float a1, float a2) { - float result = (a1 - a2); - while (result > 180) { - result -= 360; - } - while (result <= -180) { - result += 360; - } - return result; - } - - /** - * Assigns each cluster a weight value by calculating the mean average between each pixel within. - * This is useful when considering the cluster as a whole rather than an individual point for - * calculation. - * - * @param clusters a {@link List} containing {@code List}s that each refer to a cluster. - * @return an array of {@link Point2f}s which refer to the weighted average of each respective - * cluster. - */ - private Point2f[] getClusterWeights(List> clusters) { - Point2f[] clusterWeights = new Point2f[clusters.size()]; - - for (int i = 0; i < clusters.size(); i++) { - float weightX = 0; - float weightY = 0; - - for (int j = 0; j < clusters.get(i).size(); j++) { - weightX += clusters.get(i).get(j).x(); - weightY += clusters.get(i).get(j).y(); - } - clusterWeights[i] = - new Point2f((weightX / clusters.get(i).size()), (weightY / clusters.get(i).size())); - } - return clusterWeights; - } - - /** - * Calculates the largest chord between each of the cluster weights and places south at the first - * index, with east or west following afterward. This mutates the given array and does not return - * any value. - * - * @param clusterWeights an array of points referring to the weighted average of the cardinal - * clusters. - */ - private void sortClusterWeights(Point2f[] clusterWeights) { - - double d1 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[1]); - double d2 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[2]); - - if (d1 > 25) { - Point2f temp = clusterWeights[0]; - clusterWeights[0] = clusterWeights[2]; - clusterWeights[2] = temp; - } - if (d2 > 25) { - Point2f temp = clusterWeights[0]; - clusterWeights[0] = clusterWeights[1]; - clusterWeights[1] = temp; - } - } - - /** - * Gets the Euclidean distance between two {@link Point2f} values. - * - * @param a The first value to compare. - * @param b The second value to compare. - * @return The distance between the two points. - */ - private float getDistanceBetweenTwoPoints(Point2f a, Point2f b) { - return (float) - Math.sqrt(((a.x() - b.x()) * (a.x() - b.x())) + ((a.y() - b.y()) * (a.y() - b.y()))); - } - - /** - * Uses the BaseScript's controller to access the compass. Masks out the cardinal markers in the - * colour compassRed. - * - * @return the masked compass image in {@link Mat} form. - */ - private Mat captureRedMarkerMask() { - Rectangle zone = controller.zones().getMinimap().get("compassSimilarity"); - BufferedImage img = ScreenManager.captureZone(zone); - - Mat src = TemplateMatching.bufferedImageToMat(img); - Mat hsv = new Mat(); - Mat mask = new Mat(); - - Mat lower = new Mat(compassRed.hsvMin()); - Mat upper = new Mat(compassRed.hsvMax()); - - cvtColor(src, hsv, COLOR_BGR2HSV); - inRange(hsv, lower, upper, mask); - - src.release(); - hsv.release(); - lower.release(); - upper.release(); - - return mask; - } - - /** - * Uses a mask of the compass filtered by compassRed to remove the inner compass needle. Leaving - * only clusters of the outermost cardinal markers. - * - * @param mask a CU81 greyscale mask of the compass, filtered by compassRed. - * @return a list of {@link Point2f} objects to denote the outermost cardinal markers. - */ - private List extractCardinalPoints(Mat mask) { - List cardinalPoints = new ArrayList<>(); - for (int y = 0; y < mask.rows(); y++) { - for (int x = 0; x < mask.cols(); x++) { - if (mask.ptr(y, x).get() != 0) { - double dist = Math.hypot(x - getPivot().x(), y - getPivot().y()); - if (dist >= MIN_MARKER_RADIUS && dist <= MAX_MARKER_RADIUS) { - cardinalPoints.add(new Point2f(x, y)); - } - } - } - } - return cardinalPoints; - } - - /** - * Provides the compass pivot/centre based on fixed-classic or resizable-classic. - * - * @return the coordinate offset for the compass center based on the ZoneManager. - */ - private Point2f getPivot() { - if (controller.zones().getIsFixed()) { - return new Point2f(17, 17); - } else { - return new Point2f(18, 18); - } - } - - /** - * Groups nearby points together based on the CLUSTER_PROXIMITY_THRESHOLD. - * - * @param points the list of detected red pixels. - * @return a list of lists, where each inner list represents a distinct marker cluster. - */ - private List> clusterPoints(List points) { - List> clusters = new ArrayList<>(); - while (!points.isEmpty()) { - List cluster = new ArrayList<>(); - Point2f root = points.remove(0); - cluster.add(root); - points.removeIf( - p -> { - if (Math.hypot(root.x() - p.x(), root.y() - p.y()) < CLUSTER_PROXIMITY_THRESHOLD) { - cluster.add(p); - return true; - } - return false; - }); - clusters.add(cluster); - } - return clusters; - } -} +package com.chromascape.utils.domain.walker; + +import static org.bytedeco.opencv.global.opencv_core.fastAtan2; +import static org.bytedeco.opencv.global.opencv_core.inRange; +import static org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2HSV; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; + +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import org.bytedeco.javacpp.PointerScope; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point2f; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Handles detection of the in-game compass orientation by calculating the bearing, based on the + * cardinal markers within the compass (East, West and South). + * + *

This class enables angle-based transformations, such as rotating click positions on the + * minimap to match the player's camera orientation. + */ +public class Compass { + + // Minimum length from centre that the outermost cardinal compass marker pixels should be + private static final double MIN_MARKER_RADIUS = 12.0; + // Minimum length from centre that the outermost cardinal compass marker pixels should be + private static final double MAX_MARKER_RADIUS = 17.0; + // Proximity of pixels to define a cluster (within 4 px? -> part of the same cluster) + private static final int CLUSTER_PROXIMITY_THRESHOLD = 4; + double[] cardinals = {0.0, 90.0, 180.0, 270.0, 360.0}; + // How close to the cardinal an angle should be to snap to it + double cardinalSnapThreshold = 3.0; + + private final Controller controller; + + // Colour of the outer cardinal markers within the compass + private final ColourObj compassRed = + new ColourObj("CompassRed", new Scalar(0, 200, 140, 0), new Scalar(20, 255, 200, 0)); + + /** + * Constructs the Compass class. Uses the BaseScript's {@link Controller} object to access zones. + * + * @param controller the BaseScript's controller object. + */ + public Compass(Controller controller) { + this.controller = controller; + } + + /** + * Calculates the current compass angle by using the 3 red markers that denote East, South and + * West. Dependant that compassRed is accurate in the user's environment. Releases native memory + * related to JavaCV. Heavily inspired by SRL. Thank you. + * + * @return The detected angle in degrees (0-359.9). + */ + public double getCompassAngle() { + try (PointerScope ignored = new PointerScope()) { + + // Mask out the compass image by compassRed + Mat mask = captureRedMarkerMask(); + // Keep only the outermost pixels (to erase the compass needle) + // Convert them to Points for clustering + List cardinalPoints = extractCardinalPoints(mask); + // If the pixels are within 4 pixels of each other, class them as the same cluster + List> clusters = clusterPoints(cardinalPoints); + // There should be exactly 3 clusters (E, S, W) + if (clusters.size() < 3) { + return 0.0; + } + // Average each cluster into a single point (weight) + Point2f[] markers = getClusterWeights(clusters); + // Move the south cluster to index 0 by judging the longest chord (between E and W) + sortClusterWeights(markers); + // Sort the array into S, E, W by comparing the predicted south vs real south + identifyEastAndWest(markers); + // Calculate the final bearing using E and W + double degrees = fastAtan2(markers[1].y() - markers[2].y(), markers[1].x() - markers[2].x()); + // Snap to a cardinal angle if within the threshold + for (double cardinal : cardinals) { + // We use deltaAngle to handle the 359 -> 0 wrap-around + if (Math.abs(deltaAngle((float) degrees, (float) cardinal)) <= cardinalSnapThreshold) { + return (cardinal == 360.0) ? 0.0 : cardinal; + } + } + + return degrees; + } + } + + /** + * Compares predicted south to true south to sort the clusters into [South, East, West]. Uses arc + * tangents to compare the relationship between the E/S vector and S, Pivot vector. Flips the East + * and West value to sort the array. + * + * @param sortedClusterWeights an array of cluster weights sorted to [South, East, West]. + */ + private void identifyEastAndWest(Point2f[] sortedClusterWeights) { + float eastOrWestAngle = + fastAtan2( + sortedClusterWeights[1].y() - getPivot().y(), + sortedClusterWeights[1].x() - getPivot().x()); + float southAngle = + fastAtan2( + sortedClusterWeights[0].y() - getPivot().y(), + sortedClusterWeights[0].x() - getPivot().x()); + if (Math.abs(deltaAngle(eastOrWestAngle + 90, southAngle)) > 90) { + Point2f temp = sortedClusterWeights[1]; + sortedClusterWeights[1] = sortedClusterWeights[2]; + sortedClusterWeights[2] = temp; + } + } + + /** + * Finds the shortest angle between two angles. Wraps around the circle correctly as opposed to a + * traditional modulus. + * + * @param a1 The first angle. + * @param a2 The second angle. + * @return the smallest angle between the two given values. + */ + public static float deltaAngle(float a1, float a2) { + float result = (a1 - a2); + while (result > 180) { + result -= 360; + } + while (result <= -180) { + result += 360; + } + return result; + } + + /** + * Assigns each cluster a weight value by calculating the mean average between each pixel within. + * This is useful when considering the cluster as a whole rather than an individual point for + * calculation. + * + * @param clusters a {@link List} containing {@code List}s that each refer to a cluster. + * @return an array of {@link Point2f}s which refer to the weighted average of each respective + * cluster. + */ + private Point2f[] getClusterWeights(List> clusters) { + Point2f[] clusterWeights = new Point2f[clusters.size()]; + + for (int i = 0; i < clusters.size(); i++) { + float weightX = 0; + float weightY = 0; + + for (int j = 0; j < clusters.get(i).size(); j++) { + weightX += clusters.get(i).get(j).x(); + weightY += clusters.get(i).get(j).y(); + } + clusterWeights[i] = + new Point2f((weightX / clusters.get(i).size()), (weightY / clusters.get(i).size())); + } + return clusterWeights; + } + + /** + * Calculates the largest chord between each of the cluster weights and places south at the first + * index, with east or west following afterward. This mutates the given array and does not return + * any value. + * + * @param clusterWeights an array of points referring to the weighted average of the cardinal + * clusters. + */ + private void sortClusterWeights(Point2f[] clusterWeights) { + + double d1 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[1]); + double d2 = getDistanceBetweenTwoPoints(clusterWeights[0], clusterWeights[2]); + + if (d1 > 25) { + Point2f temp = clusterWeights[0]; + clusterWeights[0] = clusterWeights[2]; + clusterWeights[2] = temp; + } + if (d2 > 25) { + Point2f temp = clusterWeights[0]; + clusterWeights[0] = clusterWeights[1]; + clusterWeights[1] = temp; + } + } + + /** + * Gets the Euclidean distance between two {@link Point2f} values. + * + * @param a The first value to compare. + * @param b The second value to compare. + * @return The distance between the two points. + */ + private float getDistanceBetweenTwoPoints(Point2f a, Point2f b) { + return (float) + Math.sqrt(((a.x() - b.x()) * (a.x() - b.x())) + ((a.y() - b.y()) * (a.y() - b.y()))); + } + + /** + * Uses the BaseScript's controller to access the compass. Masks out the cardinal markers in the + * colour compassRed. + * + * @return the masked compass image in {@link Mat} form. + */ + private Mat captureRedMarkerMask() { + Rectangle zone = controller.zones().getMinimap().get("compassSimilarity"); + BufferedImage img = ScreenManager.captureZone(zone); + + Mat src = TemplateMatching.bufferedImageToMat(img); + Mat hsv = new Mat(); + Mat mask = new Mat(); + + Mat lower = new Mat(compassRed.hsvMin()); + Mat upper = new Mat(compassRed.hsvMax()); + + cvtColor(src, hsv, COLOR_BGR2HSV); + inRange(hsv, lower, upper, mask); + + src.release(); + hsv.release(); + lower.release(); + upper.release(); + + return mask; + } + + /** + * Uses a mask of the compass filtered by compassRed to remove the inner compass needle. Leaving + * only clusters of the outermost cardinal markers. + * + * @param mask a CU81 greyscale mask of the compass, filtered by compassRed. + * @return a list of {@link Point2f} objects to denote the outermost cardinal markers. + */ + private List extractCardinalPoints(Mat mask) { + List cardinalPoints = new ArrayList<>(); + for (int y = 0; y < mask.rows(); y++) { + for (int x = 0; x < mask.cols(); x++) { + if (mask.ptr(y, x).get() != 0) { + double dist = Math.hypot(x - getPivot().x(), y - getPivot().y()); + if (dist >= MIN_MARKER_RADIUS && dist <= MAX_MARKER_RADIUS) { + cardinalPoints.add(new Point2f(x, y)); + } + } + } + } + return cardinalPoints; + } + + /** + * Provides the compass pivot/centre based on fixed-classic or resizable-classic. + * + * @return the coordinate offset for the compass center based on the ZoneManager. + */ + private Point2f getPivot() { + if (controller.zones().getIsFixed()) { + return new Point2f(17, 17); + } else { + return new Point2f(18, 18); + } + } + + /** + * Groups nearby points together based on the CLUSTER_PROXIMITY_THRESHOLD. + * + * @param points the list of detected red pixels. + * @return a list of lists, where each inner list represents a distinct marker cluster. + */ + private List> clusterPoints(List points) { + List> clusters = new ArrayList<>(); + while (!points.isEmpty()) { + List cluster = new ArrayList<>(); + Point2f root = points.remove(0); + cluster.add(root); + points.removeIf( + p -> { + if (Math.hypot(root.x() - p.x(), root.y() - p.y()) < CLUSTER_PROXIMITY_THRESHOLD) { + cluster.add(p); + return true; + } + return false; + }); + clusters.add(cluster); + } + return clusters; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java b/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java index 50e5d98..e1929de 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java +++ b/src/main/java/com/chromascape/utils/domain/walker/DaxPath.java @@ -1,16 +1,16 @@ -package com.chromascape.utils.domain.walker; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; - -/** - * Record class to deserialize the raw output of the DAX API's walker. - * - * @param pathStatus Status of the request: SUCCESS or FAILURE. - * @param path A {@link List} of Tile objects leading from current to destination positions. - * @param cost How many tokens used. - */ -public record DaxPath( - @JsonProperty("pathStatus") String pathStatus, - @JsonProperty("path") List path, - @JsonProperty("cost") int cost) {} +package com.chromascape.utils.domain.walker; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Record class to deserialize the raw output of the DAX API's walker. + * + * @param pathStatus Status of the request: SUCCESS or FAILURE. + * @param path A {@link List} of Tile objects leading from current to destination positions. + * @param cost How many tokens used. + */ +public record DaxPath( + @JsonProperty("pathStatus") String pathStatus, + @JsonProperty("path") List path, + @JsonProperty("cost") int cost) {} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Tile.java b/src/main/java/com/chromascape/utils/domain/walker/Tile.java index be94c0a..9101cee 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Tile.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Tile.java @@ -1,12 +1,12 @@ -package com.chromascape.utils.domain.walker; - -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Record class to deserialize and store a DAX API path as a set of Tiles. - * - * @param x x co-ordinate. - * @param y y co-ordinate. - * @param z z co-ordinate. - */ -public record Tile(@JsonProperty("x") int x, @JsonProperty("y") int y, @JsonProperty("z") int z) {} +package com.chromascape.utils.domain.walker; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Record class to deserialize and store a DAX API path as a set of Tiles. + * + * @param x x co-ordinate. + * @param y y co-ordinate. + * @param z z co-ordinate. + */ +public record Tile(@JsonProperty("x") int x, @JsonProperty("y") int y, @JsonProperty("z") int z) {} diff --git a/src/main/java/com/chromascape/utils/domain/walker/Walker.java b/src/main/java/com/chromascape/utils/domain/walker/Walker.java index 4e7ebf0..f464099 100644 --- a/src/main/java/com/chromascape/utils/domain/walker/Walker.java +++ b/src/main/java/com/chromascape/utils/domain/walker/Walker.java @@ -1,316 +1,316 @@ -package com.chromascape.utils.domain.walker; - -import static com.chromascape.base.BaseScript.waitRandomMillis; - -import com.chromascape.api.Dax; -import com.chromascape.base.BaseScript; -import com.chromascape.controller.Controller; -import com.chromascape.utils.core.runtime.exception.DaxAuthException; -import com.chromascape.utils.core.runtime.exception.DaxException; -import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; -import com.chromascape.utils.core.screen.colour.ColourInstances; -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.utils.domain.ocr.Ocr; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.awt.Point; -import java.awt.Rectangle; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Provides high-level pathfinding and walking functionality for the bot. - * - *

The {@code Walker} integrates with the {@link Dax} pathfinding API, in-game OCR, and the - * minimap/compass systems to move the player character to a given destination tile. It has access - * to the {@link Controller}, granting it access to screen zones, the virtual mouse, and other - * utilities. - * - *

Walking is achieved by: - * - *

    - *
  • Using OCR to read the player's current position from the game client. - *
  • Querying the DAX API for a path between the current position and the destination. - *
  • Projecting intermediate path tiles onto the minimap using pixel-per-tile scaling and - * compass rotation. - *
  • Issuing randomized mouse clicks on the minimap to simulate human-like input. - *
  • Polling player movement until the character stops, recalculating the path if necessary. - *
- * - *

The {@code Walker} assumes: - * - *

    - *
  • The minimap is at the default zoom level. - *
  • OCR can reliably extract the player's current coordinates from the Tile zone. - *
  • The compass direction is available and accurate for rotation calculations. - *
- * - *

Typical usage: - * - *

{@code
- * controller().walker.pathTo(new Point(3200, 3200), true);
- * }
- * - *

This will walk the player to the given tile, respecting camera rotation and randomized path - * horizons, while logging progress to the provided {@link Logger}. - */ -public class Walker { - - private final Controller controller; - private static final Logger logger = LogManager.getLogger(Walker.class); - private final Dax dax; - private final ObjectMapper objectMapper; - private final Compass compass; - private final Random random; - private CompletableFuture pointFuture; - - /** - * Creates a new Walker for controlling player movement. Initializes dependencies including - * controller access, logging, DAX API, and compass handling. - * - * @param controller The bot's controller - */ - public Walker(Controller controller) { - this.controller = controller; - this.dax = new Dax(); - this.objectMapper = new ObjectMapper(); - this.random = new Random(); - this.compass = new Compass(controller); - this.pointFuture = new CompletableFuture<>(); - } - - /** - * Gets the player's position by using runtime OCR on the GridInfo's "Tile" zone. - * - * @return An integer array with 3 elements - x, y and z. - */ - public Tile getPlayerPosition() { - Rectangle zone = controller.zones().getGridInfo().get("Tile"); - ColourObj colour = ColourInstances.getByName("White"); - // Extracts the position using OCR and splits it into a 3 value list (x, y, z) - List stringPos = - Arrays.asList(Ocr.extractText(zone, "Plain 12", colour, true).split(",")); - return new Tile( - Integer.parseInt(stringPos.get(0)), - Integer.parseInt(stringPos.get(1)), - Integer.parseInt(stringPos.get(2))); - } - - /** - * Sends a payload to the DAX API with start/end positions and members availability. In return - - * receives a path that it deserializes and turns into {@link Tile} objects. - * - * @param destination A {@link Point} object defining the co-ordinates of your destination. - * @param isMembers A boolean dictating whether your character is a member or free to play. - * @return A {@link List} list of {@link Tile} objects with the first tile being your current - * position. - * @throws IOException If a transport error occurs during calling a path from the Dax API. - * @throws InterruptedException If the thread is interrupted or the watchdog freezes the thread. - */ - private List getPath(Point destination, boolean isMembers) - throws IOException, InterruptedException { - Tile position = getPlayerPosition(); - DaxPath daxPath = null; - int retries = 20; - int attempt = 0; - - while (attempt < retries) { - try { - String rawPath = - dax.generatePath(new Point(position.x(), position.y()), destination, isMembers); - daxPath = objectMapper.readValue(rawPath, DaxPath.class); - break; - - } catch (DaxRateLimitException e) { - // Handle the rate limit exception by waiting and retrying - attempt++; - logger.warn("Dax Rate Limit reached (Attempt {}/{}). Waiting...", attempt, retries); - waitRandomMillis(600, 1200); - - } catch (DaxAuthException e) { - // Throw RuntimeException if the API key is invalid - logger.error("Dax Authentication Failed: {}", e.getMessage()); - throw new IOException("Invalid DAX credentials. Check your API key: ", e); - - } catch (DaxException e) { - // Retry if server error - attempt++; - logger.error("Dax API error: {}. Retrying...", e.getMessage()); - waitRandomMillis(1000, 2000); - } - } - if (daxPath == null) { - throw new IOException( - "Failed to get a successful path from DAX after " + retries + " retries."); - } - return daxPath.path(); - } - - /** - * Walks the player to a given destination tile using intermediate clicks on the minimap, while - * asynchronously precomputing the next click point to improve responsiveness. - * - *

This approach ensures that the next click location is calculated while waiting, reducing - * idle time and keeping movement smooth and efficient. The path list is modified in-place by - * {@link #chooseNextTarget(List, int, int)}. - * - * @param destination the destination {@link Point} to walk to - * @param isMembers whether the player is a members account, affecting path calculation - * @throws IOException if path retrieval from DAX fails due to transport error - * @throws InterruptedException if the thread is interrupted while in the process of calling DAX - */ - public void pathTo(Point destination, boolean isMembers) - throws IOException, InterruptedException { - List path = getPath(destination, isMembers); - // How far away from the current tile the bot should click - int maxHorizon = 10; - int minHorizon = 8; - // Synchronously path once - Tile target = chooseNextTarget(path, minHorizon, maxHorizon); - logger.info("Synchronously clicking once at {}, {}", target.x(), target.y()); - controller.mouse().moveTo(getClickLocation(target, getPlayerPosition()), "medium"); - controller.mouse().leftClick(); - // Looping until at destination - while (getPlayerPosition().x() != destination.getX() - || getPlayerPosition().y() != destination.getY()) { - if (path.isEmpty()) { - break; - } - // Effectively final variables for the lambda function. - Tile newTarget = chooseNextTarget(path, minHorizon, maxHorizon); - Tile oldTarget = target; - // Async precomputing the next click point while waiting for the bot to stop - pointFuture = CompletableFuture.supplyAsync(() -> getClickLocation(newTarget, oldTarget)); - // This blocks the main thread, but the next point is being computed already. - logger.info("Precomputing next click at {}, {}", newTarget.x(), newTarget.y()); - waitToStop(); - // Recalculate path and cancel async if not at expected location - Tile position = getPlayerPosition(); - Point clickpoint; - if (position.x() != target.x() || position.y() != target.y()) { - logger.error("Veered off path, recalculating..."); - pointFuture.cancel(false); - try { - pointFuture.join(); - } catch (CancellationException | CompletionException e) { - logger.error("Async task was cancelled and thread joined"); - } - // If the path is out of range recalculate whole path - target = chooseNextTarget(path, 5, 7); - if (Math.abs(position.x() - target.x()) > 7 || Math.abs(position.y() - target.y()) > 7) { - logger.error("Too far from path, calling Dax..."); - path = getPath(destination, isMembers); - target = chooseNextTarget(path, minHorizon, maxHorizon); - } - clickpoint = getClickLocation(target, getPlayerPosition()); - } else { - clickpoint = pointFuture.join(); - // Update target - target = newTarget; - } - // Both scenarios saved as the clickPoint - controller.mouse().moveTo(clickpoint, "medium"); - controller.mouse().leftClick(); - } - } - - /** - * Selects the next intermediate target tile from the given path for the bot to click on the - * minimap. - * - *

The method randomly chooses a target a few tiles ahead of the player's current position - * (between {@code minHorizon} and {@code maxHorizon}) to simulate human-like movement and avoid - * predictable straight-line clicking. - * - *

If the path is shorter than the randomly selected horizon, the last tile in the path is - * chosen. Once a target is chosen, all preceding tiles up to the chosen target are removed from - * the path, effectively updating the path for the next iteration. - * - * @param path the list of {@link Tile} objects representing the remaining path to the - * destination; this list will be modified by removing tiles up to the chosen target - * @return the {@link Tile} selected as the next click target - */ - private Tile chooseNextTarget(List path, int minHorizon, int maxHorizon) { - if (path == null || path.isEmpty()) { - return getPlayerPosition(); - } - - int targetPos = random.nextInt(minHorizon, maxHorizon + 1); - Tile target; - - // If we're about to overshoot the last tile, just click the last tile - if (path.size() > targetPos) { - target = path.get(targetPos); - path.subList(0, targetPos).clear(); - } else { - target = path.get(path.size() - 1); - path.clear(); - } - return target; - } - - /** - * Uses the given players position and the position of the target {@link Tile} to project the - * click location of the given {@link Tile} onto the minimap. Uses the compass' angle to rotate - * the click location so it can conform to any camera position. Requires that the minimap be at - * default zoom level. - * - * @param target The target {@link Tile} to path to. - * @param playerPosition The position of the player to calculate form. - * @return Returns the {@link Point} click location to click. - * @implNote Requires default minimap zoom. Other zoom levels will misalign tile clicks. - */ - private Point getClickLocation(Tile target, Tile playerPosition) { - // 4 pixels per tile at normal zoom - int pixelsPerTile = 4; - // Save player position - int x = playerPosition.x(); - int y = playerPosition.y(); - // Calculating distance from the player to the target, in pixels - // and adding an offset of a few pixels to click randomly within the tile - double dx = ((target.x() - x) * pixelsPerTile); - double dy = ((y - target.y()) * pixelsPerTile); - // Locating the player's tile on the minimap - Rectangle playerMinimap = controller.zones().getMinimap().get("playerPos"); - // Origins dictate the perfect center - used to rotate the click location - double originX = playerMinimap.x + ((double) (pixelsPerTile - 1) / 2); - double originY = playerMinimap.y + ((double) (pixelsPerTile - 1) / 2); - // Calculate the radian based on compass rotation - double theta = Math.toRadians(compass.getCompassAngle()); - // Calculate rotated x and y - double rotX = Math.cos(theta) * dx - Math.sin(theta) * dy; - double rotY = Math.sin(theta) * dx + Math.cos(theta) * dy; - // Generate the rotated point - return new Point((int) Math.round(originX + rotX), (int) Math.round(originY + rotY)); - } - - /** - * Polls the player's position to check if the player has stopped moving. Exits out when stopped. - */ - private void waitToStop() { - // Ticks on some worlds can vary, it's usual on world 302 to be 0.618 per tick - long tick = 650; - Tile position = getPlayerPosition(); - // Wait to start moving - int attempts = 0; - while (position.equals(getPlayerPosition()) && attempts < 3) { - BaseScript.waitMillis(tick); - attempts++; - } - // Wait to stop moving - while (true) { - if (position.equals(getPlayerPosition())) { - return; - } else { - position = getPlayerPosition(); - BaseScript.waitMillis(tick); - } - } - } -} +package com.chromascape.utils.domain.walker; + +import static com.chromascape.base.BaseScript.waitRandomMillis; + +import com.chromascape.api.Dax; +import com.chromascape.base.BaseScript; +import com.chromascape.controller.Controller; +import com.chromascape.utils.core.runtime.exception.DaxAuthException; +import com.chromascape.utils.core.runtime.exception.DaxException; +import com.chromascape.utils.core.runtime.exception.DaxRateLimitException; +import com.chromascape.utils.core.screen.colour.ColourInstances; +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.utils.domain.ocr.Ocr; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.awt.Point; +import java.awt.Rectangle; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Provides high-level pathfinding and walking functionality for the bot. + * + *

The {@code Walker} integrates with the {@link Dax} pathfinding API, in-game OCR, and the + * minimap/compass systems to move the player character to a given destination tile. It has access + * to the {@link Controller}, granting it access to screen zones, the virtual mouse, and other + * utilities. + * + *

Walking is achieved by: + * + *

    + *
  • Using OCR to read the player's current position from the game client. + *
  • Querying the DAX API for a path between the current position and the destination. + *
  • Projecting intermediate path tiles onto the minimap using pixel-per-tile scaling and + * compass rotation. + *
  • Issuing randomized mouse clicks on the minimap to simulate human-like input. + *
  • Polling player movement until the character stops, recalculating the path if necessary. + *
+ * + *

The {@code Walker} assumes: + * + *

    + *
  • The minimap is at the default zoom level. + *
  • OCR can reliably extract the player's current coordinates from the Tile zone. + *
  • The compass direction is available and accurate for rotation calculations. + *
+ * + *

Typical usage: + * + *

{@code
+ * controller().walker.pathTo(new Point(3200, 3200), true);
+ * }
+ * + *

This will walk the player to the given tile, respecting camera rotation and randomized path + * horizons, while logging progress to the provided {@link Logger}. + */ +public class Walker { + + private final Controller controller; + private static final Logger logger = LogManager.getLogger(Walker.class); + private final Dax dax; + private final ObjectMapper objectMapper; + private final Compass compass; + private final Random random; + private CompletableFuture pointFuture; + + /** + * Creates a new Walker for controlling player movement. Initializes dependencies including + * controller access, logging, DAX API, and compass handling. + * + * @param controller The bot's controller + */ + public Walker(Controller controller) { + this.controller = controller; + this.dax = new Dax(); + this.objectMapper = new ObjectMapper(); + this.random = new Random(); + this.compass = new Compass(controller); + this.pointFuture = new CompletableFuture<>(); + } + + /** + * Gets the player's position by using runtime OCR on the GridInfo's "Tile" zone. + * + * @return An integer array with 3 elements - x, y and z. + */ + public Tile getPlayerPosition() { + Rectangle zone = controller.zones().getGridInfo().get("Tile"); + ColourObj colour = ColourInstances.getByName("White"); + // Extracts the position using OCR and splits it into a 3 value list (x, y, z) + List stringPos = + Arrays.asList(Ocr.extractText(zone, "Plain 12", colour, true).split(",")); + return new Tile( + Integer.parseInt(stringPos.get(0)), + Integer.parseInt(stringPos.get(1)), + Integer.parseInt(stringPos.get(2))); + } + + /** + * Sends a payload to the DAX API with start/end positions and members availability. In return - + * receives a path that it deserializes and turns into {@link Tile} objects. + * + * @param destination A {@link Point} object defining the co-ordinates of your destination. + * @param isMembers A boolean dictating whether your character is a member or free to play. + * @return A {@link List} list of {@link Tile} objects with the first tile being your current + * position. + * @throws IOException If a transport error occurs during calling a path from the Dax API. + * @throws InterruptedException If the thread is interrupted or the watchdog freezes the thread. + */ + private List getPath(Point destination, boolean isMembers) + throws IOException, InterruptedException { + Tile position = getPlayerPosition(); + DaxPath daxPath = null; + int retries = 20; + int attempt = 0; + + while (attempt < retries) { + try { + String rawPath = + dax.generatePath(new Point(position.x(), position.y()), destination, isMembers); + daxPath = objectMapper.readValue(rawPath, DaxPath.class); + break; + + } catch (DaxRateLimitException e) { + // Handle the rate limit exception by waiting and retrying + attempt++; + logger.warn("Dax Rate Limit reached (Attempt {}/{}). Waiting...", attempt, retries); + waitRandomMillis(600, 1200); + + } catch (DaxAuthException e) { + // Throw RuntimeException if the API key is invalid + logger.error("Dax Authentication Failed: {}", e.getMessage()); + throw new IOException("Invalid DAX credentials. Check your API key: ", e); + + } catch (DaxException e) { + // Retry if server error + attempt++; + logger.error("Dax API error: {}. Retrying...", e.getMessage()); + waitRandomMillis(1000, 2000); + } + } + if (daxPath == null) { + throw new IOException( + "Failed to get a successful path from DAX after " + retries + " retries."); + } + return daxPath.path(); + } + + /** + * Walks the player to a given destination tile using intermediate clicks on the minimap, while + * asynchronously precomputing the next click point to improve responsiveness. + * + *

This approach ensures that the next click location is calculated while waiting, reducing + * idle time and keeping movement smooth and efficient. The path list is modified in-place by + * {@link #chooseNextTarget(List, int, int)}. + * + * @param destination the destination {@link Point} to walk to + * @param isMembers whether the player is a members account, affecting path calculation + * @throws IOException if path retrieval from DAX fails due to transport error + * @throws InterruptedException if the thread is interrupted while in the process of calling DAX + */ + public void pathTo(Point destination, boolean isMembers) + throws IOException, InterruptedException { + List path = getPath(destination, isMembers); + // How far away from the current tile the bot should click + int maxHorizon = 10; + int minHorizon = 8; + // Synchronously path once + Tile target = chooseNextTarget(path, minHorizon, maxHorizon); + logger.info("Synchronously clicking once at {}, {}", target.x(), target.y()); + controller.mouse().moveTo(getClickLocation(target, getPlayerPosition()), "medium"); + controller.mouse().leftClick(); + // Looping until at destination + while (getPlayerPosition().x() != destination.getX() + || getPlayerPosition().y() != destination.getY()) { + if (path.isEmpty()) { + break; + } + // Effectively final variables for the lambda function. + Tile newTarget = chooseNextTarget(path, minHorizon, maxHorizon); + Tile oldTarget = target; + // Async precomputing the next click point while waiting for the bot to stop + pointFuture = CompletableFuture.supplyAsync(() -> getClickLocation(newTarget, oldTarget)); + // This blocks the main thread, but the next point is being computed already. + logger.info("Precomputing next click at {}, {}", newTarget.x(), newTarget.y()); + waitToStop(); + // Recalculate path and cancel async if not at expected location + Tile position = getPlayerPosition(); + Point clickpoint; + if (position.x() != target.x() || position.y() != target.y()) { + logger.error("Veered off path, recalculating..."); + pointFuture.cancel(false); + try { + pointFuture.join(); + } catch (CancellationException | CompletionException e) { + logger.error("Async task was cancelled and thread joined"); + } + // If the path is out of range recalculate whole path + target = chooseNextTarget(path, 5, 7); + if (Math.abs(position.x() - target.x()) > 7 || Math.abs(position.y() - target.y()) > 7) { + logger.error("Too far from path, calling Dax..."); + path = getPath(destination, isMembers); + target = chooseNextTarget(path, minHorizon, maxHorizon); + } + clickpoint = getClickLocation(target, getPlayerPosition()); + } else { + clickpoint = pointFuture.join(); + // Update target + target = newTarget; + } + // Both scenarios saved as the clickPoint + controller.mouse().moveTo(clickpoint, "medium"); + controller.mouse().leftClick(); + } + } + + /** + * Selects the next intermediate target tile from the given path for the bot to click on the + * minimap. + * + *

The method randomly chooses a target a few tiles ahead of the player's current position + * (between {@code minHorizon} and {@code maxHorizon}) to simulate human-like movement and avoid + * predictable straight-line clicking. + * + *

If the path is shorter than the randomly selected horizon, the last tile in the path is + * chosen. Once a target is chosen, all preceding tiles up to the chosen target are removed from + * the path, effectively updating the path for the next iteration. + * + * @param path the list of {@link Tile} objects representing the remaining path to the + * destination; this list will be modified by removing tiles up to the chosen target + * @return the {@link Tile} selected as the next click target + */ + private Tile chooseNextTarget(List path, int minHorizon, int maxHorizon) { + if (path == null || path.isEmpty()) { + return getPlayerPosition(); + } + + int targetPos = random.nextInt(minHorizon, maxHorizon + 1); + Tile target; + + // If we're about to overshoot the last tile, just click the last tile + if (path.size() > targetPos) { + target = path.get(targetPos); + path.subList(0, targetPos).clear(); + } else { + target = path.get(path.size() - 1); + path.clear(); + } + return target; + } + + /** + * Uses the given players position and the position of the target {@link Tile} to project the + * click location of the given {@link Tile} onto the minimap. Uses the compass' angle to rotate + * the click location so it can conform to any camera position. Requires that the minimap be at + * default zoom level. + * + * @param target The target {@link Tile} to path to. + * @param playerPosition The position of the player to calculate form. + * @return Returns the {@link Point} click location to click. + * @implNote Requires default minimap zoom. Other zoom levels will misalign tile clicks. + */ + private Point getClickLocation(Tile target, Tile playerPosition) { + // 4 pixels per tile at normal zoom + int pixelsPerTile = 4; + // Save player position + int x = playerPosition.x(); + int y = playerPosition.y(); + // Calculating distance from the player to the target, in pixels + // and adding an offset of a few pixels to click randomly within the tile + double dx = ((target.x() - x) * pixelsPerTile); + double dy = ((y - target.y()) * pixelsPerTile); + // Locating the player's tile on the minimap + Rectangle playerMinimap = controller.zones().getMinimap().get("playerPos"); + // Origins dictate the perfect center - used to rotate the click location + double originX = playerMinimap.x + ((double) (pixelsPerTile - 1) / 2); + double originY = playerMinimap.y + ((double) (pixelsPerTile - 1) / 2); + // Calculate the radian based on compass rotation + double theta = Math.toRadians(compass.getCompassAngle()); + // Calculate rotated x and y + double rotX = Math.cos(theta) * dx - Math.sin(theta) * dy; + double rotY = Math.sin(theta) * dx + Math.cos(theta) * dy; + // Generate the rotated point + return new Point((int) Math.round(originX + rotX), (int) Math.round(originY + rotY)); + } + + /** + * Polls the player's position to check if the player has stopped moving. Exits out when stopped. + */ + private void waitToStop() { + // Ticks on some worlds can vary, it's usual on world 302 to be 0.618 per tick + long tick = 650; + Tile position = getPlayerPosition(); + // Wait to start moving + int attempts = 0; + while (position.equals(getPlayerPosition()) && attempts < 3) { + BaseScript.waitMillis(tick); + attempts++; + } + // Wait to stop moving + while (true) { + if (position.equals(getPlayerPosition())) { + return; + } else { + position = getPlayerPosition(); + BaseScript.waitMillis(tick); + } + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java b/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java index b051ab8..f0b6ad6 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java +++ b/src/main/java/com/chromascape/utils/domain/zones/MaskZones.java @@ -1,85 +1,85 @@ -package com.chromascape.utils.domain.zones; - -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import org.bytedeco.javacpp.indexer.UByteRawIndexer; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Rect; - -/** - * Utility class for applying rectangular masks to images. - * - *

Provides static methods for blacking out regions of {@link BufferedImage} or OpenCV {@link - * Mat} objects based on AWT {@link Rectangle} coordinates. Used primarily for excluding visual - * zones from further processing. - */ -public class MaskZones { - - /** - * Applies a rectangular mask to a given {@link BufferedImage} and returns a new image with the - * specified area set to black. - * - * @param originalImg The original input image. - * @param maskArea The rectangular area to mask. - * @return a new {@link BufferedImage} With the specified region zeroed out. - * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. - */ - public static BufferedImage maskZones(BufferedImage originalImg, Rectangle maskArea) { - Mat original = TemplateMatching.bufferedImageToMat(originalImg); - Mat output = maskZonesMat(original, maskArea); - BufferedImage outImg = TemplateMatching.matToBufferedImage(output); - original.release(); - output.release(); - return outImg; - } - - /** - * Applies a rectangular mask directly to a {@link Mat} image and returns a new {@link Mat} with - * the specified region zeroed out. - * - * @param original The original input image as an OpenCV {@link Mat}. - * @param maskArea The rectangular area to mask, in AWT {@link Rectangle} coordinates. - * @return A cloned {@link Mat} with the masked region set to zero. - * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. - */ - public static Mat maskZonesMat(Mat original, Rectangle maskArea) { - Mat output = original.clone(); - Rect rect = new Rect(maskArea.x, maskArea.y, maskArea.width, maskArea.height); - - // Bounds check - if (rect.x() < 0 - || rect.y() < 0 - || rect.width() <= 0 - || rect.height() <= 0 - || rect.x() + rect.width() > output.cols() - || rect.y() + rect.height() > output.rows()) { - throw new IllegalArgumentException("Mask rectangle out of bounds: " + rect); - } - - Mat roi = new Mat(output, rect); - - if (output.channels() == 1) { - UByteRawIndexer indexer = roi.createIndexer(); - for (int y = 0; y < rect.height(); y++) { - for (int x = 0; x < rect.width(); x++) { - indexer.put(y, x, 0); - } - } - indexer.release(); - } else if (output.channels() == 3) { - UByteRawIndexer indexer = roi.createIndexer(); - for (int y = 0; y < rect.height(); y++) { - for (int x = 0; x < rect.width(); x++) { - indexer.put(y, x, 0, 0); // B - indexer.put(y, x, 1, 0); // G - indexer.put(y, x, 2, 0); // R - } - } - indexer.release(); - } - - roi.release(); - return output; - } -} +package com.chromascape.utils.domain.zones; + +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import org.bytedeco.javacpp.indexer.UByteRawIndexer; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Rect; + +/** + * Utility class for applying rectangular masks to images. + * + *

Provides static methods for blacking out regions of {@link BufferedImage} or OpenCV {@link + * Mat} objects based on AWT {@link Rectangle} coordinates. Used primarily for excluding visual + * zones from further processing. + */ +public class MaskZones { + + /** + * Applies a rectangular mask to a given {@link BufferedImage} and returns a new image with the + * specified area set to black. + * + * @param originalImg The original input image. + * @param maskArea The rectangular area to mask. + * @return a new {@link BufferedImage} With the specified region zeroed out. + * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. + */ + public static BufferedImage maskZones(BufferedImage originalImg, Rectangle maskArea) { + Mat original = TemplateMatching.bufferedImageToMat(originalImg); + Mat output = maskZonesMat(original, maskArea); + BufferedImage outImg = TemplateMatching.matToBufferedImage(output); + original.release(); + output.release(); + return outImg; + } + + /** + * Applies a rectangular mask directly to a {@link Mat} image and returns a new {@link Mat} with + * the specified region zeroed out. + * + * @param original The original input image as an OpenCV {@link Mat}. + * @param maskArea The rectangular area to mask, in AWT {@link Rectangle} coordinates. + * @return A cloned {@link Mat} with the masked region set to zero. + * @throws IllegalArgumentException If the rectangle is out of image bounds or invalid. + */ + public static Mat maskZonesMat(Mat original, Rectangle maskArea) { + Mat output = original.clone(); + Rect rect = new Rect(maskArea.x, maskArea.y, maskArea.width, maskArea.height); + + // Bounds check + if (rect.x() < 0 + || rect.y() < 0 + || rect.width() <= 0 + || rect.height() <= 0 + || rect.x() + rect.width() > output.cols() + || rect.y() + rect.height() > output.rows()) { + throw new IllegalArgumentException("Mask rectangle out of bounds: " + rect); + } + + Mat roi = new Mat(output, rect); + + if (output.channels() == 1) { + UByteRawIndexer indexer = roi.createIndexer(); + for (int y = 0; y < rect.height(); y++) { + for (int x = 0; x < rect.width(); x++) { + indexer.put(y, x, 0); + } + } + indexer.release(); + } else if (output.channels() == 3) { + UByteRawIndexer indexer = roi.createIndexer(); + for (int y = 0; y < rect.height(); y++) { + for (int x = 0; x < rect.width(); x++) { + indexer.put(y, x, 0, 0); // B + indexer.put(y, x, 1, 0); // G + indexer.put(y, x, 2, 0); // R + } + } + indexer.release(); + } + + roi.release(); + return output; + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java b/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java index 5d8ed0d..762a746 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java +++ b/src/main/java/com/chromascape/utils/domain/zones/SubZoneMapper.java @@ -1,194 +1,194 @@ -package com.chromascape.utils.domain.zones; - -import java.awt.Rectangle; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Utility class for mapping UI component zones within the client window. - * - *

Provides methods to derive sub-zones (like orbs, tabs, inventory slots) from known UI - * containers such as the minimap, chatbox, control panel, and inventory, using fixed offsets for - * consistent bounding boxes. - */ -public class SubZoneMapper { - - /** - * Maps all major UI components in or derived solely from the resizable mode minimap area. - * - * @param zone The base minimap zone. - * @return A map of component names to {@link Rectangle} bounds. - */ - public static Map mapMinimap(Rectangle zone) { - if (zone != null) { - - Map minimap = new HashMap<>(); - - minimap.put("compass", new Rectangle(zone.x + 40, zone.y + 7, 24, 26)); - minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 60, 20, 13)); - minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 86, 20, 20)); - minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 94, 20, 13)); - minimap.put("runOrb", new Rectangle(zone.x + 39, zone.y + 118, 20, 20)); - minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 126, 20, 13)); - minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 144, 18, 20)); - minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 151, 20, 13)); - minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 5, 154, 155)); - minimap.put("totalXP", new Rectangle(zone.x - 147, zone.y + 4, 104, 21)); - minimap.put("playerPos", new Rectangle(zone.x + 127, zone.y + 80, 4, 4)); - minimap.put("compassSimilarity", new Rectangle(zone.x + 33, zone.y + 2, 37, 37)); - return minimap; - } else { - System.out.println("No minimap found"); - return null; - } - } - - /** - * Maps UI components in or derived solely from the fixed (non-resizable) mode minimap. - * - * @param zone The base minimap zone. - * @return A map of minimap component rectangles. - */ - public static Map mapFixedMinimap(Rectangle zone) { - if (zone != null) { - - Map minimap = new HashMap<>(); - - minimap.put("compass", new Rectangle(zone.x + 31, zone.y + 7, 24, 25)); - minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 55, 20, 13)); - minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 4, 147, 160)); - minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 80, 19, 20)); - minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 89, 20, 13)); - minimap.put("runOrb", new Rectangle(zone.x + 40, zone.y + 112, 19, 20)); - minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 121, 20, 13)); - minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 137, 19, 20)); - minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 146, 20, 13)); - minimap.put("totalXP", new Rectangle(zone.x - 104, zone.y + 6, 104, 21)); - minimap.put("playerPos", new Rectangle(zone.x + 123, zone.y + 81, 4, 4)); - minimap.put("compassSimilarity", new Rectangle(zone.x + 27, zone.y + 2, 34, 35)); - return minimap; - } else { - System.out.println("No fixed minimap found"); - return null; - } - } - - /** - * Maps control panel UI tab buttons and the inventory area. - * - * @param zone The bounding zone for the control panel. - * @return A map of control panel component rectangles. - */ - public static Map mapCtrlPanel(Rectangle zone) { - if (zone != null) { - - Map ctrlPanel = new HashMap<>(); - // Top row - ctrlPanel.put("combatTab", new Rectangle(zone.x + 7, zone.y + 6, 26, 24)); - ctrlPanel.put("skillsTab", new Rectangle(zone.x + 41, zone.y + 2, 26, 28)); - ctrlPanel.put("summaryTab", new Rectangle(zone.x + 74, zone.y + 2, 26, 28)); - ctrlPanel.put("inventoryTab", new Rectangle(zone.x + 107, zone.y + 2, 26, 28)); - ctrlPanel.put("equipmentTab", new Rectangle(zone.x + 140, zone.y + 2, 26, 28)); - ctrlPanel.put("prayerTab", new Rectangle(zone.x + 173, zone.y + 2, 26, 28)); - ctrlPanel.put("spellbookTab", new Rectangle(zone.x + 206, zone.y + 6, 27, 24)); - - // Bottom row - ctrlPanel.put("channelTab", new Rectangle(zone.x + 7, zone.y + 300, 28, 25)); - ctrlPanel.put("friendsTab", new Rectangle(zone.x + 41, zone.y + 300, 26, 30)); - ctrlPanel.put("accountTab", new Rectangle(zone.x + 74, zone.y + 300, 26, 30)); - ctrlPanel.put("logoutTab", new Rectangle(zone.x + 107, zone.y + 300, 26, 30)); - ctrlPanel.put("settingsTab", new Rectangle(zone.x + 140, zone.y + 300, 26, 30)); - ctrlPanel.put("emotesTab", new Rectangle(zone.x + 173, zone.y + 300, 26, 30)); - ctrlPanel.put("musicTab", new Rectangle(zone.x + 206, zone.y + 300, 27, 25)); - - // Main inventory area - ctrlPanel.put("inventoryPanel", new Rectangle(zone.x + 28, zone.y + 35, 183, 261)); - return ctrlPanel; - } else { - System.out.println("No ctrlPanel found"); - return null; - } - } - - /** - * Maps the layout of the chat tabs and main chat display area. - * - * @param zone The bounding box for the chat region. - * @return A map of tab names and their rectangles. - */ - public static Map mapChat(Rectangle zone) { - if (zone != null) { - - Map chatTabs = new HashMap<>(); - - String[] tabNames = {"All", "Game", "Public", "Private", "Channel", "Clan", "Group"}; - - int x = 5; - int y = 143; - for (int i = 0; i < 7; i++) { - chatTabs.put(tabNames[i], new Rectangle(zone.x + x, zone.y + y, 52, 19)); - x += 62; - } - chatTabs.put("Chat", new Rectangle(zone.x + 5, zone.y + 5, 506, 129)); - chatTabs.put("Latest Message", new Rectangle(zone.x + 5, zone.y + 104, 488, 15)); - return chatTabs; - } else { - System.out.println("No Chat found"); - return null; - } - } - - /** - * Maps out the three fields contained in the Grid Info box. These fields are meant to be used - * with OCR to extract player location data. - * - * @param zone The bounding box of the parent zone. (Where the box is). - * @return A list of {@link Rectangle} subzones (Tile, ChunkID, RegionID). - */ - public static Map mapGridInfo(Rectangle zone) { - if (zone != null) { - Map gridInfo = new HashMap<>(); - gridInfo.put("Tile", new Rectangle(zone.x + 39, zone.y, 89, 22)); - gridInfo.put("ChunkID", new Rectangle(zone.x + 74, zone.y + 20, 54, 19)); - gridInfo.put("RegionID", new Rectangle(zone.x + 84, zone.y + 36, 45, 19)); - return gridInfo; - } else { - System.out.println("No Grid found"); - return null; - } - } - - /** - * Generates bounding rectangles for each inventory slot in a 4x7 grid. - * - * @param zone The top-left bounding box of the inventory panel. - * @return A list of inventory slot rectangles. - */ - public static List mapInventory(Rectangle zone) { - if (zone != null) { - - List inventorySlots = new ArrayList<>(); - - int slotWidth = 36; - int slotHeight = 32; - int gapX = 6; - int gapY = 4; - - int y = zone.y + 44; - for (int i = 0; i < 7; i++) { - int x = zone.x + 40; - for (int j = 0; j < 4; j++) { - inventorySlots.add(new Rectangle(x, y, slotWidth, slotHeight)); - x += slotWidth + gapX; - } - y += slotHeight + gapY; - } - return inventorySlots; - } else { - System.out.println("No Inventory found"); - return null; - } - } -} +package com.chromascape.utils.domain.zones; + +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for mapping UI component zones within the client window. + * + *

Provides methods to derive sub-zones (like orbs, tabs, inventory slots) from known UI + * containers such as the minimap, chatbox, control panel, and inventory, using fixed offsets for + * consistent bounding boxes. + */ +public class SubZoneMapper { + + /** + * Maps all major UI components in or derived solely from the resizable mode minimap area. + * + * @param zone The base minimap zone. + * @return A map of component names to {@link Rectangle} bounds. + */ + public static Map mapMinimap(Rectangle zone) { + if (zone != null) { + + Map minimap = new HashMap<>(); + + minimap.put("compass", new Rectangle(zone.x + 40, zone.y + 7, 24, 26)); + minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 60, 20, 13)); + minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 86, 20, 20)); + minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 94, 20, 13)); + minimap.put("runOrb", new Rectangle(zone.x + 39, zone.y + 118, 20, 20)); + minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 126, 20, 13)); + minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 144, 18, 20)); + minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 151, 20, 13)); + minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 5, 154, 155)); + minimap.put("totalXP", new Rectangle(zone.x - 147, zone.y + 4, 104, 21)); + minimap.put("playerPos", new Rectangle(zone.x + 127, zone.y + 80, 4, 4)); + minimap.put("compassSimilarity", new Rectangle(zone.x + 33, zone.y + 2, 37, 37)); + return minimap; + } else { + System.out.println("No minimap found"); + return null; + } + } + + /** + * Maps UI components in or derived solely from the fixed (non-resizable) mode minimap. + * + * @param zone The base minimap zone. + * @return A map of minimap component rectangles. + */ + public static Map mapFixedMinimap(Rectangle zone) { + if (zone != null) { + + Map minimap = new HashMap<>(); + + minimap.put("compass", new Rectangle(zone.x + 31, zone.y + 7, 24, 25)); + minimap.put("hpText", new Rectangle(zone.x + 4, zone.y + 55, 20, 13)); + minimap.put("minimap", new Rectangle(zone.x + 52, zone.y + 4, 147, 160)); + minimap.put("prayerOrb", new Rectangle(zone.x + 30, zone.y + 80, 19, 20)); + minimap.put("prayerText", new Rectangle(zone.x + 4, zone.y + 89, 20, 13)); + minimap.put("runOrb", new Rectangle(zone.x + 40, zone.y + 112, 19, 20)); + minimap.put("runText", new Rectangle(zone.x + 14, zone.y + 121, 20, 13)); + minimap.put("specOrb", new Rectangle(zone.x + 62, zone.y + 137, 19, 20)); + minimap.put("specText", new Rectangle(zone.x + 36, zone.y + 146, 20, 13)); + minimap.put("totalXP", new Rectangle(zone.x - 104, zone.y + 6, 104, 21)); + minimap.put("playerPos", new Rectangle(zone.x + 123, zone.y + 81, 4, 4)); + minimap.put("compassSimilarity", new Rectangle(zone.x + 27, zone.y + 2, 34, 35)); + return minimap; + } else { + System.out.println("No fixed minimap found"); + return null; + } + } + + /** + * Maps control panel UI tab buttons and the inventory area. + * + * @param zone The bounding zone for the control panel. + * @return A map of control panel component rectangles. + */ + public static Map mapCtrlPanel(Rectangle zone) { + if (zone != null) { + + Map ctrlPanel = new HashMap<>(); + // Top row + ctrlPanel.put("combatTab", new Rectangle(zone.x + 7, zone.y + 6, 26, 24)); + ctrlPanel.put("skillsTab", new Rectangle(zone.x + 41, zone.y + 2, 26, 28)); + ctrlPanel.put("summaryTab", new Rectangle(zone.x + 74, zone.y + 2, 26, 28)); + ctrlPanel.put("inventoryTab", new Rectangle(zone.x + 107, zone.y + 2, 26, 28)); + ctrlPanel.put("equipmentTab", new Rectangle(zone.x + 140, zone.y + 2, 26, 28)); + ctrlPanel.put("prayerTab", new Rectangle(zone.x + 173, zone.y + 2, 26, 28)); + ctrlPanel.put("spellbookTab", new Rectangle(zone.x + 206, zone.y + 6, 27, 24)); + + // Bottom row + ctrlPanel.put("channelTab", new Rectangle(zone.x + 7, zone.y + 300, 28, 25)); + ctrlPanel.put("friendsTab", new Rectangle(zone.x + 41, zone.y + 300, 26, 30)); + ctrlPanel.put("accountTab", new Rectangle(zone.x + 74, zone.y + 300, 26, 30)); + ctrlPanel.put("logoutTab", new Rectangle(zone.x + 107, zone.y + 300, 26, 30)); + ctrlPanel.put("settingsTab", new Rectangle(zone.x + 140, zone.y + 300, 26, 30)); + ctrlPanel.put("emotesTab", new Rectangle(zone.x + 173, zone.y + 300, 26, 30)); + ctrlPanel.put("musicTab", new Rectangle(zone.x + 206, zone.y + 300, 27, 25)); + + // Main inventory area + ctrlPanel.put("inventoryPanel", new Rectangle(zone.x + 28, zone.y + 35, 183, 261)); + return ctrlPanel; + } else { + System.out.println("No ctrlPanel found"); + return null; + } + } + + /** + * Maps the layout of the chat tabs and main chat display area. + * + * @param zone The bounding box for the chat region. + * @return A map of tab names and their rectangles. + */ + public static Map mapChat(Rectangle zone) { + if (zone != null) { + + Map chatTabs = new HashMap<>(); + + String[] tabNames = {"All", "Game", "Public", "Private", "Channel", "Clan", "Group"}; + + int x = 5; + int y = 143; + for (int i = 0; i < 7; i++) { + chatTabs.put(tabNames[i], new Rectangle(zone.x + x, zone.y + y, 52, 19)); + x += 62; + } + chatTabs.put("Chat", new Rectangle(zone.x + 5, zone.y + 5, 506, 129)); + chatTabs.put("Latest Message", new Rectangle(zone.x + 5, zone.y + 104, 488, 15)); + return chatTabs; + } else { + System.out.println("No Chat found"); + return null; + } + } + + /** + * Maps out the three fields contained in the Grid Info box. These fields are meant to be used + * with OCR to extract player location data. + * + * @param zone The bounding box of the parent zone. (Where the box is). + * @return A list of {@link Rectangle} subzones (Tile, ChunkID, RegionID). + */ + public static Map mapGridInfo(Rectangle zone) { + if (zone != null) { + Map gridInfo = new HashMap<>(); + gridInfo.put("Tile", new Rectangle(zone.x + 39, zone.y, 89, 22)); + gridInfo.put("ChunkID", new Rectangle(zone.x + 74, zone.y + 20, 54, 19)); + gridInfo.put("RegionID", new Rectangle(zone.x + 84, zone.y + 36, 45, 19)); + return gridInfo; + } else { + System.out.println("No Grid found"); + return null; + } + } + + /** + * Generates bounding rectangles for each inventory slot in a 4x7 grid. + * + * @param zone The top-left bounding box of the inventory panel. + * @return A list of inventory slot rectangles. + */ + public static List mapInventory(Rectangle zone) { + if (zone != null) { + + List inventorySlots = new ArrayList<>(); + + int slotWidth = 36; + int slotHeight = 32; + int gapX = 6; + int gapY = 4; + + int y = zone.y + 44; + for (int i = 0; i < 7; i++) { + int x = zone.x + 40; + for (int j = 0; j < 4; j++) { + inventorySlots.add(new Rectangle(x, y, slotWidth, slotHeight)); + x += slotWidth + gapX; + } + y += slotHeight + gapY; + } + return inventorySlots; + } else { + System.out.println("No Inventory found"); + return null; + } + } +} diff --git a/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java b/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java index 8ea5161..f2c6538 100644 --- a/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java +++ b/src/main/java/com/chromascape/utils/domain/zones/ZoneManager.java @@ -1,216 +1,216 @@ -package com.chromascape.utils.domain.zones; - -import com.chromascape.utils.core.screen.topology.MatchResult; -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.window.ScreenManager; -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.util.List; -import java.util.Map; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Manages the detection and mapping of key UI zones within the RuneLite client window, including - * the minimap, control panel, chat tabs, and inventory slots. - * - *

Supports both fixed and resizable window modes, adjusting the mapped regions accordingly. Uses - * template matching to locate UI elements within the game window for accurate zone detection. - */ -public class ZoneManager { - - /** Flag indicating whether the client window is in fixed (non-resizable) mode. */ - private final boolean isFixed; - - /** Map of minimap subcomponent names to their bounding rectangles. */ - private Map minimap; - - /** Map of control panel tab names to their bounding rectangles. */ - private Map ctrlPanel; - - /** Map of chat tab names to their bounding rectangles. */ - private Map chatTabs; - - /** List of rectangles representing individual inventory slot locations. */ - private List inventorySlots; - - /** Map of Rectangles defining the grid info box's location info. */ - private Map gridInfo; - - /** Rectangle defining the location of the mouse-over text. */ - private Rectangle mouseOver; - - // Cached bounds for getGameView optimization - private Rectangle minimapBounds; - private Rectangle ctrlPanelBounds; - private Rectangle chatBounds; - - /** Default template matching threshold to verify that an image is matched successfully. */ - private static final double THRESHOLD = 0.05; - - /** File paths to template images used for UI element detection. */ - private final String[] zoneTemplates = { - "/images/ui/minimap.png", - "/images/ui/inv.png", - "/images/ui/chat.png", - "/images/ui/minimap_fixed.png" - }; - - private static final Logger logger = LogManager.getLogger(ZoneManager.class.getName()); - - /** Constructs a new ZoneManager configured for either fixed or resizable mode. */ - public ZoneManager() { - this.isFixed = checkIfFixed(); - mapper(); - } - - /** - * Performs template matching to locate UI elements and maps their respective zones. - * - *

Any exceptions during mapping are caught and logged to standard error. - */ - public void mapper() { - // Cache the bounds first - chatBounds = locateUiElement(zoneTemplates[2]); - ctrlPanelBounds = locateUiElement(zoneTemplates[1]); - - chatTabs = SubZoneMapper.mapChat(chatBounds); - ctrlPanel = SubZoneMapper.mapCtrlPanel(ctrlPanelBounds); - inventorySlots = SubZoneMapper.mapInventory(ctrlPanelBounds); - - mouseOver = new Rectangle(0, 0, 407, 26); - - if (isFixed) { - minimapBounds = locateUiElement(zoneTemplates[3]); - minimap = SubZoneMapper.mapFixedMinimap(minimapBounds); - gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(9, 24, 129, 56)); - } else { - minimapBounds = locateUiElement(zoneTemplates[0]); - minimap = SubZoneMapper.mapMinimap(minimapBounds); - gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(5, 20, 129, 56)); - } - } - - /** - * Checks the two minimap images against the client window, compares them based on accuracy. - * - * @return {@code boolean} True if Fixed classic, false if Resizable classic. - */ - private boolean checkIfFixed() { - BufferedImage screen = ScreenManager.captureWindow(); - - MatchResult result = TemplateMatching.match(zoneTemplates[0], screen, THRESHOLD); - double resizableMinVal = result.score(); - - result = TemplateMatching.match(zoneTemplates[3], screen, THRESHOLD); - double fixedMinVal = result.score(); - - return fixedMinVal < resizableMinVal; - } - - /** - * Captures a screenshot of the current game viewport area. - * - *

Captures the full window and masks out UI zones such as minimap, control panel, and chat to - * isolate the game viewport. - * - *

You are intended to use template matching on this image directly for sprite matching You are - * also intended to use this as the image for colour detection. - * - * @return A {@link BufferedImage} representing the game viewport screenshot. - */ - public BufferedImage getGameView() { - BufferedImage gameViewMask = ScreenManager.captureWindow(); - if (ctrlPanelBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, ctrlPanelBounds); - } - if (chatBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, chatBounds); - } - if (minimapBounds != null) { - gameViewMask = MaskZones.maskZones(gameViewMask, minimapBounds); - } - return gameViewMask; - } - - /** - * Locates the bounding rectangle of a UI element by matching a template image within the current - * game window capture. - * - * @param templatePath The file path to the template image to match. - * @return A {@link Rectangle} representing the bounds of the matched UI element. - */ - public Rectangle locateUiElement(String templatePath) { - return TemplateMatching.match(templatePath, ScreenManager.captureWindow(), THRESHOLD).bounds(); - } - - /** - * Returns the map of minimap zones and their bounding rectangles. See {@link SubZoneMapper} for - * keys. - * - * @return A map where keys are minimap component names and values are their rectangles. - */ - public Map getMinimap() { - return minimap; - } - - /** - * Returns the map of control panel tabs and their bounding rectangles. See {@link SubZoneMapper} - * for keys. - * - * @return A map where keys are control panel tab names and values are their rectangles. - */ - public Map getCtrlPanel() { - return ctrlPanel; - } - - /** - * Returns the map of chat tabs and their bounding rectangles. See {@link SubZoneMapper} for keys. - * - * @return A map where keys are chat tab names and values are their rectangles. - */ - public Map getChatTabs() { - return chatTabs; - } - - /** - * Returns the list of rectangles corresponding to each inventory slot. You are intended to use - * {@link ScreenManager} to take screenshots and template match against them. These slots are - * mapped 0-27, left to right - top to bottom. - * - * @return A list of {@link Rectangle} objects representing inventory slot bounds. - */ - public List getInventorySlots() { - return inventorySlots; - } - - /** - * Returns the list of rectangles corresponding to fields in the Grid info area that contain - * location data. Useful for knowing the player's location in the game. Meant to be used by the - * Walker utility. - * - * @return {@link Rectangle} of the Grid info area. - */ - public Map getGridInfo() { - return gridInfo; - } - - /** - * Returns the mouse-over zone, where text will show if the user hovers over an interactable - * object. Intended to be used alongside the OCR engine. - * - * @return {@link Rectangle} of the mouse-over area. - */ - public Rectangle getMouseOver() { - return mouseOver; - } - - /** - * {@link Boolean} defining whether the client is in fixed or resizable mode. - * - * @return True if fixed, false if resizable. - */ - public boolean getIsFixed() { - return isFixed; - } -} +package com.chromascape.utils.domain.zones; + +import com.chromascape.utils.core.screen.topology.MatchResult; +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.window.ScreenManager; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Manages the detection and mapping of key UI zones within the RuneLite client window, including + * the minimap, control panel, chat tabs, and inventory slots. + * + *

Supports both fixed and resizable window modes, adjusting the mapped regions accordingly. Uses + * template matching to locate UI elements within the game window for accurate zone detection. + */ +public class ZoneManager { + + /** Flag indicating whether the client window is in fixed (non-resizable) mode. */ + private final boolean isFixed; + + /** Map of minimap subcomponent names to their bounding rectangles. */ + private Map minimap; + + /** Map of control panel tab names to their bounding rectangles. */ + private Map ctrlPanel; + + /** Map of chat tab names to their bounding rectangles. */ + private Map chatTabs; + + /** List of rectangles representing individual inventory slot locations. */ + private List inventorySlots; + + /** Map of Rectangles defining the grid info box's location info. */ + private Map gridInfo; + + /** Rectangle defining the location of the mouse-over text. */ + private Rectangle mouseOver; + + // Cached bounds for getGameView optimization + private Rectangle minimapBounds; + private Rectangle ctrlPanelBounds; + private Rectangle chatBounds; + + /** Default template matching threshold to verify that an image is matched successfully. */ + private static final double THRESHOLD = 0.05; + + /** File paths to template images used for UI element detection. */ + private final String[] zoneTemplates = { + "/images/ui/minimap.png", + "/images/ui/inv.png", + "/images/ui/chat.png", + "/images/ui/minimap_fixed.png" + }; + + private static final Logger logger = LogManager.getLogger(ZoneManager.class.getName()); + + /** Constructs a new ZoneManager configured for either fixed or resizable mode. */ + public ZoneManager() { + this.isFixed = checkIfFixed(); + mapper(); + } + + /** + * Performs template matching to locate UI elements and maps their respective zones. + * + *

Any exceptions during mapping are caught and logged to standard error. + */ + public void mapper() { + // Cache the bounds first + chatBounds = locateUiElement(zoneTemplates[2]); + ctrlPanelBounds = locateUiElement(zoneTemplates[1]); + + chatTabs = SubZoneMapper.mapChat(chatBounds); + ctrlPanel = SubZoneMapper.mapCtrlPanel(ctrlPanelBounds); + inventorySlots = SubZoneMapper.mapInventory(ctrlPanelBounds); + + mouseOver = new Rectangle(0, 0, 407, 26); + + if (isFixed) { + minimapBounds = locateUiElement(zoneTemplates[3]); + minimap = SubZoneMapper.mapFixedMinimap(minimapBounds); + gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(9, 24, 129, 56)); + } else { + minimapBounds = locateUiElement(zoneTemplates[0]); + minimap = SubZoneMapper.mapMinimap(minimapBounds); + gridInfo = SubZoneMapper.mapGridInfo(new Rectangle(5, 20, 129, 56)); + } + } + + /** + * Checks the two minimap images against the client window, compares them based on accuracy. + * + * @return {@code boolean} True if Fixed classic, false if Resizable classic. + */ + private boolean checkIfFixed() { + BufferedImage screen = ScreenManager.captureWindow(); + + MatchResult result = TemplateMatching.match(zoneTemplates[0], screen, THRESHOLD); + double resizableMinVal = result.score(); + + result = TemplateMatching.match(zoneTemplates[3], screen, THRESHOLD); + double fixedMinVal = result.score(); + + return fixedMinVal < resizableMinVal; + } + + /** + * Captures a screenshot of the current game viewport area. + * + *

Captures the full window and masks out UI zones such as minimap, control panel, and chat to + * isolate the game viewport. + * + *

You are intended to use template matching on this image directly for sprite matching You are + * also intended to use this as the image for colour detection. + * + * @return A {@link BufferedImage} representing the game viewport screenshot. + */ + public BufferedImage getGameView() { + BufferedImage gameViewMask = ScreenManager.captureWindow(); + if (ctrlPanelBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, ctrlPanelBounds); + } + if (chatBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, chatBounds); + } + if (minimapBounds != null) { + gameViewMask = MaskZones.maskZones(gameViewMask, minimapBounds); + } + return gameViewMask; + } + + /** + * Locates the bounding rectangle of a UI element by matching a template image within the current + * game window capture. + * + * @param templatePath The file path to the template image to match. + * @return A {@link Rectangle} representing the bounds of the matched UI element. + */ + public Rectangle locateUiElement(String templatePath) { + return TemplateMatching.match(templatePath, ScreenManager.captureWindow(), THRESHOLD).bounds(); + } + + /** + * Returns the map of minimap zones and their bounding rectangles. See {@link SubZoneMapper} for + * keys. + * + * @return A map where keys are minimap component names and values are their rectangles. + */ + public Map getMinimap() { + return minimap; + } + + /** + * Returns the map of control panel tabs and their bounding rectangles. See {@link SubZoneMapper} + * for keys. + * + * @return A map where keys are control panel tab names and values are their rectangles. + */ + public Map getCtrlPanel() { + return ctrlPanel; + } + + /** + * Returns the map of chat tabs and their bounding rectangles. See {@link SubZoneMapper} for keys. + * + * @return A map where keys are chat tab names and values are their rectangles. + */ + public Map getChatTabs() { + return chatTabs; + } + + /** + * Returns the list of rectangles corresponding to each inventory slot. You are intended to use + * {@link ScreenManager} to take screenshots and template match against them. These slots are + * mapped 0-27, left to right - top to bottom. + * + * @return A list of {@link Rectangle} objects representing inventory slot bounds. + */ + public List getInventorySlots() { + return inventorySlots; + } + + /** + * Returns the list of rectangles corresponding to fields in the Grid info area that contain + * location data. Useful for knowing the player's location in the game. Meant to be used by the + * Walker utility. + * + * @return {@link Rectangle} of the Grid info area. + */ + public Map getGridInfo() { + return gridInfo; + } + + /** + * Returns the mouse-over zone, where text will show if the user hovers over an interactable + * object. Intended to be used alongside the OCR engine. + * + * @return {@link Rectangle} of the mouse-over area. + */ + public Rectangle getMouseOver() { + return mouseOver; + } + + /** + * {@link Boolean} defining whether the client is in fixed or resizable mode. + * + * @return True if fixed, false if resizable. + */ + public boolean getIsFixed() { + return isFixed; + } +} diff --git a/src/main/java/com/chromascape/web/ChromaScapeApplication.java b/src/main/java/com/chromascape/web/ChromaScapeApplication.java index 4a0c642..faeb1c0 100644 --- a/src/main/java/com/chromascape/web/ChromaScapeApplication.java +++ b/src/main/java/com/chromascape/web/ChromaScapeApplication.java @@ -1,73 +1,73 @@ -package com.chromascape.web; - -import com.chromascape.utils.core.screen.viewport.ViewportManager; -import com.chromascape.utils.core.state.StateManager; -import com.chromascape.web.logs.LogWebSocketHandler; -import com.chromascape.web.logs.WebSocketLogAppender; -import com.chromascape.web.state.WebsocketBotStateListener; -import com.chromascape.web.viewport.WebsocketViewport; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * The main entry point for the ChromaScape Spring Boot application. - * - *

This class bootstraps the entire backend system, initializing all Spring components such as - * REST controllers, services, and configuration classes. - */ -@SpringBootApplication -@EnableScheduling -public class ChromaScapeApplication { - - /** - * Launches the ChromaScape application. - * - * @param args command-line arguments passed to the application - */ - public static void main(String[] args) { - // Disable headless mode to allow GUI components (e.g., MouseOverlay) - System.setProperty("java.awt.headless", "false"); - SpringApplication.run(ChromaScapeApplication.class, args); - } - - /** - * Injects the {@link LogWebSocketHandler} bean into the {@link WebSocketLogAppender}. - * - *

This allows the {@link WebSocketLogAppender} to send log messages over WebSocket to - * connected clients. - * - * @param handler the WebSocket handler responsible for sending log messages - */ - @Autowired - public void configureWebSocketHandler(LogWebSocketHandler handler) { - WebSocketLogAppender.setWebSocketHandler(handler); - } - - /** - * Injects the {@link WebsocketViewport} into the {@link ViewportManager}. - * - *

This hooks the static ViewportManager usage in core utils to the Spring WebSocket - * implementation. - * - * @param viewport the WebsocketViewport implementation - */ - @Autowired - public void configureViewport(WebsocketViewport viewport) { - ViewportManager.setInstance(viewport); - } - - /** - * Injects the {@link WebsocketBotStateListener} into the {@link StateManager}. - * - *

This hooks the static StateManager usage in core utils to the Spring WebSocket - * implementation. - * - * @param listener the WebsocketBotStateListener implementation - */ - @Autowired - public void configureStateManager(WebsocketBotStateListener listener) { - StateManager.setListener(listener); - } -} +package com.chromascape.web; + +import com.chromascape.utils.core.screen.viewport.ViewportManager; +import com.chromascape.utils.core.state.StateManager; +import com.chromascape.web.logs.LogWebSocketHandler; +import com.chromascape.web.logs.WebSocketLogAppender; +import com.chromascape.web.state.WebsocketBotStateListener; +import com.chromascape.web.viewport.WebsocketViewport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * The main entry point for the ChromaScape Spring Boot application. + * + *

This class bootstraps the entire backend system, initializing all Spring components such as + * REST controllers, services, and configuration classes. + */ +@SpringBootApplication +@EnableScheduling +public class ChromaScapeApplication { + + /** + * Launches the ChromaScape application. + * + * @param args command-line arguments passed to the application + */ + public static void main(String[] args) { + // Disable headless mode to allow GUI components (e.g., MouseOverlay) + System.setProperty("java.awt.headless", "false"); + SpringApplication.run(ChromaScapeApplication.class, args); + } + + /** + * Injects the {@link LogWebSocketHandler} bean into the {@link WebSocketLogAppender}. + * + *

This allows the {@link WebSocketLogAppender} to send log messages over WebSocket to + * connected clients. + * + * @param handler the WebSocket handler responsible for sending log messages + */ + @Autowired + public void configureWebSocketHandler(LogWebSocketHandler handler) { + WebSocketLogAppender.setWebSocketHandler(handler); + } + + /** + * Injects the {@link WebsocketViewport} into the {@link ViewportManager}. + * + *

This hooks the static ViewportManager usage in core utils to the Spring WebSocket + * implementation. + * + * @param viewport the WebsocketViewport implementation + */ + @Autowired + public void configureViewport(WebsocketViewport viewport) { + ViewportManager.setInstance(viewport); + } + + /** + * Injects the {@link WebsocketBotStateListener} into the {@link StateManager}. + * + *

This hooks the static StateManager usage in core utils to the Spring WebSocket + * implementation. + * + * @param listener the WebsocketBotStateListener implementation + */ + @Autowired + public void configureStateManager(WebsocketBotStateListener listener) { + StateManager.setListener(listener); + } +} diff --git a/src/main/java/com/chromascape/web/ServePages.java b/src/main/java/com/chromascape/web/ServePages.java index 6d7aa5f..0765bc5 100644 --- a/src/main/java/com/chromascape/web/ServePages.java +++ b/src/main/java/com/chromascape/web/ServePages.java @@ -1,33 +1,33 @@ -package com.chromascape.web; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; - -/** - * Controller responsible for serving main web pages. - * - *

Handles requests for the index page and the colour picker page. - */ -@Controller -public class ServePages { - - /** - * Handles GET requests for the root ("/") URL. - * - * @return the logical view name "index" - */ - @GetMapping("/") - public String serveIndexPage() { - return "index"; - } - - /** - * Handles GET requests for the "/colour" URL. - * - * @return the logical view name "colour" - */ - @GetMapping("/colour") - public String serveColourPickerPage() { - return "colour"; - } -} +package com.chromascape.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Controller responsible for serving main web pages. + * + *

Handles requests for the index page and the colour picker page. + */ +@Controller +public class ServePages { + + /** + * Handles GET requests for the root ("/") URL. + * + * @return the logical view name "index" + */ + @GetMapping("/") + public String serveIndexPage() { + return "index"; + } + + /** + * Handles GET requests for the "/colour" URL. + * + * @return the logical view name "colour" + */ + @GetMapping("/colour") + public String serveColourPickerPage() { + return "colour"; + } +} diff --git a/src/main/java/com/chromascape/web/config/StartupConfiguration.java b/src/main/java/com/chromascape/web/config/StartupConfiguration.java index e5c2869..4d7b740 100644 --- a/src/main/java/com/chromascape/web/config/StartupConfiguration.java +++ b/src/main/java/com/chromascape/web/config/StartupConfiguration.java @@ -1,54 +1,54 @@ -package com.chromascape.web.config; - -import com.chromascape.utils.core.runtime.profile.ProfileManager; -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Configuration; - -/** - * Configuration class responsible for initializing infrastructure components at application - * startup. - * - *

This class runs after all Spring beans are initialized and can be used to set up - * application-wide infrastructure, validate dependencies, and perform one-time initialization - * tasks. - */ -@Configuration -public class StartupConfiguration { - - private static Logger logger = LoggerFactory.getLogger(StartupConfiguration.class); - - /** - * Initializes application infrastructure after Spring context is fully loaded. - * - *

This method runs after all Spring beans are initialized and is the ideal place to perform - * startup tasks such as: - Validating system requirements - Initializing native libraries - - * Setting up application-wide resources - Performing health checks - Loading configuration data - */ - @PostConstruct - public void initializeInfrastructure() { - logger.info("CHROMASCAPE STARTUP CONFIGURATION RUNNING"); - logger.info("Initializing infrastructure..."); - - try { - // Examples: - // - Initialize native libraries (KInput) - // - Validate system requirements - // - Set up application-wide resources - // - Load configuration files - // - Perform health checks - - // Load bot profile config file into RuneLite - ProfileManager profileManager = new ProfileManager(); - profileManager.loadBotProfile(); - - logger.info("CHROMASCAPE STARTUP CONFIGURATION COMPLETED"); - - } catch (Exception e) { - logger.error("Error during infrastructure initialization: {}", e.getMessage()); - // You might want to throw a RuntimeException here to prevent app startup - // if critical infrastructure fails to initialize - } - } -} +package com.chromascape.web.config; + +import com.chromascape.utils.core.runtime.profile.ProfileManager; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class responsible for initializing infrastructure components at application + * startup. + * + *

This class runs after all Spring beans are initialized and can be used to set up + * application-wide infrastructure, validate dependencies, and perform one-time initialization + * tasks. + */ +@Configuration +public class StartupConfiguration { + + private static Logger logger = LoggerFactory.getLogger(StartupConfiguration.class); + + /** + * Initializes application infrastructure after Spring context is fully loaded. + * + *

This method runs after all Spring beans are initialized and is the ideal place to perform + * startup tasks such as: - Validating system requirements - Initializing native libraries - + * Setting up application-wide resources - Performing health checks - Loading configuration data + */ + @PostConstruct + public void initializeInfrastructure() { + logger.info("CHROMASCAPE STARTUP CONFIGURATION RUNNING"); + logger.info("Initializing infrastructure..."); + + try { + // Examples: + // - Initialize native libraries (KInput) + // - Validate system requirements + // - Set up application-wide resources + // - Load configuration files + // - Perform health checks + + // Load bot profile config file into RuneLite + ProfileManager profileManager = new ProfileManager(); + profileManager.loadBotProfile(); + + logger.info("CHROMASCAPE STARTUP CONFIGURATION COMPLETED"); + + } catch (Exception e) { + logger.error("Error during infrastructure initialization: {}", e.getMessage()); + // You might want to throw a RuntimeException here to prevent app startup + // if critical infrastructure fails to initialize + } + } +} diff --git a/src/main/java/com/chromascape/web/config/WebSocketConfig.java b/src/main/java/com/chromascape/web/config/WebSocketConfig.java index ea909b9..7568e6b 100644 --- a/src/main/java/com/chromascape/web/config/WebSocketConfig.java +++ b/src/main/java/com/chromascape/web/config/WebSocketConfig.java @@ -1,89 +1,89 @@ -package com.chromascape.web.config; - -import com.chromascape.web.instance.WebSocketStateHandler; -import com.chromascape.web.logs.LogWebSocketHandler; -import com.chromascape.web.state.SemanticWebSocketHandler; -import com.chromascape.web.stats.StatisticsWebSocketHandler; -import com.chromascape.web.viewport.ViewportWebSocketHandler; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -/** - * Spring configuration class for enabling WebSocket support and registering WebSocket handlers. - * - *

This configuration enables WebSocket functionality within the Spring Boot application and - * registers the {@link LogWebSocketHandler} at the endpoint {@code /ws/logs} and {@link - * WebSocketStateHandler} at {@code /ws/state}. All origins are allowed for cross-origin WebSocket - * connections, suitable for local development or trusted environments. - * - * @see LogWebSocketHandler - * @see WebSocketStateHandler - * @see org.springframework.web.socket.config.annotation.WebSocketConfigurer - */ -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - - /** The shared handler for broadcasting log messages. */ - private final LogWebSocketHandler logWebSocketHandler; - - /** The shared handler for broadcasting running state updates. */ - private final WebSocketStateHandler stateWebSocketHandler; - - /** The shared handler for broadcasting viewport updates. */ - private final ViewportWebSocketHandler viewportWebSocketHandler; - - /** The shared handler for broadcasting semantic state. */ - private final SemanticWebSocketHandler semanticWebSocketHandler; - - /** The shared handler for broadcasting bot statistic state. */ - private final StatisticsWebSocketHandler statisticsWebSocketHandler; - - /** - * Constructs the configuration with the injected handlers. - * - * @param logWebSocketHandler handler for log messages - * @param stateWebSocketHandler handler for script running state - * @param viewportWebSocketHandler handler for viewport updates - * @param semanticWebSocketHandler handler for semantic state updates - */ - @Autowired - public WebSocketConfig( - LogWebSocketHandler logWebSocketHandler, - WebSocketStateHandler stateWebSocketHandler, - ViewportWebSocketHandler viewportWebSocketHandler, - SemanticWebSocketHandler semanticWebSocketHandler, - StatisticsWebSocketHandler statisticsWebSocketHandler) { - this.logWebSocketHandler = logWebSocketHandler; - this.stateWebSocketHandler = stateWebSocketHandler; - this.viewportWebSocketHandler = viewportWebSocketHandler; - this.semanticWebSocketHandler = semanticWebSocketHandler; - this.statisticsWebSocketHandler = statisticsWebSocketHandler; - } - - /** - * Registers WebSocket handlers for the application. - * - * @param registry the registry for handler mapping - */ - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - // Log messages - registry.addHandler(logWebSocketHandler, "/ws/logs").setAllowedOrigins("*"); - - // Script running state - registry.addHandler(stateWebSocketHandler, "/ws/state").setAllowedOrigins("*"); - - // Viewport image stream - registry.addHandler(viewportWebSocketHandler, "/ws/viewport").setAllowedOrigins("*"); - - // Semantic state stream - registry.addHandler(semanticWebSocketHandler, "/ws/semantic-state").setAllowedOrigins("*"); - - // Statistics stream - registry.addHandler(statisticsWebSocketHandler, "/ws/stats").setAllowedOrigins("*"); - } -} +package com.chromascape.web.config; + +import com.chromascape.web.instance.WebSocketStateHandler; +import com.chromascape.web.logs.LogWebSocketHandler; +import com.chromascape.web.state.SemanticWebSocketHandler; +import com.chromascape.web.stats.StatisticsWebSocketHandler; +import com.chromascape.web.viewport.ViewportWebSocketHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * Spring configuration class for enabling WebSocket support and registering WebSocket handlers. + * + *

This configuration enables WebSocket functionality within the Spring Boot application and + * registers the {@link LogWebSocketHandler} at the endpoint {@code /ws/logs} and {@link + * WebSocketStateHandler} at {@code /ws/state}. All origins are allowed for cross-origin WebSocket + * connections, suitable for local development or trusted environments. + * + * @see LogWebSocketHandler + * @see WebSocketStateHandler + * @see org.springframework.web.socket.config.annotation.WebSocketConfigurer + */ +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + /** The shared handler for broadcasting log messages. */ + private final LogWebSocketHandler logWebSocketHandler; + + /** The shared handler for broadcasting running state updates. */ + private final WebSocketStateHandler stateWebSocketHandler; + + /** The shared handler for broadcasting viewport updates. */ + private final ViewportWebSocketHandler viewportWebSocketHandler; + + /** The shared handler for broadcasting semantic state. */ + private final SemanticWebSocketHandler semanticWebSocketHandler; + + /** The shared handler for broadcasting bot statistic state. */ + private final StatisticsWebSocketHandler statisticsWebSocketHandler; + + /** + * Constructs the configuration with the injected handlers. + * + * @param logWebSocketHandler handler for log messages + * @param stateWebSocketHandler handler for script running state + * @param viewportWebSocketHandler handler for viewport updates + * @param semanticWebSocketHandler handler for semantic state updates + */ + @Autowired + public WebSocketConfig( + LogWebSocketHandler logWebSocketHandler, + WebSocketStateHandler stateWebSocketHandler, + ViewportWebSocketHandler viewportWebSocketHandler, + SemanticWebSocketHandler semanticWebSocketHandler, + StatisticsWebSocketHandler statisticsWebSocketHandler) { + this.logWebSocketHandler = logWebSocketHandler; + this.stateWebSocketHandler = stateWebSocketHandler; + this.viewportWebSocketHandler = viewportWebSocketHandler; + this.semanticWebSocketHandler = semanticWebSocketHandler; + this.statisticsWebSocketHandler = statisticsWebSocketHandler; + } + + /** + * Registers WebSocket handlers for the application. + * + * @param registry the registry for handler mapping + */ + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + // Log messages + registry.addHandler(logWebSocketHandler, "/ws/logs").setAllowedOrigins("*"); + + // Script running state + registry.addHandler(stateWebSocketHandler, "/ws/state").setAllowedOrigins("*"); + + // Viewport image stream + registry.addHandler(viewportWebSocketHandler, "/ws/viewport").setAllowedOrigins("*"); + + // Semantic state stream + registry.addHandler(semanticWebSocketHandler, "/ws/semantic-state").setAllowedOrigins("*"); + + // Statistics stream + registry.addHandler(statisticsWebSocketHandler, "/ws/stats").setAllowedOrigins("*"); + } +} diff --git a/src/main/java/com/chromascape/web/image/AddColour.java b/src/main/java/com/chromascape/web/image/AddColour.java index 2441405..6590dce 100644 --- a/src/main/java/com/chromascape/web/image/AddColour.java +++ b/src/main/java/com/chromascape/web/image/AddColour.java @@ -1,43 +1,43 @@ -package com.chromascape.web.image; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - -/** - * Utility class for adding new colour data to the application's colour configuration file. - * - *

This class provides a method to append a new {@link ColourData} entry to the - * colours/colours.json file. If the file does not exist, it will be created. The file is - * expected to contain a JSON array of colour data objects. - */ -public class AddColour { - - /** - * Adds a new {@link ColourData} entry to the colours/colours.json file. - * - *

If the file already exists, the new colour is appended to the existing list. If the file - * does not exist, a new file is created with the new colour as the first entry. - * - * @param newColour the {@link ColourData} object to add - * @throws IOException if there is an error reading or writing the file - */ - public void addColour(ColourData newColour) throws IOException { - Path file = Paths.get("colours/colours.json"); - ObjectMapper mapper = new ObjectMapper(); - List colours = new ArrayList<>(); - - if (Files.exists(file)) { - colours = mapper.readValue(file.toFile(), new TypeReference<>() {}); - } - - colours.add(newColour); - - mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), colours); - } -} +package com.chromascape.web.image; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for adding new colour data to the application's colour configuration file. + * + *

This class provides a method to append a new {@link ColourData} entry to the + * colours/colours.json file. If the file does not exist, it will be created. The file is + * expected to contain a JSON array of colour data objects. + */ +public class AddColour { + + /** + * Adds a new {@link ColourData} entry to the colours/colours.json file. + * + *

If the file already exists, the new colour is appended to the existing list. If the file + * does not exist, a new file is created with the new colour as the first entry. + * + * @param newColour the {@link ColourData} object to add + * @throws IOException if there is an error reading or writing the file + */ + public void addColour(ColourData newColour) throws IOException { + Path file = Paths.get("colours/colours.json"); + ObjectMapper mapper = new ObjectMapper(); + List colours = new ArrayList<>(); + + if (Files.exists(file)) { + colours = mapper.readValue(file.toFile(), new TypeReference<>() {}); + } + + colours.add(newColour); + + mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), colours); + } +} diff --git a/src/main/java/com/chromascape/web/image/ColourData.java b/src/main/java/com/chromascape/web/image/ColourData.java index c71ac1d..bef9fb7 100644 --- a/src/main/java/com/chromascape/web/image/ColourData.java +++ b/src/main/java/com/chromascape/web/image/ColourData.java @@ -1,82 +1,82 @@ -package com.chromascape.web.image; - -/** - * Represents a color range in OpenCV's HSV color space with a name identifier and minimum and - * maximum HSV bounds. - * - *

The minimum and maximum arrays define the inclusive range of HSV values that this color - * covers. Each array is expected to have exactly three elements representing Hue (0-180), - * Saturation (0-255), and Value (0-255) respectively. Note: there is a last value of 0 to conform - * to JavaCV's scalar. - */ -public class ColourData { - - /** The name identifying this HSV color range. */ - private String name; - - /** - * The minimum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue - * ranges 0-179, Saturation and Value range 0-255. - */ - private int[] min; - - /** - * The maximum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue - * ranges 0-179, Saturation and Value range 0-255. - */ - private int[] max; - - /** - * Returns the name of this HSV color range. - * - * @return the color name. - */ - public String getName() { - return name; - } - - /** - * Returns the minimum HSV values defining this color range. - * - * @return an int array of length 4: [Hue, Saturation, Value, 0]. - */ - public int[] getMin() { - return min; - } - - /** - * Returns the maximum HSV values defining this color range. - * - * @return an int array of length 4: [Hue, Saturation, Value, 0]. - */ - public int[] getMax() { - return max; - } - - /** - * Sets the name of this HSV color range. - * - * @param name the color name to set. - */ - public void setName(String name) { - this.name = name; - } - - /** - * Sets the minimum HSV bounds for this color range. - * - * @param min an int array of length 4 representing [Hue, Saturation, Value, 0]. - */ - public void setMin(int[] min) { - this.min = min; - } - - /** - * Sets the maximum HSV bounds for this color range. - * - * @param max an int array of length 4 representing [Hue, Saturation, Value, 0]. - */ - public void setMax(int[] max) { - this.max = max; - } -} +package com.chromascape.web.image; + +/** + * Represents a color range in OpenCV's HSV color space with a name identifier and minimum and + * maximum HSV bounds. + * + *

The minimum and maximum arrays define the inclusive range of HSV values that this color + * covers. Each array is expected to have exactly three elements representing Hue (0-180), + * Saturation (0-255), and Value (0-255) respectively. Note: there is a last value of 0 to conform + * to JavaCV's scalar. + */ +public class ColourData { + + /** The name identifying this HSV color range. */ + private String name; + + /** + * The minimum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue + * ranges 0-179, Saturation and Value range 0-255. + */ + private int[] min; + + /** + * The maximum HSV bounds for this color range. Expected format: [Hue, Saturation, Value, 0]. Hue + * ranges 0-179, Saturation and Value range 0-255. + */ + private int[] max; + + /** + * Returns the name of this HSV color range. + * + * @return the color name. + */ + public String getName() { + return name; + } + + /** + * Returns the minimum HSV values defining this color range. + * + * @return an int array of length 4: [Hue, Saturation, Value, 0]. + */ + public int[] getMin() { + return min; + } + + /** + * Returns the maximum HSV values defining this color range. + * + * @return an int array of length 4: [Hue, Saturation, Value, 0]. + */ + public int[] getMax() { + return max; + } + + /** + * Sets the name of this HSV color range. + * + * @param name the color name to set. + */ + public void setName(String name) { + this.name = name; + } + + /** + * Sets the minimum HSV bounds for this color range. + * + * @param min an int array of length 4 representing [Hue, Saturation, Value, 0]. + */ + public void setMin(int[] min) { + this.min = min; + } + + /** + * Sets the maximum HSV bounds for this color range. + * + * @param max an int array of length 4 representing [Hue, Saturation, Value, 0]. + */ + public void setMax(int[] max) { + this.max = max; + } +} diff --git a/src/main/java/com/chromascape/web/image/ImageController.java b/src/main/java/com/chromascape/web/image/ImageController.java index 098afc2..54431a8 100644 --- a/src/main/java/com/chromascape/web/image/ImageController.java +++ b/src/main/java/com/chromascape/web/image/ImageController.java @@ -1,71 +1,71 @@ -package com.chromascape.web.image; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import org.apache.commons.io.IOUtils; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller to serve image files from the server. - * - *

Provides endpoints to retrieve the original and modified images as PNG byte arrays. If the - * requested image is not found in the output directory, a default fallback image from resources is - * returned. - */ -@RestController -@RequestMapping("/api") -public class ImageController { - - private final ModifyImage modifyImage; - - /** - * Constructor for the ImageController class. - * - * @param modifyImage The dependency injected Spring service class that does image operations for - * the frontend. - */ - public ImageController(ModifyImage modifyImage) { - this.modifyImage = modifyImage; - } - - /** - * Returns the original image as a PNG byte array. - * - *

Attempts to read the file "output/original.png" from disk. If the file does not exist, falls - * back to "resources/images/defaultImage/original.png" on the classpath. - * - * @return byte array representing the original PNG image. - * @throws IOException if the file cannot be read. - */ - @GetMapping(value = "/originalImage", produces = MediaType.IMAGE_PNG_VALUE) - public @ResponseBody byte[] originalImage() throws IOException { - File outputFile = new File("output/original.png"); - try (InputStream in = - outputFile.exists() - ? new FileInputStream(outputFile) - : getClass().getResourceAsStream("/images/defaultImage/original.png")) { - assert in != null; - return IOUtils.toByteArray(in); - } - } - - /** - * Returns the modified image as a PNG byte array. - * - *

Retrieved from the in-memory cache of the {@link ModifyImage} service. If the current - * screenshot is newer than the cache, the original image is returned instead. - * - * @return byte array representing the modified or original PNG image. - * @throws IOException if the file(s) cannot be read. - */ - @GetMapping(value = "/modifiedImage", produces = MediaType.IMAGE_PNG_VALUE) - public @ResponseBody byte[] modifiedImage() throws IOException { - return modifyImage.getModifiedImageBytes(); - } -} +package com.chromascape.web.image; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import org.apache.commons.io.IOUtils; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller to serve image files from the server. + * + *

Provides endpoints to retrieve the original and modified images as PNG byte arrays. If the + * requested image is not found in the output directory, a default fallback image from resources is + * returned. + */ +@RestController +@RequestMapping("/api") +public class ImageController { + + private final ModifyImage modifyImage; + + /** + * Constructor for the ImageController class. + * + * @param modifyImage The dependency injected Spring service class that does image operations for + * the frontend. + */ + public ImageController(ModifyImage modifyImage) { + this.modifyImage = modifyImage; + } + + /** + * Returns the original image as a PNG byte array. + * + *

Attempts to read the file "output/original.png" from disk. If the file does not exist, falls + * back to "resources/images/defaultImage/original.png" on the classpath. + * + * @return byte array representing the original PNG image. + * @throws IOException if the file cannot be read. + */ + @GetMapping(value = "/originalImage", produces = MediaType.IMAGE_PNG_VALUE) + public @ResponseBody byte[] originalImage() throws IOException { + File outputFile = new File("output/original.png"); + try (InputStream in = + outputFile.exists() + ? new FileInputStream(outputFile) + : getClass().getResourceAsStream("/images/defaultImage/original.png")) { + assert in != null; + return IOUtils.toByteArray(in); + } + } + + /** + * Returns the modified image as a PNG byte array. + * + *

Retrieved from the in-memory cache of the {@link ModifyImage} service. If the current + * screenshot is newer than the cache, the original image is returned instead. + * + * @return byte array representing the modified or original PNG image. + * @throws IOException if the file(s) cannot be read. + */ + @GetMapping(value = "/modifiedImage", produces = MediaType.IMAGE_PNG_VALUE) + public @ResponseBody byte[] modifiedImage() throws IOException { + return modifyImage.getModifiedImageBytes(); + } +} diff --git a/src/main/java/com/chromascape/web/image/MaskImage.java b/src/main/java/com/chromascape/web/image/MaskImage.java index 46c6dd2..af614a8 100644 --- a/src/main/java/com/chromascape/web/image/MaskImage.java +++ b/src/main/java/com/chromascape/web/image/MaskImage.java @@ -1,65 +1,65 @@ -package com.chromascape.web.image; - -import static org.bytedeco.opencv.global.opencv_core.CV_8U; -import static org.bytedeco.opencv.global.opencv_core.bitwise_and; -import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; - -import org.bytedeco.opencv.global.opencv_imgproc; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.Scalar; - -/** - * Utility class for applying a mask to an image using OpenCV. - * - *

The mask should be a single-channel 8-bit Mat where white pixels (255) indicate areas to keep - * from the original image and black pixels (0) indicate areas to mask out (set to black). - */ -public class MaskImage { - - /** - * Applies a mask to the original image. - * - *

This method takes an original color image and a mask image and returns a new image where - * pixels outside the white areas of the mask are set to black (masked out). The mask is expected - * to be a single-channel 8-bit Mat where white (255) pixels indicate the region to keep. - * - *

If the mask is not already a single-channel 8-bit Mat, it will be converted to grayscale and - * 8-bit internally. - * - * @param original the original image Mat (e.g. 3- or 4-channel color image). - * @param mask the mask image Mat; should be same size as original. - * @return a new Mat containing the original image masked by the mask. - * @throws IllegalArgumentException if original or mask is null, or if they have different sizes. - */ - public static Mat applyMaskToImage(Mat original, Mat mask) { - if (original == null || mask == null) { - throw new IllegalArgumentException("Original image and mask must not be null"); - } - if (original.rows() != mask.rows() || original.cols() != mask.cols()) { - throw new IllegalArgumentException("Original and mask must be the same size"); - } - - // Ensure mask is single channel 8-bit - Mat maskGray = new Mat(); - if (mask.channels() != 1 || mask.depth() != CV_8U) { - // Convert mask to grayscale and 8-bit if needed - cvtColor(mask, maskGray, opencv_imgproc.COLOR_BGR2GRAY); - maskGray.convertTo(maskGray, CV_8U); - } else { - maskGray = mask.clone(); - } - - // Create output Mat same size and type as original - Mat output = - new Mat( - original.size(), - original.type(), - new Scalar(0, 0, 0, 0)); // black transparent background - - // Copy original pixels where mask is white - // This can be done using bitwise_and with mask applied to each channel - bitwise_and(original, original, output, maskGray); - - return output; - } -} +package com.chromascape.web.image; + +import static org.bytedeco.opencv.global.opencv_core.CV_8U; +import static org.bytedeco.opencv.global.opencv_core.bitwise_and; +import static org.bytedeco.opencv.global.opencv_imgproc.cvtColor; + +import org.bytedeco.opencv.global.opencv_imgproc; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Scalar; + +/** + * Utility class for applying a mask to an image using OpenCV. + * + *

The mask should be a single-channel 8-bit Mat where white pixels (255) indicate areas to keep + * from the original image and black pixels (0) indicate areas to mask out (set to black). + */ +public class MaskImage { + + /** + * Applies a mask to the original image. + * + *

This method takes an original color image and a mask image and returns a new image where + * pixels outside the white areas of the mask are set to black (masked out). The mask is expected + * to be a single-channel 8-bit Mat where white (255) pixels indicate the region to keep. + * + *

If the mask is not already a single-channel 8-bit Mat, it will be converted to grayscale and + * 8-bit internally. + * + * @param original the original image Mat (e.g. 3- or 4-channel color image). + * @param mask the mask image Mat; should be same size as original. + * @return a new Mat containing the original image masked by the mask. + * @throws IllegalArgumentException if original or mask is null, or if they have different sizes. + */ + public static Mat applyMaskToImage(Mat original, Mat mask) { + if (original == null || mask == null) { + throw new IllegalArgumentException("Original image and mask must not be null"); + } + if (original.rows() != mask.rows() || original.cols() != mask.cols()) { + throw new IllegalArgumentException("Original and mask must be the same size"); + } + + // Ensure mask is single channel 8-bit + Mat maskGray = new Mat(); + if (mask.channels() != 1 || mask.depth() != CV_8U) { + // Convert mask to grayscale and 8-bit if needed + cvtColor(mask, maskGray, opencv_imgproc.COLOR_BGR2GRAY); + maskGray.convertTo(maskGray, CV_8U); + } else { + maskGray = mask.clone(); + } + + // Create output Mat same size and type as original + Mat output = + new Mat( + original.size(), + original.type(), + new Scalar(0, 0, 0, 0)); // black transparent background + + // Copy original pixels where mask is white + // This can be done using bitwise_and with mask applied to each channel + bitwise_and(original, original, output, maskGray); + + return output; + } +} diff --git a/src/main/java/com/chromascape/web/image/ModifyImage.java b/src/main/java/com/chromascape/web/image/ModifyImage.java index 0ce0b5f..4350d46 100644 --- a/src/main/java/com/chromascape/web/image/ModifyImage.java +++ b/src/main/java/com/chromascape/web/image/ModifyImage.java @@ -1,141 +1,141 @@ -package com.chromascape.web.image; - -import com.chromascape.utils.core.screen.topology.ColourContours; -import com.chromascape.web.slider.CurrentSliderState; -import java.io.File; -import java.io.IOException; -import org.bytedeco.javacpp.BytePointer; -import org.bytedeco.opencv.global.opencv_imgcodecs; -import org.bytedeco.opencv.opencv_core.Mat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -/** - * Service responsible for applying modifications to an image based on slider inputs. - * - *

This service loads an original image from the filesystem, applies colour extraction based on - * the current slider state, and caches the result in memory. It eliminates the need for disk writes - * to improve performance. - */ -@Service -public class ModifyImage { - - private static final Logger logger = LoggerFactory.getLogger(ModifyImage.class); - private static final String ORIGINAL_IMAGE_PATH = "output/original.png"; - - /** Cached bytes of the latest processing result. */ - private byte[] cachedModifiedBytes; - - /** Timestamp of the last successful processing. */ - private long lastProcessedTime = 0; - - /** Timestamp of the original file when it was last loaded or checked. */ - private long lastOriginalModTime = 0; - - private Mat cachedOriginalMat; - - /** - * Applies modifications to the original image based on the given slider state. - * - *

The method performs the following steps: - * - *

    - *
  • Checks if the original image has been modified on disk since the last load. - *
  • Loads the image into memory cache (as OpenCV Mat) if necessary. - *
  • Extracts colour contours using the slider's colour object. - *
  • Applies the extracted mask to the original image. - *
  • Encodes the result to PNG bytes in memory using OpenCV {@code imencode} and updates the - * cache. - *
- * - * @param sliderState the current state of the sliders controlling colour extraction. - * @throws IOException if the original image file is not found or cannot be read. - */ - public void applySliderChanges(CurrentSliderState sliderState) throws IOException { - // Load original image from file system with caching - File originalFile = new File(ORIGINAL_IMAGE_PATH); - if (!originalFile.exists()) { - logger.error("Original image file not found at: {}", ORIGINAL_IMAGE_PATH); - throw new IOException("Original image file not found at: " + ORIGINAL_IMAGE_PATH); - } - - // Refresh cache if file timestamp changed or cache is empty - if (cachedOriginalMat == null || originalFile.lastModified() > lastOriginalModTime) { - logger.info( - "Reloading original image from disk. File time: {}, Last known: {}", - originalFile.lastModified(), - lastOriginalModTime); - - if (cachedOriginalMat != null) { - cachedOriginalMat.release(); - } - - // Use imread for direct Mat loading - cachedOriginalMat = opencv_imgcodecs.imread(originalFile.getAbsolutePath()); - if (cachedOriginalMat == null || cachedOriginalMat.empty()) { - throw new IOException("Failed to read original image from: " + ORIGINAL_IMAGE_PATH); - } - lastOriginalModTime = originalFile.lastModified(); - } - - // Apply colour extraction and encode - try (Mat modifiedMat = - ColourContours.extractColours(cachedOriginalMat, sliderState.getColourObj())) { - Mat result = MaskImage.applyMaskToImage(cachedOriginalMat, modifiedMat); - - // Fast encoding to PNG buffer using OpenCV (skips BufferedImage conversion) - try (BytePointer ext = new BytePointer(".png"); - BytePointer buffer = new BytePointer()) { - - opencv_imgcodecs.imencode(ext, result, buffer); - - // Transfer bytes from native buffer to Java array - long size = buffer.limit(); - byte[] pngBytes = new byte[(int) size]; - buffer.get(pngBytes); - - this.cachedModifiedBytes = pngBytes; - this.lastProcessedTime = System.currentTimeMillis(); - } - - // Clean up local Mats - result.release(); - } catch (Exception e) { - logger.error("Error processing image in applySliderChanges", e); - throw new IOException("Error processing image in applySliderChanges", e); - } - } - - /** - * Retrieves the modified image bytes. - * - *

If the original image on disk is newer than our last processed result (e.g., a new - * screenshot was taken), this returns the raw bytes of the original image instead. This ensures - * users don't see stale processing on a new screenshot. - * - * @return byte array containing the PNG image data (either modified or original). - * @throws IOException if reading the original file fails. - */ - public byte[] getModifiedImageBytes() throws IOException { - File originalFile = new File(ORIGINAL_IMAGE_PATH); - - // If we have no cached result, or the file on disk is newer than our last - // process... - boolean isStale = (originalFile.exists() && originalFile.lastModified() > lastProcessedTime); - - if (cachedModifiedBytes == null || isStale) { - logger.info( - "Serving original image (fallback). Cache empty? {}, Original > Processed? {} (Orig: {}, " - + "Proc: {})", - cachedModifiedBytes == null, - isStale, - originalFile.lastModified(), - lastProcessedTime); - // Serve the original image (fallback logic) - return org.apache.commons.io.FileUtils.readFileToByteArray(originalFile); - } - - return cachedModifiedBytes; - } -} +package com.chromascape.web.image; + +import com.chromascape.utils.core.screen.topology.ColourContours; +import com.chromascape.web.slider.CurrentSliderState; +import java.io.File; +import java.io.IOException; +import org.bytedeco.javacpp.BytePointer; +import org.bytedeco.opencv.global.opencv_imgcodecs; +import org.bytedeco.opencv.opencv_core.Mat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service responsible for applying modifications to an image based on slider inputs. + * + *

This service loads an original image from the filesystem, applies colour extraction based on + * the current slider state, and caches the result in memory. It eliminates the need for disk writes + * to improve performance. + */ +@Service +public class ModifyImage { + + private static final Logger logger = LoggerFactory.getLogger(ModifyImage.class); + private static final String ORIGINAL_IMAGE_PATH = "output/original.png"; + + /** Cached bytes of the latest processing result. */ + private byte[] cachedModifiedBytes; + + /** Timestamp of the last successful processing. */ + private long lastProcessedTime = 0; + + /** Timestamp of the original file when it was last loaded or checked. */ + private long lastOriginalModTime = 0; + + private Mat cachedOriginalMat; + + /** + * Applies modifications to the original image based on the given slider state. + * + *

The method performs the following steps: + * + *

    + *
  • Checks if the original image has been modified on disk since the last load. + *
  • Loads the image into memory cache (as OpenCV Mat) if necessary. + *
  • Extracts colour contours using the slider's colour object. + *
  • Applies the extracted mask to the original image. + *
  • Encodes the result to PNG bytes in memory using OpenCV {@code imencode} and updates the + * cache. + *
+ * + * @param sliderState the current state of the sliders controlling colour extraction. + * @throws IOException if the original image file is not found or cannot be read. + */ + public void applySliderChanges(CurrentSliderState sliderState) throws IOException { + // Load original image from file system with caching + File originalFile = new File(ORIGINAL_IMAGE_PATH); + if (!originalFile.exists()) { + logger.error("Original image file not found at: {}", ORIGINAL_IMAGE_PATH); + throw new IOException("Original image file not found at: " + ORIGINAL_IMAGE_PATH); + } + + // Refresh cache if file timestamp changed or cache is empty + if (cachedOriginalMat == null || originalFile.lastModified() > lastOriginalModTime) { + logger.info( + "Reloading original image from disk. File time: {}, Last known: {}", + originalFile.lastModified(), + lastOriginalModTime); + + if (cachedOriginalMat != null) { + cachedOriginalMat.release(); + } + + // Use imread for direct Mat loading + cachedOriginalMat = opencv_imgcodecs.imread(originalFile.getAbsolutePath()); + if (cachedOriginalMat == null || cachedOriginalMat.empty()) { + throw new IOException("Failed to read original image from: " + ORIGINAL_IMAGE_PATH); + } + lastOriginalModTime = originalFile.lastModified(); + } + + // Apply colour extraction and encode + try (Mat modifiedMat = + ColourContours.extractColours(cachedOriginalMat, sliderState.getColourObj())) { + Mat result = MaskImage.applyMaskToImage(cachedOriginalMat, modifiedMat); + + // Fast encoding to PNG buffer using OpenCV (skips BufferedImage conversion) + try (BytePointer ext = new BytePointer(".png"); + BytePointer buffer = new BytePointer()) { + + opencv_imgcodecs.imencode(ext, result, buffer); + + // Transfer bytes from native buffer to Java array + long size = buffer.limit(); + byte[] pngBytes = new byte[(int) size]; + buffer.get(pngBytes); + + this.cachedModifiedBytes = pngBytes; + this.lastProcessedTime = System.currentTimeMillis(); + } + + // Clean up local Mats + result.release(); + } catch (Exception e) { + logger.error("Error processing image in applySliderChanges", e); + throw new IOException("Error processing image in applySliderChanges", e); + } + } + + /** + * Retrieves the modified image bytes. + * + *

If the original image on disk is newer than our last processed result (e.g., a new + * screenshot was taken), this returns the raw bytes of the original image instead. This ensures + * users don't see stale processing on a new screenshot. + * + * @return byte array containing the PNG image data (either modified or original). + * @throws IOException if reading the original file fails. + */ + public byte[] getModifiedImageBytes() throws IOException { + File originalFile = new File(ORIGINAL_IMAGE_PATH); + + // If we have no cached result, or the file on disk is newer than our last + // process... + boolean isStale = (originalFile.exists() && originalFile.lastModified() > lastProcessedTime); + + if (cachedModifiedBytes == null || isStale) { + logger.info( + "Serving original image (fallback). Cache empty? {}, Original > Processed? {} (Orig: {}, " + + "Proc: {})", + cachedModifiedBytes == null, + isStale, + originalFile.lastModified(), + lastProcessedTime); + // Serve the original image (fallback logic) + return org.apache.commons.io.FileUtils.readFileToByteArray(originalFile); + } + + return cachedModifiedBytes; + } +} diff --git a/src/main/java/com/chromascape/web/image/SubmitColour.java b/src/main/java/com/chromascape/web/image/SubmitColour.java index d3d1394..126a2e1 100644 --- a/src/main/java/com/chromascape/web/image/SubmitColour.java +++ b/src/main/java/com/chromascape/web/image/SubmitColour.java @@ -1,72 +1,72 @@ -package com.chromascape.web.image; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import com.chromascape.web.slider.CurrentSliderState; -import java.io.IOException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for handling colour submission requests. - * - *

This controller receives colour names via POST requests and saves the corresponding HSV colour - * ranges from the current slider state into persistent storage via AddColour. - */ -@RestController -@RequestMapping("/api") -public class SubmitColour { - - private final CurrentSliderState currentSliderState; - - /** - * Constructs a SubmitColour controller with the provided current slider state. - * - * @param currentSliderState the object holding the current HSV colour range slider values - */ - public SubmitColour(CurrentSliderState currentSliderState) { - this.currentSliderState = currentSliderState; - } - - /** - * Handles POST requests to submit a new colour. - * - *

Extracts the current HSV minimum and maximum values from the slider state, creates a - * ColourData object with the submitted name and HSV ranges, and saves it using the AddColour - * service. - * - * @param name the name of the colour to be added, sent as the raw request body - * @return a ResponseEntity with HTTP 200 OK status if successful - * @throws IOException if adding the colour fails due to IO errors - */ - @PostMapping("/submitColour") - public ResponseEntity submitColour(@RequestBody String name) throws IOException { - ColourObj colourObj = currentSliderState.getColourObj(); - - ColourData colour = new ColourData(); - colour.setName(name); - - colour.setMin( - new int[] { - (int) colourObj.hsvMin().get(0), - (int) colourObj.hsvMin().get(1), - (int) colourObj.hsvMin().get(2), - (int) colourObj.hsvMin().get(3) - }); - - colour.setMax( - new int[] { - (int) colourObj.hsvMax().get(0), - (int) colourObj.hsvMax().get(1), - (int) colourObj.hsvMax().get(2), - (int) colourObj.hsvMax().get(3) - }); - - AddColour addColour = new AddColour(); - addColour.addColour(colour); - - return ResponseEntity.ok().build(); - } -} +package com.chromascape.web.image; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import com.chromascape.web.slider.CurrentSliderState; +import java.io.IOException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for handling colour submission requests. + * + *

This controller receives colour names via POST requests and saves the corresponding HSV colour + * ranges from the current slider state into persistent storage via AddColour. + */ +@RestController +@RequestMapping("/api") +public class SubmitColour { + + private final CurrentSliderState currentSliderState; + + /** + * Constructs a SubmitColour controller with the provided current slider state. + * + * @param currentSliderState the object holding the current HSV colour range slider values + */ + public SubmitColour(CurrentSliderState currentSliderState) { + this.currentSliderState = currentSliderState; + } + + /** + * Handles POST requests to submit a new colour. + * + *

Extracts the current HSV minimum and maximum values from the slider state, creates a + * ColourData object with the submitted name and HSV ranges, and saves it using the AddColour + * service. + * + * @param name the name of the colour to be added, sent as the raw request body + * @return a ResponseEntity with HTTP 200 OK status if successful + * @throws IOException if adding the colour fails due to IO errors + */ + @PostMapping("/submitColour") + public ResponseEntity submitColour(@RequestBody String name) throws IOException { + ColourObj colourObj = currentSliderState.getColourObj(); + + ColourData colour = new ColourData(); + colour.setName(name); + + colour.setMin( + new int[] { + (int) colourObj.hsvMin().get(0), + (int) colourObj.hsvMin().get(1), + (int) colourObj.hsvMin().get(2), + (int) colourObj.hsvMin().get(3) + }); + + colour.setMax( + new int[] { + (int) colourObj.hsvMax().get(0), + (int) colourObj.hsvMax().get(1), + (int) colourObj.hsvMax().get(2), + (int) colourObj.hsvMax().get(3) + }); + + AddColour addColour = new AddColour(); + addColour.addColour(colour); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chromascape/web/instance/RunConfig.java b/src/main/java/com/chromascape/web/instance/RunConfig.java index 31624ef..f49342d 100644 --- a/src/main/java/com/chromascape/web/instance/RunConfig.java +++ b/src/main/java/com/chromascape/web/instance/RunConfig.java @@ -1,27 +1,27 @@ -package com.chromascape.web.instance; - -/** - * Represents the configuration settings for running a script instance. - * - *

Contains the duration the script should run, the script identifier, and a flag indicating - * whether the client UI is fixed or resizable. - */ -public record RunConfig(String script) { - - /** - * Constructs a new RunConfig with the specified duration, script, and fixed flag. - * - * @param script the identifier or name of the script to run - */ - public RunConfig {} - - /** - * Returns the identifier or name of the script to run. - * - * @return the script name or ID - */ - @Override - public String script() { - return script; - } -} +package com.chromascape.web.instance; + +/** + * Represents the configuration settings for running a script instance. + * + *

Contains the duration the script should run, the script identifier, and a flag indicating + * whether the client UI is fixed or resizable. + */ +public record RunConfig(String script) { + + /** + * Constructs a new RunConfig with the specified duration, script, and fixed flag. + * + * @param script the identifier or name of the script to run + */ + public RunConfig {} + + /** + * Returns the identifier or name of the script to run. + * + * @return the script name or ID + */ + @Override + public String script() { + return script; + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptControl.java b/src/main/java/com/chromascape/web/instance/ScriptControl.java index da559c8..9322019 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptControl.java +++ b/src/main/java/com/chromascape/web/instance/ScriptControl.java @@ -1,95 +1,95 @@ -package com.chromascape.web.instance; - -import java.lang.reflect.InvocationTargetException; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller that handles starting and stopping of scripts, and querying their running state. - * - *

Provides endpoints to submit a run configuration, start a script instance, stop the currently - * running script, and check if a script is running. All responses include appropriate HTTP status - * codes and messages. - */ -@RestController -@RequestMapping("/api") -public class ScriptControl { - - private static final Logger logger = LogManager.getLogger(ScriptControl.class.getName()); - private final WebSocketStateHandler stateHandler; - - /** - * Constructs the script controller with a state handler. - * - * @param webSocketStateHandler state handler used to send state to the client via web-socket. - */ - public ScriptControl(WebSocketStateHandler webSocketStateHandler) { - this.stateHandler = webSocketStateHandler; - } - - /** - * Starts a script based on the provided run configuration. - * - *

Validates the input configuration fields: script name, duration, and window style. If valid, - * it attempts to instantiate and start the script. Logs relevant information and returns HTTP - * status codes accordingly. - * - * @param config the RunConfig object containing script parameters (JSON in request body) - * @return ResponseEntity with status and message indicating success or error details - */ - @PostMapping(path = "/runConfig", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getRunConfig(@RequestBody RunConfig config) { - try { - // Validation checks - if (config.script() == null || config.script().isEmpty()) { - logger.error("No script is selected"); - return ResponseEntity.badRequest().body("Script must be specified."); - } - - logger.info("Config valid: attempting to run script"); - - // Instantiate and start the script instance - ScriptInstance instance = new ScriptInstance(config, stateHandler); - ScriptInstanceManager.getInstance().setInstance(instance); - instance.start(); - - return ResponseEntity.ok("Script started successfully."); - - } catch (ClassNotFoundException e) { - logger.error("Script class not found: {}", e.getMessage()); - return ResponseEntity.badRequest().body("Script class not found."); - } catch (NoSuchMethodException e) { - logger.error("Script constructor not found: {}", e.getMessage()); - return ResponseEntity.badRequest().body("Script constructor not valid."); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - logger.error("Failed to instantiate script: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to start script."); - } catch (Exception e) { - logger.error("Unexpected error: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Unexpected error: " + e.getMessage()); - } - } - - /** - * Stops the currently running script instance. - * - *

Logs the stop request and interrupts the running script thread. - * - * @return ResponseEntity with HTTP 200 OK status after attempting to stop the script - */ - @PostMapping(path = "/stop", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity stopScript() { - logger.info("Received stop request"); - ScriptInstanceManager.getInstance().getInstanceRef().stop(); - return ResponseEntity.ok().build(); - } -} +package com.chromascape.web.instance; + +import java.lang.reflect.InvocationTargetException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller that handles starting and stopping of scripts, and querying their running state. + * + *

Provides endpoints to submit a run configuration, start a script instance, stop the currently + * running script, and check if a script is running. All responses include appropriate HTTP status + * codes and messages. + */ +@RestController +@RequestMapping("/api") +public class ScriptControl { + + private static final Logger logger = LogManager.getLogger(ScriptControl.class.getName()); + private final WebSocketStateHandler stateHandler; + + /** + * Constructs the script controller with a state handler. + * + * @param webSocketStateHandler state handler used to send state to the client via web-socket. + */ + public ScriptControl(WebSocketStateHandler webSocketStateHandler) { + this.stateHandler = webSocketStateHandler; + } + + /** + * Starts a script based on the provided run configuration. + * + *

Validates the input configuration fields: script name, duration, and window style. If valid, + * it attempts to instantiate and start the script. Logs relevant information and returns HTTP + * status codes accordingly. + * + * @param config the RunConfig object containing script parameters (JSON in request body) + * @return ResponseEntity with status and message indicating success or error details + */ + @PostMapping(path = "/runConfig", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getRunConfig(@RequestBody RunConfig config) { + try { + // Validation checks + if (config.script() == null || config.script().isEmpty()) { + logger.error("No script is selected"); + return ResponseEntity.badRequest().body("Script must be specified."); + } + + logger.info("Config valid: attempting to run script"); + + // Instantiate and start the script instance + ScriptInstance instance = new ScriptInstance(config, stateHandler); + ScriptInstanceManager.getInstance().setInstance(instance); + instance.start(); + + return ResponseEntity.ok("Script started successfully."); + + } catch (ClassNotFoundException e) { + logger.error("Script class not found: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Script class not found."); + } catch (NoSuchMethodException e) { + logger.error("Script constructor not found: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Script constructor not valid."); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + logger.error("Failed to instantiate script: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to start script."); + } catch (Exception e) { + logger.error("Unexpected error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Unexpected error: " + e.getMessage()); + } + } + + /** + * Stops the currently running script instance. + * + *

Logs the stop request and interrupts the running script thread. + * + * @return ResponseEntity with HTTP 200 OK status after attempting to stop the script + */ + @PostMapping(path = "/stop", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity stopScript() { + logger.info("Received stop request"); + ScriptInstanceManager.getInstance().getInstanceRef().stop(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptInstance.java b/src/main/java/com/chromascape/web/instance/ScriptInstance.java index 6387dd5..4a68346 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptInstance.java +++ b/src/main/java/com/chromascape/web/instance/ScriptInstance.java @@ -1,95 +1,95 @@ -package com.chromascape.web.instance; - -import com.chromascape.base.BaseScript; -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; - -/** - * Manages the lifecycle of a script instance. - * - *

This class dynamically loads and instantiates a script class based on the provided - * configuration, runs the script in its own thread, and provides control methods to start and stop - * the script execution. - */ -public class ScriptInstance { - - private final BaseScript instance; - private volatile Thread thread; - private final WebSocketStateHandler stateHandler; - - /** - * Constructs a ScriptInstance by dynamically loading the script class specified in the config. - * - * @param config the RunConfig containing script name - * @throws NoSuchMethodException if the expected constructor is not found - * @throws ClassNotFoundException if the script class cannot be found - * @throws InvocationTargetException if the constructor throws an exception - * @throws InstantiationException if the class is abstract or an interface - * @throws IllegalAccessException if the constructor is not accessible - */ - public ScriptInstance(RunConfig config, WebSocketStateHandler stateHandler) - throws NoSuchMethodException, - ClassNotFoundException, - InvocationTargetException, - InstantiationException, - IllegalAccessException { - this.stateHandler = stateHandler; - - String fileName = config.script(); - String className = fileName.replace(".java", "").replace("/", "."); - - Class script = Class.forName("com.chromascape.scripts." + className); - Constructor constructor = script.getDeclaredConstructor(); - instance = (BaseScript) constructor.newInstance(); - } - - /** - * Starts the script execution in a new thread. - * - *

Resets the statistics via {@link StatisticsManager#reset()} before running, so that each run - * starts with fresh metrics. - * - *

Also broadcasts a {@code true} state to clients. - */ - public void start() { - thread = - new Thread( - () -> { - stateHandler.broadcast(true); - StatisticsManager.reset(); - try { - instance.run(); - } finally { - StatisticsManager.stop(); - // Clear the interrupted flag so the blocking WebSocket send in broadcast() - // can acquire its semaphore. The thread was interrupted by stop() to end - // the script; the flag is no longer meaningful at this point. - Thread.interrupted(); - stateHandler.broadcast(false); - } - }); - thread.start(); - } - - /** - * Stops the script execution by requesting the script to stop, interrupting the running thread, - * and waiting for it to terminate. - * - *

explicitly calls {@link StatisticsManager#stop()} to freeze metrics immediately. Also - * broadcasts a {@code false} state to clients. - */ - public void stop() { - instance.stop(); - StatisticsManager.stop(); - if (thread != null) { - thread.interrupt(); - try { - thread.join(); - } catch (InterruptedException ignored) { - // Thread join interrupted, ignore to proceed with shutdown - } - } - stateHandler.broadcast(false); - } -} +package com.chromascape.web.instance; + +import com.chromascape.base.BaseScript; +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Manages the lifecycle of a script instance. + * + *

This class dynamically loads and instantiates a script class based on the provided + * configuration, runs the script in its own thread, and provides control methods to start and stop + * the script execution. + */ +public class ScriptInstance { + + private final BaseScript instance; + private volatile Thread thread; + private final WebSocketStateHandler stateHandler; + + /** + * Constructs a ScriptInstance by dynamically loading the script class specified in the config. + * + * @param config the RunConfig containing script name + * @throws NoSuchMethodException if the expected constructor is not found + * @throws ClassNotFoundException if the script class cannot be found + * @throws InvocationTargetException if the constructor throws an exception + * @throws InstantiationException if the class is abstract or an interface + * @throws IllegalAccessException if the constructor is not accessible + */ + public ScriptInstance(RunConfig config, WebSocketStateHandler stateHandler) + throws NoSuchMethodException, + ClassNotFoundException, + InvocationTargetException, + InstantiationException, + IllegalAccessException { + this.stateHandler = stateHandler; + + String fileName = config.script(); + String className = fileName.replace(".java", "").replace("/", "."); + + Class script = Class.forName("com.chromascape.scripts." + className); + Constructor constructor = script.getDeclaredConstructor(); + instance = (BaseScript) constructor.newInstance(); + } + + /** + * Starts the script execution in a new thread. + * + *

Resets the statistics via {@link StatisticsManager#reset()} before running, so that each run + * starts with fresh metrics. + * + *

Also broadcasts a {@code true} state to clients. + */ + public void start() { + thread = + new Thread( + () -> { + stateHandler.broadcast(true); + StatisticsManager.reset(); + try { + instance.run(); + } finally { + StatisticsManager.stop(); + // Clear the interrupted flag so the blocking WebSocket send in broadcast() + // can acquire its semaphore. The thread was interrupted by stop() to end + // the script; the flag is no longer meaningful at this point. + Thread.interrupted(); + stateHandler.broadcast(false); + } + }); + thread.start(); + } + + /** + * Stops the script execution by requesting the script to stop, interrupting the running thread, + * and waiting for it to terminate. + * + *

explicitly calls {@link StatisticsManager#stop()} to freeze metrics immediately. Also + * broadcasts a {@code false} state to clients. + */ + public void stop() { + instance.stop(); + StatisticsManager.stop(); + if (thread != null) { + thread.interrupt(); + try { + thread.join(); + } catch (InterruptedException ignored) { + // Thread join interrupted, ignore to proceed with shutdown + } + } + stateHandler.broadcast(false); + } +} diff --git a/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java b/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java index cdd8b56..2a5a12c 100644 --- a/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java +++ b/src/main/java/com/chromascape/web/instance/ScriptInstanceManager.java @@ -1,47 +1,47 @@ -package com.chromascape.web.instance; - -/** - * Singleton manager class that maintains the current active ScriptInstance. - * - *

This class provides thread-safe access to a single ScriptInstance, allowing setting and - * retrieving the currently running script instance. - */ -public class ScriptInstanceManager { - private static ScriptInstanceManager instance; - private ScriptInstance currentInstance; - - /** Private constructor to enforce singleton pattern. */ - private ScriptInstanceManager() {} - - /** - * Returns the singleton instance of ScriptInstanceManager. - * - *

This method is synchronized to ensure thread-safe lazy initialization. - * - * @return the singleton ScriptInstanceManager instance - */ - public static synchronized ScriptInstanceManager getInstance() { - if (instance == null) { - instance = new ScriptInstanceManager(); - } - return instance; - } - - /** - * Sets the current active ScriptInstance. - * - * @param scriptInstance the ScriptInstance to set as current - */ - public void setInstance(ScriptInstance scriptInstance) { - this.currentInstance = scriptInstance; - } - - /** - * Returns a reference to the current active ScriptInstance. - * - * @return the current ScriptInstance, or null if none is set - */ - public ScriptInstance getInstanceRef() { - return currentInstance; - } -} +package com.chromascape.web.instance; + +/** + * Singleton manager class that maintains the current active ScriptInstance. + * + *

This class provides thread-safe access to a single ScriptInstance, allowing setting and + * retrieving the currently running script instance. + */ +public class ScriptInstanceManager { + private static ScriptInstanceManager instance; + private ScriptInstance currentInstance; + + /** Private constructor to enforce singleton pattern. */ + private ScriptInstanceManager() {} + + /** + * Returns the singleton instance of ScriptInstanceManager. + * + *

This method is synchronized to ensure thread-safe lazy initialization. + * + * @return the singleton ScriptInstanceManager instance + */ + public static synchronized ScriptInstanceManager getInstance() { + if (instance == null) { + instance = new ScriptInstanceManager(); + } + return instance; + } + + /** + * Sets the current active ScriptInstance. + * + * @param scriptInstance the ScriptInstance to set as current + */ + public void setInstance(ScriptInstance scriptInstance) { + this.currentInstance = scriptInstance; + } + + /** + * Returns a reference to the current active ScriptInstance. + * + * @return the current ScriptInstance, or null if none is set + */ + public ScriptInstance getInstanceRef() { + return currentInstance; + } +} diff --git a/src/main/java/com/chromascape/web/instance/SendScripts.java b/src/main/java/com/chromascape/web/instance/SendScripts.java index 87487eb..1c3370b 100644 --- a/src/main/java/com/chromascape/web/instance/SendScripts.java +++ b/src/main/java/com/chromascape/web/instance/SendScripts.java @@ -1,46 +1,46 @@ -package com.chromascape.web.instance; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for providing available script names. - * - *

This controller scans the {@code scripts} directory for available script files and returns - * their names to the client. - */ -@RestController -@RequestMapping("/api") -public class SendScripts { - - /** The directory where script classes are located. */ - private static final Path SCRIPTS_DIR = Paths.get("src/main/java/com/chromascape/scripts"); - - /** - * Returns a list of script file names located in the {@code scripts} directory. - * - *

This endpoint scans the directory recursively so nested folders are included in the results. - * - * @return a list of script file names relative to {@code SCRIPTS_DIR} - * @throws IOException if an I/O error occurs while reading the directory - */ - @GetMapping("/scripts") - public List getScripts() throws IOException { - try (Stream stream = Files.walk(SCRIPTS_DIR)) { - return stream - .filter(Files::isRegularFile) - .map(SCRIPTS_DIR::relativize) - .map(path -> path.toString().replace("\\", "/")) - .sorted() - .collect(Collectors.toList()); - } - } -} +package com.chromascape.web.instance; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for providing available script names. + * + *

This controller scans the {@code scripts} directory for available script files and returns + * their names to the client. + */ +@RestController +@RequestMapping("/api") +public class SendScripts { + + /** The directory where script classes are located. */ + private static final Path SCRIPTS_DIR = Paths.get("src/main/java/com/chromascape/scripts"); + + /** + * Returns a list of script file names located in the {@code scripts} directory. + * + *

This endpoint scans the directory recursively so nested folders are included in the results. + * + * @return a list of script file names relative to {@code SCRIPTS_DIR} + * @throws IOException if an I/O error occurs while reading the directory + */ + @GetMapping("/scripts") + public List getScripts() throws IOException { + try (Stream stream = Files.walk(SCRIPTS_DIR)) { + return stream + .filter(Files::isRegularFile) + .map(SCRIPTS_DIR::relativize) + .map(path -> path.toString().replace("\\", "/")) + .sorted() + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java b/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java index 932e011..33e4567 100644 --- a/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java +++ b/src/main/java/com/chromascape/web/instance/WebSocketStateHandler.java @@ -1,98 +1,98 @@ -package com.chromascape.web.instance; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler responsible for broadcasting the current running state of a script to all - * connected WebSocket clients. - * - *

Clients subscribing to this endpoint will receive messages containing {@code "true"} if a - * script is running, or {@code "false"} if no script is active. The handler maintains a thread-safe - * set of active sessions and automatically handles client connect/disconnect events. - * - *

This component is typically registered in {@link - * org.springframework.web.socket.config.annotation.WebSocketConfigurer} to expose a `/ws/state` - * endpoint. - */ -@Component -public class WebSocketStateHandler extends TextWebSocketHandler { - - /** Logger for internal events and errors. */ - private final Logger logger = LogManager.getLogger(this.getClass().getName()); - - /** Thread-safe set of all currently connected WebSocket sessions. */ - private final Set sessions = ConcurrentHashMap.newKeySet(); - - /** - * Tracks the last broadcasted state (running or not) to immediately synchronize new connections. - * - *

Initialized to {@code false}. Updated every time {@link #broadcast(boolean)} is called. - */ - private volatile boolean lastState = false; - - /** - * Invoked after a new WebSocket connection is established. - * - *

Adds the session to the active set and immediately sends the {@code lastState} so the client - * UI can sync its start/stop button without waiting for a new event. - * - * @param session the session that was established - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - // Send current state immediately to the new session - if (session != null && session.isOpen()) { - try { - session.sendMessage(new TextMessage(Boolean.toString(lastState))); - } catch (IOException e) { - logger.error("Failed to send initial state to client", e); - } - } - } - - /** - * Invoked after a WebSocket connection is closed. - * - * @param session the session that was closed - * @param status the status describing why the connection was closed - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - } - - /** - * Broadcasts the current script running state to all connected clients. - * - *

The message sent is a simple {@code "true"} or {@code "false"} string, representing whether - * a script is currently active. - * - *

Also updates {@link #lastState} to persist this state for future connections. - * - * @param isRunning {@code true} if a script is running, {@code false} otherwise - */ - public void broadcast(boolean isRunning) { - this.lastState = isRunning; - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage(Boolean.toString(isRunning))); - } catch (IOException e) { - logger.error("Failed to send running state to client", e); - } - } - } - } -} +package com.chromascape.web.instance; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler responsible for broadcasting the current running state of a script to all + * connected WebSocket clients. + * + *

Clients subscribing to this endpoint will receive messages containing {@code "true"} if a + * script is running, or {@code "false"} if no script is active. The handler maintains a thread-safe + * set of active sessions and automatically handles client connect/disconnect events. + * + *

This component is typically registered in {@link + * org.springframework.web.socket.config.annotation.WebSocketConfigurer} to expose a `/ws/state` + * endpoint. + */ +@Component +public class WebSocketStateHandler extends TextWebSocketHandler { + + /** Logger for internal events and errors. */ + private final Logger logger = LogManager.getLogger(this.getClass().getName()); + + /** Thread-safe set of all currently connected WebSocket sessions. */ + private final Set sessions = ConcurrentHashMap.newKeySet(); + + /** + * Tracks the last broadcasted state (running or not) to immediately synchronize new connections. + * + *

Initialized to {@code false}. Updated every time {@link #broadcast(boolean)} is called. + */ + private volatile boolean lastState = false; + + /** + * Invoked after a new WebSocket connection is established. + * + *

Adds the session to the active set and immediately sends the {@code lastState} so the client + * UI can sync its start/stop button without waiting for a new event. + * + * @param session the session that was established + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + // Send current state immediately to the new session + if (session != null && session.isOpen()) { + try { + session.sendMessage(new TextMessage(Boolean.toString(lastState))); + } catch (IOException e) { + logger.error("Failed to send initial state to client", e); + } + } + } + + /** + * Invoked after a WebSocket connection is closed. + * + * @param session the session that was closed + * @param status the status describing why the connection was closed + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + } + + /** + * Broadcasts the current script running state to all connected clients. + * + *

The message sent is a simple {@code "true"} or {@code "false"} string, representing whether + * a script is currently active. + * + *

Also updates {@link #lastState} to persist this state for future connections. + * + * @param isRunning {@code true} if a script is running, {@code false} otherwise + */ + public void broadcast(boolean isRunning) { + this.lastState = isRunning; + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + session.sendMessage(new TextMessage(Boolean.toString(isRunning))); + } catch (IOException e) { + logger.error("Failed to send running state to client", e); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java b/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java index b6ee2ab..a5183a6 100644 --- a/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/logs/LogWebSocketHandler.java @@ -1,121 +1,121 @@ -package com.chromascape.web.logs; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler for broadcasting log messages to connected clients. - * - *

This handler manages active WebSocket sessions and provides a broadcast method for delivering - * log messages to all connected clients. It is designed to be thread-safe and robust against - * session disconnects and transport errors. - * - *

Usage: Register this handler as a bean in your Spring application context and wire it in your - * WebSocket config. The {@code broadcast} method should be called by your log appender whenever a - * new log message is available for real-time delivery. - * - *

All log messages and transport errors are recorded via SLF4J for operational visibility. - * - * @see org.springframework.web.socket.handler.TextWebSocketHandler - * @see com.chromascape.web.logs.WebSocketLogAppender - */ -@Component -public class LogWebSocketHandler extends TextWebSocketHandler { - - /** SLF4J logger for connection and error events. */ - private static final Logger logger = LoggerFactory.getLogger(LogWebSocketHandler.class); - - /** - * Thread-safe set of active WebSocket sessions. {@link CopyOnWriteArraySet} is used to avoid - * concurrent modification issues during broadcast. - */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** Executor service to send WebSocket requests without blocking the main thread. */ - private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); - - /** - * Called when a new WebSocket connection is established. Adds the session to the active set and - * logs the connection. - * - * @param session the new WebSocket session (maybe null, per Spring contract) - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - // Defensive null check; session is typically non-null after establishment. - if (session != null) { - logger.info("WebSocket client connected"); - } - } - - /** - * Called when a WebSocket connection is closed. Removes the session from the active set and logs - * the disconnection. - * - * @param session the closed WebSocket session (maybe null) - * @param status the close status (maybe null) - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - if (session != null) { - logger.info("WebSocket client disconnected"); - } - } - - /** - * Called when a transport error occurs for a session. Removes the session, closes it with a - * server error status, and logs the error. - * - * @param session the affected WebSocket session (maybe null) - * @param exception the thrown error/exception - * @throws Exception if closing the session fails - */ - @Override - public void handleTransportError( - @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { - sessions.remove(session); - if (session != null) { - session.close(CloseStatus.SERVER_ERROR); - assert exception != null; - logger.error("WebSocket transport error for session: {}", exception.getMessage()); - } - } - - /** - * Broadcasts a message to all connected WebSocket clients. If a session fails to receive the - * message, it is removed from the active set and the failure is logged. - * - * @param message the message to broadcast - */ - public void broadcast(String message) { - for (WebSocketSession session : sessions) { - wsExecutor.submit( - () -> { - try { - synchronized (session) { - if (session.isOpen()) { - session.sendMessage(new TextMessage(message)); - } - } - } catch (IOException e) { - sessions.remove(session); - logger.warn("Failed to send message to session: {}", e.getMessage()); - } - }); - } - } -} +package com.chromascape.web.logs; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler for broadcasting log messages to connected clients. + * + *

This handler manages active WebSocket sessions and provides a broadcast method for delivering + * log messages to all connected clients. It is designed to be thread-safe and robust against + * session disconnects and transport errors. + * + *

Usage: Register this handler as a bean in your Spring application context and wire it in your + * WebSocket config. The {@code broadcast} method should be called by your log appender whenever a + * new log message is available for real-time delivery. + * + *

All log messages and transport errors are recorded via SLF4J for operational visibility. + * + * @see org.springframework.web.socket.handler.TextWebSocketHandler + * @see com.chromascape.web.logs.WebSocketLogAppender + */ +@Component +public class LogWebSocketHandler extends TextWebSocketHandler { + + /** SLF4J logger for connection and error events. */ + private static final Logger logger = LoggerFactory.getLogger(LogWebSocketHandler.class); + + /** + * Thread-safe set of active WebSocket sessions. {@link CopyOnWriteArraySet} is used to avoid + * concurrent modification issues during broadcast. + */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** Executor service to send WebSocket requests without blocking the main thread. */ + private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); + + /** + * Called when a new WebSocket connection is established. Adds the session to the active set and + * logs the connection. + * + * @param session the new WebSocket session (maybe null, per Spring contract) + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + // Defensive null check; session is typically non-null after establishment. + if (session != null) { + logger.info("WebSocket client connected"); + } + } + + /** + * Called when a WebSocket connection is closed. Removes the session from the active set and logs + * the disconnection. + * + * @param session the closed WebSocket session (maybe null) + * @param status the close status (maybe null) + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + if (session != null) { + logger.info("WebSocket client disconnected"); + } + } + + /** + * Called when a transport error occurs for a session. Removes the session, closes it with a + * server error status, and logs the error. + * + * @param session the affected WebSocket session (maybe null) + * @param exception the thrown error/exception + * @throws Exception if closing the session fails + */ + @Override + public void handleTransportError( + @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { + sessions.remove(session); + if (session != null) { + session.close(CloseStatus.SERVER_ERROR); + assert exception != null; + logger.error("WebSocket transport error for session: {}", exception.getMessage()); + } + } + + /** + * Broadcasts a message to all connected WebSocket clients. If a session fails to receive the + * message, it is removed from the active set and the failure is logged. + * + * @param message the message to broadcast + */ + public void broadcast(String message) { + for (WebSocketSession session : sessions) { + wsExecutor.submit( + () -> { + try { + synchronized (session) { + if (session.isOpen()) { + session.sendMessage(new TextMessage(message)); + } + } + } catch (IOException e) { + sessions.remove(session); + logger.warn("Failed to send message to session: {}", e.getMessage()); + } + }); + } + } +} diff --git a/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java b/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java index 013ec23..d67ef8d 100644 --- a/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java +++ b/src/main/java/com/chromascape/web/logs/WebSocketLogAppender.java @@ -1,107 +1,107 @@ -package com.chromascape.web.logs; - -import org.apache.logging.log4j.core.Appender; -import org.apache.logging.log4j.core.LogEvent; -import org.apache.logging.log4j.core.appender.AbstractAppender; -import org.apache.logging.log4j.core.config.plugins.Plugin; -import org.apache.logging.log4j.core.config.plugins.PluginAttribute; -import org.apache.logging.log4j.core.config.plugins.PluginFactory; - -/** - * A custom Log4j2 appender that broadcasts log messages to all connected WebSocket clients. - * - *

This appender is intended for use in a Spring Boot application where {@link - * LogWebSocketHandler} manages client connections. Each log message emitted by Log4j2 will be sent - * to every active WebSocket session via the {@code broadcast} method. - * - *

Typical usage involves registering this appender in {@code log4j2.xml} and configuring the - * WebSocket handler via Spring. See documentation for wiring instructions. - * - *

- * Example XML registration:
- * <Appenders>
- *   <WebSocketLogAppender name="WebSocket"/>
- * </Appenders>
- * 
- * - *

The WebSocket handler must be set using {@link #setWebSocketHandler(LogWebSocketHandler)} - * after the Spring application context has fully initialized. - * - * @see LogWebSocketHandler - */ -@Plugin( - name = "WebSocketLogAppender", - category = "Core", - elementType = Appender.ELEMENT_TYPE, - printObject = true) -public class WebSocketLogAppender extends AbstractAppender { - - /** - * The handler managing WebSocket sessions. This is set by Spring after context initialization. - * Must be thread-safe. - */ - private static LogWebSocketHandler webSocketHandler; - - /** - * Allows Spring to inject the {@link LogWebSocketHandler} instance after application startup. - * This method should be called from a Spring bean, typically in a {@code @Configuration} class. - * - * @param handler the shared WebSocket handler bean - */ - public static void setWebSocketHandler(LogWebSocketHandler handler) { - webSocketHandler = handler; - } - - /** - * Constructs the appender with the provided name. Other parameters (filter, layout) are omitted - * for simplicity, but can be added if needed. - * - * @param name the appender name - */ - protected WebSocketLogAppender(String name) { - super(name, null, null, true, null); - // Start the appender immediately on creation. - start(); - } - - /** - * Factory method required by Log4j2 for plugin discovery and instantiation via XML configuration. - * This method is called when the appender is referenced in {@code log4j2.xml}. - * - * @param name the appender name as specified in XML - * @return a new instance of {@link WebSocketLogAppender} - */ - @PluginFactory - public static WebSocketLogAppender createAppender(@PluginAttribute("name") String name) { - return new WebSocketLogAppender(name); - } - - /** - * Called by Log4j2 for each log event. Broadcasts the formatted log message to all connected - * WebSocket clients. If the handler is not set, the message is silently dropped. - * - * @param event the log event to append/broadcast - */ - @Override - public void append(LogEvent event) { - if (webSocketHandler == null) { - // Handler not configured; drop message. - return; - } - String level = event.getLevel().name(); - String message = event.getMessage().getFormattedMessage(); - // Simple JSON construction (escaping quotes in message is prudent but assuming - // simple logs for now) - // A more robust way would be using a library, but this keeps deps low. - String json = - String.format( - "{\"level\": \"%s\", \"message\": \"%s\"}", - level, message.replace("\"", "\\\"").replace("\n", " ").replace("\r", "")); - - try { - webSocketHandler.broadcast(json); - } catch (Exception e) { - System.err.println("Failed to broadcast log: " + e.getMessage()); - } - } -} +package com.chromascape.web.logs; + +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; + +/** + * A custom Log4j2 appender that broadcasts log messages to all connected WebSocket clients. + * + *

This appender is intended for use in a Spring Boot application where {@link + * LogWebSocketHandler} manages client connections. Each log message emitted by Log4j2 will be sent + * to every active WebSocket session via the {@code broadcast} method. + * + *

Typical usage involves registering this appender in {@code log4j2.xml} and configuring the + * WebSocket handler via Spring. See documentation for wiring instructions. + * + *

+ * Example XML registration:
+ * <Appenders>
+ *   <WebSocketLogAppender name="WebSocket"/>
+ * </Appenders>
+ * 
+ * + *

The WebSocket handler must be set using {@link #setWebSocketHandler(LogWebSocketHandler)} + * after the Spring application context has fully initialized. + * + * @see LogWebSocketHandler + */ +@Plugin( + name = "WebSocketLogAppender", + category = "Core", + elementType = Appender.ELEMENT_TYPE, + printObject = true) +public class WebSocketLogAppender extends AbstractAppender { + + /** + * The handler managing WebSocket sessions. This is set by Spring after context initialization. + * Must be thread-safe. + */ + private static LogWebSocketHandler webSocketHandler; + + /** + * Allows Spring to inject the {@link LogWebSocketHandler} instance after application startup. + * This method should be called from a Spring bean, typically in a {@code @Configuration} class. + * + * @param handler the shared WebSocket handler bean + */ + public static void setWebSocketHandler(LogWebSocketHandler handler) { + webSocketHandler = handler; + } + + /** + * Constructs the appender with the provided name. Other parameters (filter, layout) are omitted + * for simplicity, but can be added if needed. + * + * @param name the appender name + */ + protected WebSocketLogAppender(String name) { + super(name, null, null, true, null); + // Start the appender immediately on creation. + start(); + } + + /** + * Factory method required by Log4j2 for plugin discovery and instantiation via XML configuration. + * This method is called when the appender is referenced in {@code log4j2.xml}. + * + * @param name the appender name as specified in XML + * @return a new instance of {@link WebSocketLogAppender} + */ + @PluginFactory + public static WebSocketLogAppender createAppender(@PluginAttribute("name") String name) { + return new WebSocketLogAppender(name); + } + + /** + * Called by Log4j2 for each log event. Broadcasts the formatted log message to all connected + * WebSocket clients. If the handler is not set, the message is silently dropped. + * + * @param event the log event to append/broadcast + */ + @Override + public void append(LogEvent event) { + if (webSocketHandler == null) { + // Handler not configured; drop message. + return; + } + String level = event.getLevel().name(); + String message = event.getMessage().getFormattedMessage(); + // Simple JSON construction (escaping quotes in message is prudent but assuming + // simple logs for now) + // A more robust way would be using a library, but this keeps deps low. + String json = + String.format( + "{\"level\": \"%s\", \"message\": \"%s\"}", + level, message.replace("\"", "\\\"").replace("\n", " ").replace("\r", "")); + + try { + webSocketHandler.broadcast(json); + } catch (Exception e) { + System.err.println("Failed to broadcast log: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/chromascape/web/slider/CurrentSliderState.java b/src/main/java/com/chromascape/web/slider/CurrentSliderState.java index 3ffb35b..435483b 100644 --- a/src/main/java/com/chromascape/web/slider/CurrentSliderState.java +++ b/src/main/java/com/chromascape/web/slider/CurrentSliderState.java @@ -1,84 +1,84 @@ -package com.chromascape.web.slider; - -import com.chromascape.utils.core.screen.colour.ColourObj; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.bytedeco.opencv.opencv_core.Scalar; -import org.springframework.stereotype.Component; - -/** - * Stores the current HSV slider state for color selection in the UI. - * - *

This class is a thread-safe singleton Spring component used to manage real-time updates to - * hue, saturation, and value (HSV) bounds, which can be retrieved as a {@link ColourObj} for image - * processing or color detection. - */ -@Component -public class CurrentSliderState { - private final Map sliderValues = new ConcurrentHashMap<>(); - - /** Constructs a new {@code CurrentSliderState} with default HSV bounds. */ - public CurrentSliderState() { - reset(); - } - - /** - * Updates a specific slider's value by ID. - * - * @param id the slider identifier (e.g. "hueMin", "satMax") - * @param value the new slider value - */ - public void set(String id, int value) { - sliderValues.put(id, value); - } - - /** - * Retrieves the current value of a slider by ID. - * - * @param id the slider identifier - * @return the current value, or 0 if not present - */ - public int get(String id) { - return sliderValues.getOrDefault(id, 0); - } - - /** - * Retrieves all current slider values as a map. - * - * @return a map of slider IDs to their integer values. - */ - public Map getAll() { - return new ConcurrentHashMap<>(sliderValues); - } - - /** - * Converts the current slider state into a {@link ColourObj} using OpenCV HSV bounds. - * - * @return a {@link ColourObj} representing the selected HSV range - */ - public ColourObj getColourObj() { - Scalar min = - new Scalar( - sliderValues.getOrDefault("hueMin", 0), - sliderValues.getOrDefault("satMin", 0), - sliderValues.getOrDefault("valMin", 0), - 0); - Scalar max = - new Scalar( - sliderValues.getOrDefault("hueMax", 179), - sliderValues.getOrDefault("satMax", 255), - sliderValues.getOrDefault("valMax", 255), - 0); - return new ColourObj("custom-slider-colour", min, max); - } - - /** Resets all sliders to default HSV values: hue [0–179], saturation [0–255], value [0–255]. */ - public void reset() { - sliderValues.put("hueMin", 0); - sliderValues.put("hueMax", 179); - sliderValues.put("satMin", 0); - sliderValues.put("satMax", 255); - sliderValues.put("valMin", 0); - sliderValues.put("valMax", 255); - } -} +package com.chromascape.web.slider; + +import com.chromascape.utils.core.screen.colour.ColourObj; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.bytedeco.opencv.opencv_core.Scalar; +import org.springframework.stereotype.Component; + +/** + * Stores the current HSV slider state for color selection in the UI. + * + *

This class is a thread-safe singleton Spring component used to manage real-time updates to + * hue, saturation, and value (HSV) bounds, which can be retrieved as a {@link ColourObj} for image + * processing or color detection. + */ +@Component +public class CurrentSliderState { + private final Map sliderValues = new ConcurrentHashMap<>(); + + /** Constructs a new {@code CurrentSliderState} with default HSV bounds. */ + public CurrentSliderState() { + reset(); + } + + /** + * Updates a specific slider's value by ID. + * + * @param id the slider identifier (e.g. "hueMin", "satMax") + * @param value the new slider value + */ + public void set(String id, int value) { + sliderValues.put(id, value); + } + + /** + * Retrieves the current value of a slider by ID. + * + * @param id the slider identifier + * @return the current value, or 0 if not present + */ + public int get(String id) { + return sliderValues.getOrDefault(id, 0); + } + + /** + * Retrieves all current slider values as a map. + * + * @return a map of slider IDs to their integer values. + */ + public Map getAll() { + return new ConcurrentHashMap<>(sliderValues); + } + + /** + * Converts the current slider state into a {@link ColourObj} using OpenCV HSV bounds. + * + * @return a {@link ColourObj} representing the selected HSV range + */ + public ColourObj getColourObj() { + Scalar min = + new Scalar( + sliderValues.getOrDefault("hueMin", 0), + sliderValues.getOrDefault("satMin", 0), + sliderValues.getOrDefault("valMin", 0), + 0); + Scalar max = + new Scalar( + sliderValues.getOrDefault("hueMax", 179), + sliderValues.getOrDefault("satMax", 255), + sliderValues.getOrDefault("valMax", 255), + 0); + return new ColourObj("custom-slider-colour", min, max); + } + + /** Resets all sliders to default HSV values: hue [0–179], saturation [0–255], value [0–255]. */ + public void reset() { + sliderValues.put("hueMin", 0); + sliderValues.put("hueMax", 179); + sliderValues.put("satMin", 0); + sliderValues.put("satMax", 255); + sliderValues.put("valMin", 0); + sliderValues.put("valMax", 255); + } +} diff --git a/src/main/java/com/chromascape/web/slider/SliderConfig.java b/src/main/java/com/chromascape/web/slider/SliderConfig.java index 660c624..94c5129 100644 --- a/src/main/java/com/chromascape/web/slider/SliderConfig.java +++ b/src/main/java/com/chromascape/web/slider/SliderConfig.java @@ -1,50 +1,50 @@ -package com.chromascape.web.slider; - -/** - * DTO representing an individual slider update sent from the frontend. - * - *

This class is used as the {@code @RequestBody} for slider update POST requests in the HSV - * filter tuning UI. - * - *

Each instance represents a single slider (e.g., "hueMin") and its corresponding value. - */ -public class SliderConfig { - - /** The name of the slider being adjusted (e.g., "hueMin", "satMax"). */ - private String sliderName; - - /** The updated value of the slider. */ - private int sliderValue; - - /** Default constructor required for JSON deserialization. */ - public SliderConfig() {} - - /** - * Constructs a {@code SliderConfig} with a name and value. - * - * @param sliderName the identifier of the slider - * @param sliderValue the new value of the slider - */ - public SliderConfig(final String sliderName, final int sliderValue) { - this.sliderName = sliderName; - this.sliderValue = sliderValue; - } - - /** - * Returns the slider's name (e.g., "hueMin"). - * - * @return the slider name - */ - public String getSliderName() { - return sliderName; - } - - /** - * Returns the new value of the slider. - * - * @return the slider value - */ - public int getSliderValue() { - return sliderValue; - } -} +package com.chromascape.web.slider; + +/** + * DTO representing an individual slider update sent from the frontend. + * + *

This class is used as the {@code @RequestBody} for slider update POST requests in the HSV + * filter tuning UI. + * + *

Each instance represents a single slider (e.g., "hueMin") and its corresponding value. + */ +public class SliderConfig { + + /** The name of the slider being adjusted (e.g., "hueMin", "satMax"). */ + private String sliderName; + + /** The updated value of the slider. */ + private int sliderValue; + + /** Default constructor required for JSON deserialization. */ + public SliderConfig() {} + + /** + * Constructs a {@code SliderConfig} with a name and value. + * + * @param sliderName the identifier of the slider + * @param sliderValue the new value of the slider + */ + public SliderConfig(final String sliderName, final int sliderValue) { + this.sliderName = sliderName; + this.sliderValue = sliderValue; + } + + /** + * Returns the slider's name (e.g., "hueMin"). + * + * @return the slider name + */ + public String getSliderName() { + return sliderName; + } + + /** + * Returns the new value of the slider. + * + * @return the slider value + */ + public int getSliderValue() { + return sliderValue; + } +} diff --git a/src/main/java/com/chromascape/web/slider/SliderController.java b/src/main/java/com/chromascape/web/slider/SliderController.java index e1d90b6..2619e2d 100644 --- a/src/main/java/com/chromascape/web/slider/SliderController.java +++ b/src/main/java/com/chromascape/web/slider/SliderController.java @@ -1,68 +1,68 @@ -package com.chromascape.web.slider; - -import com.chromascape.web.image.ModifyImage; -import java.io.IOException; -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller responsible for handling slider input changes from the frontend UI. - * - *

This controller updates the internal HSV slider state based on user input and triggers - * downstream image modification logic in real time. - */ -@RestController -@RequestMapping("/api") -public class SliderController { - - /** Holds the current state of all slider values (e.g., hueMin, satMax). */ - private final CurrentSliderState sliderState; - - /** Applies visual changes based on updated HSV thresholds. */ - private final ModifyImage modifyImage; - - /** - * Constructs a new {@code SliderController}. - * - * @param sliderState shared singleton bean tracking live slider positions - * @param modifyImage service for applying image modifications - */ - public SliderController(CurrentSliderState sliderState, ModifyImage modifyImage) { - this.sliderState = sliderState; - this.modifyImage = modifyImage; - } - - /** - * Updates the server-side HSV slider state based on frontend input and applies the updated - * thresholds to the live image preview. - * - * @param config the updated slider configuration (name and value) - * @return HTTP 200 with success message upon update - * @throws IOException if image processing fails during application of changes - */ - @PostMapping("/slider") - public ResponseEntity updateSlider(@RequestBody SliderConfig config) throws IOException { - String id = config.getSliderName(); - int val = config.getSliderValue(); - - sliderState.set(id, val); - modifyImage.applySliderChanges(sliderState); - - return ResponseEntity.ok(Map.of("status", "success")); - } - - /** - * Retrieves the current configuration of all sliders. - * - * @return a map containing the current values for all HSV sliders. - */ - @GetMapping("/slider") - public ResponseEntity> getSliderState() { - return ResponseEntity.ok(sliderState.getAll()); - } -} +package com.chromascape.web.slider; + +import com.chromascape.web.image.ModifyImage; +import java.io.IOException; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller responsible for handling slider input changes from the frontend UI. + * + *

This controller updates the internal HSV slider state based on user input and triggers + * downstream image modification logic in real time. + */ +@RestController +@RequestMapping("/api") +public class SliderController { + + /** Holds the current state of all slider values (e.g., hueMin, satMax). */ + private final CurrentSliderState sliderState; + + /** Applies visual changes based on updated HSV thresholds. */ + private final ModifyImage modifyImage; + + /** + * Constructs a new {@code SliderController}. + * + * @param sliderState shared singleton bean tracking live slider positions + * @param modifyImage service for applying image modifications + */ + public SliderController(CurrentSliderState sliderState, ModifyImage modifyImage) { + this.sliderState = sliderState; + this.modifyImage = modifyImage; + } + + /** + * Updates the server-side HSV slider state based on frontend input and applies the updated + * thresholds to the live image preview. + * + * @param config the updated slider configuration (name and value) + * @return HTTP 200 with success message upon update + * @throws IOException if image processing fails during application of changes + */ + @PostMapping("/slider") + public ResponseEntity updateSlider(@RequestBody SliderConfig config) throws IOException { + String id = config.getSliderName(); + int val = config.getSliderValue(); + + sliderState.set(id, val); + modifyImage.applySliderChanges(sliderState); + + return ResponseEntity.ok(Map.of("status", "success")); + } + + /** + * Retrieves the current configuration of all sliders. + * + * @return a map containing the current values for all HSV sliders. + */ + @GetMapping("/slider") + public ResponseEntity> getSliderState() { + return ResponseEntity.ok(sliderState.getAll()); + } +} diff --git a/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java b/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java index b30a50a..3c2e1a7 100644 --- a/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/state/SemanticWebSocketHandler.java @@ -1,88 +1,88 @@ -package com.chromascape.web.state; - -import com.chromascape.utils.core.state.BotState; -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler responsible for broadcasting the bot's current semantic state to all connected - * frontend clients. - * - *

This handler manages a thread-safe set of active WebSocket sessions. It listens for - * connections at {@code /ws/semantic-state} and provides methods to push state updates via JSON - * messages containing the state name, display label, and CSS styling class. - * - * @see com.chromascape.utils.core.state.BotState - * @see WebsocketBotStateListener - */ -@Component -public class SemanticWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(SemanticWebSocketHandler.class); - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** - * Registers a new WebSocket session when a client connects. - * - * @param session the new WebSocket session - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - } - - /** - * Removes a WebSocket session when the connection is closed. - * - * @param session the closed WebSocket session - * @param status the closure status - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - } - - /** - * Broadcasts the specified {@link BotState} to all currently connected WebSocket sessions. - * - *

The state is serialized into a JSON object with the following fields: - * - *

    - *
  • {@code state}: The enum name of the state. - *
  • {@code label}: The user-friendly display name. - *
  • {@code css}: The associated CSS class for styling UI elements. - *
- * - * @param state the new state to broadcast; must not be null - */ - public void broadcastState(BotState state) { - String json = - String.format( - "{\"state\": \"%s\", \"label\": \"%s\", \"css\": \"%s\"}", - state.name(), state.getDisplayName(), state.getCssClass()); - - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - synchronized (session) { - if (session.isOpen()) { - session.sendMessage(new TextMessage(json)); - } - } - } catch (IOException e) { - logger.warn("Failed to send state update: {}", e.getMessage()); - } - } - } - } -} +package com.chromascape.web.state; + +import com.chromascape.utils.core.state.BotState; +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler responsible for broadcasting the bot's current semantic state to all connected + * frontend clients. + * + *

This handler manages a thread-safe set of active WebSocket sessions. It listens for + * connections at {@code /ws/semantic-state} and provides methods to push state updates via JSON + * messages containing the state name, display label, and CSS styling class. + * + * @see com.chromascape.utils.core.state.BotState + * @see WebsocketBotStateListener + */ +@Component +public class SemanticWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(SemanticWebSocketHandler.class); + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** + * Registers a new WebSocket session when a client connects. + * + * @param session the new WebSocket session + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + } + + /** + * Removes a WebSocket session when the connection is closed. + * + * @param session the closed WebSocket session + * @param status the closure status + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + } + + /** + * Broadcasts the specified {@link BotState} to all currently connected WebSocket sessions. + * + *

The state is serialized into a JSON object with the following fields: + * + *

    + *
  • {@code state}: The enum name of the state. + *
  • {@code label}: The user-friendly display name. + *
  • {@code css}: The associated CSS class for styling UI elements. + *
+ * + * @param state the new state to broadcast; must not be null + */ + public void broadcastState(BotState state) { + String json = + String.format( + "{\"state\": \"%s\", \"label\": \"%s\", \"css\": \"%s\"}", + state.name(), state.getDisplayName(), state.getCssClass()); + + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + synchronized (session) { + if (session.isOpen()) { + session.sendMessage(new TextMessage(json)); + } + } + } catch (IOException e) { + logger.warn("Failed to send state update: {}", e.getMessage()); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java b/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java index 6f247cf..320471d 100644 --- a/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java +++ b/src/main/java/com/chromascape/web/state/WebsocketBotStateListener.java @@ -1,51 +1,51 @@ -package com.chromascape.web.state; - -import com.chromascape.utils.core.state.BotState; -import com.chromascape.utils.core.state.BotStateListener; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -/** - * A Spring component that implements {@link BotStateListener} to act as a bridge between the core - * application state and the web layer. - * - *

This listener subscribes to state changes from the core {@link - * com.chromascape.utils.core.state.StateManager} (via the listener infrastructure) and forwards - * them to the {@link SemanticWebSocketHandler} for broadcast to connected web clients. - * - * @see SemanticWebSocketHandler - * @see BotStateListener - */ -@Component -public class WebsocketBotStateListener implements BotStateListener { - - /** - * The WebSocket handler responsible for managing connections and broadcasting messages to web - * clients. - */ - private final SemanticWebSocketHandler handler; - - /** - * Constructs a new {@code WebsocketBotStateListener} with the specified WebSocket handler. - * - * @param handler the {@link SemanticWebSocketHandler} used to broadcast state updates; must not - * be null - */ - @Autowired - public WebsocketBotStateListener(SemanticWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Invoked when the bot's state changes. - * - *

This method delegates the new state to the {@link SemanticWebSocketHandler} to be broadcast - * to all active WebSocket sessions. - * - * @param state the new {@link BotState} of the application - */ - @Override - public void onStateChange(BotState state) { - handler.broadcastState(state); - } -} +package com.chromascape.web.state; + +import com.chromascape.utils.core.state.BotState; +import com.chromascape.utils.core.state.BotStateListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * A Spring component that implements {@link BotStateListener} to act as a bridge between the core + * application state and the web layer. + * + *

This listener subscribes to state changes from the core {@link + * com.chromascape.utils.core.state.StateManager} (via the listener infrastructure) and forwards + * them to the {@link SemanticWebSocketHandler} for broadcast to connected web clients. + * + * @see SemanticWebSocketHandler + * @see BotStateListener + */ +@Component +public class WebsocketBotStateListener implements BotStateListener { + + /** + * The WebSocket handler responsible for managing connections and broadcasting messages to web + * clients. + */ + private final SemanticWebSocketHandler handler; + + /** + * Constructs a new {@code WebsocketBotStateListener} with the specified WebSocket handler. + * + * @param handler the {@link SemanticWebSocketHandler} used to broadcast state updates; must not + * be null + */ + @Autowired + public WebsocketBotStateListener(SemanticWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Invoked when the bot's state changes. + * + *

This method delegates the new state to the {@link SemanticWebSocketHandler} to be broadcast + * to all active WebSocket sessions. + * + * @param state the new {@link BotState} of the application + */ + @Override + public void onStateChange(BotState state) { + handler.broadcastState(state); + } +} diff --git a/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java b/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java index 949a6c4..d579dea 100644 --- a/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java +++ b/src/main/java/com/chromascape/web/stats/StatisticsBroadcaster.java @@ -1,74 +1,74 @@ -package com.chromascape.web.stats; - -import com.chromascape.utils.core.statistics.StatisticsManager; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * Component responsible for broadcasting application statistics to connected WebSocket clients. - * - *

This class executes a scheduled task every second to aggregate performance metrics from the - * {@link StatisticsManager} (such as uptime, CPU cycles, inputs, and object detections). These - * metrics are formatted into a JSON payload and sent to the {@link StatisticsWebSocketHandler}. - */ -@Component -public class StatisticsBroadcaster { - - private final StatisticsWebSocketHandler handler; - - /** - * Constructs a new broadcaster with the given WebSocket handler. - * - * @param handler the handler used to send messages to clients - */ - @Autowired - public StatisticsBroadcaster(StatisticsWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Periodically fetches the latest statistics and broadcasts them. - * - *

This method runs on a fixed schedule of 1000ms. It retrieves the following data: - * - *

    - *
  • Elapsed Time (formatted as HH:mm:ss) - *
  • Logic Cycles - *
  • Input Actions - *
  • Objects Detected - *
- * - *

The data is serialized into a simple JSON string before broadcast. - */ - @Scheduled(fixedRate = 1000) - public void pushStats() { - long elapsedTime = StatisticsManager.getElapsedTime(); - String duration = formatDuration(elapsedTime); - int cycles = StatisticsManager.getCycles(); - int inputs = StatisticsManager.getInputs(); - int objects = StatisticsManager.getObjectsDetected(); - - String json = - String.format( - "{\"time\": \"%s\", \"cycles\": %d, \"inputs\": %d, \"objects\": %d}", - duration, cycles, inputs, objects); - - handler.broadcast(json); - } - - /** - * Formats a duration in milliseconds into a readable string (HH:mm:ss). - * - * @param millis the duration in milliseconds - * @return a formatted time string - */ - private String formatDuration(long millis) { - Duration d = Duration.ofMillis(millis); - long hours = d.toHours(); - long minutes = d.toMinutesPart(); - long seconds = d.toSecondsPart(); - return String.format("%02d:%02d:%02d", hours, minutes, seconds); - } -} +package com.chromascape.web.stats; + +import com.chromascape.utils.core.statistics.StatisticsManager; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Component responsible for broadcasting application statistics to connected WebSocket clients. + * + *

This class executes a scheduled task every second to aggregate performance metrics from the + * {@link StatisticsManager} (such as uptime, CPU cycles, inputs, and object detections). These + * metrics are formatted into a JSON payload and sent to the {@link StatisticsWebSocketHandler}. + */ +@Component +public class StatisticsBroadcaster { + + private final StatisticsWebSocketHandler handler; + + /** + * Constructs a new broadcaster with the given WebSocket handler. + * + * @param handler the handler used to send messages to clients + */ + @Autowired + public StatisticsBroadcaster(StatisticsWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Periodically fetches the latest statistics and broadcasts them. + * + *

This method runs on a fixed schedule of 1000ms. It retrieves the following data: + * + *

    + *
  • Elapsed Time (formatted as HH:mm:ss) + *
  • Logic Cycles + *
  • Input Actions + *
  • Objects Detected + *
+ * + *

The data is serialized into a simple JSON string before broadcast. + */ + @Scheduled(fixedRate = 1000) + public void pushStats() { + long elapsedTime = StatisticsManager.getElapsedTime(); + String duration = formatDuration(elapsedTime); + int cycles = StatisticsManager.getCycles(); + int inputs = StatisticsManager.getInputs(); + int objects = StatisticsManager.getObjectsDetected(); + + String json = + String.format( + "{\"time\": \"%s\", \"cycles\": %d, \"inputs\": %d, \"objects\": %d}", + duration, cycles, inputs, objects); + + handler.broadcast(json); + } + + /** + * Formats a duration in milliseconds into a readable string (HH:mm:ss). + * + * @param millis the duration in milliseconds + * @return a formatted time string + */ + private String formatDuration(long millis) { + Duration d = Duration.ofMillis(millis); + long hours = d.toHours(); + long minutes = d.toMinutesPart(); + long seconds = d.toSecondsPart(); + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } +} diff --git a/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java b/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java index 32011f3..02e2767 100644 --- a/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/stats/StatisticsWebSocketHandler.java @@ -1,91 +1,91 @@ -package com.chromascape.web.stats; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * WebSocket handler that manages connections for the statistics endpoint. - * - *

Listens on {@code /ws/stats}. This handler maintains a registry of active sessions and - * provides a mechanism to broadcast statistical data updates to all connected clients in real-time. - */ -@Component -public class StatisticsWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(StatisticsWebSocketHandler.class); - - /** - * A thread-safe set of active WebSocket sessions. - * - *

{@link CopyOnWriteArraySet} is used here to ensure safe iteration during broadcasts while - * allowing concurrent additions and removals of sessions. - */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** - * Invoked after a WebSocket negotiation has succeeded and the WebSocket connection is opened and - * ready for use. - * - *

This implementation registers the new session in the tracking set to receive future - * broadcasts. - * - * @param session the new {@link WebSocketSession}; may be {@code null} if the framework passes a - * null session (though unlikely in standard Spring WebSocket flow) - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - if (session != null) { - sessions.add(session); - } - } - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - *

This implementation removes the session from the tracking set to prevent memory leaks and - * attempted writes to closed connections. - * - * @param session the {@link WebSocketSession} that was closed - * @param status the close status code and reason - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - if (session != null) { - sessions.remove(session); - } - } - - /** - * Broadcasts the provided statistics JSON string to all currently connected and open clients. - * - *

If a session is closed or encounters an I/O error during sending, the exception is logged, - * but the broadcast continues to other clients. - * - * @param statsJson The JSON string containing the current stats to be sent to clients. - */ - public void broadcast(String statsJson) { - if (sessions.isEmpty()) { - return; - } - for (WebSocketSession session : sessions) { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage(statsJson)); - } catch (IOException e) { - logger.warn("Failed to send stats update: {}", e.getMessage()); - } - } - } - } -} +package com.chromascape.web.stats; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * WebSocket handler that manages connections for the statistics endpoint. + * + *

Listens on {@code /ws/stats}. This handler maintains a registry of active sessions and + * provides a mechanism to broadcast statistical data updates to all connected clients in real-time. + */ +@Component +public class StatisticsWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(StatisticsWebSocketHandler.class); + + /** + * A thread-safe set of active WebSocket sessions. + * + *

{@link CopyOnWriteArraySet} is used here to ensure safe iteration during broadcasts while + * allowing concurrent additions and removals of sessions. + */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** + * Invoked after a WebSocket negotiation has succeeded and the WebSocket connection is opened and + * ready for use. + * + *

This implementation registers the new session in the tracking set to receive future + * broadcasts. + * + * @param session the new {@link WebSocketSession}; may be {@code null} if the framework passes a + * null session (though unlikely in standard Spring WebSocket flow) + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + if (session != null) { + sessions.add(session); + } + } + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + *

This implementation removes the session from the tracking set to prevent memory leaks and + * attempted writes to closed connections. + * + * @param session the {@link WebSocketSession} that was closed + * @param status the close status code and reason + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + if (session != null) { + sessions.remove(session); + } + } + + /** + * Broadcasts the provided statistics JSON string to all currently connected and open clients. + * + *

If a session is closed or encounters an I/O error during sending, the exception is logged, + * but the broadcast continues to other clients. + * + * @param statsJson The JSON string containing the current stats to be sent to clients. + */ + public void broadcast(String statsJson) { + if (sessions.isEmpty()) { + return; + } + for (WebSocketSession session : sessions) { + if (session.isOpen()) { + try { + session.sendMessage(new TextMessage(statsJson)); + } catch (IOException e) { + logger.warn("Failed to send stats update: {}", e.getMessage()); + } + } + } + } +} diff --git a/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java b/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java index dcd9023..64a4adf 100644 --- a/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java +++ b/src/main/java/com/chromascape/web/viewport/ViewportWebSocketHandler.java @@ -1,114 +1,114 @@ -package com.chromascape.web.viewport; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -/** - * A WebSocket handler specifically for the viewport endpoint. - * - *

This class manages the active WebSocket connections and provides functionality to broadcast - * image updates to all connected clients. - */ -@Component -public class ViewportWebSocketHandler extends TextWebSocketHandler { - - private static final Logger logger = LoggerFactory.getLogger(ViewportWebSocketHandler.class); - - /** A thread-safe set of active WebSocket sessions. */ - private final Set sessions = new CopyOnWriteArraySet<>(); - - /** Executor service for sending messages asynchronously to avoid blocking. */ - private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - * @param session The session that was established. - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - sessions.add(session); - if (session != null) { - logger.info("Viewport WebSocket client connected"); - } - } - - /** - * Invoked after the WebSocket connection has been closed by either side, or after a transport - * error has occurred. - * - * @param session The session that was closed. - * @param status The status code indicating why the session was closed. - */ - @Override - public void afterConnectionClosed( - @Nullable WebSocketSession session, @Nullable CloseStatus status) { - sessions.remove(session); - if (session != null) { - logger.info("Viewport WebSocket client disconnected"); - } - } - - /** - * Invoked when an error occurs in the underlying communication channel. - * - * @param session The session where the error occurred. - * @param exception The exception that occurred. - * @throws Exception If handling the error fails. - */ - @Override - public void handleTransportError( - @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { - sessions.remove(session); - if (session != null) { - session.close(CloseStatus.SERVER_ERROR); - logger.error( - "Viewport WebSocket transport error: {}", - exception != null ? exception.getMessage() : "Unknown error"); - } - } - - /** - * Broadcasts a text message to all currently connected clients. - * - *

Broadcasting is done asynchronously for each client to prevent one slow client from blocking - * the update for others. - * - * @param message The message string to broadcast (e.g., a Data URI). - */ - public void broadcast(String message) { - if (sessions.isEmpty()) { - return; - } - for (WebSocketSession session : sessions) { - if (!session.isOpen()) { - sessions.remove(session); - continue; - } - wsExecutor.submit( - () -> { - try { - // Double check before sending - if (session.isOpen()) { - session.sendMessage(new TextMessage(message)); - } - } catch (IOException e) { - logger.warn("Failed to send viewport data to session: {}", e.getMessage()); - sessions.remove(session); - } - }); - } - } -} +package com.chromascape.web.viewport; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * A WebSocket handler specifically for the viewport endpoint. + * + *

This class manages the active WebSocket connections and provides functionality to broadcast + * image updates to all connected clients. + */ +@Component +public class ViewportWebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(ViewportWebSocketHandler.class); + + /** A thread-safe set of active WebSocket sessions. */ + private final Set sessions = new CopyOnWriteArraySet<>(); + + /** Executor service for sending messages asynchronously to avoid blocking. */ + private final ExecutorService wsExecutor = Executors.newCachedThreadPool(); + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + * @param session The session that was established. + */ + @Override + public void afterConnectionEstablished(@Nullable WebSocketSession session) { + sessions.add(session); + if (session != null) { + logger.info("Viewport WebSocket client connected"); + } + } + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a transport + * error has occurred. + * + * @param session The session that was closed. + * @param status The status code indicating why the session was closed. + */ + @Override + public void afterConnectionClosed( + @Nullable WebSocketSession session, @Nullable CloseStatus status) { + sessions.remove(session); + if (session != null) { + logger.info("Viewport WebSocket client disconnected"); + } + } + + /** + * Invoked when an error occurs in the underlying communication channel. + * + * @param session The session where the error occurred. + * @param exception The exception that occurred. + * @throws Exception If handling the error fails. + */ + @Override + public void handleTransportError( + @Nullable WebSocketSession session, @Nullable Throwable exception) throws Exception { + sessions.remove(session); + if (session != null) { + session.close(CloseStatus.SERVER_ERROR); + logger.error( + "Viewport WebSocket transport error: {}", + exception != null ? exception.getMessage() : "Unknown error"); + } + } + + /** + * Broadcasts a text message to all currently connected clients. + * + *

Broadcasting is done asynchronously for each client to prevent one slow client from blocking + * the update for others. + * + * @param message The message string to broadcast (e.g., a Data URI). + */ + public void broadcast(String message) { + if (sessions.isEmpty()) { + return; + } + for (WebSocketSession session : sessions) { + if (!session.isOpen()) { + sessions.remove(session); + continue; + } + wsExecutor.submit( + () -> { + try { + // Double check before sending + if (session.isOpen()) { + session.sendMessage(new TextMessage(message)); + } + } catch (IOException e) { + logger.warn("Failed to send viewport data to session: {}", e.getMessage()); + sessions.remove(session); + } + }); + } + } +} diff --git a/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java b/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java index 47be119..4f2d7b9 100644 --- a/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java +++ b/src/main/java/com/chromascape/web/viewport/WebsocketViewport.java @@ -1,133 +1,133 @@ -package com.chromascape.web.viewport; - -import com.chromascape.utils.core.screen.topology.TemplateMatching; -import com.chromascape.utils.core.screen.viewport.Viewport; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Base64; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicReference; -import javax.imageio.ImageIO; -import org.bytedeco.opencv.opencv_core.Mat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Component; - -/** - * A Websocket-based implementation of the {@link Viewport} interface. - * - *

This component is responsible for receiving visual updates from the bot, converting them to a - * web-friendly format (Base64 PNG), and broadcasting them to connected clients via the {@link - * ViewportWebSocketHandler}. - * - *

To ensure optimal performance, image conversion and network transmission are handled - * asynchronously on a separate thread, with frame dropping logic to prevent backpressure on the - * main bot loop. - */ -@Component -public class WebsocketViewport implements Viewport { - - /** logger for logging things :) . */ - private static final Logger logger = LoggerFactory.getLogger(WebsocketViewport.class); - - /** Websocket handler to broadcast messages. */ - private final ViewportWebSocketHandler handler; - - /** Holds the latest update to be processed, or null if empty. */ - private final AtomicReference pendingUpdate = new AtomicReference<>(); - - /** Executor service for running the background processing tasks. */ - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - - /** Flag indicating whether the background worker is currently busy. */ - private volatile boolean isProcessing = false; - - /** - * Constructs a new WebsocketViewport. - * - * @param handler The websocket handler for broadcasting messages. Injected lazily. - */ - @Autowired - public WebsocketViewport(@Lazy ViewportWebSocketHandler handler) { - this.handler = handler; - } - - /** - * Accepts a new image state from the bot. - * - *

If the worker thread is free, the {@link Mat} is converted to a {@link BufferedImage} and - * queued for processing. If the worker is busy, the frame is dropped to maintain performance. - * - * @param mat The raw OpenCV matrix representing the new state. - */ - @Override - public void updateState(Mat mat) { - // Optimization: Check if we are already processing a frame. - // If we are backlogged, DROP this frame immediately to save CPU. - // We only convert to BufferedImage if we actually plan to queue it. - if (isProcessing && pendingUpdate.get() != null) { - return; - } - - // Convert here. This cost is only incurred if we are NOT backlogged. - BufferedImage image = TemplateMatching.matToBufferedImage(mat); - - // Atomically set the latest update - pendingUpdate.set(image); - - // If not currently processing, trigger the worker - if (!isProcessing) { - executor.submit(this::processPendingUpdate); - } - } - - /** - * The background worker loop that processes and sends images. - * - *

It continues running as long as there are pending updates in the {@code pendingUpdate} - * reference. - */ - private void processPendingUpdate() { - isProcessing = true; - try { - // Keep processing as long as there is a pending update - BufferedImage image = pendingUpdate.getAndSet(null); - while (image != null) { - try { - String base64Image = encodeImageToBase64(image); - // Send raw data URI string directly - String message = "data:image/png;base64," + base64Image; - handler.broadcast(message); - } catch (IOException e) { - logger.error("Failed to encode image for Viewport: {}", e.getMessage()); - } - - // Check if a new update came in while we were processing - image = pendingUpdate.getAndSet(null); - } - } finally { - isProcessing = false; - // Double check race condition - if (pendingUpdate.get() != null) { - executor.submit(this::processPendingUpdate); - } - } - } - - /** - * Encodes a BufferedImage into a Base64 string representation of a PNG. - * - * @param image The image to encode. - * @return The Base64 encoded string. - * @throws IOException If writing the image fails. - */ - private String encodeImageToBase64(BufferedImage image) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ImageIO.write(image, "png", outputStream); - return Base64.getEncoder().encodeToString(outputStream.toByteArray()); - } -} +package com.chromascape.web.viewport; + +import com.chromascape.utils.core.screen.topology.TemplateMatching; +import com.chromascape.utils.core.screen.viewport.Viewport; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import javax.imageio.ImageIO; +import org.bytedeco.opencv.opencv_core.Mat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +/** + * A Websocket-based implementation of the {@link Viewport} interface. + * + *

This component is responsible for receiving visual updates from the bot, converting them to a + * web-friendly format (Base64 PNG), and broadcasting them to connected clients via the {@link + * ViewportWebSocketHandler}. + * + *

To ensure optimal performance, image conversion and network transmission are handled + * asynchronously on a separate thread, with frame dropping logic to prevent backpressure on the + * main bot loop. + */ +@Component +public class WebsocketViewport implements Viewport { + + /** logger for logging things :) . */ + private static final Logger logger = LoggerFactory.getLogger(WebsocketViewport.class); + + /** Websocket handler to broadcast messages. */ + private final ViewportWebSocketHandler handler; + + /** Holds the latest update to be processed, or null if empty. */ + private final AtomicReference pendingUpdate = new AtomicReference<>(); + + /** Executor service for running the background processing tasks. */ + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + /** Flag indicating whether the background worker is currently busy. */ + private volatile boolean isProcessing = false; + + /** + * Constructs a new WebsocketViewport. + * + * @param handler The websocket handler for broadcasting messages. Injected lazily. + */ + @Autowired + public WebsocketViewport(@Lazy ViewportWebSocketHandler handler) { + this.handler = handler; + } + + /** + * Accepts a new image state from the bot. + * + *

If the worker thread is free, the {@link Mat} is converted to a {@link BufferedImage} and + * queued for processing. If the worker is busy, the frame is dropped to maintain performance. + * + * @param mat The raw OpenCV matrix representing the new state. + */ + @Override + public void updateState(Mat mat) { + // Optimization: Check if we are already processing a frame. + // If we are backlogged, DROP this frame immediately to save CPU. + // We only convert to BufferedImage if we actually plan to queue it. + if (isProcessing && pendingUpdate.get() != null) { + return; + } + + // Convert here. This cost is only incurred if we are NOT backlogged. + BufferedImage image = TemplateMatching.matToBufferedImage(mat); + + // Atomically set the latest update + pendingUpdate.set(image); + + // If not currently processing, trigger the worker + if (!isProcessing) { + executor.submit(this::processPendingUpdate); + } + } + + /** + * The background worker loop that processes and sends images. + * + *

It continues running as long as there are pending updates in the {@code pendingUpdate} + * reference. + */ + private void processPendingUpdate() { + isProcessing = true; + try { + // Keep processing as long as there is a pending update + BufferedImage image = pendingUpdate.getAndSet(null); + while (image != null) { + try { + String base64Image = encodeImageToBase64(image); + // Send raw data URI string directly + String message = "data:image/png;base64," + base64Image; + handler.broadcast(message); + } catch (IOException e) { + logger.error("Failed to encode image for Viewport: {}", e.getMessage()); + } + + // Check if a new update came in while we were processing + image = pendingUpdate.getAndSet(null); + } + } finally { + isProcessing = false; + // Double check race condition + if (pendingUpdate.get() != null) { + executor.submit(this::processPendingUpdate); + } + } + } + + /** + * Encodes a BufferedImage into a Base64 string representation of a PNG. + * + * @param image The image to encode. + * @return The Base64 encoded string. + * @throws IOException If writing the image fails. + */ + private String encodeImageToBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } +} diff --git a/src/test/java/com/chromascape/ChromaScapeApplicationTests.java b/src/test/java/com/chromascape/ChromaScapeApplicationTests.java index e51258d..186acc8 100644 --- a/src/test/java/com/chromascape/ChromaScapeApplicationTests.java +++ b/src/test/java/com/chromascape/ChromaScapeApplicationTests.java @@ -1,11 +1,11 @@ -package com.chromascape; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest(classes = com.chromascape.web.ChromaScapeApplication.class) -class ChromaScapeApplicationTests { - - @Test - void contextLoads() {} -} +package com.chromascape; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = com.chromascape.web.ChromaScapeApplication.class) +class ChromaScapeApplicationTests { + + @Test + void contextLoads() {} +}