From 52efd1fb4c3f2f6a5b4cceb63105f5b2679cc78e Mon Sep 17 00:00:00 2001 From: guivnf Date: Sat, 15 Aug 2026 00:57:47 -0300 Subject: [PATCH 1/4] add grappling hook new item in the ParCool tab, crafted from a new hook part. attack throws and releases it, holding use hauls you in, sneak pays rope out. the swing is a rope length constraint solved with substeps. the rope slides along block edges and bends around corners as you swing past them, and lets go if the line gets overstretched or overloaded. adds two ActionOption flags for abilities gated by an item rather than by the skill tree or stamina. --- changelog/v_3.4.4.0.md | 19 + .../com/alrex/parcool/api/action/Action.java | 3 +- .../parcool/api/action/ActionOption.java | 16 +- .../parcool/client/GrappleCameraHandler.java | 91 +++ .../parcool/client/GrappleTargetOverlay.java | 83 ++ .../parcool/client/input/ParCoolKeyBinds.java | 2 + .../client/renderer/GrappleRopeRenderer.java | 400 ++++++++++ .../renderer/GrapplingHookItemRenderer.java | 53 ++ .../parcool/client/renderer/RenderTypes.java | 14 + .../renderer/entity/GrappleTipModel.java | 44 ++ .../entity/layers/ParCoolModelLayers.java | 3 + .../sound/GrappleSwingSoundInstance.java | 30 + .../alrex/parcool/common/Parkourability.java | 5 +- .../parcool/common/action/ParCoolActions.java | 9 + .../parcool/common/action/impl/Castaway.java | 4 +- .../parcool/common/action/impl/ClimbUp.java | 4 +- .../parcool/common/action/impl/Dive.java | 2 +- .../parcool/common/action/impl/Grapple.java | 732 ++++++++++++++++++ .../parcool/common/action/impl/HangDown.java | 2 +- .../parcool/common/action/impl/HangOn.java | 1 + .../common/action/impl/HorizontalWallRun.java | 2 +- .../parcool/common/action/impl/PoleClimb.java | 1 + .../common/action/impl/RideZipline.java | 1 + .../parcool/common/action/impl/SlideDown.java | 1 + .../parcool/common/action/impl/WallRun.java | 1 + .../parcool/common/grapple/GrapplePhase.java | 9 + .../common/grapple/GrapplePhysics.java | 63 ++ .../common/grapple/GrappleTargeting.java | 122 +++ .../parcool/common/grapple/RopeState.java | 347 +++++++++ .../parcool/common/handlers/InputHandler.java | 4 + .../parcool/common/item/ParCoolItems.java | 3 + .../common/item/misc/GrapplingHookItem.java | 71 ++ .../alrex/parcool/config/ParCoolConfig.java | 103 +++ .../mixin/client/ItemInHandRendererMixin.java | 7 +- .../client/PlayerItemInHandLayerMixin.java | 3 + .../ServerGamePacketListenerImplMixin.java | 3 +- .../com/alrex/parcool/proxy/ClientProxy.java | 8 + .../resources/assets/parcool/lang/en_us.json | 8 + .../resources/assets/parcool/lang/ja_jp.json | 8 + .../parcool/models/item/grappling_hook.json | 141 ++++ .../models/item/grappling_hook_gui.json | 6 + .../models/item/grappling_hook_in_hand.json | 135 ++++ .../assets/parcool/models/item/hook.json | 6 + .../parcool/textures/item/grappling_hook.png | Bin 0 -> 439 bytes .../textures/item/grappling_hook_model.png | Bin 0 -> 569 bytes .../assets/parcool/textures/item/hook.png | Bin 0 -> 431 bytes .../parcool/textures/misc/grapple_rope.png | Bin 0 -> 108 bytes .../parcool/textures/misc/grapple_target.png | Bin 0 -> 201 bytes .../data/parcool/recipes/grappling_hook.json | 26 + .../resources/data/parcool/recipes/hook.json | 17 + 50 files changed, 2602 insertions(+), 11 deletions(-) create mode 100644 changelog/v_3.4.4.0.md create mode 100644 src/main/java/com/alrex/parcool/client/GrappleCameraHandler.java create mode 100644 src/main/java/com/alrex/parcool/client/GrappleTargetOverlay.java create mode 100644 src/main/java/com/alrex/parcool/client/renderer/GrappleRopeRenderer.java create mode 100644 src/main/java/com/alrex/parcool/client/renderer/GrapplingHookItemRenderer.java create mode 100644 src/main/java/com/alrex/parcool/client/renderer/entity/GrappleTipModel.java create mode 100644 src/main/java/com/alrex/parcool/client/sound/GrappleSwingSoundInstance.java create mode 100644 src/main/java/com/alrex/parcool/common/action/impl/Grapple.java create mode 100644 src/main/java/com/alrex/parcool/common/grapple/GrapplePhase.java create mode 100644 src/main/java/com/alrex/parcool/common/grapple/GrapplePhysics.java create mode 100644 src/main/java/com/alrex/parcool/common/grapple/GrappleTargeting.java create mode 100644 src/main/java/com/alrex/parcool/common/grapple/RopeState.java create mode 100644 src/main/java/com/alrex/parcool/common/item/misc/GrapplingHookItem.java create mode 100644 src/main/resources/assets/parcool/models/item/grappling_hook.json create mode 100644 src/main/resources/assets/parcool/models/item/grappling_hook_gui.json create mode 100644 src/main/resources/assets/parcool/models/item/grappling_hook_in_hand.json create mode 100644 src/main/resources/assets/parcool/models/item/hook.json create mode 100644 src/main/resources/assets/parcool/textures/item/grappling_hook.png create mode 100644 src/main/resources/assets/parcool/textures/item/grappling_hook_model.png create mode 100644 src/main/resources/assets/parcool/textures/item/hook.png create mode 100644 src/main/resources/assets/parcool/textures/misc/grapple_rope.png create mode 100644 src/main/resources/assets/parcool/textures/misc/grapple_target.png create mode 100644 src/main/resources/data/parcool/recipes/grappling_hook.json create mode 100644 src/main/resources/data/parcool/recipes/hook.json diff --git a/changelog/v_3.4.4.0.md b/changelog/v_3.4.4.0.md new file mode 100644 index 000000000..0b9a77516 --- /dev/null +++ b/changelog/v_3.4.4.0.md @@ -0,0 +1,19 @@ +### New Features + +- Grappling Hook + - New item + - Physics based rope swinging + - Aim assist cone, used only when the crosshair itself misses + +### Modified + +- ParCool Action API + - `ActionOption.needLearning` : lets an action opt out of skill tree gating, for abilities that + are already gated by something else such as an item + - `ActionOption.availableWhileExhausted` : lets an action run on empty stamina, for abilities + that are not paid for with stamina + +### Translation + +- English +- Japanese diff --git a/src/main/java/com/alrex/parcool/api/action/Action.java b/src/main/java/com/alrex/parcool/api/action/Action.java index 981b88bfb..c89a31320 100644 --- a/src/main/java/com/alrex/parcool/api/action/Action.java +++ b/src/main/java/com/alrex/parcool/api/action/Action.java @@ -102,8 +102,9 @@ public void startExplicitly() { protected final boolean isPossible() { var player = parkourability.player(); - if (parkourability.player().isSpectator() || (parkourability.getStamina().isExhausted())) return false; var option = entry.option(); + if (player.isSpectator()) return false; + if (!option.availableWhileExhausted() && parkourability.getStamina().isExhausted()) return false; if ((option.neededPose() != null && option.neededPose() != player.getPose()) || (!option.availableInFluid() && player.isInFluidType()) || (!option.availableNotInFluid() && !player.isInFluidType()) diff --git a/src/main/java/com/alrex/parcool/api/action/ActionOption.java b/src/main/java/com/alrex/parcool/api/action/ActionOption.java index 79bc182d1..4d795fb31 100644 --- a/src/main/java/com/alrex/parcool/api/action/ActionOption.java +++ b/src/main/java/com/alrex/parcool/api/action/ActionOption.java @@ -20,6 +20,8 @@ public record Value( boolean availableInFluid, boolean availableNotInFluid, boolean availableWithFallFlying, + boolean availableWhileExhausted, + boolean needLearning, LogicalSide triggeredSide ) { } @@ -34,13 +36,15 @@ public record Value( private boolean availableInFluid = false; private boolean availableNotInFluid = true; private boolean availableWithFallFlying = false; + private boolean availableWhileExhausted = false; + private boolean needLearning = true; private boolean needOnGround = false; private boolean needNotOnGround = false; private LogicalSide triggeredSide = LogicalSide.CLIENT; public Value build() { return new Value( - staminaConsumption, learningCost, parent, neededPose, beforeProcessedActions, needOnGround, needNotOnGround, availableInFluid, availableNotInFluid, availableWithFallFlying, triggeredSide + staminaConsumption, learningCost, parent, neededPose, beforeProcessedActions, needOnGround, needNotOnGround, availableInFluid, availableNotInFluid, availableWithFallFlying, availableWhileExhausted, needLearning, triggeredSide ); } @@ -93,6 +97,16 @@ public ActionOption availableWithFallFlying(boolean availableWithFallFlying) { return this; } + public ActionOption availableWhileExhausted(boolean availableWhileExhausted) { + this.availableWhileExhausted = availableWhileExhausted; + return this; + } + + public ActionOption needLearning(boolean needLearning) { + this.needLearning = needLearning; + return this; + } + public ActionOption triggeredSide(LogicalSide side) { this.triggeredSide = side; return this; diff --git a/src/main/java/com/alrex/parcool/client/GrappleCameraHandler.java b/src/main/java/com/alrex/parcool/client/GrappleCameraHandler.java new file mode 100644 index 000000000..1d0721615 --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/GrappleCameraHandler.java @@ -0,0 +1,91 @@ +package com.alrex.parcool.client; + +import com.alrex.parcool.ParCool; +import com.alrex.parcool.common.Parkourability; +import com.alrex.parcool.common.action.ParCoolActions; +import com.alrex.parcool.common.action.impl.Grapple; +import com.alrex.parcool.util.EntityUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.ComputeFovModifierEvent; +import net.minecraftforge.client.event.ViewportEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; + +@OnlyIn(Dist.CLIENT) +public class GrappleCameraHandler { + private static final double REFERENCE_SPEED = 1.6; + + private static final float MAX_FOV_GAIN = 0.18f; + + private static final float MAX_ROLL_DEGREES = 8f; + + private static final double SMOOTHING = 0.18; + + private static float previousIntensity = 0; + private static float currentIntensity = 0; + private static float previousRoll = 0; + private static float currentRoll = 0; + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase == TickEvent.Phase.START) return; + previousIntensity = currentIntensity; + previousRoll = currentRoll; + + float targetIntensity = 0; + float targetRoll = 0; + + LocalPlayer player = Minecraft.getInstance().player; + if (player != null && !Minecraft.getInstance().isPaused()) { + Parkourability parkourability = Parkourability.get(player); + if (parkourability != null) { + Grapple grapple = parkourability.get(ParCoolActions.GRAPPLE); + if (grapple.isAttached() || grapple.isMomentumActive()) { + Vec3 movement = player.getDeltaMovement(); + double speed = Math.sqrt(movement.x * movement.x + movement.z * movement.z); + targetIntensity = (float) Mth.clamp(speed / REFERENCE_SPEED, 0, 1); + targetRoll = grapple.isAttached() ? bankAngle(player, grapple, targetIntensity) : 0; + } + } + } + currentIntensity = (float) Mth.lerp(SMOOTHING, currentIntensity, targetIntensity); + currentRoll = (float) Mth.lerp(SMOOTHING, currentRoll, targetRoll); + } + + private static float bankAngle(LocalPlayer player, Grapple grapple, float intensity) { + Vec3 pivot = grapple.getPivot(); + if (pivot == null) return 0; + Vec3 toPivot = pivot.subtract(player.position()); + double horizontal = Math.sqrt(toPivot.x * toPivot.x + toPivot.z * toPivot.z); + if (horizontal < 1.0e-4) return 0; + + Vec3 forward = EntityUtil.getHorizontalLookAngle(player); + Vec3 right = new Vec3(-forward.z, 0, forward.x); + double lateral = (toPivot.x * right.x + toPivot.z * right.z) / horizontal; + return (float) (lateral * MAX_ROLL_DEGREES * intensity); + } + + @SubscribeEvent + public static void onComputeFov(ComputeFovModifierEvent event) { + double configured = ParCool.getConfig().client().grapplingHook.fovIntensity().get(); + if (configured <= 0) return; + float partialTick = Minecraft.getInstance().getFrameTime(); + float intensity = Mth.lerp(partialTick, previousIntensity, currentIntensity); + if (intensity <= 1.0e-4) return; + event.setNewFovModifier(event.getNewFovModifier() * (1f + MAX_FOV_GAIN * intensity * (float) configured)); + } + + @SubscribeEvent + public static void onComputeCameraAngles(ViewportEvent.ComputeCameraAngles event) { + double configured = ParCool.getConfig().client().grapplingHook.cameraRollIntensity().get(); + if (configured <= 0) return; + float roll = Mth.lerp((float) event.getPartialTick(), previousRoll, currentRoll); + if (Math.abs(roll) <= 1.0e-4) return; + event.setRoll(event.getRoll() + roll * (float) configured); + } +} diff --git a/src/main/java/com/alrex/parcool/client/GrappleTargetOverlay.java b/src/main/java/com/alrex/parcool/client/GrappleTargetOverlay.java new file mode 100644 index 000000000..4b29db4e9 --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/GrappleTargetOverlay.java @@ -0,0 +1,83 @@ +package com.alrex.parcool.client; + +import com.alrex.parcool.ParCool; +import com.alrex.parcool.common.Parkourability; +import com.alrex.parcool.common.action.ParCoolActions; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Vector4f; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiComponent; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderGuiEvent; +import net.minecraftforge.client.event.RenderLevelStageEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; + +@OnlyIn(Dist.CLIENT) +public class GrappleTargetOverlay { + public static final ResourceLocation TEXTURE = ParCool.resourceLocation("textures/misc/grapple_target.png"); + private static final int TEXTURE_SIZE = 15; + + private static float screenX; + private static float screenY; + private static boolean visible = false; + + @SubscribeEvent + public static void onRenderLevel(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) return; + visible = false; + + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player == null || minecraft.options.hideGui) return; + Parkourability parkourability = Parkourability.get(minecraft.player); + if (parkourability == null) return; + Vec3 target = parkourability.get(ParCoolActions.GRAPPLE).getPreviewTarget(); + if (target == null) return; + + Vec3 camera = event.getCamera().getPosition(); + Vector4f point = new Vector4f( + (float) (target.x - camera.x), + (float) (target.y - camera.y), + (float) (target.z - camera.z), + 1f + ); + point.transform(event.getPoseStack().last().pose()); + point.transform(event.getProjectionMatrix()); + + if (point.w() <= 1.0e-4) return; + + float normalisedX = point.x() / point.w(); + float normalisedY = point.y() / point.w(); + if (Math.abs(normalisedX) > 1.5f || Math.abs(normalisedY) > 1.5f) return; + + var window = minecraft.getWindow(); + screenX = (normalisedX * 0.5f + 0.5f) * window.getGuiScaledWidth(); + screenY = (1 - (normalisedY * 0.5f + 0.5f)) * window.getGuiScaledHeight(); + visible = true; + } + + @SubscribeEvent + public static void onRenderGui(RenderGuiEvent.Post event) { + if (!visible) return; + int size = ParCool.getConfig().client().targetIndicatorSize.get(); + PoseStack poseStack = event.getPoseStack(); + + RenderSystem.setShader(GameRenderer::getPositionTexShader); + RenderSystem.setShaderTexture(0, TEXTURE); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + + GuiComponent.blit( + poseStack, + Math.round(screenX - size / 2f), Math.round(screenY - size / 2f), + size, size, + 0, 0, TEXTURE_SIZE, TEXTURE_SIZE, TEXTURE_SIZE, TEXTURE_SIZE + ); + + RenderSystem.disableBlend(); + } +} diff --git a/src/main/java/com/alrex/parcool/client/input/ParCoolKeyBinds.java b/src/main/java/com/alrex/parcool/client/input/ParCoolKeyBinds.java index 21194e6a3..8936bbf48 100644 --- a/src/main/java/com/alrex/parcool/client/input/ParCoolKeyBinds.java +++ b/src/main/java/com/alrex/parcool/client/input/ParCoolKeyBinds.java @@ -112,6 +112,8 @@ private static LogicalInput listen(BooleanSupplier keyDownSupplier) { public static final Input HIDE_IN_BLOCK = register(new KeyMapping("key.parcool.hide_in_block", GLFW.GLFW_KEY_C, KEY_CATEGORY)); public static final LogicalInput JUMP = listen(Minecraft.getInstance().options.keyJump::isDown); + public static final LogicalInput USE_ITEM = listen(Minecraft.getInstance().options.keyUse::isDown); + public static final LogicalInput ATTACK = listen(Minecraft.getInstance().options.keyAttack::isDown); public static final LogicalInput SHIFT = listen(Minecraft.getInstance().options.keyShift::isDown); public static final LogicalInput MOVEMENT_FORWARD = listen(Minecraft.getInstance().options.keyUp::isDown); public static final LogicalInput MOVEMENT_BACK = listen(Minecraft.getInstance().options.keyDown::isDown); diff --git a/src/main/java/com/alrex/parcool/client/renderer/GrappleRopeRenderer.java b/src/main/java/com/alrex/parcool/client/renderer/GrappleRopeRenderer.java new file mode 100644 index 000000000..bd037e861 --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/renderer/GrappleRopeRenderer.java @@ -0,0 +1,400 @@ +package com.alrex.parcool.client.renderer; + +import com.alrex.parcool.ParCool; +import com.alrex.parcool.common.Parkourability; +import com.alrex.parcool.common.action.ParCoolActions; +import com.alrex.parcool.common.action.impl.Grapple; +import com.alrex.parcool.common.grapple.GrapplePhase; +import com.alrex.parcool.common.grapple.GrapplePhysics; +import com.alrex.parcool.client.renderer.entity.GrappleTipModel; +import com.alrex.parcool.client.renderer.entity.layers.ParCoolModelLayers; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Matrix4f; +import com.mojang.math.Vector3f; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.RenderLevelStageEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; + +import java.util.ArrayList; +import java.util.List; + +@OnlyIn(Dist.CLIENT) +public class GrappleRopeRenderer { + public static final ResourceLocation TEXTURE_LOCATION = ParCool.resourceLocation("textures/misc/grapple_rope.png"); + + public static final ResourceLocation TIP_TEXTURE_LOCATION = ParCool.resourceLocation("textures/item/grappling_hook_model.png"); + + private static GrappleTipModel tipModel = null; + + private static final float ROPE_RADIUS = 0.04f; + + private static final double SEGMENT_LENGTH = 0.6; + + private static final double TEXTURE_REPEAT_LENGTH = 1.0; + private static final int MAX_SEGMENTS = 80; + private static final double MAX_SAG = 0.45; + + private static final double CORNER_RADIUS = 0.4; + private static final int CORNER_STEPS = 7; + + @SubscribeEvent + public static void onRenderLevel(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_CUTOUT_BLOCKS) return; + Minecraft minecraft = Minecraft.getInstance(); + Level level = minecraft.level; + if (level == null) return; + + float partialTick = event.getPartialTick(); + Camera camera = event.getCamera(); + MultiBufferSource.BufferSource bufferSource = minecraft.renderBuffers().bufferSource(); + List paths = null; + for (Player player : level.players()) { + RopePath path = buildPath(minecraft, player, camera, partialTick); + if (path == null) continue; + if (paths == null) paths = new ArrayList<>(2); + paths.add(path); + } + if (paths == null) return; + + PoseStack poseStack = event.getPoseStack(); + Vec3 cameraPosition = camera.getPosition(); + VertexConsumer consumer = bufferSource.getBuffer(RenderTypes.GRAPPLE_ROPE); + + poseStack.pushPose(); + poseStack.translate(-cameraPosition.x, -cameraPosition.y, -cameraPosition.z); + Matrix4f matrix = poseStack.last().pose(); + for (RopePath path : paths) { + renderRope(matrix, consumer, level, path); + } + bufferSource.endBatch(RenderTypes.GRAPPLE_ROPE); + + VertexConsumer tipConsumer = bufferSource.getBuffer(RenderType.entityCutoutNoCull(TIP_TEXTURE_LOCATION)); + for (RopePath path : paths) { + renderTip(poseStack, tipConsumer, level, path); + } + poseStack.popPose(); + bufferSource.endBatch(RenderType.entityCutoutNoCull(TIP_TEXTURE_LOCATION)); + } + + private static void renderTip(PoseStack poseStack, VertexConsumer consumer, Level level, RopePath path) { + List points = path.points(); + if (points.size() < 2) return; + if (tipModel == null) { + tipModel = new GrappleTipModel(Minecraft.getInstance().getEntityModels().bakeLayer(ParCoolModelLayers.GRAPPLE_TIP)); + } + + Vec3 end = points.get(points.size() - 1); + Vec3 along = end.subtract(points.get(points.size() - 2)); + double length = along.length(); + if (length < 1.0e-6) return; + + Vec3 up = along.scale(-1 / length); + double horizontal = Math.sqrt(up.x * up.x + up.z * up.z); + + poseStack.pushPose(); + poseStack.translate(end.x, end.y, end.z); + poseStack.mulPose(Vector3f.YP.rotation((float) Math.atan2(up.x, up.z))); + poseStack.mulPose(Vector3f.XP.rotation((float) Math.atan2(horizontal, up.y))); + + poseStack.scale(-1f, -1f, 1f); + tipModel.render(poseStack, consumer, lightAt(level, end), OverlayTexture.NO_OVERLAY); + poseStack.popPose(); + } + + private record RopePath(List points, Grapple grapple) { + } + + private static RopePath buildPath(Minecraft minecraft, Player player, Camera camera, float partialTick) { + Parkourability parkourability = Parkourability.get(player); + if (parkourability == null) return null; + Grapple grapple = parkourability.get(ParCoolActions.GRAPPLE); + if (!grapple.isDoing()) return null; + Vec3 anchor = grapple.getAnchor(); + if (anchor == null) return null; + + Vec3 origin = ropeOrigin(minecraft, player, camera, partialTick); + GrapplePhase phase = grapple.getPhase(); + + if (phase != GrapplePhase.ATTACHED) { + Vec3 hook = origin.add(anchor.subtract(origin).scale(grapple.getFlightProgress(partialTick))); + List flying = new ArrayList<>(); + appendStraight(flying, origin, hook, 0, Vec3.ZERO); + return new RopePath(flying, grapple); + } + + List corners = new ArrayList<>(4); + List bends = grapple.getBends(); + if (!bends.isEmpty()) { + for (int i = bends.size() - 1; i >= 0; i--) { + corners.add(bends.get(i)); + } + } else { + Vec3 pivot = grapple.getPivot(); + if (pivot != null && pivot.distanceToSqr(anchor) > 1.0e-4) corners.add(pivot); + } + + List nodes = new ArrayList<>(corners.size() + 2); + nodes.add(origin); + nodes.addAll(corners); + nodes.add(anchor); + + double slack = grapple.getRopeLength() - origin.distanceTo(nodes.get(1)); + return new RopePath(buildPolyline(nodes, slack, grapple.getRopeWobble()), grapple); + } + + private static List buildPolyline(List nodes, double slack, Vec3 wobble) { + int count = nodes.size(); + List points = new ArrayList<>(); + Vec3 from = nodes.get(0); + + for (int i = 1; i < count; i++) { + Vec3 corner = nodes.get(i); + boolean isCorner = i < count - 1; + + Vec3 enter = corner; + Vec3 exit = corner; + if (isCorner) { + Vec3 incoming = corner.subtract(from); + Vec3 outgoing = nodes.get(i + 1).subtract(corner); + double incomingLength = incoming.length(); + double outgoingLength = outgoing.length(); + if (incomingLength > 1.0e-4 && outgoingLength > 1.0e-4) { + double radius = Math.min(CORNER_RADIUS, Math.min(incomingLength, outgoingLength) * 0.45); + enter = corner.subtract(incoming.scale(radius / incomingLength)); + exit = corner.add(outgoing.scale(radius / outgoingLength)); + } + } + + appendStraight(points, from, enter, i == 1 ? slack : 0, i == 1 ? wobble : Vec3.ZERO); + if (isCorner) { + appendCorner(points, enter, corner, exit); + from = exit; + } + } + return points; + } + + private static void appendStraight(List points, Vec3 from, Vec3 to, double slack, Vec3 wobble) { + double length = from.distanceTo(to); + int steps = Mth.clamp(Mth.ceil(length / SEGMENT_LENGTH), 1, MAX_SEGMENTS); + double sag = 0; + if (slack > 0 && length > 1.0e-6) { + double horizontal = Math.sqrt(Mth.square(to.x - from.x) + Mth.square(to.z - from.z)) / length; + sag = Math.min(slack * 0.2, MAX_SAG) + * horizontal + * ParCool.getConfig().client().grapplingHook.ropeSag().get(); + } + boolean bowed = wobble.lengthSqr() > 1.0e-8; + + for (int i = points.isEmpty() ? 0 : 1; i <= steps; i++) { + double t = (double) i / steps; + Vec3 point = from.add(to.subtract(from).scale(t)); + + double bulge = 4 * t * (1 - t); + if (sag > 0) { + point = point.subtract(0, sag * bulge, 0); + } + if (bowed) { + point = point.add(wobble.scale(bulge)); + } + points.add(point); + } + } + + private static void appendCorner(List points, Vec3 enter, Vec3 corner, Vec3 exit) { + for (int i = 1; i <= CORNER_STEPS; i++) { + double t = (double) i / CORNER_STEPS; + double inverse = 1 - t; + points.add(new Vec3( + inverse * inverse * enter.x + 2 * inverse * t * corner.x + t * t * exit.x, + inverse * inverse * enter.y + 2 * inverse * t * corner.y + t * t * exit.y, + inverse * inverse * enter.z + 2 * inverse * t * corner.z + t * t * exit.z + )); + } + } + + private static Vec3 ropeOrigin(Minecraft minecraft, Player player, Camera camera, float partialTick) { + boolean firstPerson = player == minecraft.player + && minecraft.options.getCameraType().isFirstPerson() + && camera.getEntity() == player; + if (firstPerson) { + Vec3 position = camera.getPosition(); + Vec3 look = toVec3(camera.getLookVector()); + Vec3 up = toVec3(camera.getUpVector()); + Vec3 right = toVec3(camera.getLeftVector()).reverse(); + return position.add(look.scale(0.45)).add(right.scale(0.3)).subtract(up.scale(0.28)); + } + + double x = Mth.lerp(partialTick, player.xo, player.getX()); + double y = Mth.lerp(partialTick, player.yo, player.getY()); + double z = Mth.lerp(partialTick, player.zo, player.getZ()); + float bodyYaw = Mth.rotLerp(partialTick, player.yBodyRotO, player.yBodyRot); + Vec3 forward = Vec3.directionFromRotation(0, bodyYaw); + Vec3 right = new Vec3(-forward.z, 0, forward.x); + + double side = player.getMainArm() == HumanoidArm.RIGHT ? 0.22 : -0.22; + return new Vec3(x, y + GrapplePhysics.ATTACH_HEIGHT, z) + .add(right.scale(side)) + .add(forward.scale(0.10)); + } + + private static Vec3 toVec3(com.mojang.math.Vector3f vector) { + return new Vec3(vector.x(), vector.y(), vector.z()); + } + + private static void renderRope(Matrix4f matrix, VertexConsumer consumer, Level level, RopePath path) { + List points = path.points(); + int count = points.size(); + if (count < 2) return; + + Vec3[] tangents = new Vec3[count]; + for (int i = 0; i < count; i++) { + Vec3 tangent = i == 0 + ? points.get(1).subtract(points.get(0)) + : i == count - 1 + ? points.get(count - 1).subtract(points.get(count - 2)) + : points.get(i + 1).subtract(points.get(i - 1)); + double length = tangent.length(); + tangents[i] = length < 1.0e-8 ? new Vec3(0, -1, 0) : tangent.scale(1 / length); + } + + Vec3[] sides = new Vec3[count]; + Vec3[] others = new Vec3[count]; + + Vec3 side = advanceFrame(path.grapple(), tangents[0]); + for (int i = 0; i < count; i++) { + if (i > 0) { + Vec3 previous = tangents[i - 1]; + Vec3 axis = previous.cross(tangents[i]); + double sin = axis.length(); + if (sin > 1.0e-9) { + side = rotateAround(side, axis.scale(1 / sin), Math.atan2(sin, previous.dot(tangents[i]))); + } + } + + side = side.subtract(tangents[i].scale(side.dot(tangents[i]))); + side = side.lengthSqr() < 1.0e-10 ? anyPerpendicular(tangents[i]) : side.normalize(); + sides[i] = side; + others[i] = tangents[i].cross(side); + } + + int startLight = lightAt(level, points.get(0)); + int endLight = lightAt(level, points.get(count - 1)); + int lastIndex = count - 1; + double travelled = 0; + + for (int i = 0; i < lastIndex; i++) { + Vec3 from = points.get(i); + Vec3 to = points.get(i + 1); + int lightFrom = lightAlong(startLight, endLight, (float) i / lastIndex); + int lightTo = lightAlong(startLight, endLight, (float) (i + 1) / lastIndex); + + float vFrom = (float) (travelled / TEXTURE_REPEAT_LENGTH); + travelled += from.distanceTo(to); + float vTo = (float) (travelled / TEXTURE_REPEAT_LENGTH); + + for (int face = 0; face < 4; face++) { + Vec3 a0 = corner(sides[i], others[i], face); + Vec3 b0 = corner(sides[i], others[i], face + 1); + Vec3 a1 = corner(sides[i + 1], others[i + 1], face); + Vec3 b1 = corner(sides[i + 1], others[i + 1], face + 1); + + float shade = faceShade(a0.add(b0)); + float u0 = face / 4f; + float u1 = (face + 1) / 4f; + + vertex(matrix, consumer, from.add(a0.scale(ROPE_RADIUS)), u0, vFrom, lightFrom, shade); + vertex(matrix, consumer, from.add(b0.scale(ROPE_RADIUS)), u1, vFrom, lightFrom, shade); + vertex(matrix, consumer, to.add(b1.scale(ROPE_RADIUS)), u1, vTo, lightTo, shade); + vertex(matrix, consumer, to.add(a1.scale(ROPE_RADIUS)), u0, vTo, lightTo, shade); + } + } + } + + private static int lightAlong(int startLight, int endLight, float phase) { + return LightTexture.pack( + (int) Mth.lerp(phase, LightTexture.block(startLight), LightTexture.block(endLight)), + (int) Mth.lerp(phase, LightTexture.sky(startLight), LightTexture.sky(endLight)) + ); + } + + private static Vec3 advanceFrame(Grapple grapple, Vec3 tangent) { + Vec3 previousSide = grapple.getRenderFrameSide(); + Vec3 previousTangent = grapple.getRenderFrameTangent(); + + Vec3 side; + if (previousSide == null || previousTangent == null) { + side = anyPerpendicular(tangent); + } else { + side = previousSide; + Vec3 axis = previousTangent.cross(tangent); + double sin = axis.length(); + if (sin > 1.0e-9) { + side = rotateAround(side, axis.scale(1 / sin), Math.atan2(sin, previousTangent.dot(tangent))); + } + } + side = side.subtract(tangent.scale(side.dot(tangent))); + side = side.lengthSqr() < 1.0e-10 ? anyPerpendicular(tangent) : side.normalize(); + grapple.setRenderFrame(side, tangent); + return side; + } + + private static Vec3 anyPerpendicular(Vec3 tangent) { + Vec3 reference = Math.abs(tangent.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0); + return tangent.cross(reference).normalize(); + } + + private static Vec3 rotateAround(Vec3 vector, Vec3 axis, double angle) { + double cos = Math.cos(angle); + double sin = Math.sin(angle); + return vector.scale(cos) + .add(axis.cross(vector).scale(sin)) + .add(axis.scale(axis.dot(vector) * (1 - cos))); + } + + private static float faceShade(Vec3 outward) { + double length = outward.length(); + if (length < 1.0e-8) return 1f; + Vec3 normal = outward.scale(1 / length); + float shade = (float) (0.62 + 0.38 * (0.5 + 0.5 * normal.y)); + return shade * (float) (1 - 0.12 * Math.abs(normal.x)); + } + + private static Vec3 corner(Vec3 side, Vec3 other, int index) { + return switch (index % 4) { + case 0 -> side.add(other); + case 1 -> side.subtract(other); + case 2 -> side.reverse().subtract(other); + default -> side.reverse().add(other); + }; + } + + private static void vertex(Matrix4f matrix, VertexConsumer consumer, Vec3 position, float u, float v, int light, float shade) { + consumer.vertex(matrix, (float) position.x, (float) position.y, (float) position.z) + .color(shade, shade, shade, 1f) + .uv(u, v) + .uv2(light) + .endVertex(); + } + + private static int lightAt(Level level, Vec3 position) { + BlockPos pos = new BlockPos(position); + return LightTexture.pack(level.getBrightness(LightLayer.BLOCK, pos), level.getBrightness(LightLayer.SKY, pos)); + } +} diff --git a/src/main/java/com/alrex/parcool/client/renderer/GrapplingHookItemRenderer.java b/src/main/java/com/alrex/parcool/client/renderer/GrapplingHookItemRenderer.java new file mode 100644 index 000000000..bd3c48fbf --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/renderer/GrapplingHookItemRenderer.java @@ -0,0 +1,53 @@ +package com.alrex.parcool.client.renderer; + +import com.alrex.parcool.ParCool; +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.block.model.ItemTransforms; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.event.ModelEvent; + +import javax.annotation.Nonnull; + +@OnlyIn(Dist.CLIENT) +public class GrapplingHookItemRenderer extends BlockEntityWithoutLevelRenderer { + public static final ResourceLocation GUI_MODEL = ParCool.resourceLocation("item/grappling_hook_gui"); + public static final ResourceLocation HELD_MODEL = ParCool.resourceLocation("item/grappling_hook_in_hand"); + + public GrapplingHookItemRenderer() { + super(Minecraft.getInstance().getBlockEntityRenderDispatcher(), Minecraft.getInstance().getEntityModels()); + } + + public static void registerModels(ModelEvent.RegisterAdditional event) { + event.register(GUI_MODEL); + event.register(HELD_MODEL); + } + + @Override + public void renderByItem( + @Nonnull ItemStack stack, + @Nonnull ItemTransforms.TransformType transformType, + @Nonnull PoseStack poseStack, + @Nonnull MultiBufferSource buffer, + int light, + int overlay + ) { + Minecraft minecraft = Minecraft.getInstance(); + boolean flat = transformType == ItemTransforms.TransformType.GUI + || transformType == ItemTransforms.TransformType.FIXED + || transformType == ItemTransforms.TransformType.GROUND; + BakedModel model = minecraft.getModelManager().getModel(flat ? GUI_MODEL : HELD_MODEL); + + poseStack.pushPose(); + + poseStack.translate(0.5, 0.5, 0.5); + minecraft.getItemRenderer().render(stack, transformType, false, poseStack, buffer, light, overlay, model); + poseStack.popPose(); + } +} diff --git a/src/main/java/com/alrex/parcool/client/renderer/RenderTypes.java b/src/main/java/com/alrex/parcool/client/renderer/RenderTypes.java index d66bcbfcb..50b90480f 100644 --- a/src/main/java/com/alrex/parcool/client/renderer/RenderTypes.java +++ b/src/main/java/com/alrex/parcool/client/renderer/RenderTypes.java @@ -11,8 +11,22 @@ @OnlyIn(Dist.CLIENT) public class RenderTypes { public static final RenderType ZIPLINE_3D; + public static final RenderType GRAPPLE_ROPE; static { + GRAPPLE_ROPE = RenderType.create( + "parcool_grapple_rope", + DefaultVertexFormat.POSITION_COLOR_TEX_LIGHTMAP, + VertexFormat.Mode.QUADS, 256, + false, false, + RenderType.CompositeState.builder() + .setShaderState(RenderStateShard.POSITION_COLOR_TEX_LIGHTMAP_SHADER) + .setTextureState(new RenderStateShard.TextureStateShard(GrappleRopeRenderer.TEXTURE_LOCATION, false, false)) + .setCullState(RenderStateShard.NO_CULL) + .setLightmapState(RenderStateShard.LIGHTMAP) + .createCompositeState(false) + ); + ZIPLINE_3D = RenderType.create( "zipline3d", DefaultVertexFormat.POSITION_COLOR_TEX_LIGHTMAP, diff --git a/src/main/java/com/alrex/parcool/client/renderer/entity/GrappleTipModel.java b/src/main/java/com/alrex/parcool/client/renderer/entity/GrappleTipModel.java new file mode 100644 index 000000000..e8dbd2e90 --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/renderer/entity/GrappleTipModel.java @@ -0,0 +1,44 @@ +package com.alrex.parcool.client.renderer.entity; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.geom.PartPose; +import net.minecraft.client.model.geom.builders.CubeListBuilder; +import net.minecraft.client.model.geom.builders.LayerDefinition; +import net.minecraft.client.model.geom.builders.MeshDefinition; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +@OnlyIn(Dist.CLIENT) +public class GrappleTipModel { + public static final int TEXTURE_WIDTH = 32; + public static final int TEXTURE_HEIGHT = 32; + private static final String PART_NAME = "tip"; + + private final ModelPart tip; + + public GrappleTipModel(ModelPart root) { + this.tip = root.getChild(PART_NAME); + } + + public static LayerDefinition createLayer() { + MeshDefinition mesh = new MeshDefinition(); + mesh.getRoot().addOrReplaceChild( + PART_NAME, + CubeListBuilder.create() + .texOffs(24, 0).addBox(-1.0F, -8.0F, -1.0F, 2.0F, 8.0F, 2.0F) + .texOffs(0, 6).addBox(-3.0F, -6.0F, -3.0F, 6.0F, 6.0F, 0.0F) + .texOffs(12, 0).addBox(-3.0F, -6.0F, 3.0F, 6.0F, 6.0F, 0.0F) + .texOffs(-6, 0).addBox(-3.0F, 0.0F, -3.0F, 6.0F, 0.0F, 6.0F) + .texOffs(12, 0).mirror().addBox(-3.0F, -6.0F, -3.0F, 0.0F, 6.0F, 6.0F).mirror(false) + .texOffs(12, 0).addBox(3.0F, -6.0F, -3.0F, 0.0F, 6.0F, 6.0F), + PartPose.ZERO + ); + return LayerDefinition.create(mesh, TEXTURE_WIDTH, TEXTURE_HEIGHT); + } + + public void render(PoseStack poseStack, VertexConsumer consumer, int light, int overlay) { + tip.render(poseStack, consumer, light, overlay); + } +} diff --git a/src/main/java/com/alrex/parcool/client/renderer/entity/layers/ParCoolModelLayers.java b/src/main/java/com/alrex/parcool/client/renderer/entity/layers/ParCoolModelLayers.java index 03beb05fa..7a5d0b3b7 100644 --- a/src/main/java/com/alrex/parcool/client/renderer/entity/layers/ParCoolModelLayers.java +++ b/src/main/java/com/alrex/parcool/client/renderer/entity/layers/ParCoolModelLayers.java @@ -1,6 +1,7 @@ package com.alrex.parcool.client.renderer.entity.layers; import com.alrex.parcool.ParCool; +import com.alrex.parcool.client.renderer.entity.GrappleTipModel; import net.minecraft.client.model.HumanoidModel; import net.minecraft.client.model.geom.ModelLayerLocation; import net.minecraft.client.model.geom.builders.CubeDeformation; @@ -15,6 +16,7 @@ public class ParCoolModelLayers { public static final ModelLayerLocation INNER_EQUIPMENT_SLIM = new ModelLayerLocation(ParCool.resourceLocation("equipment"), "equipment"); public static final ModelLayerLocation OUTER_EQUIPMENT = new ModelLayerLocation(ParCool.resourceLocation("equipment"), "equipment"); public static final ModelLayerLocation OUTER_EQUIPMENT_SLIM = new ModelLayerLocation(ParCool.resourceLocation("equipment"), "equipment"); + public static final ModelLayerLocation GRAPPLE_TIP = new ModelLayerLocation(ParCool.resourceLocation("grapple_tip"), "main"); public static void register(EntityRenderersEvent.RegisterLayerDefinitions event) { var innerEquipmentDefinition = LayerDefinition.create(HumanoidModel.createMesh(new CubeDeformation(0.6f), 0.0F), 64, 32); @@ -23,5 +25,6 @@ public static void register(EntityRenderersEvent.RegisterLayerDefinitions event) event.registerLayerDefinition(INNER_EQUIPMENT_SLIM, () -> innerEquipmentDefinition); event.registerLayerDefinition(OUTER_EQUIPMENT, () -> outerEquipmentDefinition); event.registerLayerDefinition(OUTER_EQUIPMENT_SLIM, () -> outerEquipmentDefinition); + event.registerLayerDefinition(GRAPPLE_TIP, GrappleTipModel::createLayer); } } diff --git a/src/main/java/com/alrex/parcool/client/sound/GrappleSwingSoundInstance.java b/src/main/java/com/alrex/parcool/client/sound/GrappleSwingSoundInstance.java new file mode 100644 index 000000000..d186cf474 --- /dev/null +++ b/src/main/java/com/alrex/parcool/client/sound/GrappleSwingSoundInstance.java @@ -0,0 +1,30 @@ +package com.alrex.parcool.client.sound; + +import com.alrex.parcool.common.action.impl.Grapple; +import com.alrex.parcool.common.grapple.GrapplePhase; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.Mth; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +@OnlyIn(Dist.CLIENT) +public class GrappleSwingSoundInstance extends ActionLoopSoundInstance { + private static final double FULL_VOLUME_SPEED = 1.4; + private static final float MAX_VOLUME = 0.7f; + + public GrappleSwingSoundInstance(LocalPlayer player, Grapple grapple) { + super(player, grapple, SoundEvents.ELYTRA_FLYING); + } + + @Override + protected void tickInAlive() { + if (action.getPhase() != GrapplePhase.ATTACHED) { + this.volume = 0f; + return; + } + double speed = action.getVelocity().length(); + this.volume = (float) Mth.clamp(speed / FULL_VOLUME_SPEED, 0.0, 1.0) * MAX_VOLUME; + this.pitch = (float) Mth.clamp(0.7 + speed / FULL_VOLUME_SPEED * 0.5, 0.7, 1.5); + } +} diff --git a/src/main/java/com/alrex/parcool/common/Parkourability.java b/src/main/java/com/alrex/parcool/common/Parkourability.java index ca8920ae6..dd3e45ae7 100644 --- a/src/main/java/com/alrex/parcool/common/Parkourability.java +++ b/src/main/java/com/alrex/parcool/common/Parkourability.java @@ -96,7 +96,10 @@ public void updateEnabledActions(ActionCapabilities capabilities) { public boolean permit(ActionEntry actionEntry) { var config = ParCool.getConfig().server(); - return config.get(actionEntry).permit().get() && (!config.enableSkillTree.get() || capabilities.can(actionEntry)) && enabledActionStates.can(actionEntry); + boolean learned = !actionEntry.option().needLearning() + || !config.enableSkillTree.get() + || capabilities.can(actionEntry); + return config.get(actionEntry).permit().get() && learned && enabledActionStates.can(actionEntry); } /// Request the action start. diff --git a/src/main/java/com/alrex/parcool/common/action/ParCoolActions.java b/src/main/java/com/alrex/parcool/common/action/ParCoolActions.java index fa3ada073..d7f3c9abe 100644 --- a/src/main/java/com/alrex/parcool/common/action/ParCoolActions.java +++ b/src/main/java/com/alrex/parcool/common/action/ParCoolActions.java @@ -32,9 +32,18 @@ public class ParCoolActions { public static final ActionEntry RIDE_ZIPLINE; public static final ActionEntry WALL_RUN; public static final ActionEntry POLE_CLIMB; + public static final ActionEntry GRAPPLE; static { var builder = new ActionGroup.Builder(ParCool.MOD_ID); + + GRAPPLE = builder.add("grapple", Grapple.class, Grapple::new, new ActionOption() + .needPose(null) + .availableInFluid(true) + .availableWhileExhausted(true) + .needLearning(false) + ); + WALL_JUMP = builder.add("wall_jump", WallJump.class, WallJump::new, new ActionOption() .cost(StaminaConsumption.get(50, 0, 0)) .needNotOnGround(true) diff --git a/src/main/java/com/alrex/parcool/common/action/impl/Castaway.java b/src/main/java/com/alrex/parcool/common/action/impl/Castaway.java index defced223..53862a8f1 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/Castaway.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/Castaway.java @@ -7,11 +7,13 @@ import com.alrex.parcool.client.animation.system.PlayerAnimator; import com.alrex.parcool.common.Parkourability; import com.alrex.parcool.common.action.IRequestable; +import com.alrex.parcool.common.action.ParCoolActions; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import javax.annotation.Nullable; +import java.util.List; public class Castaway extends ContinuableAction implements IRequestable { private static final int MAX_TICK = 14; @@ -20,7 +22,7 @@ public class Castaway extends ContinuableAction implements IRequestable entry) { - super(parkourability, entry); + super(parkourability, entry, List.of(ParCoolActions.GRAPPLE)); } @Override diff --git a/src/main/java/com/alrex/parcool/common/action/impl/ClimbUp.java b/src/main/java/com/alrex/parcool/common/action/impl/ClimbUp.java index 2a2cab41e..70a6eec28 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/ClimbUp.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/ClimbUp.java @@ -8,6 +8,7 @@ import com.alrex.parcool.common.Parkourability; import com.alrex.parcool.common.action.IRequestable; import com.alrex.parcool.common.action.InteractingWallDirection; +import com.alrex.parcool.common.action.ParCoolActions; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.util.Mth; import net.minecraft.world.phys.AABB; @@ -16,6 +17,7 @@ import net.minecraftforge.api.distmarker.OnlyIn; import javax.annotation.Nullable; +import java.util.List; public class ClimbUp extends ContinuableAction implements IRequestable { private static final int MAX_TICK = 10; @@ -27,7 +29,7 @@ public class ClimbUp extends ContinuableAction implements IRequestable propertyDirection; public ClimbUp(Parkourability parkourability, ActionEntry entry) { - super(parkourability, entry); + super(parkourability, entry, List.of(ParCoolActions.GRAPPLE)); dataHolder = SynchronizedDataHolder.create(entry, propertyDirection = SynchronizedProperty.newEnum(InteractingWallDirection.class) ); diff --git a/src/main/java/com/alrex/parcool/common/action/impl/Dive.java b/src/main/java/com/alrex/parcool/common/action/impl/Dive.java index 9045c35e0..92a3bfdb6 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/Dive.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/Dive.java @@ -34,7 +34,7 @@ public class Dive extends ContinuableAction implements ActionExtension.JumpListe private boolean jumped; public Dive(Parkourability parkourability, ActionEntry entry) { - super(parkourability, entry, List.of(ParCoolActions.RIDE_ZIPLINE)); + super(parkourability, entry, List.of(ParCoolActions.GRAPPLE, ParCoolActions.RIDE_ZIPLINE)); dataHolder = SynchronizedDataHolder.create(entry, propertyYSpeedOnBeginning = SynchronizedProperty.newFloat(), propertyStartInAir = SynchronizedProperty.newBoolean() diff --git a/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java new file mode 100644 index 000000000..9edbcb908 --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java @@ -0,0 +1,732 @@ +package com.alrex.parcool.common.action.impl; + +import com.alrex.parcool.ParCool; +import com.alrex.parcool.api.action.*; +import com.alrex.parcool.client.input.ParCoolKeyBinds; +import com.alrex.parcool.client.sound.GrappleSwingSoundInstance; +import com.alrex.parcool.common.Parkourability; +import com.alrex.parcool.common.action.BehaviorEnforcer; +import com.alrex.parcool.common.action.ParCoolActions; +import com.alrex.parcool.common.grapple.GrapplePhase; +import com.alrex.parcool.common.grapple.GrapplePhysics; +import com.alrex.parcool.common.grapple.GrappleTargeting; +import com.alrex.parcool.common.grapple.RopeState; +import com.alrex.parcool.common.item.misc.GrapplingHookItem; +import com.alrex.parcool.config.ParCoolConfig; +import com.alrex.parcool.util.EntityUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.common.ForgeMod; + +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; + +public class Grapple extends ContinuableAction { + private static final org.apache.logging.log4j.Logger LOGGER = org.apache.logging.log4j.LogManager.getLogger(); + private static final BehaviorEnforcer.ID ID_FALL_FLY_CANCEL = BehaviorEnforcer.newID(); + + private static final int ASSIST_RINGS = 3; + private static final int ASSIST_SAMPLES_PER_RING = 12; + private static final double MIN_TARGET_DISTANCE = 3.0; + private static final int PREVIEW_ASSIST_INTERVAL = 3; + + private static final int MIN_FLIGHT_TICKS = 2; + private static final int MAX_FLIGHT_TICKS = 12; + + private static final float RETRACT_SPEED_FACTOR = 2f; + + private static final double ATTACH_SLACK = 0.05; + + private static final int CATCH_SOFTEN_TICKS = 3; + private static final double CATCH_COMPLIANCE = 0.02; + + private static final double MAX_SLACK = 2.0; + + private static final double DESYNC_TOLERANCE = 2.0; + + private static final double VANILLA_AIR_FRICTION = 0.91; + + private static final double OVERSTRETCH_FACTOR = 1.2; + + private static final int CONTACT_SEARCH_STEPS = 8; + + private static final double WOBBLE_DRIVE = 0.7; + private static final double WOBBLE_STIFFNESS = 0.40; + private static final double WOBBLE_DAMPING = 0.82; + private static final double WOBBLE_MAX = 0.08; + + private final SynchronizedDataHolder dataHolder; + private final SynchronizedProperty propertyPhase; + private final SynchronizedProperty propertyAnchor; + private final SynchronizedProperty propertyPivot; + private final SynchronizedProperty propertyRopeLength; + private final SynchronizedProperty propertyFlightDuration; + + @Nullable + private RopeState rope; + @Nullable + private GrappleTargeting.Result pendingTarget; + private Vec3 velocity = Vec3.ZERO; + private Vec3 previousPosition = Vec3.ZERO; + private Vec3 plannedDelta = Vec3.ZERO; + private Vec3 plannedPosition = Vec3.ZERO; + private int attachedTicks = 0; + private int flightDuration = MIN_FLIGHT_TICKS; + private boolean releaseWithBoost = false; + private int momentumTicksLeft = 0; + private double momentumSpeedCap = 0; + private int lastRopeReportTick = Integer.MIN_VALUE; + private double peakTension = 0; + + @Nullable + private Vec3 previewTarget = null; + private int previewCooldown = 0; + + private boolean pulling = false; + private int unresolvedContacts = 0; + + private Vec3 ropeWobble = Vec3.ZERO; + private Vec3 ropeWobbleVelocity = Vec3.ZERO; + private Vec3 previousRenderMovement = Vec3.ZERO; + + @Nullable + private Vec3 renderFrameSide = null; + @Nullable + private Vec3 renderFrameTangent = null; + + public Grapple(Parkourability parkourability, ActionEntry entry) { + super(parkourability, entry, List.of( + ParCoolActions.HANG_ON, + ParCoolActions.HANG_DOWN, + ParCoolActions.CLIMB_UP, + ParCoolActions.POLE_CLIMB, + ParCoolActions.SLIDE_DOWN, + ParCoolActions.CASTAWAY, + ParCoolActions.RIDE_ZIPLINE, + ParCoolActions.HORIZONTAL_WALL_RUN, + ParCoolActions.DIVE + )); + dataHolder = SynchronizedDataHolder.create(entry, + propertyPhase = SynchronizedProperty.newEnum(GrapplePhase.class, this::onPhaseChanged), + propertyAnchor = SynchronizedProperty.newVec3(), + propertyPivot = SynchronizedProperty.newVec3(), + propertyRopeLength = SynchronizedProperty.newFloat(), + propertyFlightDuration = SynchronizedProperty.newByte() + ); + } + + @Override + public SynchronizedDataHolder getSynchronizedData() { + return dataHolder; + } + + private static ParCoolConfig.Server.GrapplingHook config() { + return ParCool.getConfig().server().grapplingHook; + } + + public GrapplePhase getPhase() { + return propertyPhase.getOrDefaultIfNull(GrapplePhase.RETRACTING); + } + + public boolean isAttached() { + return isDoing() && getPhase() == GrapplePhase.ATTACHED; + } + + @Nullable + public Vec3 getAnchor() { + return propertyAnchor.get(); + } + + @Nullable + public Vec3 getPivot() { + Vec3 pivot = propertyPivot.get(); + return pivot != null ? pivot : propertyAnchor.get(); + } + + public float getRopeLength() { + return propertyRopeLength.getOrDefaultIfNull(0f); + } + + public List getBends() { + return rope != null ? rope.bends() : Collections.emptyList(); + } + + public float getFlightProgress(float partialTick) { + int duration = Math.max(1, propertyFlightDuration.getOrDefaultIfNull((byte) MIN_FLIGHT_TICKS)); + float elapsed = getDoingTick() + partialTick; + return switch (getPhase()) { + case FLYING -> Mth.clamp(elapsed / duration, 0f, 1f); + case ATTACHED -> 1f; + case RETRACTING -> Mth.clamp(1f - (elapsed - duration) * RETRACT_SPEED_FACTOR / duration, 0f, 1f); + }; + } + + public Vec3 getVelocity() { + return velocity; + } + + @Nullable + public Vec3 getPreviewTarget() { + return previewTarget; + } + + public Vec3 getRopeWobble() { + return ropeWobble; + } + + @Nullable + public Vec3 getRenderFrameSide() { + return renderFrameSide; + } + + @Nullable + public Vec3 getRenderFrameTangent() { + return renderFrameTangent; + } + + public void setRenderFrame(Vec3 side, Vec3 tangent) { + this.renderFrameSide = side; + this.renderFrameTangent = tangent; + } + + public boolean isMomentumActive() { + return momentumTicksLeft > 0; + } + + @Override + public boolean canStart() { + if (!(parkourability.player() instanceof LocalPlayer player)) return false; + if (!GrapplingHookItem.isHeld(player)) return false; + if (!ParCoolKeyBinds.ATTACK.state().isJustPressed()) return false; + prepareThrow(player); + return true; + } + + @Override + public boolean canContinue() { + if (!(parkourability.player() instanceof LocalPlayer player)) return false; + if (!GrapplingHookItem.isHeld(player)) return false; + if (ParCoolKeyBinds.JUMP.state().isJustPressed()) { + releaseWithBoost = true; + return false; + } + + if (ParCoolKeyBinds.ATTACK.state().isJustPressed()) return false; + + return switch (getPhase()) { + case FLYING -> true; + case RETRACTING -> getDoingTick() < flightDuration * (1 + 1 / RETRACT_SPEED_FACTOR); + case ATTACHED -> canStayAttached(player); + }; + } + + private boolean canStayAttached(Player player) { + if (rope == null) return false; + if (player.isPassenger() || player.isFallFlying()) return false; + + if (pendingTarget != null && player.level.getBlockState(pendingTarget.blockPos()).isAir()) return false; + + var config = config(); + + if (rope.anchor().distanceTo(GrapplePhysics.attachmentOf(player.position())) + > config.maxRange().get() * OVERSTRETCH_FACTOR) return false; + double tensionLimit = config.maxTension().get(); + if (tensionLimit > 0 && peakTension > tensionLimit) return false; + + if (player.horizontalCollision || player.verticalCollision) return true; + return GrapplePhysics.attachmentOf(player.position()).distanceToSqr(plannedPosition) + <= DESYNC_TOLERANCE * DESYNC_TOLERANCE; + } + + private void prepareThrow(Player player) { + var config = config(); + double maxRange = config.maxRange().get(); + + pendingTarget = GrappleTargeting.find( + player, + maxRange, + config.aimAssistAngle().get(), + ASSIST_RINGS, + ASSIST_SAMPLES_PER_RING, + MIN_TARGET_DISTANCE + ); + + Vec3 origin = player.getEyePosition(); + Vec3 endpoint = pendingTarget != null + ? pendingTarget.point() + : origin.add(player.getLookAngle().scale(maxRange)); + + flightDuration = Mth.clamp( + Mth.ceil(origin.distanceTo(endpoint) / config.hookTravelSpeed().get()), + MIN_FLIGHT_TICKS, + MAX_FLIGHT_TICKS + ); + + rope = null; + attachedTicks = 0; + peakTension = 0; + releaseWithBoost = false; + momentumTicksLeft = 0; + + propertyPhase.set(GrapplePhase.FLYING); + propertyAnchor.set(endpoint); + propertyPivot.set(endpoint); + propertyRopeLength.set(0f); + propertyFlightDuration.set((byte) flightDuration); + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onStartInClient() { + renderFrameSide = null; + renderFrameTangent = null; + + playSound(parkourability.player(), SoundEvents.CROSSBOW_SHOOT, 0.7f, 1.5f); + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onStartInLocalClient() { + if (!(parkourability.player() instanceof LocalPlayer player)) return; + parkourability.getBehaviorEnforcer().addMarkerEnforcingNoFallFlying(ID_FALL_FLY_CANCEL, this::isDoing); + Minecraft.getInstance().getSoundManager().play(new GrappleSwingSoundInstance(player, this)); + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onStopInClient() { + playSound( + parkourability.player(), + getPhase() == GrapplePhase.ATTACHED ? SoundEvents.ITEM_FRAME_REMOVE_ITEM : SoundEvents.FISHING_BOBBER_RETRIEVE, + 0.4f, + getPhase() == GrapplePhase.ATTACHED ? 1.6f : 1.3f + ); + } + + private void onPhaseChanged(GrapplePhase phase, @Nullable GrapplePhase previous) { + if (phase != GrapplePhase.ATTACHED || previous == GrapplePhase.ATTACHED) return; + var player = parkourability.player(); + if (!player.level.isClientSide()) return; + playSound(player, SoundEvents.CHAIN_PLACE, 0.9f, 1.4f); + spawnAttachParticles(player, propertyAnchor.get()); + } + + private static void spawnAttachParticles(Player player, @Nullable Vec3 anchor) { + if (anchor == null) return; + BlockState state = anchoredState(player.level, anchor); + if (state.isAir()) return; + for (int i = 0; i < 8; i++) { + player.level.addParticle( + new BlockParticleOption(ParticleTypes.BLOCK, state), + anchor.x, anchor.y, anchor.z, + (player.getRandom().nextDouble() - 0.5) * 0.2, + (player.getRandom().nextDouble() - 0.5) * 0.2, + (player.getRandom().nextDouble() - 0.5) * 0.2 + ); + } + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onStopInLocalClient() { + if (!(parkourability.player() instanceof LocalPlayer player)) return; + var config = config(); + + if (getPhase() == GrapplePhase.ATTACHED) { + Vec3 exitVelocity = velocity; + if (releaseWithBoost) { + exitVelocity = exitVelocity.add(0, config.releaseBoost().get(), 0); + } + exitVelocity = GrapplePhysics.clampSpeed(exitVelocity, config.maxSpeed().get()); + player.setDeltaMovement(exitVelocity); + + momentumTicksLeft = config.momentumKeepTicks().get(); + momentumSpeedCap = Math.sqrt(exitVelocity.x * exitVelocity.x + exitVelocity.z * exitVelocity.z); + } + + rope = null; + pendingTarget = null; + plannedDelta = Vec3.ZERO; + ropeWobble = ropeWobbleVelocity = previousRenderMovement = Vec3.ZERO; + releaseWithBoost = false; + } + + @Override + public void onWorkingTick() { + if (getPhase() == GrapplePhase.ATTACHED) { + parkourability.player().fallDistance = 0; + } + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onWorkingTickInClient() { + if (getPhase() != GrapplePhase.ATTACHED) { + ropeWobble = ropeWobbleVelocity = Vec3.ZERO; + return; + } + var player = parkourability.player(); + Vec3 pivot = getPivot(); + if (pivot == null) return; + + Vec3 movement = new Vec3(player.getX() - player.xo, player.getY() - player.yo, player.getZ() - player.zo); + Vec3 ropeDirection = GrapplePhysics.ropeDirection(pivot, GrapplePhysics.attachmentOf(player.position())); + + Vec3 impulse = GrapplePhysics.tangential(movement.subtract(previousRenderMovement), ropeDirection); + previousRenderMovement = movement; + + ropeWobbleVelocity = ropeWobbleVelocity + .add(impulse.scale(WOBBLE_DRIVE)) + .subtract(ropeWobble.scale(WOBBLE_STIFFNESS)) + .scale(WOBBLE_DAMPING); + ropeWobble = GrapplePhysics.tangential(ropeWobble.add(ropeWobbleVelocity), ropeDirection); + double magnitude = ropeWobble.length(); + if (magnitude > WOBBLE_MAX) ropeWobble = ropeWobble.scale(WOBBLE_MAX / magnitude); + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onWorkingTickInLocalClient() { + if (!(parkourability.player() instanceof LocalPlayer player)) return; + switch (getPhase()) { + case FLYING -> tickFlight(player); + case ATTACHED -> tickSwing(player); + case RETRACTING -> { + } + } + } + + @OnlyIn(Dist.CLIENT) + private void tickFlight(LocalPlayer player) { + if (getDoingTick() < flightDuration) return; + if (pendingTarget == null) { + propertyPhase.set(GrapplePhase.RETRACTING); + return; + } + attach(player); + } + + @OnlyIn(Dist.CLIENT) + private void attach(LocalPlayer player) { + var config = config(); + Vec3 anchor = pendingTarget.point(); + Vec3 position = GrapplePhysics.attachmentOf(player.position()); + + double length = Math.min(position.distanceTo(anchor) + ATTACH_SLACK, config.maxRange().get()); + rope = new RopeState(anchor, length); + + velocity = player.getDeltaMovement(); + peakTension = 0; + previousPosition = position; + plannedDelta = Vec3.ZERO; + plannedPosition = position; + attachedTicks = 0; + + parkourability.getBehaviorEnforcer().setMarkerEnforcingDeltaMovement(this::isAttached, () -> plannedDelta); + propertyPhase.set(GrapplePhase.ATTACHED); + propertyPivot.set(anchor); + propertyRopeLength.set((float) length); + + tickSwing(player); + } + + @OnlyIn(Dist.CLIENT) + private void tickSwing(LocalPlayer player) { + if (rope == null) return; + var config = config(); + Vec3 position = GrapplePhysics.attachmentOf(player.position()); + parkourability.getBehaviorEnforcer().setMarkerEnforcingDeltaMovement(this::isAttached, () -> plannedDelta); + + if (attachedTicks > 0) { + velocity = reconcile(velocity, plannedDelta, position.subtract(previousPosition)); + } + previousPosition = position; + + int substeps = Math.max(1, config.physicsSubsteps().get()); + double dt = 1.0 / substeps; + double dragPerSubstep = Math.pow(config.ropeDrag().get(), dt); + double gravity = player.getAttributeValue(ForgeMod.ENTITY_GRAVITY.get()); + double airResistance = config.airResistance().get(); + double maxSpeed = config.maxSpeed().get(); + int maxBends = config.maxRopeBends().get(); + boolean allowWrap = config.allowRopeWrapping().get(); + double compliance = config.ropeCompliance().get() + + CATCH_COMPLIANCE * Mth.clamp(1 - attachedTicks / (double) CATCH_SOFTEN_TICKS, 0, 1); + + peakTension = 0; + pulling = false; + Vec3 current = position; + Vec3 currentVelocity = velocity; + Vec3 pivot = rope.pivot(); + + for (int i = 0; i < substeps; i++) { + rope.update(player.level, player, current, maxBends, allowWrap, i == 0); + pivot = rope.pivot(); + Vec3 ropeDirection = GrapplePhysics.ropeDirection(pivot, current); + + if (i == 0) { + double damping = config.swingDamping().get(); + if (damping > 0) { + currentVelocity = currentVelocity.subtract( + GrapplePhysics.tangential(currentVelocity, ropeDirection).scale(damping)); + } + } + + Vec3 acceleration = new Vec3(0, -gravity, 0) + .add(steering(player, config, ropeDirection, currentVelocity)) + .add(winch(config, ropeDirection, current, dt, gravity)); + + if (airResistance > 0) { + acceleration = acceleration.subtract(currentVelocity.scale(airResistance * currentVelocity.length())); + } + + var solved = GrapplePhysics.substep( + current, currentVelocity, pivot, rope.freeLength(), + acceleration, dragPerSubstep, dt, compliance + ); + peakTension = Math.max(peakTension, solved.tension()); + + Vec3 next = solved.position(); + Vec3 nextVelocity = solved.velocity(); + + if (!RopeState.isSegmentClear(player.level, player, rope.pivot(), next)) { + Vec3[] boundary = contactBoundary(player, rope.pivot(), current, next); + rope.update(player.level, player, boundary[1], maxBends, allowWrap, false); + if (!RopeState.isSegmentClear(player.level, player, rope.pivot(), next)) { + unresolvedContacts++; + } + } + + current = next; + currentVelocity = nextVelocity; + + if (pulling) { + Vec3 outward = GrapplePhysics.ropeDirection(rope.pivot(), current); + double inward = -currentVelocity.dot(outward); + double limit = config.pullSpeedLimit().get(); + if (inward > limit) { + currentVelocity = currentVelocity.add(outward.scale(inward - limit)); + } + } + } + + Vec3 delta = current.subtract(position); + double distance = delta.length(); + if (distance > maxSpeed) delta = delta.scale(maxSpeed / distance); + + velocity = GrapplePhysics.clampSpeed(currentVelocity, maxSpeed); + plannedDelta = delta; + plannedPosition = position.add(delta); + attachedTicks++; + + propertyPivot.set(pivot); + propertyRopeLength.set((float) rope.freeLength()); + if (ParCool.getConfig().client().debugRope.get()) reportRopeProblems(player, current); + unresolvedContacts = 0; + } + + @OnlyIn(Dist.CLIENT) + private Vec3 winch(ParCoolConfig.Server.GrapplingHook config, Vec3 ropeDirection, Vec3 position, double dt, double gravity) { + if (rope == null) return Vec3.ZERO; + double distance = position.distanceTo(rope.pivot()); + + if (ParCoolKeyBinds.SHIFT.state().isDown()) { + double limit = Math.min(config.maxRange().get() - rope.wrappedLength(), distance + MAX_SLACK); + + rope.setFreeLength(Math.min(limit, rope.freeLength() + config.reelOutSpeed().get() * dt)); + return Vec3.ZERO; + } + + double minLength = config.minRopeLength().get(); + if (!ParCoolKeyBinds.USE_ITEM.state().isDown() || distance <= minLength) return Vec3.ZERO; + + rope.setFreeLength(Math.max(minLength, Math.min(rope.freeLength(), distance))); + pulling = true; + + Vec3 pullDirection = ropeDirection.reverse(); + double againstGravity = Math.max(0, gravity * pullDirection.y); + return pullDirection.scale(config.pullStrength().get() + againstGravity); + } + + @OnlyIn(Dist.CLIENT) + private Vec3 steering(LocalPlayer player, ParCoolConfig.Server.GrapplingHook config, Vec3 ropeDirection, Vec3 currentVelocity) { + Vec3 forward = EntityUtil.getHorizontalLookAngle(player); + Vec3 input = forward.scale(player.input.forwardImpulse) + .add(forward.yRot(Mth.HALF_PI).scale(player.input.leftImpulse)); + double inputStrength = input.length(); + if (inputStrength < 0.1) return Vec3.ZERO; + if (inputStrength > 1) { + input = input.scale(1 / inputStrength); + inputStrength = 1; + } + + double force = config.swingControlForce().get(); + Vec3 steer = input.scale(force); + + double assist = config.swingAssist().get(); + if (assist > 0) { + double verticality = Mth.clamp(-ropeDirection.y, 0, 1); + Vec3 along = GrapplePhysics.tangential(currentVelocity, ropeDirection); + double speed = along.length(); + if (verticality > 0 && speed > 1.0e-4) { + double headroom = Mth.clamp(1 - speed / config.maxSpeed().get(), 0, 1); + steer = steer.add(along.scale(force * assist * inputStrength * verticality * headroom / speed)); + } + } + return GrapplePhysics.tangential(steer, ropeDirection); + } + + @OnlyIn(Dist.CLIENT) + private Vec3[] contactBoundary(LocalPlayer player, Vec3 pivot, Vec3 from, Vec3 to) { + if (!RopeState.isSegmentClear(player.level, player, pivot, from)) return new Vec3[]{from, to}; + double clear = 0; + double blocked = 1; + for (int i = 0; i < CONTACT_SEARCH_STEPS; i++) { + double middle = (clear + blocked) * 0.5; + if (RopeState.isSegmentClear(player.level, player, pivot, from.add(to.subtract(from).scale(middle)))) { + clear = middle; + } else { + blocked = middle; + } + } + Vec3 span = to.subtract(from); + return new Vec3[]{from.add(span.scale(clear)), from.add(span.scale(blocked))}; + } + + @OnlyIn(Dist.CLIENT) + private void reportRopeProblems(LocalPlayer player, Vec3 attachment) { + if (rope == null) return; + List path = new java.util.ArrayList<>(rope.bends().size() + 2); + path.add(rope.anchor()); + path.addAll(rope.bends()); + path.add(attachment); + + for (int i = 0; i < path.size() - 1; i++) { + Vec3 from = path.get(i); + Vec3 to = path.get(i + 1); + if (RopeState.isSegmentClear(player.level, player, from, to)) continue; + if (player.tickCount - lastRopeReportTick < 20) return; + lastRopeReportTick = player.tickCount; + LOGGER.warn( + "[ParCool grapple] rope segment {} of {} passes through the world: {} -> {} ({} contacts, free {}, wrapped {}, {} corners the wrap could not resolve this tick)", + i, path.size() - 1, + String.format("%.2f,%.2f,%.2f", from.x, from.y, from.z), + String.format("%.2f,%.2f,%.2f", to.x, to.y, to.z), + rope.contactCount(), + String.format("%.2f", rope.freeLength()), + String.format("%.2f", rope.wrappedLength()), + unresolvedContacts + ); + player.displayClientMessage(net.minecraft.network.chat.Component.literal( + "\u00a7cGrapple: rope segment " + i + "/" + (path.size() - 1) + + " through world, " + rope.contactCount() + " contacts"), true); + return; + } + } + + private static Vec3 reconcile(Vec3 velocity, Vec3 planned, Vec3 achieved) { + return new Vec3( + reconcileAxis(velocity.x, planned.x, achieved.x), + reconcileAxis(velocity.y, planned.y, achieved.y), + reconcileAxis(velocity.z, planned.z, achieved.z) + ); + } + + private static double reconcileAxis(double velocity, double planned, double achieved) { + return Math.abs(achieved) < Math.abs(planned) - 1.0e-5 ? achieved : velocity; + } + + @OnlyIn(Dist.CLIENT) + @Override + public void onTickInLocalClient() { + updatePreviewTarget(); + if (momentumTicksLeft <= 0) return; + if (!(parkourability.player() instanceof LocalPlayer player)) return; + if (isDoing() || player.isOnGround() || player.horizontalCollision + || player.isInFluidType() || player.isFallFlying() || player.getAbilities().flying) { + momentumTicksLeft = 0; + return; + } + momentumTicksLeft--; + + var config = config(); + double keep = config.momentumDrag().get(); + Vec3 movement = player.getDeltaMovement(); + double speed = Math.sqrt(movement.x * movement.x + movement.z * movement.z); + if (speed < 1.0e-4) return; + + momentumSpeedCap *= keep; + double factor = Math.min(keep / VANILLA_AIR_FRICTION, momentumSpeedCap / speed); + if (factor <= 1.0) return; + player.setDeltaMovement(movement.x * factor, movement.y, movement.z * factor); + } + + private static BlockState anchoredState(Level level, Vec3 anchor) { + BlockPos base = new BlockPos(anchor); + BlockState state = level.getBlockState(base); + if (!state.isAir()) return state; + for (Direction direction : Direction.values()) { + BlockState neighbour = level.getBlockState(base.relative(direction)); + if (!neighbour.isAir()) return neighbour; + } + return state; + } + + @OnlyIn(Dist.CLIENT) + private void updatePreviewTarget() { + var player = parkourability.player(); + if (isDoing() + || !ParCool.getConfig().client().showTargetIndicator.get() + || !GrapplingHookItem.isHeld(player) + || player.getAbilities().flying + || player.isSpectator()) { + previewTarget = null; + return; + } + var config = config(); + double maxRange = config.maxRange().get(); + + var direct = GrappleTargeting.findDirect(player, maxRange, MIN_TARGET_DISTANCE); + if (direct != null) { + previewTarget = direct.point(); + previewCooldown = 0; + return; + } + + if (previewCooldown > 0) { + previewCooldown--; + return; + } + previewCooldown = PREVIEW_ASSIST_INTERVAL; + + var target = GrappleTargeting.find( + player, + maxRange, + config.aimAssistAngle().get(), + ASSIST_RINGS, + ASSIST_SAMPLES_PER_RING, + MIN_TARGET_DISTANCE + ); + previewTarget = target != null ? target.point() : null; + } + + private static void playSound(Player player, SoundEvent sound, float volume, float pitch) { + if (!ParCool.getConfig().client().enableActionSounds.get()) return; + player.level.playLocalSound(player.getX(), player.getY(), player.getZ(), sound, SoundSource.PLAYERS, volume, pitch, false); + } +} diff --git a/src/main/java/com/alrex/parcool/common/action/impl/HangDown.java b/src/main/java/com/alrex/parcool/common/action/impl/HangDown.java index 6f6ac591c..be4725197 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/HangDown.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/HangDown.java @@ -70,7 +70,7 @@ private record HangAbleBarInfo(BlockPos pos, HangDown.BarAxis axis, double yColl private float oldAngularSpeed; public HangDown(Parkourability parkourability, ActionEntry entry) { - super(parkourability, entry, List.of(ParCoolActions.CLIMB_UP, ParCoolActions.DIVE, ParCoolActions.HANG_ON, ParCoolActions.POLE_CLIMB)); + super(parkourability, entry, List.of(ParCoolActions.GRAPPLE, ParCoolActions.CLIMB_UP, ParCoolActions.DIVE, ParCoolActions.HANG_ON, ParCoolActions.POLE_CLIMB)); dataHolder = SynchronizedDataHolder.create(entry, propertyHangingBarAxis = SynchronizedProperty.newEnum(BarAxis.class), propertyBodySwingAngleInRad = SynchronizedProperty.newFloat(), diff --git a/src/main/java/com/alrex/parcool/common/action/impl/HangOn.java b/src/main/java/com/alrex/parcool/common/action/impl/HangOn.java index 11a3bfef8..711d26bc2 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/HangOn.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/HangOn.java @@ -49,6 +49,7 @@ public class HangOn extends ContinuableAction implements ActionExtension.LeaveFr public HangOn(Parkourability parkourability, ActionEntry entry) { super(parkourability, entry, List.of( + ParCoolActions.GRAPPLE, ParCoolActions.CLIMB_UP, ParCoolActions.DIVE, ParCoolActions.HANG_DOWN, diff --git a/src/main/java/com/alrex/parcool/common/action/impl/HorizontalWallRun.java b/src/main/java/com/alrex/parcool/common/action/impl/HorizontalWallRun.java index bfd908ae6..3fb141218 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/HorizontalWallRun.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/HorizontalWallRun.java @@ -36,7 +36,7 @@ public class HorizontalWallRun extends ContinuableAction implements ActionExtens private short tickSinceCanceled = 0; public HorizontalWallRun(Parkourability parkourability, ActionEntry entry) { - super(parkourability, entry, List.of(ParCoolActions.DIVE)); + super(parkourability, entry, List.of(ParCoolActions.GRAPPLE, ParCoolActions.DIVE)); dataHolder = SynchronizedDataHolder.create(entry, propertyDirection = SynchronizedProperty.newEnum(InteractingWallDirection.class), propertyLeftToWall = SynchronizedProperty.newBoolean() diff --git a/src/main/java/com/alrex/parcool/common/action/impl/PoleClimb.java b/src/main/java/com/alrex/parcool/common/action/impl/PoleClimb.java index 981e51cbd..489804744 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/PoleClimb.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/PoleClimb.java @@ -33,6 +33,7 @@ public class PoleClimb extends ContinuableAction { public PoleClimb(Parkourability parkourability, ActionEntry entry) { super(parkourability, entry, List.of( + ParCoolActions.GRAPPLE, ParCoolActions.HANG_ON, ParCoolActions.HANG_DOWN, ParCoolActions.CLIMB_UP, diff --git a/src/main/java/com/alrex/parcool/common/action/impl/RideZipline.java b/src/main/java/com/alrex/parcool/common/action/impl/RideZipline.java index a7d29a9f5..9547811b1 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/RideZipline.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/RideZipline.java @@ -53,6 +53,7 @@ public class RideZipline extends ContinuableAction { public RideZipline(Parkourability parkourability, ActionEntry entry) { super(parkourability, entry, List.of( + ParCoolActions.GRAPPLE, ParCoolActions.VAULT, ParCoolActions.HANG_ON, ParCoolActions.HANG_DOWN, diff --git a/src/main/java/com/alrex/parcool/common/action/impl/SlideDown.java b/src/main/java/com/alrex/parcool/common/action/impl/SlideDown.java index 01873946e..7819140ff 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/SlideDown.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/SlideDown.java @@ -45,6 +45,7 @@ public class SlideDown extends ContinuableAction implements ActionExtension.Leav public SlideDown(Parkourability parkourability, ActionEntry entry) { super(parkourability, entry, List.of( + ParCoolActions.GRAPPLE, ParCoolActions.CLIMB_UP, ParCoolActions.VAULT, ParCoolActions.HANG_ON, diff --git a/src/main/java/com/alrex/parcool/common/action/impl/WallRun.java b/src/main/java/com/alrex/parcool/common/action/impl/WallRun.java index fe25a5861..18e3844ec 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/WallRun.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/WallRun.java @@ -30,6 +30,7 @@ public class WallRun extends Action implements ActionExtension.JumpListener { public WallRun(Parkourability parkourability, ActionEntry entry) { super(parkourability, entry, List.of( + ParCoolActions.GRAPPLE, ParCoolActions.CRAWL, ParCoolActions.HANG_ON, ParCoolActions.SLIDE_DOWN, diff --git a/src/main/java/com/alrex/parcool/common/grapple/GrapplePhase.java b/src/main/java/com/alrex/parcool/common/grapple/GrapplePhase.java new file mode 100644 index 000000000..d890e0494 --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/grapple/GrapplePhase.java @@ -0,0 +1,9 @@ +package com.alrex.parcool.common.grapple; + +public enum GrapplePhase { + FLYING, + + ATTACHED, + + RETRACTING +} diff --git a/src/main/java/com/alrex/parcool/common/grapple/GrapplePhysics.java b/src/main/java/com/alrex/parcool/common/grapple/GrapplePhysics.java new file mode 100644 index 000000000..0d985dba9 --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/grapple/GrapplePhysics.java @@ -0,0 +1,63 @@ +package com.alrex.parcool.common.grapple; + +import net.minecraft.world.phys.Vec3; + +public final class GrapplePhysics { + private GrapplePhysics() { + } + + public static final double ATTACH_HEIGHT = 1.4; + + public static Vec3 attachmentOf(Vec3 feetPosition) { + return feetPosition.add(0, ATTACH_HEIGHT, 0); + } + + public record State(Vec3 position, Vec3 velocity) { + } + + public record Solve(Vec3 position, Vec3 velocity, double tension) { + } + + public static Solve substep( + Vec3 position, + Vec3 velocity, + Vec3 pivot, + double ropeLength, + Vec3 acceleration, + double dragPerSubstep, + double dt, + double compliance + ) { + velocity = velocity.add(acceleration.scale(dt)).scale(dragPerSubstep); + Vec3 predicted = position.add(velocity.scale(dt)); + + Vec3 offset = predicted.subtract(pivot); + double distance = offset.length(); + double error = distance - ropeLength; + + double tension = 0; + if (error > 0 && distance > 1.0e-7) { + double alpha = compliance / (dt * dt); + double deltaLambda = -error / (1 + alpha); + predicted = predicted.add(offset.scale(deltaLambda / distance)); + tension = Math.abs(deltaLambda) / (dt * dt); + } + return new Solve(predicted, predicted.subtract(position).scale(1 / dt), tension); + } + + public static Vec3 tangential(Vec3 vector, Vec3 ropeDirection) { + return vector.subtract(ropeDirection.scale(vector.dot(ropeDirection))); + } + + public static Vec3 ropeDirection(Vec3 pivot, Vec3 position) { + Vec3 offset = position.subtract(pivot); + double length = offset.length(); + return length < 1.0e-6 ? new Vec3(0, -1, 0) : offset.scale(1.0 / length); + } + + public static Vec3 clampSpeed(Vec3 velocity, double maxSpeed) { + double speedSqr = velocity.lengthSqr(); + if (speedSqr <= maxSpeed * maxSpeed || speedSqr < 1.0e-12) return velocity; + return velocity.scale(maxSpeed / Math.sqrt(speedSqr)); + } +} diff --git a/src/main/java/com/alrex/parcool/common/grapple/GrappleTargeting.java b/src/main/java/com/alrex/parcool/common/grapple/GrappleTargeting.java new file mode 100644 index 000000000..d39ca30d1 --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/grapple/GrappleTargeting.java @@ -0,0 +1,122 @@ +package com.alrex.parcool.common.grapple; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +import javax.annotation.Nullable; + +public final class GrappleTargeting { + private static final double IDEAL_ELEVATION = Math.toRadians(50); + private static final double IDEAL_RANGE_BASE = 10.0; + private static final double IDEAL_RANGE_PER_SPEED = 12.0; + + private static final double SURFACE_OFFSET = 0.09; + + private GrappleTargeting() { + } + + public record Result(Vec3 point, BlockPos blockPos, Direction face, boolean assisted) { + } + + @Nullable + public static Result findDirect(Player player, double maxRange, double minDistance) { + return cast(player.level, player, player.getEyePosition(), player.getLookAngle().normalize(), maxRange, minDistance, false); + } + + @Nullable + public static Result find( + Player player, + double maxRange, + double assistAngleDegrees, + int rings, + int samplesPerRing, + double minDistance + ) { + Level level = player.level; + Vec3 eye = player.getEyePosition(); + Vec3 look = player.getLookAngle().normalize(); + + Result direct = cast(level, player, eye, look, maxRange, minDistance, false); + if (direct != null) return direct; + if (assistAngleDegrees <= 0 || rings < 1 || samplesPerRing < 1) return null; + + Vec3 right = look.cross(new Vec3(0, 1, 0)); + if (right.lengthSqr() < 1.0e-6) right = new Vec3(1, 0, 0); + right = right.normalize(); + Vec3 up = right.cross(look).normalize(); + + Vec3 perfectPoint = perfectSwingPoint(player, maxRange); + Result best = null; + double bestScore = Double.MAX_VALUE; + + double maxAngle = Math.toRadians(assistAngleDegrees); + for (int ring = 1; ring <= rings; ring++) { + double angle = maxAngle * ring / rings; + double sin = Math.sin(angle); + double cos = Math.cos(angle); + + double azimuthOffset = Math.PI * ring / samplesPerRing; + for (int i = 0; i < samplesPerRing; i++) { + double azimuth = azimuthOffset + 2 * Math.PI * i / samplesPerRing; + Vec3 direction = look.scale(cos) + .add(right.scale(Math.cos(azimuth) * sin)) + .add(up.scale(Math.sin(azimuth) * sin)); + + Result candidate = cast(level, player, eye, direction, maxRange, minDistance, true); + if (candidate == null) continue; + double score = candidate.point().distanceToSqr(perfectPoint); + if (score < bestScore) { + bestScore = score; + best = candidate; + } + } + } + return best; + } + + public static Vec3 perfectSwingPoint(Player player, double maxRange) { + Vec3 look = player.getLookAngle(); + Vec3 horizontalLook = new Vec3(look.x, 0, look.z); + horizontalLook = horizontalLook.lengthSqr() < 1.0e-6 + ? new Vec3(0, 0, 1) + : horizontalLook.normalize(); + + Vec3 movement = player.getDeltaMovement(); + double speed = Math.sqrt(movement.x * movement.x + movement.z * movement.z); + double range = Math.min(IDEAL_RANGE_BASE + speed * IDEAL_RANGE_PER_SPEED, maxRange * 0.8); + + return player.getEyePosition() + .add(horizontalLook.scale(range * Math.cos(IDEAL_ELEVATION))) + .add(0, range * Math.sin(IDEAL_ELEVATION), 0); + } + + @Nullable + private static Result cast( + Level level, + Player player, + Vec3 origin, + Vec3 direction, + double maxRange, + double minDistance, + boolean assisted + ) { + Vec3 end = origin.add(direction.scale(maxRange)); + HitResult hit = level.clip(new ClipContext(origin, end, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, player)); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) return null; + if (origin.distanceToSqr(hit.getLocation()) < minDistance * minDistance) return null; + + Direction face = blockHit.getDirection(); + Vec3 point = hit.getLocation().add( + face.getStepX() * SURFACE_OFFSET, + face.getStepY() * SURFACE_OFFSET, + face.getStepZ() * SURFACE_OFFSET + ); + return new Result(point, blockHit.getBlockPos(), face, assisted); + } +} diff --git a/src/main/java/com/alrex/parcool/common/grapple/RopeState.java b/src/main/java/com/alrex/parcool/common/grapple/RopeState.java new file mode 100644 index 000000000..f0321dd2b --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/grapple/RopeState.java @@ -0,0 +1,347 @@ +package com.alrex.parcool.common.grapple; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.VoxelShape; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class RopeState { + private static final double EDGE_CLEARANCE = 0.13; + + private static final double RAY_SHRINK = 0.03; + + private static final double MIN_FREE_LENGTH = 0.6; + + private static final double GUARANTEED_FREE_LENGTH = 2.5; + + private static final double MIN_HINGE_SEPARATION = 0.3; + private static final int SLIDE_ITERATIONS = 18; + private static final int SLIDE_PASSES = 2; + private static final int MAX_WRAPS_PER_TICK = 6; + + private static final double STILL_WRAPPED_OPENING = 0.35; + + private static final double TAUT_MARGIN = 0.25; + private static final double DEGENERATE = 1.0e-4; + + private static final double MAX_EDGE_LENGTH = 24; + private static final int MAX_EDGE_GROWTH_PER_SOLVE = 6; + private static final double EDGE_END_EPSILON = 1.0e-3; + + private static final double SUPPORT_PROBE = 0.28; + + private static final class Hinge { + private Vec3 start; + private final Vec3 direction; + private double length; + + private final Vec3 outward; + private double parameter; + + private Hinge(Vec3 start, Vec3 direction, double length, Vec3 outward, double parameter) { + this.start = start; + this.direction = direction; + this.length = length; + this.outward = outward; + this.parameter = parameter; + } + + private Vec3 position() { + return start.add(direction.scale(parameter)); + } + } + + private final Vec3 anchor; + private final ArrayList hinges = new ArrayList<>(4); + + public int contactCount() { + return hinges.size(); + } + + private final ArrayList positions = new ArrayList<>(4); + private final List positionsView = Collections.unmodifiableList(positions); + + private double totalLength; + private double wrappedLength = 0; + + public RopeState(Vec3 anchor, double length) { + this.anchor = anchor; + this.totalLength = length; + } + + public Vec3 anchor() { + return anchor; + } + + public List bends() { + return positionsView; + } + + public Vec3 pivot() { + return hinges.isEmpty() ? anchor : hinges.get(hinges.size() - 1).position(); + } + + public double length() { + return totalLength; + } + + public double wrappedLength() { + return wrappedLength; + } + + public double freeLength() { + return Math.max(MIN_FREE_LENGTH, totalLength - wrappedLength); + } + + public void setFreeLength(double newFreeLength) { + this.totalLength = wrappedLength + Math.max(MIN_FREE_LENGTH, newFreeLength); + } + + public void update(Level level, Entity owner, Vec3 playerPos, int maxHinges, boolean allowWrap) { + update(level, owner, playerPos, maxHinges, allowWrap, true); + } + + public void update(Level level, Entity owner, Vec3 playerPos, int maxHinges, boolean allowWrap, boolean allowRelease) { + dropUnsupported(level); + slide(level, playerPos); + + boolean taut = freeLength() - pivot().distanceTo(playerPos) < TAUT_MARGIN; + + if (allowRelease) release(level, owner, playerPos, taut); + + if (allowWrap && taut) { + int added = 0; + while (hinges.size() < maxHinges && added < MAX_WRAPS_PER_TICK && wrapOnce(level, owner, playerPos)) { + added++; + } + if (added > 0) slide(level, playerPos); + } + + refresh(); + + double needed = Math.max(GUARANTEED_FREE_LENGTH, pivot().distanceTo(playerPos)); + totalLength = Math.max(totalLength, wrappedLength + needed); + } + + private void slide(Level level, Vec3 playerPos) { + if (hinges.isEmpty()) return; + for (int pass = 0; pass < SLIDE_PASSES; pass++) { + for (int i = 0; i < hinges.size(); i++) { + Hinge hinge = hinges.get(i); + Vec3 previous = i == 0 ? anchor : hinges.get(i - 1).position(); + Vec3 next = i == hinges.size() - 1 ? playerPos : hinges.get(i + 1).position(); + hinge.parameter = solveContact(hinge, previous, next); + growWhileClamped(level, hinge, previous, next); + } + } + } + + private static void growWhileClamped(Level level, Hinge hinge, Vec3 from, Vec3 to) { + for (int i = 0; i < MAX_EDGE_GROWTH_PER_SOLVE; i++) { + boolean atStart = hinge.parameter <= EDGE_END_EPSILON; + boolean atEnd = hinge.parameter >= hinge.length - EDGE_END_EPSILON; + if (!atStart && !atEnd) return; + if (!grow(level, hinge, atStart)) return; + hinge.parameter = solveContact(hinge, from, to); + } + } + + private static boolean grow(Level level, Hinge hinge, boolean atStart) { + if (hinge.length >= MAX_EDGE_LENGTH) return false; + Vec3 probe = atStart + ? hinge.start.subtract(hinge.direction.scale(0.5)) + : hinge.start.add(hinge.direction.scale(hinge.length + 0.5)); + if (!isFreeSpace(level, probe)) return false; + if (isFreeSpace(level, probe.subtract(hinge.outward.scale(SUPPORT_PROBE)))) return false; + + if (atStart) hinge.start = hinge.start.subtract(hinge.direction); + hinge.length += 1; + return true; + } + + private static double solveContact(Hinge hinge, Vec3 from, Vec3 to) { + double low = 0; + double high = hinge.length; + for (int i = 0; i < SLIDE_ITERATIONS; i++) { + double third = (high - low) / 3; + double first = low + third; + double second = high - third; + if (pathLength(hinge, first, from, to) < pathLength(hinge, second, from, to)) { + high = second; + } else { + low = first; + } + } + return (low + high) * 0.5; + } + + private static double pathLength(Hinge hinge, double parameter, Vec3 from, Vec3 to) { + Vec3 point = hinge.start.add(hinge.direction.scale(parameter)); + return from.distanceTo(point) + point.distanceTo(to); + } + + private void release(Level level, Entity owner, Vec3 playerPos, boolean taut) { + if (hinges.isEmpty()) return; + int last = hinges.size() - 1; + Hinge hinge = hinges.get(last); + Vec3 position = hinge.position(); + Vec3 previous = last == 0 ? anchor : hinges.get(last - 1).position(); + + if (!isVisible(level, owner, previous, playerPos)) return; + + Vec3 toPrevious = previous.subtract(position); + Vec3 toPlayer = playerPos.subtract(position); + if (taut && toPrevious.lengthSqr() > DEGENERATE && toPlayer.lengthSqr() > DEGENERATE) { + double opening = toPrevious.normalize().add(toPlayer.normalize()).dot(hinge.outward); + if (opening > STILL_WRAPPED_OPENING) return; + } + hinges.remove(last); + } + + private void dropUnsupported(Level level) { + for (int i = hinges.size() - 1; i >= 0; i--) { + Hinge hinge = hinges.get(i); + if (isFreeSpace(level, hinge.position().subtract(hinge.outward.scale(SUPPORT_PROBE)))) { + hinges.remove(i); + } + } + } + + private boolean wrapOnce(Level level, Entity owner, Vec3 playerPos) { + Vec3 pivot = pivot(); + if (pivot.distanceToSqr(playerPos) < DEGENERATE) return false; + + HitResult hit = clip(level, owner, pivot, playerPos); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) return false; + + BlockPos support = blockHit.getBlockPos(); + VoxelShape shape = level.getBlockState(support).getCollisionShape(level, support); + if (shape.isEmpty()) return false; + AABB box = shape.bounds().move(support); + + List candidates = new ArrayList<>(12); + List scores = new ArrayList<>(12); + collectEdges(box, pivot, playerPos, candidates, scores); + + Hinge best = null; + double bestScore = Double.MAX_VALUE; + Hinge partial = null; + double partialScore = Double.MAX_VALUE; + double remaining = pivot.distanceTo(playerPos); + + for (int i = 0; i < candidates.size(); i++) { + double score = scores.get(i); + if (score >= bestScore) continue; + Hinge candidate = candidates.get(i); + Vec3 contact = candidate.position(); + if (contact.distanceToSqr(pivot) < MIN_HINGE_SEPARATION * MIN_HINGE_SEPARATION) continue; + if (!isFreeSpace(level, contact)) continue; + if (!isVisible(level, owner, pivot, contact)) continue; + + if (isVisible(level, owner, contact, playerPos)) { + bestScore = score; + best = candidate; + } else if (score < partialScore + && contact.distanceTo(playerPos) < remaining - MIN_HINGE_SEPARATION) { + partialScore = score; + partial = candidate; + } + } + + Hinge chosen = best != null ? best : partial; + if (chosen == null) return false; + + hinges.add(chosen); + return true; + } + + private static void collectEdges( + AABB box, + Vec3 from, + Vec3 to, + List candidates, + List scores + ) { + for (int axis = 0; axis < 3; axis++) { + for (int first = 0; first <= 1; first++) { + for (int second = 0; second <= 1; second++) { + Hinge hinge = buildEdge(box, axis, first, second); + if (hinge == null) continue; + hinge.parameter = solveContact(hinge, from, to); + candidates.add(hinge); + scores.add(pathLength(hinge, hinge.parameter, from, to)); + } + } + } + } + + @Nullable + private static Hinge buildEdge(AABB box, int axis, int firstSign, int secondSign) { + double[] min = {box.minX, box.minY, box.minZ}; + double[] max = {box.maxX, box.maxY, box.maxZ}; + int firstAxis = (axis + 1) % 3; + int secondAxis = (axis + 2) % 3; + + double length = max[axis] - min[axis]; + if (length < DEGENERATE) return null; + + double[] start = new double[3]; + double[] outward = new double[3]; + start[axis] = min[axis]; + start[firstAxis] = firstSign == 0 ? min[firstAxis] : max[firstAxis]; + start[secondAxis] = secondSign == 0 ? min[secondAxis] : max[secondAxis]; + outward[axis] = 0; + outward[firstAxis] = firstSign == 0 ? -1 : 1; + outward[secondAxis] = secondSign == 0 ? -1 : 1; + + Vec3 outwardVector = new Vec3(outward[0], outward[1], outward[2]).normalize(); + Vec3 startVector = new Vec3(start[0], start[1], start[2]).add(outwardVector.scale(EDGE_CLEARANCE)); + Vec3 direction = new Vec3(axis == 0 ? 1 : 0, axis == 1 ? 1 : 0, axis == 2 ? 1 : 0); + return new Hinge(startVector, direction, length, outwardVector, 0); + } + + private void refresh() { + positions.clear(); + wrappedLength = 0; + Vec3 previous = anchor; + for (Hinge hinge : hinges) { + Vec3 position = hinge.position(); + wrappedLength += previous.distanceTo(position); + positions.add(position); + previous = position; + } + } + + private static boolean isFreeSpace(Level level, Vec3 point) { + return level.noCollision(new AABB(point, point).inflate(0.01)); + } + + private static boolean isVisible(Level level, Entity owner, Vec3 from, Vec3 to) { + Vec3 delta = to.subtract(from); + double length = delta.length(); + if (length <= 2 * RAY_SHRINK) return true; + Vec3 direction = delta.scale(1 / length); + return clip(level, owner, + from.add(direction.scale(RAY_SHRINK)), + to.subtract(direction.scale(RAY_SHRINK)) + ).getType() == HitResult.Type.MISS; + } + + public static boolean isSegmentClear(Level level, Entity owner, Vec3 from, Vec3 to) { + return isVisible(level, owner, from, to); + } + + private static HitResult clip(Level level, Entity owner, Vec3 from, Vec3 to) { + return level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, owner)); + } +} diff --git a/src/main/java/com/alrex/parcool/common/handlers/InputHandler.java b/src/main/java/com/alrex/parcool/common/handlers/InputHandler.java index ef4895ecd..4dc27bee3 100644 --- a/src/main/java/com/alrex/parcool/common/handlers/InputHandler.java +++ b/src/main/java/com/alrex/parcool/common/handlers/InputHandler.java @@ -12,6 +12,10 @@ public class InputHandler { public static void onInput(InputEvent.InteractionKeyMappingTriggered event) { var player = Minecraft.getInstance().player; if (player == null) return; + if (event.isAttack() && com.alrex.parcool.common.item.misc.GrapplingHookItem.isHeld(player)) { + event.setSwingHand(false); + event.setCanceled(true); + } var parkourability = Parkourability.get(player); parkourability.getAdditionalProperties().onJump(); for (var listener : parkourability.getActions().getExtensionListeners(ActionExtension.KeyMapTriggeredListener.class)) { diff --git a/src/main/java/com/alrex/parcool/common/item/ParCoolItems.java b/src/main/java/com/alrex/parcool/common/item/ParCoolItems.java index f4929237d..bc454bc9c 100644 --- a/src/main/java/com/alrex/parcool/common/item/ParCoolItems.java +++ b/src/main/java/com/alrex/parcool/common/item/ParCoolItems.java @@ -4,6 +4,7 @@ import com.alrex.parcool.common.block.Blocks; import com.alrex.parcool.common.item.armor.TraceurBootsItem; import com.alrex.parcool.common.item.armor.TraceurGlovesItem; +import com.alrex.parcool.common.item.misc.GrapplingHookItem; import com.alrex.parcool.common.item.misc.ParCoolGuideItem; import com.alrex.parcool.common.item.misc.ZiplineRopeItem; import net.minecraft.client.Minecraft; @@ -22,6 +23,8 @@ public class ParCoolItems { public static final RegistryObject WOODEN_ZIPLINE_HOOK = ITEMS.register("wooden_zipline_hook", () -> new BlockItem(Blocks.WOODEN_ZIPLINE_HOOK.get(), new Item.Properties().tab(ParCoolItemGroup.INSTANCE))); public static final RegistryObject IRON_ZIPLINE_HOOK = ITEMS.register("iron_zipline_hook", () -> new BlockItem(Blocks.IRON_ZIPLINE_HOOK.get(), new Item.Properties().tab(ParCoolItemGroup.INSTANCE))); public static final RegistryObject ZIPLINE_ROPE = ITEMS.register("zipline_rope", () -> new ZiplineRopeItem(new Item.Properties().tab(ParCoolItemGroup.INSTANCE))); + public static final RegistryObject HOOK = ITEMS.register("hook", () -> new Item(new Item.Properties().tab(ParCoolItemGroup.INSTANCE))); + public static final RegistryObject GRAPPLING_HOOK = ITEMS.register("grappling_hook", () -> new GrapplingHookItem(new Item.Properties().tab(ParCoolItemGroup.INSTANCE).stacksTo(1).rarity(Rarity.UNCOMMON))); public static final RegistryObject TRACEUR_GLOVES = ITEMS.register("traceur_gloves", () -> new TraceurGlovesItem(new Item.Properties().tab(ParCoolItemGroup.INSTANCE).stacksTo(1))); public static final RegistryObject TRACEUR_BOOTS = ITEMS.register("traceur_boots", () -> new TraceurBootsItem(new Item.Properties().tab(ParCoolItemGroup.INSTANCE).stacksTo(1))); public static final RegistryObject PARCOOL_GUIDE = ITEMS.register("parcool_guide", () -> new ParCoolGuideItem(new Item.Properties().tab(ParCoolItemGroup.INSTANCE).stacksTo(1).rarity(Rarity.UNCOMMON))); diff --git a/src/main/java/com/alrex/parcool/common/item/misc/GrapplingHookItem.java b/src/main/java/com/alrex/parcool/common/item/misc/GrapplingHookItem.java new file mode 100644 index 000000000..dc76abd03 --- /dev/null +++ b/src/main/java/com/alrex/parcool/common/item/misc/GrapplingHookItem.java @@ -0,0 +1,71 @@ +package com.alrex.parcool.common.item.misc; + +import com.alrex.parcool.common.action.impl.Grapple; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.client.extensions.common.IClientItemExtensions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.function.Consumer; + +public class GrapplingHookItem extends Item { + public GrapplingHookItem(Properties properties) { + super(properties); + } + + public static boolean isHeld(Player player) { + return player.getMainHandItem().getItem() instanceof GrapplingHookItem; + } + + public static boolean isDeployed(net.minecraft.world.entity.LivingEntity entity) { + if (!(entity instanceof Player player)) return false; + var parkourability = com.alrex.parcool.common.Parkourability.get(player); + return parkourability != null + && parkourability.get(com.alrex.parcool.common.action.ParCoolActions.GRAPPLE).isDoing(); + } + + @Nonnull + @Override + public InteractionResultHolder use(@Nonnull Level level, Player player, @Nonnull InteractionHand hand) { + return InteractionResultHolder.pass(player.getItemInHand(hand)); + } + + @Override + public void initializeClient(Consumer consumer) { + consumer.accept(new IClientItemExtensions() { + @Override + public net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer getCustomRenderer() { + return ClientRendererHolder.get(); + } + }); + } + + @OnlyIn(Dist.CLIENT) + private static final class ClientRendererHolder { + private static com.alrex.parcool.client.renderer.GrapplingHookItemRenderer renderer; + + private static com.alrex.parcool.client.renderer.GrapplingHookItemRenderer get() { + if (renderer == null) renderer = new com.alrex.parcool.client.renderer.GrapplingHookItemRenderer(); + return renderer; + } + } + + @Override + public void appendHoverText(@Nonnull ItemStack stack, @Nullable Level level, @Nonnull List lines, @Nonnull TooltipFlag flag) { + lines.add(Component.translatable("parcool.gui.text.grappling_hook.throw").withStyle(ChatFormatting.GRAY)); + lines.add(Component.translatable("parcool.gui.text.grappling_hook.reel").withStyle(ChatFormatting.GRAY)); + lines.add(Component.translatable("parcool.gui.text.grappling_hook.steer").withStyle(ChatFormatting.GRAY)); + lines.add(Component.translatable("parcool.gui.text.grappling_hook.release").withStyle(ChatFormatting.GRAY)); + } +} diff --git a/src/main/java/com/alrex/parcool/config/ParCoolConfig.java b/src/main/java/com/alrex/parcool/config/ParCoolConfig.java index 9b0666818..21ca9b01b 100644 --- a/src/main/java/com/alrex/parcool/config/ParCoolConfig.java +++ b/src/main/java/com/alrex/parcool/config/ParCoolConfig.java @@ -58,9 +58,20 @@ public record StaminaHud( ) { } + public record GrapplingHookView( + ForgeConfigSpec.DoubleValue fovIntensity, + ForgeConfigSpec.DoubleValue cameraRollIntensity, + ForgeConfigSpec.DoubleValue ropeSag + ) { + } + public final ForgeConfigSpec.BooleanValue enableActionSounds; public final ForgeConfigSpec.BooleanValue parcoolIsActive; + public final ForgeConfigSpec.BooleanValue showTargetIndicator; + public final ForgeConfigSpec.IntValue targetIndicatorSize; + public final ForgeConfigSpec.BooleanValue debugRope; public final StaminaHud staminaHud; + public final GrapplingHookView grapplingHook; public Client() { @@ -78,6 +89,24 @@ public Client() { ); } builder.pop(); + builder.push("GrapplingHook"); + { + grapplingHook = new GrapplingHookView( + builder.comment("how much speed widens the view. 0 disables") + .defineInRange("fov_intensity", 0.6, 0.0, 1.0), + builder.comment("how far the camera leans towards the rope. 0 disables") + .defineInRange("camera_roll_intensity", 0.6, 0.0, 1.0), + builder.comment("how much a loose rope sags. 0 for straight") + .defineInRange("rope_sag", 1.0, 0.0, 2.0) + ); + showTargetIndicator = builder.comment("show a marker where the hook would land") + .define("show_target_indicator", true); + targetIndicatorSize = builder.comment("size of that marker in pixels") + .defineInRange("target_indicator_size", 11, 3, 64); + debugRope = builder.comment("log it when the rope ends up inside a block") + .define("debug_rope", false); + } + builder.pop(); builder.push("Other"); { enableActionSounds = builder.define("enable_sounds", true); @@ -89,10 +118,36 @@ public Client() { } public static class Server { + public record GrapplingHook( + ForgeConfigSpec.DoubleValue maxRange, + ForgeConfigSpec.DoubleValue hookTravelSpeed, + ForgeConfigSpec.DoubleValue minRopeLength, + ForgeConfigSpec.DoubleValue reelOutSpeed, + ForgeConfigSpec.DoubleValue swingControlForce, + ForgeConfigSpec.DoubleValue swingAssist, + ForgeConfigSpec.DoubleValue swingDamping, + ForgeConfigSpec.DoubleValue airResistance, + ForgeConfigSpec.DoubleValue maxSpeed, + ForgeConfigSpec.DoubleValue ropeDrag, + ForgeConfigSpec.DoubleValue ropeCompliance, + ForgeConfigSpec.DoubleValue releaseBoost, + ForgeConfigSpec.IntValue momentumKeepTicks, + ForgeConfigSpec.DoubleValue momentumDrag, + ForgeConfigSpec.IntValue aimAssistAngle, + ForgeConfigSpec.IntValue physicsSubsteps, + ForgeConfigSpec.BooleanValue allowRopeWrapping, + ForgeConfigSpec.IntValue maxRopeBends, + ForgeConfigSpec.DoubleValue maxTension, + ForgeConfigSpec.DoubleValue pullStrength, + ForgeConfigSpec.DoubleValue pullSpeedLimit + ) { + } + private final ForgeConfigSpec builtConfig; private final TreeMap, ActionValue>> actionMap; public final ForgeConfigSpec.BooleanValue damageWithoutGlove; public final ForgeConfigSpec.BooleanValue enableSkillTree; + public final GrapplingHook grapplingHook; public final ResourceLocation getStaminaTypeID() { var id = ResourceLocation.tryParse(staminaType.get()); @@ -139,6 +194,54 @@ public Server(ActionRegistry actionRegistry, StaminaTypeRegistry staminaTypeRegi damageWithoutGlove = builder.define("damage_without_glove", true); } builder.pop(); + builder.push("GrapplingHook"); + { + grapplingHook = new GrapplingHook( + builder.comment("max reach in blocks") + .defineInRange("max_range", 48.0, 8.0, 128.0), + builder.comment("how fast the thrown hook flies") + .defineInRange("hook_travel_speed", 4.0, 1.0, 24.0), + builder.comment("shortest the rope can get") + .defineInRange("min_rope_length", 1.5, 0.5, 8.0), + builder.comment("rope let out per tick while sneaking") + .defineInRange("reel_out_speed", 0.35, 0.0, 1.0), + builder.comment("steering force while swinging. gravity is 0.08") + .defineInRange("swing_control_force", 0.012, 0.0, 0.16), + builder.comment("how much steering pumps the swing. 0 for a plain pendulum") + .defineInRange("swing_assist", 0.35, 0.0, 2.0), + builder.comment("raise this if a swing wobbles for too long") + .defineInRange("swing_damping", 0.015, 0.0, 0.3), + builder.comment("drag at speed. raise for a heavier swing") + .defineInRange("air_resistance", 0.013, 0.0, 0.1), + builder.comment("speed limit while swinging") + .defineInRange("max_speed", 1.6, 0.5, 5.0), + builder.comment("flat speed kept per tick") + .defineInRange("rope_drag", 0.997, 0.9, 1.0), + builder.comment("how much the rope stretches. 0 for none") + .defineInRange("rope_compliance", 0.0005, 0.0, 0.05), + builder.comment("upward boost when letting go with jump. a jump is 0.42") + .defineInRange("release_boost", 0.36, 0.0, 1.5), + builder.comment("ticks of momentum kept after letting go. 0 disables") + .defineInRange("momentum_keep_ticks", 30, 0, 200), + builder.comment("speed kept per tick during that. vanilla air is 0.91") + .defineInRange("momentum_drag", 0.98, 0.9, 1.0), + builder.comment("aim assist cone in degrees. 0 disables") + .defineInRange("aim_assist_angle", 14, 0, 45), + builder.comment("physics steps per tick") + .defineInRange("physics_substeps", 6, 1, 12), + builder.comment("let the rope bend around corners") + .define("allow_rope_wrapping", true), + builder.comment("max corners one rope can wrap around") + .defineInRange("max_rope_bends", 40, 0, 64), + builder.comment("load before the hook tears off. 0 never breaks") + .defineInRange("max_tension", 26.0, 0.0, 200.0), + builder.comment("pull force while holding use. gravity is 0.08") + .defineInRange("pull_strength", 0.12, 0.0, 2.0), + builder.comment("fastest the rope reels you in, in blocks per tick") + .defineInRange("pull_speed_limit", 0.75, 0.05, 4.0) + ); + } + builder.pop(); builder.push("Stamina"); { var registeredItems = staminaTypeRegistry.getEntries(); diff --git a/src/main/java/com/alrex/parcool/mixin/client/ItemInHandRendererMixin.java b/src/main/java/com/alrex/parcool/mixin/client/ItemInHandRendererMixin.java index ecbafd91e..d5e60e5a5 100644 --- a/src/main/java/com/alrex/parcool/mixin/client/ItemInHandRendererMixin.java +++ b/src/main/java/com/alrex/parcool/mixin/client/ItemInHandRendererMixin.java @@ -1,6 +1,7 @@ package com.alrex.parcool.mixin.client; import com.alrex.parcool.common.item.armor.EquipAble; +import com.alrex.parcool.common.item.misc.GrapplingHookItem; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.renderer.ItemInHandRenderer; @@ -21,9 +22,11 @@ public abstract class ItemInHandRendererMixin { @Inject(method = "renderArmWithItem", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/ItemInHandRenderer;renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/client/renderer/block/model/ItemTransforms$TransformType;ZLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/MultiBufferSource;I)V")) private void onRenderItemInRenderArmWithItem(AbstractClientPlayer player, float p_109373_, float p_109374_, InteractionHand hand, float p_109376_, ItemStack stack, float p_109378_, PoseStack poseStack, MultiBufferSource bufferSource, int p_109381_, CallbackInfo ci) { - if (!(stack.getItem() instanceof EquipAble equipAble)) return; var arm = hand == InteractionHand.MAIN_HAND ? player.getMainArm() : player.getMainArm().getOpposite(); - if (equipAble.renderWhenIn(player, arm)) { + boolean showBareArm = stack.getItem() instanceof EquipAble equipAble + ? equipAble.renderWhenIn(player, arm) + : stack.getItem() instanceof GrapplingHookItem && GrapplingHookItem.isDeployed(player); + if (showBareArm) { poseStack.popPose(); this.renderPlayerArm(poseStack, bufferSource, p_109381_, p_109378_, p_109376_, arm); poseStack.pushPose(); diff --git a/src/main/java/com/alrex/parcool/mixin/client/PlayerItemInHandLayerMixin.java b/src/main/java/com/alrex/parcool/mixin/client/PlayerItemInHandLayerMixin.java index 54612e6be..b7c9b985a 100644 --- a/src/main/java/com/alrex/parcool/mixin/client/PlayerItemInHandLayerMixin.java +++ b/src/main/java/com/alrex/parcool/mixin/client/PlayerItemInHandLayerMixin.java @@ -1,6 +1,7 @@ package com.alrex.parcool.mixin.client; import com.alrex.parcool.common.item.armor.EquipAble; +import com.alrex.parcool.common.item.misc.GrapplingHookItem; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.model.ArmedModel; import net.minecraft.client.model.EntityModel; @@ -38,6 +39,8 @@ private void onRenderArmWithItem(LivingEntity entity, ItemStack stack, ItemTrans if (equipAble.renderWhenIn(entity, arm)) { ci.cancel(); } + } else if (stack.getItem() instanceof GrapplingHookItem && GrapplingHookItem.isDeployed(entity)) { + ci.cancel(); } } } diff --git a/src/main/java/com/alrex/parcool/mixin/common/ServerGamePacketListenerImplMixin.java b/src/main/java/com/alrex/parcool/mixin/common/ServerGamePacketListenerImplMixin.java index a3ac1981f..a417764a7 100644 --- a/src/main/java/com/alrex/parcool/mixin/common/ServerGamePacketListenerImplMixin.java +++ b/src/main/java/com/alrex/parcool/mixin/common/ServerGamePacketListenerImplMixin.java @@ -26,7 +26,8 @@ public abstract class ServerGamePacketListenerImplMixin implements ServerPlayerC private void onHandleMovePlayer(ServerboundMovePlayerPacket packet, CallbackInfo ci) { if (clientIsFloating) { var parkourability = Parkourability.get(player); - if (parkourability.get(ParCoolActions.RIDE_ZIPLINE).isDoing()) { + if (parkourability.get(ParCoolActions.RIDE_ZIPLINE).isDoing() + || parkourability.get(ParCoolActions.GRAPPLE).isDoing()) { clientIsFloating = false; } } diff --git a/src/main/java/com/alrex/parcool/proxy/ClientProxy.java b/src/main/java/com/alrex/parcool/proxy/ClientProxy.java index d0525c791..0ba1014a0 100644 --- a/src/main/java/com/alrex/parcool/proxy/ClientProxy.java +++ b/src/main/java/com/alrex/parcool/proxy/ClientProxy.java @@ -8,11 +8,15 @@ import com.alrex.parcool.client.animation.system.handle.AnimationSystemEventHandler; import com.alrex.parcool.client.animation.system.registration.AnimationSets; import com.alrex.parcool.client.animation.system.resource.AnimationResourceManager; +import com.alrex.parcool.client.GrappleCameraHandler; +import com.alrex.parcool.client.GrappleTargetOverlay; import com.alrex.parcool.client.gui.screen.ParCoolGuideScreen; import com.alrex.parcool.client.gui.screen.SkillTreeScreen; import com.alrex.parcool.client.hud.HUDRegistry; import com.alrex.parcool.client.input.ParCoolKeyBinds; import com.alrex.parcool.client.md.resource.GuideResourceManager; +import com.alrex.parcool.client.renderer.GrappleRopeRenderer; +import com.alrex.parcool.client.renderer.GrapplingHookItemRenderer; import com.alrex.parcool.client.renderer.entity.layers.ParCoolModelLayers; import com.alrex.parcool.client.skilltree.ParCoolSkillTrees; import com.alrex.parcool.client.textures.ParCoolTextures; @@ -45,6 +49,7 @@ public void init() { bus.addListener(ParCoolTextures::init); bus.addListener(AnimationResourceManager::register); bus.addListener(GuideResourceManager::register); + bus.addListener(GrapplingHookItemRenderer::registerModels); bus = MinecraftForge.EVENT_BUS; bus.addListener(ParCoolKeyBinds::tick); @@ -52,6 +57,9 @@ public void init() { bus.register(InputHandler.class); bus.register(AnimationSystemEventHandler.class); bus.register(ParCoolSkillTrees.class); + bus.register(GrappleCameraHandler.class); + bus.register(GrappleRopeRenderer.class); + bus.register(GrappleTargetOverlay.class); bus.register(new PassiveAnimationProcessor()); var registerAnimationEntryEvent = new RegisterAnimationEntryEvent(); diff --git a/src/main/resources/assets/parcool/lang/en_us.json b/src/main/resources/assets/parcool/lang/en_us.json index 089d4eb4e..2e933720f 100644 --- a/src/main/resources/assets/parcool/lang/en_us.json +++ b/src/main/resources/assets/parcool/lang/en_us.json @@ -17,6 +17,8 @@ "item.parcool.zipline_rope": "Zipline Rope", "item.parcool.traceur_gloves": "Traceur's Gloves", "item.parcool.traceur_boots": "Traceur's Boots", + "item.parcool.hook": "Hook", + "item.parcool.grappling_hook": "Grappling Hook", "block.parcool.wooden_zipline_hook": "Wooden Zipline Hook", "block.parcool.iron_zipline_hook": "Iron Zipline Hook", @@ -71,6 +73,7 @@ "parcool.action.parcool.horizontal_wall_run": "H Wall Run", "parcool.action.parcool.pole_climb": "Pole Climb", "parcool.action.parcool.ride_zipline": "Ride Zipline", + "parcool.action.parcool.grapple": "Grapple", "parcool.action.parcool.skydive": "Skydive", "parcool.action.parcool.slide": "Slide", "parcool.action.parcool.slide_down": "Slide Down", @@ -83,6 +86,11 @@ "parcool.gui.text.action": "Action", "parcool.gui.text.actionName": "Action Name", "parcool.gui.text.config": "Config", + "parcool.gui.text.grappling_hook.throw": "Attack to throw the hook, Attack again to let go", + "parcool.gui.text.grappling_hook.reel": "Hold Use to haul yourself along the rope", + "parcool.gui.text.grappling_hook.steer": "Move to steer the swing, Sneak to pay rope out", + "parcool.gui.text.grappling_hook.release": "Jump to let go with a boost", + "parcool.gui.text.zipline.color": "Zipline Color", "parcool.gui.text.zipline.bind_pos": "Position [%s]", "parcool.gui.text.zipline.not_bound": "No Bind", diff --git a/src/main/resources/assets/parcool/lang/ja_jp.json b/src/main/resources/assets/parcool/lang/ja_jp.json index 80abe215d..2fe0245fc 100644 --- a/src/main/resources/assets/parcool/lang/ja_jp.json +++ b/src/main/resources/assets/parcool/lang/ja_jp.json @@ -14,6 +14,8 @@ "item.parcool.zipline_rope": "ジップラインのロープ", "item.parcool.traceur_gloves": "トレーサーのグローブ", "item.parcool.traceur_boots": "トレーサーのブーツ", + "item.parcool.hook": "フック", + "item.parcool.grappling_hook": "グラップリングフック", "block.parcool.wooden_zipline_hook": "木製のジップラインフック", "block.parcool.iron_zipline_hook": "鉄製のジップラインフック", "key.category.parcool": "ParCool", @@ -64,6 +66,7 @@ "parcool.action.parcool.horizontal_wall_run": "水平ウォールラン", "parcool.action.parcool.pole_climb": "棒登り", "parcool.action.parcool.ride_zipline": "ジップラインを使う", + "parcool.action.parcool.grapple": "グラップル", "parcool.action.parcool.skydive": "スカイダイビング", "parcool.action.parcool.slide": "スライディング", "parcool.action.parcool.slide_down": "壁滑り", @@ -75,6 +78,11 @@ "parcool.gui.text.action": "アクション", "parcool.gui.text.actionName": "アクション名", "parcool.gui.text.config": "コンフィグ", + "parcool.gui.text.grappling_hook.throw": "攻撃キーで射出し、もう一度押すと手を放します", + "parcool.gui.text.grappling_hook.reel": "使用キーを押し続けるとロープを手繰り寄せます", + "parcool.gui.text.grappling_hook.steer": "移動キーでスイングを操作し、スニークでロープを繰り出します", + "parcool.gui.text.grappling_hook.release": "ジャンプで勢いをつけて離れます", + "parcool.gui.text.zipline.color": "ジップラインの色", "parcool.gui.text.zipline.bind_pos": "座標 [%s]", "parcool.gui.text.zipline.not_bound": "紐づけなし", diff --git a/src/main/resources/assets/parcool/models/item/grappling_hook.json b/src/main/resources/assets/parcool/models/item/grappling_hook.json new file mode 100644 index 000000000..d9c6fc722 --- /dev/null +++ b/src/main/resources/assets/parcool/models/item/grappling_hook.json @@ -0,0 +1,141 @@ +{ + "parent": "builtin/entity", + "display": { + "thirdperson_righthand": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "thirdperson_lefthand": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "firstperson_righthand": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "firstperson_lefthand": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "ground": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 2, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ] + }, + "gui": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "fixed": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "head": { + "rotation": [ + 0, + 0, + 0 + ], + "translation": [ + 0, + 13, + 7 + ], + "scale": [ + 1, + 1, + 1 + ] + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/parcool/models/item/grappling_hook_gui.json b/src/main/resources/assets/parcool/models/item/grappling_hook_gui.json new file mode 100644 index 000000000..2cae1c057 --- /dev/null +++ b/src/main/resources/assets/parcool/models/item/grappling_hook_gui.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "parcool:item/grappling_hook" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/parcool/models/item/grappling_hook_in_hand.json b/src/main/resources/assets/parcool/models/item/grappling_hook_in_hand.json new file mode 100644 index 000000000..8e06600bd --- /dev/null +++ b/src/main/resources/assets/parcool/models/item/grappling_hook_in_hand.json @@ -0,0 +1,135 @@ +{ + "texture_size": [ + 32, + 32 + ], + "textures": { + "1": "parcool:item/grappling_hook_model", + "particle": "parcool:item/grappling_hook_model" + }, + "elements": [ + { + "from": [5.9, 3.9, 9.9], + "to": [10.1, 8.1, 12.1], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 10]}, + "faces": { + "north": {"uv": [1, 7, 3, 9], "texture": "#1"}, + "east": {"uv": [0, 7, 1, 9], "texture": "#1"}, + "south": {"uv": [4, 7, 6, 9], "texture": "#1"}, + "west": {"uv": [3, 7, 4, 9], "texture": "#1"}, + "up": {"uv": [3, 7, 1, 6], "texture": "#1"}, + "down": {"uv": [5, 6, 3, 7], "texture": "#1"} + } + }, + { + "from": [7, 8, 8], + "to": [9, 8, 10], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 8]}, + "faces": { + "north": {"uv": [6, 7, 7, 7], "texture": "#1"}, + "east": {"uv": [5, 7, 6, 7], "texture": "#1"}, + "south": {"uv": [8, 7, 9, 7], "texture": "#1"}, + "west": {"uv": [7, 7, 8, 7], "texture": "#1"}, + "up": {"uv": [7, 7, 6, 6], "texture": "#1"}, + "down": {"uv": [8, 6, 7, 7], "texture": "#1"} + } + }, + { + "from": [7, 0, 6], + "to": [9, 8, 8], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 6]}, + "faces": { + "north": {"uv": [13, 1, 14, 5], "texture": "#1"}, + "east": {"uv": [12, 1, 13, 5], "texture": "#1"}, + "south": {"uv": [15, 1, 16, 5], "texture": "#1"}, + "west": {"uv": [14, 1, 15, 5], "texture": "#1"}, + "up": {"uv": [14, 1, 13, 0], "texture": "#1"}, + "down": {"uv": [15, 0, 14, 1], "texture": "#1"} + } + }, + { + "from": [5, 0, 4], + "to": [11, 6, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 6]}, + "faces": { + "north": {"uv": [0, 3, 3, 6], "texture": "#1"}, + "east": {"uv": [0, 3, 0, 6], "texture": "#1"}, + "south": {"uv": [3, 3, 6, 6], "texture": "#1"}, + "west": {"uv": [3, 3, 3, 6], "texture": "#1"}, + "up": {"uv": [3, 3, 0, 3], "texture": "#1"}, + "down": {"uv": [6, 3, 3, 3], "texture": "#1"} + } + }, + { + "from": [5, 0, 10], + "to": [11, 6, 10], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 12]}, + "faces": { + "north": {"uv": [6, 0, 9, 3], "texture": "#1"}, + "east": {"uv": [6, 0, 6, 3], "texture": "#1"}, + "south": {"uv": [9, 0, 12, 3], "texture": "#1"}, + "west": {"uv": [9, 0, 9, 3], "texture": "#1"}, + "up": {"uv": [9, 0, 6, 0], "texture": "#1"}, + "down": {"uv": [12, 0, 9, 0], "texture": "#1"} + } + }, + { + "from": [5, 0, 4], + "to": [11, 0, 10], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 12]}, + "faces": { + "north": {"uv": [0, 3, 3, 3], "texture": "#1"}, + "east": {"uv": [-3, 3, 0, 3], "texture": "#1"}, + "south": {"uv": [6, 3, 9, 3], "texture": "#1"}, + "west": {"uv": [3, 3, 6, 3], "texture": "#1"}, + "up": {"uv": [3, 3, 0, 0], "texture": "#1"}, + "down": {"uv": [6, 0, 3, 3], "texture": "#1"} + } + }, + { + "from": [5, 0, 4], + "to": [5, 6, 10], + "rotation": {"angle": 0, "axis": "y", "origin": [9, 4, 6]}, + "faces": { + "north": {"uv": [9, 3, 9, 6], "texture": "#1"}, + "east": {"uv": [12, 3, 9, 6], "texture": "#1"}, + "south": {"uv": [12, 3, 12, 6], "texture": "#1"}, + "west": {"uv": [9, 3, 6, 6], "texture": "#1"}, + "up": {"uv": [9, 3, 9, 0], "texture": "#1"}, + "down": {"uv": [9, 0, 9, 3], "texture": "#1"} + } + }, + { + "from": [11, 0, 4], + "to": [11, 6, 10], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 4, 6]}, + "faces": { + "north": {"uv": [9, 3, 9, 6], "texture": "#1"}, + "east": {"uv": [6, 3, 9, 6], "texture": "#1"}, + "south": {"uv": [12, 3, 12, 6], "texture": "#1"}, + "west": {"uv": [9, 3, 12, 6], "texture": "#1"}, + "up": {"uv": [9, 3, 9, 0], "texture": "#1"}, + "down": {"uv": [9, 0, 9, 3], "texture": "#1"} + } + } + ], + "groups": [ + { + "name": "grappling_hook", + "origin": [8, 8, 8], + "scope": 0, + "color": 0, + "children": [ + 0, + 1, + { + "name": "hook", + "origin": [7, 4, 6], + "scope": 0, + "color": 0, + "children": [2, 3, 4, 5, 6, 7] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/parcool/models/item/hook.json b/src/main/resources/assets/parcool/models/item/hook.json new file mode 100644 index 000000000..b08aba133 --- /dev/null +++ b/src/main/resources/assets/parcool/models/item/hook.json @@ -0,0 +1,6 @@ +{ + "parent": "item/generated", + "textures": { + "layer0": "parcool:item/hook" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/parcool/textures/item/grappling_hook.png b/src/main/resources/assets/parcool/textures/item/grappling_hook.png new file mode 100644 index 0000000000000000000000000000000000000000..fe1550dca366d5c548866dc18af6f4ce45d52987 GIT binary patch literal 439 zcmV;o0Z9IdP)i2Mx#;8X0y26 zZYg9knZ#TrRbGy&lZ{em~~(Id4K`2iX07AJgeH#^Z5nU!acG z>vamHsSI$c)1BDqbfVE{1f!a6%;8}GL9^MU2_c3#7z}<-b{(3^4meHU3I=Lr(v3a* z8<0irY+&x~CCsGT?S^2Spg#fS&*ueNtyVDPd*_+>UoMx4^VGiqyw5L#0Id-k7~^KM z3HkfMonC-fO@F42Q#^e0`gwU>bWNcn76`H6XNDADqlg5;d#K?v=rg_n00960b&esJ h00006NklrKSJ?002ovPDHLkV1k5IyL=G`P)+DFOy3fMoBRmjZ%-M$r|YQ`)Rk^H9+ej8V}I& z%fxoOZLnM}4KzpA0LgSZH9(7Ivngp_W6}_yJ$N9GU9Z>cRQYf?%;v&}tN|vU&*urC zRZOQI`9$gf?ZE?ijF(o#6Ilc7oUQ?_!qsXeX+xvm-9Uj|J=gjc$1`nyaC-*R^c7!fU z;jiHO;>0{f7mVwJb`w9?KiJQ$*XKv$bf`_DUtUyF1izckdDp;uXgoldTxaL7@c#L2 zLRHEYy)Ru=42z$IiK|}EK)E6wkXN;^<_V*hr$pRbo+hAN5f8{Uu&P-2E2wH=%~Ll} zpFn*A^$FA`@c&Oh|M|P(F8}}l|NqXsO-%p*00v1!K~w_(rj~|KAx?+P00000NkvXX Hu0mjfshR)W literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/parcool/textures/item/hook.png b/src/main/resources/assets/parcool/textures/item/hook.png new file mode 100644 index 0000000000000000000000000000000000000000..f2ae8deda59516ad7c9dc309e07790f3991708d9 GIT binary patch literal 431 zcmV;g0Z{&lP)~5C!03FM+@zxBwbS!WFQb067Vogi~O-0tph$1rRs{2SCsESGzqKNEWk%M^D$Q z_v(lFihq7y3?`FF3$xj*?c-C8&fr%V>-9SJ`+Y2z%P6sUJRYC)kp?AE#h6Z~F`v(4 zx7)?xaERma7}x7{z~Qfs(m(?7<#LJDYL&wC`8>Ga?}I8VMY5~+jyeOCnJ}v;qSh$T zG);C@xq7C-^Z5*IGm%_Lab1a@PN&>Q9jWG|flDDAK`BAIPC@2&!q8o3;5iM< zdb{01m8P_+i^XDrx5V5DULN6aIE-qR2ANrYRA-{)x{oUCi;GFg{`tNb7=sw)lxPg& zfU08ETwWR=B_efzfc`jm=!@l~fd_LFsLaITyc5PE!Yd$G%n4trZ#J9m4J6ubx8355 z!DN$!BF5T}D20U-$ihzj?2RL=Pg00960H9+S@00006NklKe#wHBWmXk*FkmM6zavx$&Vc z^OPIgx>`doU!IuwJcK92*WbO()L%03_MGO`tUwj!hq#z#FxqD6%a^1?FhAq@H!=7} u#=Wca4_N~p@c;junDq@n1{=sF%naA%TjWpF#O(x%FnGH9xvX Date: Sun, 16 Aug 2026 12:38:56 +0900 Subject: [PATCH 2/4] move changelog to latest file (v_4.0.0.0.md) --- changelog/v_3.4.4.0.md | 19 ------------------- changelog/v_4.0.0.0.md | 12 ++++++++++-- 2 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 changelog/v_3.4.4.0.md diff --git a/changelog/v_3.4.4.0.md b/changelog/v_3.4.4.0.md deleted file mode 100644 index 0b9a77516..000000000 --- a/changelog/v_3.4.4.0.md +++ /dev/null @@ -1,19 +0,0 @@ -### New Features - -- Grappling Hook - - New item - - Physics based rope swinging - - Aim assist cone, used only when the crosshair itself misses - -### Modified - -- ParCool Action API - - `ActionOption.needLearning` : lets an action opt out of skill tree gating, for abilities that - are already gated by something else such as an item - - `ActionOption.availableWhileExhausted` : lets an action run on empty stamina, for abilities - that are not paid for with stamina - -### Translation - -- English -- Japanese diff --git a/changelog/v_4.0.0.0.md b/changelog/v_4.0.0.0.md index 3047d95f7..b8625c5b6 100644 --- a/changelog/v_4.0.0.0.md +++ b/changelog/v_4.0.0.0.md @@ -15,16 +15,24 @@ - Pole Climbing - Hang Down Swinging - Creative Fly +- New Items + - *ParCool Guide* + - Builtin in-game Guide and Skill Tree + - Equipments + - *Traceur's Gloves* + - *Traceur's Boots* + - Grappling Hook + - Physics based Rope Swinging + - Aim Assist Cone, used only when the crosshair itself misses - Reworked Existing Animations - Powered Zipline -- Some Equipment Items -- Builtin in-game Guide and Skill Tree - Additional APIs - Some Attributes - Sounds for Some Actions ### Modified +- ParCool API - Re-implement Internal Base System - Re-implement Actions - Re-implement Zipline From caa9788faca0e94a349694d3d4d8a98c4937fa10 Mon Sep 17 00:00:00 2001 From: alRex-U <83890990+alRex-U@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:49:37 +0900 Subject: [PATCH 3/4] fix : load LocalPlayer in dedicated server --- .../java/com/alrex/parcool/common/action/impl/Grapple.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java index 9edbcb908..9519a2f85 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java @@ -118,8 +118,7 @@ public Grapple(Parkourability parkourability, ActionEntry entr ParCoolActions.SLIDE_DOWN, ParCoolActions.CASTAWAY, ParCoolActions.RIDE_ZIPLINE, - ParCoolActions.HORIZONTAL_WALL_RUN, - ParCoolActions.DIVE + ParCoolActions.HORIZONTAL_WALL_RUN )); dataHolder = SynchronizedDataHolder.create(entry, propertyPhase = SynchronizedProperty.newEnum(GrapplePhase.class, this::onPhaseChanged), @@ -210,7 +209,7 @@ public boolean isMomentumActive() { @Override public boolean canStart() { - if (!(parkourability.player() instanceof LocalPlayer player)) return false; + var player = parkourability.player(); if (!GrapplingHookItem.isHeld(player)) return false; if (!ParCoolKeyBinds.ATTACK.state().isJustPressed()) return false; prepareThrow(player); @@ -219,7 +218,7 @@ public boolean canStart() { @Override public boolean canContinue() { - if (!(parkourability.player() instanceof LocalPlayer player)) return false; + var player = parkourability.player(); if (!GrapplingHookItem.isHeld(player)) return false; if (ParCoolKeyBinds.JUMP.state().isJustPressed()) { releaseWithBoost = true; From f27ca633d3374fd8fffa9a72913d8a3b7107fa12 Mon Sep 17 00:00:00 2001 From: alRex-U <83890990+alRex-U@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:41:48 +0900 Subject: [PATCH 4/4] weaken pull power of grapple for vertical direction --- .../java/com/alrex/parcool/common/action/impl/Grapple.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java index 9519a2f85..8629b5780 100644 --- a/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java +++ b/src/main/java/com/alrex/parcool/common/action/impl/Grapple.java @@ -558,8 +558,9 @@ private Vec3 winch(ParCoolConfig.Server.GrapplingHook config, Vec3 ropeDirection pulling = true; Vec3 pullDirection = ropeDirection.reverse(); + double pullVerticallity = Mth.clamp(Math.abs(pullDirection.y / (pullDirection.length() + 1e-5)), 0, 1); double againstGravity = Math.max(0, gravity * pullDirection.y); - return pullDirection.scale(config.pullStrength().get() + againstGravity); + return pullDirection.scale((config.pullStrength().get() + againstGravity) * Mth.lerp(pullVerticallity, 1, 0.2)); } @OnlyIn(Dist.CLIENT)