diff --git a/src/client/java/skybot/SkybotClient.java b/src/client/java/skybot/SkybotClient.java index 5629dd3..ade59c7 100644 --- a/src/client/java/skybot/SkybotClient.java +++ b/src/client/java/skybot/SkybotClient.java @@ -1,14 +1,41 @@ package skybot; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.minecraft.client.network.ServerInfo; +import skybot.controller.Controller; +import skybot.controller.task.HoldKeyTask; +import skybot.controller.task.PauseTask; +import skybot.controller.task.TaskQueue; +import skybot.utilities.ChatMenu; public class SkybotClient implements ClientModInitializer { public static final String HYPIXEL_URL = "hypixel.net"; + private static final TaskQueue TASK_QUEUE = new TaskQueue(); + @Override public void onInitializeClient() { + ClientTickEvents.END_CLIENT_TICK.register(client -> { + TASK_QUEUE.tick(); + Controller.tick(client); + }); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { + dispatcher.register(literal("test").executes(context -> { + TASK_QUEUE.clear(); + TASK_QUEUE.add(new HoldKeyTask(Controller::setForward, 200)); + TASK_QUEUE.add(new PauseTask(400)); + TASK_QUEUE.add(new HoldKeyTask(Controller::setRight, 400)); + ChatMenu.send("running: forward 2s -> pause 4s -> right 4s"); + return 1; + })); + }); + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { client.execute(() -> { if (client.player == null) @@ -21,6 +48,9 @@ public class SkybotClient implements ClientModInitializer { }); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { + TASK_QUEUE.clear(); + Controller.releaseAll(); + ServerInfo server = client.getCurrentServerEntry(); if (server == null || !server.address.contains(HYPIXEL_URL)) return; diff --git a/src/client/java/skybot/controller/Controller.java b/src/client/java/skybot/controller/Controller.java new file mode 100644 index 0000000..fec1aea --- /dev/null +++ b/src/client/java/skybot/controller/Controller.java @@ -0,0 +1,134 @@ +package skybot.controller; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.option.GameOptions; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.util.math.MathHelper; + +/** + * Owns simulated input state (movement keys + look direction) and applies it + * to the client each tick. Higher-level behavior code should set state here + * rather than touching GameOptions/player rotation directly. + */ +public class Controller { + private final static KeyOverride forward = new KeyOverride(); + private final static KeyOverride back = new KeyOverride(); + private final static KeyOverride left = new KeyOverride(); + private final static KeyOverride right = new KeyOverride(); + private final static KeyOverride jumping = new KeyOverride(); + private final static KeyOverride sprinting = new KeyOverride(); + private final static KeyOverride sneaking = new KeyOverride(); + + private static Double targetYaw = null; + private static Double targetPitch = null; + private static double turnSpeed = 10.0; // max degrees per tick + + public static void setForward(Boolean pressed) { + forward.set(pressed); + } + + public static void setBack(Boolean pressed) { + back.set(pressed); + } + + public static void setLeft(Boolean pressed) { + left.set(pressed); + } + + public static void setRight(Boolean pressed) { + right.set(pressed); + } + + public static void setJumping(Boolean pressed) { + jumping.set(pressed); + } + + public static void setSprinting(Boolean pressed) { + sprinting.set(pressed); + } + + public static void setSneaking(Boolean pressed) { + sneaking.set(pressed); + } + + public static void lookAt(double yaw, double pitch) { + targetYaw = yaw; + targetPitch = MathHelper.clamp(pitch, -90.0, 90.0); + } + + public static void setTurnSpeed(double degreesPerTick) { + turnSpeed = degreesPerTick; + } + + public static void clearLook() { + targetYaw = null; + targetPitch = null; + } + + /** Clears every override, handing all input back to the player. Call on disconnect. */ + public static void releaseAll() { + forward.set(null); + back.set(null); + left.set(null); + right.set(null); + jumping.set(null); + sprinting.set(null); + sneaking.set(null); + clearLook(); + } + + public static void tick(MinecraftClient client) { + ClientPlayerEntity player = client.player; + if (player == null) + return; + + GameOptions options = client.options; + forward.apply(options.forwardKey); + back.apply(options.backKey); + left.apply(options.leftKey); + right.apply(options.rightKey); + jumping.apply(options.jumpKey); + sprinting.apply(options.sprintKey); + sneaking.apply(options.sneakKey); + + if (targetYaw != null && targetPitch != null) { + applyLook(player); + } + } + + private static void applyLook(ClientPlayerEntity player) { + double yawDiff = MathHelper.wrapDegrees(targetYaw - player.getYaw()); + double pitchDiff = targetPitch - player.getPitch(); + + double yawStep = MathHelper.clamp(yawDiff, -turnSpeed, turnSpeed); + double pitchStep = MathHelper.clamp(pitchDiff, -turnSpeed, turnSpeed); + + player.setYaw((float) (player.getYaw() + yawStep)); + player.setPitch((float) MathHelper.clamp(player.getPitch() + pitchStep, -90.0, 90.0)); + } + + /** + * Tracks one key's override state. null means "no override, leave real input alone." + * When an override is released (goes back to null), forces the key up exactly once + * so it doesn't stay latched from the last forced value, then stops touching it. + */ + private static class KeyOverride { + private Boolean state; + private boolean controlled = false; + + void set(Boolean pressed) { + state = pressed; + } + + void apply(KeyBinding binding) { + if (state != null) { + binding.setPressed(state); + controlled = true; + } else if (controlled) { + binding.setPressed(false); + controlled = false; + } + } + } +} diff --git a/src/client/java/skybot/controller/task/HoldKeyTask.java b/src/client/java/skybot/controller/task/HoldKeyTask.java new file mode 100644 index 0000000..906eb65 --- /dev/null +++ b/src/client/java/skybot/controller/task/HoldKeyTask.java @@ -0,0 +1,39 @@ +package skybot.controller.task; + +import java.util.function.Consumer; + +/** Holds one Controller key override for a fixed duration, then releases it. */ +public class HoldKeyTask implements Task { + private final Consumer setter; + private final long durationMillis; + private long endTime; + + public HoldKeyTask(Consumer setter, long durationMillis) { + this.setter = setter; + this.durationMillis = durationMillis; + } + + @Override + public void start() { + endTime = System.currentTimeMillis() + durationMillis; + setter.accept(true); + } + + @Override + public void tick() { + } + + @Override + public boolean isDone() { + if (endTime == 0) { + return false; + } + + return System.currentTimeMillis() >= endTime; + } + + @Override + public void stop() { + setter.accept(null); + } +} diff --git a/src/client/java/skybot/controller/task/PauseTask.java b/src/client/java/skybot/controller/task/PauseTask.java new file mode 100644 index 0000000..19e16ee --- /dev/null +++ b/src/client/java/skybot/controller/task/PauseTask.java @@ -0,0 +1,32 @@ +package skybot.controller.task; + +/** + * Does nothing for a fixed duration; leaves whatever keys were already released + * alone. + */ +public class PauseTask implements Task { + private final long durationMillis; + private long endTime; + + public PauseTask(long durationMillis) { + this.durationMillis = durationMillis; + } + + @Override + public void start() { + endTime = System.currentTimeMillis() + durationMillis; + } + + @Override + public void tick() { + } + + @Override + public boolean isDone() { + if (endTime == 0) { + return false; + } + + return System.currentTimeMillis() >= endTime; + } +} diff --git a/src/client/java/skybot/controller/task/Task.java b/src/client/java/skybot/controller/task/Task.java new file mode 100644 index 0000000..7ba0736 --- /dev/null +++ b/src/client/java/skybot/controller/task/Task.java @@ -0,0 +1,16 @@ +package skybot.controller.task; + +public interface Task { + /** Called once, the tick this task becomes active. */ + default void start() { + } + + /** Called every tick while this task is active. */ + void tick(); + + boolean isDone(); + + /** Called once, right after isDone() first returns true, before the next task starts. */ + default void stop() { + } +} diff --git a/src/client/java/skybot/controller/task/TaskQueue.java b/src/client/java/skybot/controller/task/TaskQueue.java new file mode 100644 index 0000000..9d7c214 --- /dev/null +++ b/src/client/java/skybot/controller/task/TaskQueue.java @@ -0,0 +1,70 @@ +package skybot.controller.task; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.ThreadLocalRandom; +import skybot.utilities.ChatMenu; + +/** + * Runs Tasks one at a time, in order, advancing once the active task reports + * done. + */ +public class TaskQueue { + private final Deque pending = new ArrayDeque<>(); + private Task current; + private boolean currentStarted = false; + + private long delayUntil = 0; + + // Track the exact random delay we rolled for the active task + private long rolledDelay = 0; + + public TaskQueue add(Task task) { + pending.add(task); + return this; + } + + public void tick() { + if (current != null && current.isDone()) { + current.stop(); + current = null; + currentStarted = false; + } + + if (current == null) { + current = pending.poll(); + if (current != null) { + // Roll the random delay + rolledDelay = ThreadLocalRandom.current().nextLong(51, 200); + delayUntil = System.currentTimeMillis() + rolledDelay; + } + } + + if (current != null && System.currentTimeMillis() >= delayUntil) { + if (!currentStarted) { + // Calculate the actual elapsed overhead due to Minecraft's 50ms tick rate + long actualOverhead = System.currentTimeMillis() - delayUntil; + long totalDelta = rolledDelay + actualOverhead; + + ChatMenu.send( + String.format("Starting new job... (Target delay: %dms, Actual delta: %dms)", rolledDelay, totalDelta)); + + current.start(); + currentStarted = true; + } + + current.tick(); + } + } + + public boolean isDone() { + return current == null && pending.isEmpty(); + } + + public void clear() { + pending.clear(); + current = null; + delayUntil = 0; + rolledDelay = 0; + } +} \ No newline at end of file