diff --git a/changelog/v_4.0.0.0.md b/changelog/v_4.0.0.0.md index 3047d95f..b8625c5b 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 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 981b88bf..c89a3132 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 79bc182d..4d795fb3 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 00000000..1d072161 --- /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 00000000..4b29db4e --- /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 21194e6a..8936bbf4 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 00000000..bd037e86 --- /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 00000000..bd3c48fb --- /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 24efb907..f3b48e80 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 00000000..e8dbd2e9 --- /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 03beb05f..7a5d0b3b 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 00000000..d186cf47 --- /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 ca8920ae..dd3e45ae 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 fa3ada07..d7f3c9ab 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 defced22..53862a8f 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 2a2cab41..70a6eec2 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 9045c35e..92a3bfdb 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 00000000..8629b578 --- /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 + )); + 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() { + var player = parkourability.player(); + if (!GrapplingHookItem.isHeld(player)) return false; + if (!ParCoolKeyBinds.ATTACK.state().isJustPressed()) return false; + prepareThrow(player); + return true; + } + + @Override + public boolean canContinue() { + var player = parkourability.player(); + 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 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) * Mth.lerp(pullVerticallity, 1, 0.2)); + } + + @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 63a56864..2b490a68 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 @@ -81,7 +81,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 008ecfc4..02fea35e 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 @@ -50,6 +50,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 bfd908ae..3fb14121 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 10f1d6a9..60ad6d6b 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 @@ -34,6 +34,7 @@ public class PoleClimb extends ContinuableAction implements ActionExtension.Leav 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 0376d3ef..18303eb8 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 @@ -55,6 +55,7 @@ public class RideZipline extends ContinuableAction implements ActionExtension.Ke 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 105642fb..783a9ace 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 @@ -46,6 +46,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 fe25a586..18e3844e 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 00000000..d890e049 --- /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 00000000..0d985dba --- /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 00000000..d39ca30d --- /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 00000000..f0321dd2 --- /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 ef4895ec..4dc27bee 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 f4929237..bc454bc9 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 00000000..dc76abd0 --- /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 9b066681..21ca9b01 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 ecbafd91..d5e60e5a 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 44c20966..be6b7f8d 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; @@ -33,6 +34,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 a3ac1981..a417764a 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 bf3b8326..bf23b7b3 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 089d4eb4..2e933720 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 80abe215..2fe0245f 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 00000000..d9c6fc72 --- /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 00000000..2cae1c05 --- /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 00000000..8e06600b --- /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 00000000..b08aba13 --- /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 00000000..fe1550dc Binary files /dev/null and b/src/main/resources/assets/parcool/textures/item/grappling_hook.png differ diff --git a/src/main/resources/assets/parcool/textures/item/grappling_hook_model.png b/src/main/resources/assets/parcool/textures/item/grappling_hook_model.png new file mode 100644 index 00000000..ade7cc9b Binary files /dev/null and b/src/main/resources/assets/parcool/textures/item/grappling_hook_model.png differ 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 00000000..f2ae8ded Binary files /dev/null and b/src/main/resources/assets/parcool/textures/item/hook.png differ diff --git a/src/main/resources/assets/parcool/textures/misc/grapple_rope.png b/src/main/resources/assets/parcool/textures/misc/grapple_rope.png new file mode 100644 index 00000000..f3cf38e9 Binary files /dev/null and b/src/main/resources/assets/parcool/textures/misc/grapple_rope.png differ diff --git a/src/main/resources/assets/parcool/textures/misc/grapple_target.png b/src/main/resources/assets/parcool/textures/misc/grapple_target.png new file mode 100644 index 00000000..4a265295 Binary files /dev/null and b/src/main/resources/assets/parcool/textures/misc/grapple_target.png differ diff --git a/src/main/resources/data/parcool/recipes/grappling_hook.json b/src/main/resources/data/parcool/recipes/grappling_hook.json new file mode 100644 index 00000000..a43ec4f6 --- /dev/null +++ b/src/main/resources/data/parcool/recipes/grappling_hook.json @@ -0,0 +1,26 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " h ", + " s ", + "iIi" + ], + "key": { + "h": { + "item": "parcool:hook" + }, + "s": { + "item": "minecraft:string" + }, + "i": { + "item": "minecraft:iron_nugget" + }, + "I": { + "item": "minecraft:iron_ingot" + } + }, + "result": { + "item": "parcool:grappling_hook", + "count": 1 + } +} \ No newline at end of file diff --git a/src/main/resources/data/parcool/recipes/hook.json b/src/main/resources/data/parcool/recipes/hook.json new file mode 100644 index 00000000..08090cb2 --- /dev/null +++ b/src/main/resources/data/parcool/recipes/hook.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " i", + "i ", + "i " + ], + "key": { + "i": { + "item": "minecraft:iron_ingot" + } + }, + "result": { + "item": "parcool:hook", + "count": 1 + } +} \ No newline at end of file