diff --git a/.github/workflows/publish-devbuilds.yml b/.github/workflows/publish-devbuilds.yml index a55f55c9..1677976d 100644 --- a/.github/workflows/publish-devbuilds.yml +++ b/.github/workflows/publish-devbuilds.yml @@ -49,7 +49,7 @@ jobs: # BETA: 1 # exclude assets if it's a beta dev build - name: Publish artifacts - uses: drtheodor/discord-webhook-upload-action@1959209c809d121da994387257f14502a8350452 # v0.2 + uses: drtheodor/discord-webhook-upload-action@5d7497d4deb77d02dfb9605f9da85fa780364d00 # v0.3 with: url: ${{ secrets.DEV_BUILDS }} username: tony stank diff --git a/.gitignore b/.gitignore index afe78aa5..492e50c2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,9 @@ run/ src/main/generated/.cache src/main/generated -.direnv \ No newline at end of file +.direnv + +# claude + +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/scripts/remap_spiderman.py b/scripts/remap_spiderman.py new file mode 100644 index 00000000..cbc20830 --- /dev/null +++ b/scripts/remap_spiderman.py @@ -0,0 +1,104 @@ +""" +Remap spiderman.png using corrected Steve UVs. + +Source: D:/james/downloads/spiderman.png (64x64) with UV layout per spiderman.bbmodel +Target: 64x64 Steve player skin with UV layout per PlayerModelTrue.bbmodel +""" +import json +from PIL import Image + +SRC_BBMODEL = 'D:/james/downloads/spiderman.bbmodel' +DST_BBMODEL = 'D:/james/downloads/PlayerModelTrue.bbmodel' +SRC_TEXTURE = 'D:/james/downloads/spiderman.png' +DST_TEXTURE = 'E:/IdeaProjects/superhero/src/main/resources/assets/timeless/textures/suit/spiderman.png' + +with open(SRC_BBMODEL, 'r', encoding='utf-8') as f: + src_model = json.load(f) +with open(DST_BBMODEL, 'r', encoding='utf-8') as f: + dst_model = json.load(f) + +src_img = Image.open(SRC_TEXTURE).convert('RGBA') +src_tex_w, src_tex_h = src_img.size + +src_uv_w = src_model['resolution']['width'] +src_uv_h = src_model['resolution']['height'] +dst_uv_w = dst_model['resolution']['width'] +dst_uv_h = dst_model['resolution']['height'] + +dst_tex_w = dst_uv_w +dst_tex_h = dst_uv_h +dst_img = Image.new('RGBA', (dst_tex_w, dst_tex_h), (0, 0, 0, 0)) + +src_elements_by_name = {} +for e in src_model['elements']: + src_elements_by_name.setdefault(e['name'], e) + +FACE_NAMES = ['north', 'south', 'east', 'west', 'up', 'down'] + + +def copy_face(src_uv, dst_uv): + sx1, sy1, sx2, sy2 = src_uv + dx1, dy1, dx2, dy2 = dst_uv + + sx1p = sx1 * src_tex_w / src_uv_w + sy1p = sy1 * src_tex_h / src_uv_h + sx2p = sx2 * src_tex_w / src_uv_w + sy2p = sy2 * src_tex_h / src_uv_h + + dx1p = dx1 * dst_tex_w / dst_uv_w + dy1p = dy1 * dst_tex_h / dst_uv_h + dx2p = dx2 * dst_tex_w / dst_uv_w + dy2p = dy2 * dst_tex_h / dst_uv_h + + src_flip_x = sx1p > sx2p + src_flip_y = sy1p > sy2p + dst_flip_x = dx1p > dx2p + dst_flip_y = dy1p > dy2p + + sl, sr = sorted([sx1p, sx2p]) + st, sb = sorted([sy1p, sy2p]) + dl, dr = sorted([dx1p, dx2p]) + dt, db = sorted([dy1p, dy2p]) + + sw = int(round(sr - sl)) + sh = int(round(sb - st)) + dw = int(round(dr - dl)) + dh = int(round(db - dt)) + + if sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0: + return + + sli = int(round(sl)) + sti = int(round(st)) + rect = src_img.crop((sli, sti, sli + sw, sti + sh)) + + if src_flip_x: + rect = rect.transpose(Image.FLIP_LEFT_RIGHT) + if src_flip_y: + rect = rect.transpose(Image.FLIP_TOP_BOTTOM) + + rect = rect.resize((dw, dh), Image.NEAREST) + + if dst_flip_x: + rect = rect.transpose(Image.FLIP_LEFT_RIGHT) + if dst_flip_y: + rect = rect.transpose(Image.FLIP_TOP_BOTTOM) + + dst_img.paste(rect, (int(round(dl)), int(round(dt)))) + + +for dst_el in dst_model['elements']: + name = dst_el['name'] + src_el = src_elements_by_name.get(name) + if not src_el: + print(f'skip: no source element "{name}"') + continue + src_faces = src_el.get('faces', {}) + dst_faces = dst_el.get('faces', {}) + for face in FACE_NAMES: + if face not in src_faces or face not in dst_faces: + continue + copy_face(src_faces[face]['uv'], dst_faces[face]['uv']) + +dst_img.save(DST_TEXTURE) +print(f'wrote {DST_TEXTURE} ({dst_img.size[0]}x{dst_img.size[1]})') diff --git a/scripts/remap_spidernoir.py b/scripts/remap_spidernoir.py new file mode 100644 index 00000000..8a9f2ab7 --- /dev/null +++ b/scripts/remap_spidernoir.py @@ -0,0 +1,184 @@ +""" +Remap SpiderNoir 128x128 texture to a 64x64 player skin using corrected UVs. + +Source: D:/james/downloads/spidernoir.png (128x128) with UV layout per SpiderNoir.bbmodel +Target: 64x64 player skin with UV layout per PlayerModelTrue.bbmodel +""" +import json +from PIL import Image + +SRC_BBMODEL = 'D:/james/downloads/SpiderNoir.bbmodel' +DST_BBMODEL = 'D:/james/downloads/PlayerModelTrue.bbmodel' +SRC_TEXTURE = 'D:/james/downloads/spidernoir.png' +DST_TEXTURE = 'E:/IdeaProjects/superhero/src/main/resources/assets/timeless/textures/suit/spiderman_spidernoir.png' + +with open(SRC_BBMODEL, 'r', encoding='utf-8') as f: + src_model = json.load(f) +with open(DST_BBMODEL, 'r', encoding='utf-8') as f: + dst_model = json.load(f) + +src_img = Image.open(SRC_TEXTURE).convert('RGBA') +src_tex_w, src_tex_h = src_img.size + +# UV coord space (from resolution) +src_uv_w = src_model['resolution']['width'] +src_uv_h = src_model['resolution']['height'] +dst_uv_w = dst_model['resolution']['width'] +dst_uv_h = dst_model['resolution']['height'] + +# Output dimensions: 64x128 — top 64x64 is the standard player skin, bottom 64x64 is fedora UVs. +# UV conversion in copy_face uses dst_uv_w/dst_uv_h (=64) so player-skin coords land in the top half. +dst_tex_w = dst_uv_w +dst_tex_h = dst_uv_h +dst_img = Image.new('RGBA', (dst_tex_w, dst_tex_h * 2), (0, 0, 0, 0)) + +src_elements_by_name = {} +for e in src_model['elements']: + src_elements_by_name.setdefault(e['name'], e) + +FACE_NAMES = ['north', 'south', 'east', 'west', 'up', 'down'] + +# Override the destination arm UVs to Alex (3-wide arm) layout. PlayerModelTrue is +# Steve (4-wide); we keep its head/body/legs but use Alex arm coords so the output +# matches AlexSuitModel. +ALEX_ARM_UVS = { + 'Right Arm': { + 'north': [44, 20, 47, 32], + 'east': [40, 20, 44, 32], + 'south': [51, 20, 54, 32], + 'west': [47, 20, 51, 32], + 'up': [47, 20, 44, 16], + 'down': [50, 16, 47, 20], + }, + 'Right Arm Layer': { + 'north': [44, 36, 47, 48], + 'east': [40, 36, 44, 48], + 'south': [51, 36, 54, 48], + 'west': [47, 36, 51, 48], + 'up': [47, 36, 44, 32], + 'down': [50, 32, 47, 36], + }, + 'Left Arm': { + 'north': [36, 52, 39, 64], + 'east': [32, 52, 36, 64], + 'south': [43, 52, 46, 64], + 'west': [39, 52, 43, 64], + 'up': [39, 52, 36, 48], + 'down': [42, 48, 39, 52], + }, + 'Left Arm Layer': { + 'north': [52, 52, 55, 64], + 'east': [48, 52, 52, 64], + 'south': [59, 52, 62, 64], + 'west': [55, 52, 59, 64], + 'up': [55, 52, 52, 48], + 'down': [58, 48, 55, 52], + }, +} + + +def copy_face(src_uv, dst_uv): + sx1, sy1, sx2, sy2 = src_uv + dx1, dy1, dx2, dy2 = dst_uv + + # UV coords -> texture pixel coords + sx1p = sx1 * src_tex_w / src_uv_w + sy1p = sy1 * src_tex_h / src_uv_h + sx2p = sx2 * src_tex_w / src_uv_w + sy2p = sy2 * src_tex_h / src_uv_h + + dx1p = dx1 * dst_tex_w / dst_uv_w + dy1p = dy1 * dst_tex_h / dst_uv_h + dx2p = dx2 * dst_tex_w / dst_uv_w + dy2p = dy2 * dst_tex_h / dst_uv_h + + src_flip_x = sx1p > sx2p + src_flip_y = sy1p > sy2p + dst_flip_x = dx1p > dx2p + dst_flip_y = dy1p > dy2p + + sl, sr = sorted([sx1p, sx2p]) + st, sb = sorted([sy1p, sy2p]) + dl, dr = sorted([dx1p, dx2p]) + dt, db = sorted([dy1p, dy2p]) + + sw = int(round(sr - sl)) + sh = int(round(sb - st)) + dw = int(round(dr - dl)) + dh = int(round(db - dt)) + + if sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0: + return + + sli = int(round(sl)) + sti = int(round(st)) + rect = src_img.crop((sli, sti, sli + sw, sti + sh)) + + # Normalize source so that "corner1 logical" sits at top-left + if src_flip_x: + rect = rect.transpose(Image.FLIP_LEFT_RIGHT) + if src_flip_y: + rect = rect.transpose(Image.FLIP_TOP_BOTTOM) + + rect = rect.resize((dw, dh), Image.NEAREST) + + # Apply destination orientation + if dst_flip_x: + rect = rect.transpose(Image.FLIP_LEFT_RIGHT) + if dst_flip_y: + rect = rect.transpose(Image.FLIP_TOP_BOTTOM) + + dst_img.paste(rect, (int(round(dl)), int(round(dt)))) + + +for dst_el in dst_model['elements']: + name = dst_el['name'] + src_el = src_elements_by_name.get(name) + if not src_el: + print(f'skip: no source element "{name}"') + continue + src_faces = src_el.get('faces', {}) + dst_faces = dst_el.get('faces', {}) + alex_override = ALEX_ARM_UVS.get(name) + for face in FACE_NAMES: + if face not in src_faces or face not in dst_faces: + continue + dst_uv = alex_override[face] if alex_override and face in alex_override else dst_faces[face]['uv'] + copy_face(src_faces[face]['uv'], dst_uv) + + +# --- Fedora cubes (custom Spider-Noir geometry, not in PlayerModelTrue) --- +# Source faces are looked up by element uuid in SpiderNoir.bbmodel; destination UVs are +# laid out in the bottom half of the 64x128 output (V >= 64) using standard box_uv ordering. +def box_uv_faces(uoff, voff, w, h, d): + return { + 'up': [uoff + d + w, voff + d, uoff + d, voff], + 'down': [uoff + d + w + w, voff, uoff + d + w, voff + d], + 'east': [uoff, voff + d, uoff + d, voff + d + h], + 'north': [uoff + d, voff + d, uoff + d + w, voff + d + h], + 'west': [uoff + d + w, voff + d, uoff + d + w + d, voff + d + h], + 'south': [uoff + d + w + d, voff + d, uoff + d + w + d + w, voff + d + h], + } + +FEDORA = [ + # uuid, (W, H, D), (uoff, voff) + ('fe5c9ef0-6ab1-e6e0-4c5d-849cbcb51b2e', (8, 3, 8), (0, 64)), # crown + ('a1c701d0-7baa-7c71-6509-2472420f169a', (8, 3, 8), (32, 64)), # crown overlay + ('42c6d339-27bf-8489-2174-dfe498233e98', (11, 1, 11), (0, 80)), # brim +] + +src_elements_by_uuid = {e['uuid']: e for e in src_model['elements']} +for uuid, (w, h, d), (uoff, voff) in FEDORA: + src_el = src_elements_by_uuid.get(uuid) + if not src_el: + print(f'skip fedora {uuid}: no source element') + continue + src_faces = src_el.get('faces', {}) + dst_face_uvs = box_uv_faces(uoff, voff, w, h, d) + for face, dst_uv in dst_face_uvs.items(): + if face not in src_faces: + continue + copy_face(src_faces[face]['uv'], dst_uv) + +dst_img.save(DST_TEXTURE) +print(f'wrote {DST_TEXTURE} ({dst_img.size[0]}x{dst_img.size[1]})') diff --git a/src/main/java/mc/duzo/timeless/Timeless.java b/src/main/java/mc/duzo/timeless/Timeless.java index 4d4afcc8..de5f0e7a 100644 --- a/src/main/java/mc/duzo/timeless/Timeless.java +++ b/src/main/java/mc/duzo/timeless/Timeless.java @@ -37,5 +37,7 @@ public void onInitialize() { TimelessCommands.init(); // Networking Network.init(); + + mc.duzo.timeless.util.SwingFallGrace.init(); } } \ No newline at end of file diff --git a/src/main/java/mc/duzo/timeless/client/TimelessClient.java b/src/main/java/mc/duzo/timeless/client/TimelessClient.java index dbaa2dc1..bb051c85 100644 --- a/src/main/java/mc/duzo/timeless/client/TimelessClient.java +++ b/src/main/java/mc/duzo/timeless/client/TimelessClient.java @@ -30,11 +30,14 @@ public void onInitializeClient() { ClientNetwork.init(); ClientSuitRegistry.init(); TimelessKeybinds.init(); + mc.duzo.timeless.client.render.WebSwingState.init(); + mc.duzo.timeless.client.render.SpideySenseTracker.init(); registerRenderers(); HudRenderCallback.EVENT.register((stack, delta) -> { JarvisGui.render(stack, delta); UtilityBeltGui.render(stack, delta); + mc.duzo.timeless.client.gui.SpideySenseHud.render(stack, delta); }); registerRenderers(); @@ -45,11 +48,13 @@ public void onInitializeClient() { ItemStack stack = player.getEquippedStack(EquipmentSlot.CHEST); return !(stack.getItem() instanceof MoonKnightSuitItem); }); + } public static void registerRenderers() { EntityRendererRegistry.register(TimelessEntityTypes.IRON_MAN, IronManEntityRenderer::new); EntityRendererRegistry.register(TimelessEntityTypes.BASE_RANG_ENTITY_ENTITY_TYPE, BaseRangEntityRenderer::new); + EntityRendererRegistry.register(TimelessEntityTypes.WEB_ROPE, mc.duzo.timeless.client.render.entity.WebRopeRenderer::new); } public static void totemPredicate() { diff --git a/src/main/java/mc/duzo/timeless/client/gui/SpideySenseHud.java b/src/main/java/mc/duzo/timeless/client/gui/SpideySenseHud.java new file mode 100644 index 00000000..c11842ea --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/gui/SpideySenseHud.java @@ -0,0 +1,51 @@ +package mc.duzo.timeless.client.gui; + +import com.mojang.blaze3d.systems.RenderSystem; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.client.render.SpideySenseTracker; +import mc.duzo.timeless.power.impl.SpideySensePower; + +public final class SpideySenseHud { + private static final Identifier VIGNETTE = new Identifier("textures/misc/vignette.png"); + + private SpideySenseHud() {} + + public static void render(DrawContext ctx, float tickDelta) { + MinecraftClient client = MinecraftClient.getInstance(); + ClientPlayerEntity player = client.player; + if (player == null) return; + if (!SpideySensePower.isEnabled(player)) return; + + float alpha = SpideySenseTracker.vignetteAlpha(tickDelta); + if (alpha <= 0.01f) return; + + // Pulse: the closer the threat, the faster and brighter the pulse. + float pulse = 0.65f + 0.35f * MathHelper.sin((player.age + tickDelta) * 0.35f); + float a = MathHelper.clamp(alpha * pulse, 0f, 0.85f); + + int width = client.getWindow().getScaledWidth(); + int height = client.getWindow().getScaledHeight(); + + RenderSystem.disableDepthTest(); + RenderSystem.depthMask(false); + RenderSystem.enableBlend(); + RenderSystem.blendFunc(770, 1); + RenderSystem.setShaderColor(1.0f, 0.15f, 0.15f, a); + + ctx.drawTexture(VIGNETTE, 0, 0, -90, 0f, 0f, width, height, width, height); + + RenderSystem.setShaderColor(1f, 1f, 1f, 1f); + RenderSystem.defaultBlendFunc(); + RenderSystem.disableBlend(); + RenderSystem.depthMask(true); + RenderSystem.enableDepthTest(); + } +} diff --git a/src/main/java/mc/duzo/timeless/client/keybind/PowerRightClickHandler.java b/src/main/java/mc/duzo/timeless/client/keybind/PowerRightClickHandler.java new file mode 100644 index 00000000..32c7b47c --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/keybind/PowerRightClickHandler.java @@ -0,0 +1,37 @@ +package mc.duzo.timeless.client.keybind; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; + +import mc.duzo.timeless.network.c2s.UsePowerC2SPacket; +import mc.duzo.timeless.power.Power; +import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.suit.Suit; + +public class PowerRightClickHandler { + private static boolean wasPressed = false; + + public static void tick(MinecraftClient client) { + if (client.player == null) return; + + boolean isPressed = client.options.useKey.isPressed(); + boolean justPressed = isPressed && !wasPressed; + wasPressed = isPressed; + if (!justPressed) return; + + ClientPlayerEntity player = client.player; + Suit suit = Suit.findSuit(player).orElse(null); + if (suit == null || !suit.getSet().isWearing(player)) return; + + PowerList powers = suit.getPowers(); + for (int i = 0; i < powers.size(); i++) { + Power p = powers.get(i); + if (p.handlesRightClick(player)) { + ClientPlayNetworking.send(new UsePowerC2SPacket(i + 1)); + return; + } + } + } +} diff --git a/src/main/java/mc/duzo/timeless/client/keybind/TimelessKeybinds.java b/src/main/java/mc/duzo/timeless/client/keybind/TimelessKeybinds.java index 977f2336..5cc151a3 100644 --- a/src/main/java/mc/duzo/timeless/client/keybind/TimelessKeybinds.java +++ b/src/main/java/mc/duzo/timeless/client/keybind/TimelessKeybinds.java @@ -34,5 +34,6 @@ private static void tick(MinecraftClient client) { binds.forEach(bind -> bind.tick(player)); KeybindSync.tick(player); + PowerRightClickHandler.tick(client); } } diff --git a/src/main/java/mc/duzo/timeless/client/render/SpideySenseTracker.java b/src/main/java/mc/duzo/timeless/client/render/SpideySenseTracker.java new file mode 100644 index 00000000..b38fd98b --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/render/SpideySenseTracker.java @@ -0,0 +1,117 @@ +package mc.duzo.timeless.client.render; + +import java.util.ArrayList; +import java.util.List; + +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.projectile.ProjectileEntity; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.Vec3d; + +import mc.duzo.timeless.power.impl.SpideySensePower; + +/** + * Client-side tracker for spidey-sense threats. Builds and caches the threat list each tick + * (instead of every render frame) and provides intensity values used by both the entity-glow + * mixin and the HUD vignette overlay. + */ +public final class SpideySenseTracker { + private static final List THREATS = new ArrayList<>(); + private static float currentVignetteAlpha = 0f; + private static float prevVignetteAlpha = 0f; + private static int pingCooldown = 0; + + public record Threat(Entity entity, float intensity) {} + + private SpideySenseTracker() {} + + public static void init() { + ClientTickEvents.END_CLIENT_TICK.register(client -> tick(client)); + } + + private static void tick(MinecraftClient client) { + prevVignetteAlpha = currentVignetteAlpha; + THREATS.clear(); + + ClientPlayerEntity player = client.player; + ClientWorld world = client.world; + if (player == null || world == null || !SpideySensePower.isEnabled(player)) { + currentVignetteAlpha = Math.max(0f, currentVignetteAlpha - 0.05f); + pingCooldown = Math.max(0, pingCooldown - 1); + return; + } + + double radius = SpideySensePower.DETECT_RADIUS; + double radiusSq = radius * radius; + Vec3d eye = player.getEyePos(); + + Box area = player.getBoundingBox().expand(radius); + for (Entity e : world.getOtherEntities(player, area, SpideySensePower::isThreat)) { + float intensity = computeIntensity(player, eye, e, radiusSq); + if (intensity > 0f) THREATS.add(new Threat(e, intensity)); + } + + float maxIntensity = 0f; + for (Threat t : THREATS) maxIntensity = Math.max(maxIntensity, t.intensity); + + // Vignette pulses when the closest threat is within the imminent band. + currentVignetteAlpha += (maxIntensity * 0.45f - currentVignetteAlpha) * 0.25f; + if (currentVignetteAlpha < 0f) currentVignetteAlpha = 0f; + + if (pingCooldown > 0) pingCooldown--; + } + + /** 0 = far/unthreatening, 1 = right next to player or projectile aimed straight at them. */ + private static float computeIntensity(ClientPlayerEntity player, Vec3d eye, Entity threat, double radiusSq) { + double distSq = threat.squaredDistanceTo(player); + if (distSq > radiusSq) return 0f; + + // Base falloff: closer = stronger. + float proximity = (float) (1.0 - Math.sqrt(distSq) / Math.sqrt(radiusSq)); + proximity = Math.max(0f, Math.min(1f, proximity)); + + // Projectile heading toward the player ramps intensity hard. + if (threat instanceof ProjectileEntity p) { + Vec3d v = p.getVelocity(); + if (v.lengthSquared() > 1.0e-3) { + Vec3d toPlayer = eye.subtract(p.getPos()).normalize(); + double dot = v.normalize().dotProduct(toPlayer); + if (dot > 0.5) return Math.min(1f, proximity + (float) dot * 0.5f); + } + return proximity * 0.6f; + } + + // A LivingEntity getting closer is a bigger threat than one standing still or retreating. + if (threat instanceof LivingEntity le) { + Vec3d v = le.getVelocity(); + if (v.lengthSquared() > 1.0e-3) { + Vec3d toPlayer = eye.subtract(le.getPos()).normalize(); + double dot = v.normalize().dotProduct(toPlayer); + if (dot > 0) proximity = Math.min(1f, proximity + (float) dot * 0.25f); + } + } + + return proximity; + } + + public static List threats() { + return THREATS; + } + + public static float vignetteAlpha(float tickDelta) { + return prevVignetteAlpha + (currentVignetteAlpha - prevVignetteAlpha) * tickDelta; + } + + public static float intensityFor(Entity entity) { + for (Threat t : THREATS) { + if (t.entity == entity) return t.intensity; + } + return 0f; + } +} diff --git a/src/main/java/mc/duzo/timeless/client/render/WebSwingBodyTilt.java b/src/main/java/mc/duzo/timeless/client/render/WebSwingBodyTilt.java new file mode 100644 index 00000000..de35207b --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/render/WebSwingBodyTilt.java @@ -0,0 +1,42 @@ +package mc.duzo.timeless.client.render; + +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.RotationAxis; + +public final class WebSwingBodyTilt { + private static final float Y_OFFSET = 1.62f; + + private WebSwingBodyTilt() {} + + public static boolean isSwinging(LivingEntity entity) { + if (!(entity instanceof PlayerEntity player)) return false; + return WebSwingState.get(player).interpolate(1f) > 0.001f + && WebSwingState.findActiveRope(player) != null; + } + + public static void apply(LivingEntity entity, MatrixStack matrices, float tickDelta) { + if (!(entity instanceof PlayerEntity player)) return; + + WebSwingState state = WebSwingState.get(player); + float timer = state.interpolate(tickDelta); + if (timer <= 0.001f) return; + + float curve = MathHelper.sin(timer * MathHelper.HALF_PI); + curve *= curve; + + float pitchDeg = state.pitchDeg(tickDelta) * curve; + float rollDeg = state.rollDeg(tickDelta) * curve; + + matrices.translate(0f, Y_OFFSET, 0f); + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(pitchDeg)); + matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(rollDeg)); + matrices.translate(0f, -Y_OFFSET, 0f); + + if (player.isInSneakingPose()) { + matrices.translate(0f, -0.125f * curve, 0f); + } + } +} diff --git a/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java b/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java new file mode 100644 index 00000000..29fc414f --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java @@ -0,0 +1,166 @@ +package mc.duzo.timeless.client.render; + +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import mc.duzo.timeless.entity.WebRopeEntity; + +public final class WebSwingState { + private static final float STEP = 1f / 6f; + private static final float TILT_LERP = 0.18f; + + private static final Map STATES = new WeakHashMap<>(); + + private float prev; + private float curr; + + // Smoothed body tilt. Targets are computed each tick from raw motion / rope direction; + // current values lerp toward target with TILT_LERP, hiding per-tick spikes from constraint snaps. + private float prevPitchDeg; + private float currPitchDeg; + private float prevRollDeg; + private float currRollDeg; + + // Post-swing: once you start swinging, the pose holds through the air until the next + // ground contact, even after the rope releases. + private boolean postSwing; + + private WebSwingState() {} + + public boolean isPostSwing() { return this.postSwing; } + + public static void init() { + ClientTickEvents.END_CLIENT_TICK.register(client -> { + ClientWorld world = client.world; + if (world == null) { + STATES.clear(); + return; + } + + Iterator> it = STATES.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = it.next(); + PlayerEntity player = playerByUuid(world, e.getKey()); + if (player == null) { + it.remove(); + continue; + } + tickPlayer(world, player, e.getValue()); + } + + AbstractClientPlayerEntity self = client.player; + if (self != null && !STATES.containsKey(self.getUuid())) { + tickPlayer(world, self, getOrCreate(self.getUuid())); + } + }); + } + + private static void tickPlayer(ClientWorld world, PlayerEntity player, WebSwingState state) { + WebRopeEntity rope = findActiveRopeIn(world, player); + boolean onGround = player.isOnGround(); + if (rope != null && !onGround) { + state.postSwing = true; + } else if (onGround) { + state.postSwing = false; + } + boolean swinging = (rope != null || state.postSwing) && !onGround; + state.prev = state.curr; + state.curr = MathHelper.clamp(state.curr + (swinging ? STEP : -STEP), 0f, 1f); + + state.prevPitchDeg = state.currPitchDeg; + state.prevRollDeg = state.currRollDeg; + + float targetPitch = 0f; + float targetRoll = 0f; + if (swinging) { + double mx = player.getX() - player.prevX; + double my = player.getY() - player.prevY; + double mz = player.getZ() - player.prevZ; + double speed = Math.sqrt(mx * mx + my * my + mz * mz); + + double yawRad = Math.toRadians(player.bodyYaw); + Vec3d look = Vec3d.fromPolar(0f, player.bodyYaw).normalize(); + Vec3d motionDir = speed > 1.0e-3 ? new Vec3d(mx, my, mz).normalize() : look; + Vec3d blended = motionDir.add(look).multiply(0.5).normalize(); + + double horizSpeed = Math.max(Math.sqrt(blended.x * blended.x + blended.z * blended.z), 1.0e-3); + targetPitch = (float) MathHelper.clamp(Math.toDegrees(Math.atan2(blended.y, horizSpeed)), -25.0, 25.0); + + if (rope != null) { + double dx = rope.getX() - player.getX(); + double dz = rope.getZ() - player.getZ(); + double horizDist = Math.sqrt(dx * dx + dz * dz); + if (horizDist > 1.0e-3) { + Vec3d ropeBodyLocal = new Vec3d(dx / horizDist, 0, dz / horizDist).rotateY((float) yawRad); + targetRoll = (float) MathHelper.clamp(ropeBodyLocal.x * Math.min(speed, 1.0) * 25.0, -25.0, 25.0); + } + } else { + // No rope: roll from sideways motion relative to body yaw. + double horizSpeed2 = Math.sqrt(mx * mx + mz * mz); + if (horizSpeed2 > 1.0e-3) { + Vec3d motionLocal = new Vec3d(mx, 0, mz).normalize().rotateY((float) yawRad); + targetRoll = (float) MathHelper.clamp(-motionLocal.x * Math.min(horizSpeed2 / 0.4, 1.0) * 18.0, -25.0, 25.0); + } + } + } + + state.currPitchDeg = MathHelper.lerp(TILT_LERP, state.currPitchDeg, targetPitch); + state.currRollDeg = MathHelper.lerp(TILT_LERP, state.currRollDeg, targetRoll); + } + + private static WebRopeEntity findActiveRopeIn(ClientWorld world, PlayerEntity player) { + UUID uuid = player.getUuid(); + for (Entity e : world.getEntities()) { + if (e instanceof WebRopeEntity rope + && rope.isAnchored() + && !rope.isDisconnected() + && uuid.equals(rope.getShooterUuid())) { + return rope; + } + } + return null; + } + + private static PlayerEntity playerByUuid(ClientWorld world, UUID uuid) { + for (PlayerEntity p : world.getPlayers()) { + if (p.getUuid().equals(uuid)) return p; + } + return null; + } + + public static WebSwingState get(PlayerEntity player) { + return getOrCreate(player.getUuid()); + } + + private static WebSwingState getOrCreate(UUID uuid) { + return STATES.computeIfAbsent(uuid, k -> new WebSwingState()); + } + + public float interpolate(float tickDelta) { + return MathHelper.lerp(tickDelta, this.prev, this.curr); + } + + public float pitchDeg(float tickDelta) { + return MathHelper.lerp(tickDelta, this.prevPitchDeg, this.currPitchDeg); + } + + public float rollDeg(float tickDelta) { + return MathHelper.lerp(tickDelta, this.prevRollDeg, this.currRollDeg); + } + + public static WebRopeEntity findActiveRope(PlayerEntity player) { + if (!(player.getWorld() instanceof ClientWorld cw)) return null; + return findActiveRopeIn(cw, player); + } +} diff --git a/src/main/java/mc/duzo/timeless/client/render/entity/WebRopeRenderer.java b/src/main/java/mc/duzo/timeless/client/render/entity/WebRopeRenderer.java new file mode 100644 index 00000000..8cfc48f6 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/render/entity/WebRopeRenderer.java @@ -0,0 +1,124 @@ +package mc.duzo.timeless.client.render.entity; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.Frustum; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.EntityRendererFactory; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import org.joml.Matrix4f; + +import mc.duzo.timeless.entity.WebRopeEntity; + +public class WebRopeRenderer extends EntityRenderer { + private static final Identifier TEXTURE = new Identifier("textures/entity/fishing_hook.png"); + private static final int SEGMENTS = 16; + private static final double VIEW_BOBBING_SCALE = 960.0; + + public WebRopeRenderer(EntityRendererFactory.Context ctx) { + super(ctx); + } + + @Override + public Identifier getTexture(WebRopeEntity entity) { + return TEXTURE; + } + + @Override + public boolean shouldRender(WebRopeEntity entity, Frustum frustum, double camX, double camY, double camZ) { + return true; + } + + @Override + public void render(WebRopeEntity entity, float yaw, float tickDelta, MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light) { + LivingEntity shooter = entity.getShooter(); + if (!(shooter instanceof PlayerEntity player)) return; + + matrices.push(); + + int handSide = entity.isLeftArm() ? -1 : 1; + boolean firstPerson = this.dispatcher.gameOptions != null + && this.dispatcher.gameOptions.getPerspective().isFirstPerson() + && player == MinecraftClient.getInstance().player; + + Vec3d handPos = getPlayerHandPos(player, handSide, tickDelta, firstPerson); + + double entityX = MathHelper.lerp(tickDelta, entity.prevX, entity.getX()); + double entityY = MathHelper.lerp(tickDelta, entity.prevY, entity.getY()) + 0.25; + double entityZ = MathHelper.lerp(tickDelta, entity.prevZ, entity.getZ()); + + float dx = (float) (handPos.x - entityX); + float dy = (float) (handPos.y - entityY); + float dz = (float) (handPos.z - entityZ); + + VertexConsumer buffer = vertexConsumers.getBuffer(RenderLayer.getLineStrip()); + Matrix4f model = matrices.peek().getPositionMatrix(); + org.joml.Matrix3f normal = matrices.peek().getNormalMatrix(); + + for (int i = 0; i <= SEGMENTS; i++) { + float t = (float) i / (float) SEGMENTS; + float x = dx * t; + float y = dy * (t * t + t) * 0.5f + 0.25f; + float z = dz * t; + buffer.vertex(model, x, y, z) + .color(0.95f, 0.95f, 0.95f, 1.0f) + .normal(normal, dx, dy, dz) + .next(); + } + + matrices.pop(); + super.render(entity, yaw, tickDelta, matrices, vertexConsumers, light); + } + + private Vec3d getPlayerHandPos(PlayerEntity player, int handSide, float tickDelta, boolean firstPerson) { + double interpX = MathHelper.lerp(tickDelta, player.prevX, player.getX()); + double interpY = MathHelper.lerp(tickDelta, player.prevY, player.getY()); + double interpZ = MathHelper.lerp(tickDelta, player.prevZ, player.getZ()); + double eyeY = interpY + player.getStandingEyeHeight(); + + if (firstPerson) { + double fovScale = VIEW_BOBBING_SCALE / this.dispatcher.gameOptions.getFov().getValue(); + Vec3d offset = this.dispatcher.camera.getProjection().getPosition(handSide * 0.4f, -0.35f).multiply(fovScale); + return new Vec3d(interpX + offset.x, eyeY + offset.y, interpZ + offset.z); + } + + float bodyYawRad = MathHelper.lerp(tickDelta, player.prevBodyYaw, player.bodyYaw) * ((float) Math.PI / 180f); + double sinBody = MathHelper.sin(bodyYawRad); + double cosBody = MathHelper.cos(bodyYawRad); + double sideOffset = handSide * 0.4; + double crouch = player.isInSneakingPose() ? -0.1875 : 0.0; + + Vec3d shoulder = new Vec3d( + interpX - cosBody * sideOffset, + eyeY + crouch - 0.55, + interpZ - sinBody * sideOffset + ); + + // Gripping arm reaches toward the rope, so the hand sits ARM_LENGTH along that line + // rather than hanging at the shoulder's rest position. + WebRopeEntity rope = mc.duzo.timeless.client.render.WebSwingState.findActiveRope(player); + if (rope != null && rope.isAnchored()) { + Vec3d ropePos = new Vec3d( + MathHelper.lerp(tickDelta, rope.prevX, rope.getX()), + MathHelper.lerp(tickDelta, rope.prevY, rope.getY()) + 0.25, + MathHelper.lerp(tickDelta, rope.prevZ, rope.getZ()) + ); + Vec3d toRope = ropePos.subtract(shoulder); + double dist = toRope.length(); + if (dist > 1.0e-3) { + double armLength = 0.75; + Vec3d dir = toRope.multiply(1.0 / dist); + return shoulder.add(dir.multiply(Math.min(armLength, dist))); + } + } + + return shoulder; + } +} diff --git a/src/main/java/mc/duzo/timeless/core/TimelessEntityTypes.java b/src/main/java/mc/duzo/timeless/core/TimelessEntityTypes.java index b20e18b7..e3df69cb 100644 --- a/src/main/java/mc/duzo/timeless/core/TimelessEntityTypes.java +++ b/src/main/java/mc/duzo/timeless/core/TimelessEntityTypes.java @@ -14,6 +14,7 @@ import net.minecraft.util.Identifier; import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.entity.WebRopeEntity; import mc.duzo.timeless.suit.ironman.IronManEntity; public class TimelessEntityTypes { @@ -25,6 +26,12 @@ public class TimelessEntityTypes { .setDimensions(0.5f, 0.5f) .build("base_rang_entity")); + public static final EntityType WEB_ROPE = register(Registries.ENTITY_TYPE, "web_rope", EntityType.Builder.create(WebRopeEntity::new, SpawnGroup.MISC) + .setDimensions(0.25f, 0.25f) + .maxTrackingRange(96) + .trackingTickInterval(2) + .build("web_rope")); + public static void init() { registerAttributes(IRON_MAN, MobEntity.createMobAttributes().add(EntityAttributes.GENERIC_MOVEMENT_SPEED, 0.3F).build()); } diff --git a/src/main/java/mc/duzo/timeless/core/TimelessSounds.java b/src/main/java/mc/duzo/timeless/core/TimelessSounds.java index 2efe23da..9576f0c7 100644 --- a/src/main/java/mc/duzo/timeless/core/TimelessSounds.java +++ b/src/main/java/mc/duzo/timeless/core/TimelessSounds.java @@ -16,6 +16,7 @@ public class TimelessSounds implements SoundContainer { public static final SoundEvent IRONMAN_MASK = SoundEvent.of(new Identifier(Timeless.MOD_ID, "ironman_mask")); public static final SoundEvent IRONMAN_POWERUP = SoundEvent.of(new Identifier(Timeless.MOD_ID, "ironman_powerup")); public static final SoundEvent IRONMAN_POWERDOWN = SoundEvent.of(new Identifier(Timeless.MOD_ID, "ironman_powerdown")); + public static final SoundEvent SPIDERMAN_SHOOT = SoundEvent.of(new Identifier(Timeless.MOD_ID, "spiderman_shoot")); private static SoundEvent register(String name) { return register(new Identifier(Timeless.MOD_ID, name)); diff --git a/src/main/java/mc/duzo/timeless/datagen/TimelessDataGenerator.java b/src/main/java/mc/duzo/timeless/datagen/TimelessDataGenerator.java index 0c68e8c7..1cf21743 100644 --- a/src/main/java/mc/duzo/timeless/datagen/TimelessDataGenerator.java +++ b/src/main/java/mc/duzo/timeless/datagen/TimelessDataGenerator.java @@ -93,6 +93,7 @@ private void genSounds(FabricDataGenerator.Pack pack) { provider.addSound("ironman_mask", TimelessSounds.IRONMAN_MASK); provider.addSound("ironman_powerup", TimelessSounds.IRONMAN_POWERUP); provider.addSound("ironman_powerdown", TimelessSounds.IRONMAN_POWERDOWN); + provider.addSound("spiderman_shoot", TimelessSounds.SPIDERMAN_SHOOT); return provider; }))); diff --git a/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java b/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java new file mode 100644 index 00000000..3f2ffe2f --- /dev/null +++ b/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java @@ -0,0 +1,483 @@ +package mc.duzo.timeless.entity; + +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.MovementType; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.effect.StatusEffects; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.RaycastContext; +import net.minecraft.world.World; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class WebRopeEntity extends Entity { + public enum Mode { + SWING, // pendulum constraint + ZIP, // pull shooter toward anchor on impact + BIND; // bind a hit entity in place + + public static Mode byId(int id) { + return switch (id) { + case 1 -> ZIP; + case 2 -> BIND; + default -> SWING; + }; + } + + public int toId() { + return switch (this) { + case ZIP -> 1; + case BIND -> 2; + default -> 0; + }; + } + } + + private static final TrackedData ANCHORED = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + private static final TrackedData DISCONNECTED = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + private static final TrackedData LEFT_ARM = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + private static final TrackedData LENGTH = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.FLOAT); + private static final TrackedData> SHOOTER = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.OPTIONAL_UUID); + private static final TrackedData MODE_ID = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.INTEGER); + private static final TrackedData HOOKED_ID = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.INTEGER); + private static final TrackedData REELING = DataTracker.registerData(WebRopeEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + + private static final double STIFFNESS = 0.2; + private static final float DETACH_GRAVITY = 0.04f; + private static final float SHOT_SPEED = 2.4f; + private static final int MAX_FLIGHT_TICKS = 60; + private static final int DETACH_FADE_TICKS = 60; + private static final int RELEASE_GRACE_TICKS = 10; + + // ZIP tuning + private static final int ZIP_RAMP_TICKS = 6; + private static final int ZIP_MAX_TICKS = 50; + private static final double ZIP_PEAK_SPEED = 1.4; + private static final double ZIP_FINISH_DIST = 1.6; + + // BIND tuning + private static final int BIND_DURATION_TICKS = 160; + private static final int BIND_REEL_DURATION_TICKS = 30; + private static final double BIND_REEL_SPEED = 0.55; + private static final int BIND_LINGER_DAMAGE = 6; + + // RAPPEL tuning + private static final float RAPPEL_LERP = 0.18f; + private static final float RAPPEL_STEP = 0.45f; + private static final float RAPPEL_MIN = 1.5f; + private static final float RAPPEL_MAX = 48f; + + private int flightTicks; + private float targetLength = -1f; + private float maxLength = -1f; + private int ticksDetached; + private int ticksAnchored; + private int reelTicksRemaining; + private float lastBoundHealth = -1f; + + public WebRopeEntity(EntityType type, World world) { + super(type, world); + this.noClip = false; + } + + public WebRopeEntity(World world, LivingEntity shooter, boolean isLeftArm, Mode mode) { + this(mc.duzo.timeless.core.TimelessEntityTypes.WEB_ROPE, world); + this.dataTracker.set(SHOOTER, Optional.of(shooter.getUuid())); + this.dataTracker.set(LEFT_ARM, isLeftArm); + this.dataTracker.set(MODE_ID, mode.toId()); + + Vec3d eye = shooter.getEyePos(); + this.setPosition(eye.x, eye.y, eye.z); + + Vec3d look = shooter.getRotationVec(1f); + this.setVelocity(look.multiply(SHOT_SPEED)); + Vec3d shooterVel = shooter.getVelocity(); + this.setVelocity(this.getVelocity().add(shooterVel.x, 0, shooterVel.z)); + } + + @Override + protected void initDataTracker() { + this.dataTracker.startTracking(ANCHORED, false); + this.dataTracker.startTracking(DISCONNECTED, false); + this.dataTracker.startTracking(LEFT_ARM, false); + this.dataTracker.startTracking(LENGTH, -1f); + this.dataTracker.startTracking(SHOOTER, Optional.empty()); + this.dataTracker.startTracking(MODE_ID, 0); + this.dataTracker.startTracking(HOOKED_ID, -1); + this.dataTracker.startTracking(REELING, false); + } + + public boolean isAnchored() { return this.dataTracker.get(ANCHORED); } + public boolean isDisconnected() { return this.dataTracker.get(DISCONNECTED); } + public void setDisconnected(boolean disconnected) { this.dataTracker.set(DISCONNECTED, disconnected); } + public boolean isLeftArm() { return this.dataTracker.get(LEFT_ARM); } + public float getLength() { return this.dataTracker.get(LENGTH); } + public Mode getMode() { return Mode.byId(this.dataTracker.get(MODE_ID)); } + public int getHookedId() { return this.dataTracker.get(HOOKED_ID); } + public boolean isReeling() { return this.dataTracker.get(REELING); } + + public LivingEntity getShooter() { + UUID uuid = this.dataTracker.get(SHOOTER).orElse(null); + if (uuid == null) return null; + if (this.getWorld() instanceof ServerWorld sw) { + Entity e = sw.getEntity(uuid); + return e instanceof LivingEntity le ? le : null; + } + for (PlayerEntity p : this.getWorld().getPlayers()) { + if (p.getUuid().equals(uuid)) return p; + } + return null; + } + + public UUID getShooterUuid() { return this.dataTracker.get(SHOOTER).orElse(null); } + + public Entity getHookedEntity() { + int id = this.getHookedId(); + if (id < 0) return null; + return this.getWorld().getEntityById(id); + } + + /** Begin a reel-in cycle: pulls the bound entity toward the shooter for a fixed time. */ + public void requestReel() { + if (this.getMode() != Mode.BIND) return; + if (this.getHookedEntity() == null) return; + this.reelTicksRemaining = BIND_REEL_DURATION_TICKS; + this.dataTracker.set(REELING, true); + } + + public void rappel(int direction) { + if (this.getMode() != Mode.SWING) return; + if (this.getLength() <= 0f) return; + if (this.targetLength < 0f) this.targetLength = this.getLength(); + float next = MathHelper.clamp(this.targetLength - RAPPEL_STEP * direction, RAPPEL_MIN, RAPPEL_MAX); + if (Math.abs(next - this.targetLength) > 0.01f) { + this.targetLength = next; + if (!this.getWorld().isClient) { + this.getWorld().playSound(null, this.getX(), this.getY(), this.getZ(), + SoundEvents.BLOCK_TRIPWIRE_CLICK_ON, SoundCategory.PLAYERS, 0.25f, direction > 0 ? 1.6f : 1.0f); + } + } + } + + @Override + public void tick() { + super.tick(); + + LivingEntity shooter = this.getShooter(); + if (shooter == null || !shooter.isAlive()) { + if (!this.getWorld().isClient) this.discard(); + return; + } + + if (this.isDisconnected()) { + this.tickDetached(); + return; + } + + Mode mode = this.getMode(); + if (!this.isAnchored()) { + this.tickProjectile(shooter, mode); + } else if (shooter instanceof PlayerEntity player) { + switch (mode) { + case SWING -> this.tickSwing(player); + case ZIP -> this.tickZip(player); + case BIND -> this.tickBind(player); + } + } + } + + private void tickProjectile(LivingEntity shooter, Mode mode) { + this.flightTicks++; + if (this.flightTicks > MAX_FLIGHT_TICKS) { + if (!this.getWorld().isClient) this.setDisconnected(true); + return; + } + + Vec3d start = this.getPos(); + Vec3d v = this.getVelocity(); + Vec3d end = start.add(v); + + if (mode == Mode.BIND) { + Entity hit = raycastEntity(start, end, shooter); + if (hit instanceof LivingEntity le && le != shooter) { + this.dataTracker.set(HOOKED_ID, le.getId()); + this.dataTracker.set(ANCHORED, true); + this.setPosition(le.getX(), le.getBodyY(0.5), le.getZ()); + this.setVelocity(Vec3d.ZERO); + this.dataTracker.set(LENGTH, (float) le.getPos().distanceTo(getShooterAnchorPos(shooter))); + this.lastBoundHealth = le.getHealth(); + if (!this.getWorld().isClient) { + le.addStatusEffect(new StatusEffectInstance(StatusEffects.SLOWNESS, BIND_DURATION_TICKS, 9, false, false, true)); + le.addStatusEffect(new StatusEffectInstance(StatusEffects.MINING_FATIGUE, BIND_DURATION_TICKS, 9, false, false, true)); + le.addStatusEffect(new StatusEffectInstance(StatusEffects.WEAKNESS, BIND_DURATION_TICKS, 4, false, false, true)); + if (this.getWorld() instanceof ServerWorld sw) { + sw.spawnParticles(ParticleTypes.WHITE_ASH, le.getX(), le.getBodyY(0.5), le.getZ(), 24, 0.3, 0.4, 0.3, 0.02); + sw.playSound(null, le.getX(), le.getY(), le.getZ(), SoundEvents.BLOCK_WOOL_PLACE, SoundCategory.PLAYERS, 0.6f, 0.8f); + } + } + return; + } + } + + BlockHitResult bhit = this.getWorld().raycast(new RaycastContext( + start, end, + RaycastContext.ShapeType.COLLIDER, + RaycastContext.FluidHandling.NONE, + this + )); + + if (bhit.getType() == HitResult.Type.BLOCK) { + Vec3d hitPos = bhit.getPos(); + this.setPosition(hitPos.x, hitPos.y, hitPos.z); + this.setVelocity(Vec3d.ZERO); + this.dataTracker.set(ANCHORED, true); + + double initialLength = hitPos.distanceTo(getShooterAnchorPos(shooter)); + this.dataTracker.set(LENGTH, (float) initialLength); + this.maxLength = (float) initialLength; + this.targetLength = (float) initialLength; + if (!this.getWorld().isClient && this.getWorld() instanceof ServerWorld sw && mode == Mode.SWING) { + sw.playSound(null, hitPos.x, hitPos.y, hitPos.z, SoundEvents.BLOCK_WOOL_PLACE, SoundCategory.PLAYERS, 0.4f, 1.4f); + } + } else { + this.setPosition(end.x, end.y, end.z); + } + } + + private static Entity raycastEntity(Vec3d start, Vec3d end, Entity ignore) { + Box box = new Box(start, end).expand(0.4); + World world = ignore.getWorld(); + List candidates = world.getOtherEntities(ignore, box, e -> e instanceof LivingEntity && e.isAlive() && !e.isSpectator()); + Entity best = null; + double bestDist = Double.MAX_VALUE; + for (Entity e : candidates) { + Box ebox = e.getBoundingBox().expand(0.3); + Optional hit = ebox.raycast(start, end); + if (hit.isPresent()) { + double d = start.squaredDistanceTo(hit.get()); + if (d < bestDist) { + bestDist = d; + best = e; + } + } else if (ebox.contains(start)) { + return e; + } + } + return best; + } + + private static Vec3d getShooterAnchorPos(LivingEntity shooter) { + return new Vec3d(shooter.getX(), shooter.getBodyY(0.5), shooter.getZ()); + } + + private void tickSwing(PlayerEntity shooter) { + this.ticksAnchored++; + + if (!this.getWorld().isClient + && this.ticksAnchored > RELEASE_GRACE_TICKS + && shooter.handSwinging + && shooter.handSwingTicks >= 0 + && shooter.handSwingTicks <= 2) { + Vec3d look = shooter.getRotationVec(1f); + Vec3d v = shooter.getVelocity(); + shooter.setVelocity(v.x + look.x * 0.45, Math.max(v.y, 0) + 0.35, v.z + look.z * 0.45); + shooter.fallDistance = 0; + shooter.velocityModified = true; + mc.duzo.timeless.util.SwingFallGrace.grant(shooter); + this.setDisconnected(true); + return; + } + + Vec3d anchor = this.getPos(); + Vec3d shooterPos = getShooterAnchorPos(shooter); + + if (!this.getWorld().isClient) { + if (this.targetLength < 0f) this.targetLength = this.getLength(); + if (this.maxLength < 0f) this.maxLength = this.getLength(); + + // Smoothly converge actual length toward target so rappel feels eased rather than stepped. + float curr = this.getLength(); + float lerped = MathHelper.lerp(RAPPEL_LERP, curr, this.targetLength); + if (Math.abs(lerped - curr) > 0.001f) { + this.dataTracker.set(LENGTH, lerped); + } + } + + Vec3d constraint = restrictMotion(anchor, shooterPos, STIFFNESS, this.getLength()); + if (constraint != null) { + shooter.move(MovementType.SELF, constraint); + Vec3d v = shooter.getVelocity(); + shooter.setVelocity(v.add(constraint)); + } + if (!this.getWorld().isClient) mc.duzo.timeless.util.SwingFallGrace.grant(shooter); + } + + private void tickZip(PlayerEntity shooter) { + this.ticksAnchored++; + Vec3d anchor = this.getPos(); + Vec3d shooterAnchor = getShooterAnchorPos(shooter); + Vec3d toAnchor = anchor.subtract(shooterAnchor); + double dist = toAnchor.length(); + + boolean cancel = shooter.handSwinging + && shooter.handSwingTicks >= 0 + && shooter.handSwingTicks <= 2; + + if (dist < ZIP_FINISH_DIST || this.ticksAnchored > ZIP_MAX_TICKS || cancel) { + if (!this.getWorld().isClient) { + if (dist < ZIP_FINISH_DIST + 0.5) { + // Ground/wall snap: small upward boost so the player lands on top of + // a surface instead of clipping into it. + Vec3d v = shooter.getVelocity(); + shooter.setVelocity(v.x * 0.2, Math.max(v.y, 0) + 0.3, v.z * 0.2); + shooter.fallDistance = 0; + shooter.velocityModified = true; + } + mc.duzo.timeless.util.SwingFallGrace.grant(shooter); + this.setDisconnected(true); + } + return; + } + + // Eased velocity: ramps up over ZIP_RAMP_TICKS then stays at peak. + float t = MathHelper.clamp((float) this.ticksAnchored / ZIP_RAMP_TICKS, 0f, 1f); + float ease = MathHelper.sin(t * MathHelper.HALF_PI); + double speed = ZIP_PEAK_SPEED * ease; + + Vec3d dir = toAnchor.multiply(1.0 / Math.max(dist, 1.0e-3)); + shooter.setVelocity(dir.multiply(speed)); + shooter.fallDistance = 0; + shooter.velocityModified = true; + if (!this.getWorld().isClient) mc.duzo.timeless.util.SwingFallGrace.grant(shooter); + + if (!this.getWorld().isClient && this.getWorld() instanceof ServerWorld sw && this.age % 3 == 0) { + Vec3d trail = shooter.getPos().add(0, shooter.getHeight() * 0.5, 0); + sw.spawnParticles(ParticleTypes.WHITE_ASH, trail.x, trail.y, trail.z, 1, 0.05, 0.05, 0.05, 0.0); + } + } + + private void tickBind(PlayerEntity shooter) { + this.ticksAnchored++; + Entity hooked = this.getHookedEntity(); + if (!(hooked instanceof LivingEntity bound) || !bound.isAlive()) { + if (!this.getWorld().isClient) this.setDisconnected(true); + return; + } + + if (!this.getWorld().isClient) { + // Auto-release if bound entity has taken meaningful damage since binding. + if (this.lastBoundHealth > 0 && this.lastBoundHealth - bound.getHealth() >= BIND_LINGER_DAMAGE) { + this.setDisconnected(true); + return; + } + // Auto-release after the bind duration. + if (this.ticksAnchored > BIND_DURATION_TICKS) { + this.setDisconnected(true); + return; + } + // Refresh the immobilization effects so they don't fade. + if (this.ticksAnchored % 40 == 0) { + bound.addStatusEffect(new StatusEffectInstance(StatusEffects.SLOWNESS, BIND_DURATION_TICKS - this.ticksAnchored, 9, false, false, true)); + bound.addStatusEffect(new StatusEffectInstance(StatusEffects.MINING_FATIGUE, BIND_DURATION_TICKS - this.ticksAnchored, 9, false, false, true)); + } + // Pin them in place until/unless reeling. + bound.setVelocity(0, Math.min(bound.getVelocity().y, 0), 0); + bound.velocityModified = true; + } + + // Reel in: pulls bound entity toward shooter at fixed speed for the reel duration. + if (this.reelTicksRemaining > 0) { + this.reelTicksRemaining--; + Vec3d toShooter = getShooterAnchorPos(shooter).subtract(bound.getPos()); + double d = toShooter.length(); + if (d > 1.4) { + Vec3d pull = toShooter.multiply(BIND_REEL_SPEED / Math.max(d, 1.0e-3)); + bound.setVelocity(pull.x, pull.y + 0.05, pull.z); + bound.velocityModified = true; + } else { + this.reelTicksRemaining = 0; + } + if (this.reelTicksRemaining == 0) this.dataTracker.set(REELING, false); + } else if (this.isReeling()) { + this.dataTracker.set(REELING, false); + } + + this.setPosition(bound.getX(), bound.getBodyY(0.5), bound.getZ()); + } + + private void tickDetached() { + this.ticksDetached++; + Vec3d v = this.getVelocity(); + this.setVelocity(v.x * 0.7, (v.y * 0.7) - DETACH_GRAVITY, v.z * 0.7); + this.setPosition(this.getX() + this.getVelocity().x, this.getY() + this.getVelocity().y, this.getZ() + this.getVelocity().z); + if (!this.getWorld().isClient && this.ticksDetached > DETACH_FADE_TICKS) { + this.discard(); + } + } + + private static Vec3d restrictMotion(Vec3d anchor, Vec3d shooter, double stiffness, double length) { + if (length < 0) return null; + double dx = anchor.x - shooter.x; + double dy = anchor.y - shooter.y; + double dz = anchor.z - shooter.z; + double d2 = dx * dx + dy * dy + dz * dz; + double dist = Math.max(length, 1.0); + if (d2 > dist * dist) { + double d = Math.sqrt(d2); + double scale = (d - dist) * stiffness / d; + return new Vec3d(dx * scale, dy * scale, dz * scale); + } + return null; + } + + @Override + protected void readCustomDataFromNbt(NbtCompound nbt) { + if (nbt.contains("Shooter")) this.dataTracker.set(SHOOTER, Optional.of(nbt.getUuid("Shooter"))); + if (nbt.contains("Anchored")) this.dataTracker.set(ANCHORED, nbt.getBoolean("Anchored")); + if (nbt.contains("Disconnected")) this.dataTracker.set(DISCONNECTED, nbt.getBoolean("Disconnected")); + if (nbt.contains("LeftArm")) this.dataTracker.set(LEFT_ARM, nbt.getBoolean("LeftArm")); + if (nbt.contains("Length")) this.dataTracker.set(LENGTH, nbt.getFloat("Length")); + if (nbt.contains("FlightTicks")) this.flightTicks = nbt.getInt("FlightTicks"); + if (nbt.contains("TargetLength")) this.targetLength = nbt.getFloat("TargetLength"); + if (nbt.contains("MaxLength")) this.maxLength = nbt.getFloat("MaxLength"); + if (nbt.contains("Mode")) this.dataTracker.set(MODE_ID, nbt.getInt("Mode")); + if (nbt.contains("HookedId")) this.dataTracker.set(HOOKED_ID, nbt.getInt("HookedId")); + } + + @Override + protected void writeCustomDataToNbt(NbtCompound nbt) { + UUID uuid = this.dataTracker.get(SHOOTER).orElse(null); + if (uuid != null) nbt.putUuid("Shooter", uuid); + nbt.putBoolean("Anchored", this.isAnchored()); + nbt.putBoolean("Disconnected", this.isDisconnected()); + nbt.putBoolean("LeftArm", this.isLeftArm()); + nbt.putFloat("Length", this.getLength()); + nbt.putInt("FlightTicks", this.flightTicks); + nbt.putFloat("TargetLength", this.targetLength); + nbt.putFloat("MaxLength", this.maxLength); + nbt.putInt("Mode", this.getMode().toId()); + nbt.putInt("HookedId", this.getHookedId()); + } + + @Override + public boolean shouldRender(double distance) { + return distance < MathHelper.square(this.getRenderDistanceMultiplier() * 64.0); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/SwingFallDamageMixin.java b/src/main/java/mc/duzo/timeless/mixin/SwingFallDamageMixin.java new file mode 100644 index 00000000..9e38abaa --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/SwingFallDamageMixin.java @@ -0,0 +1,26 @@ +package mc.duzo.timeless.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; + +import mc.duzo.timeless.util.SwingFallGrace; + +@Mixin(LivingEntity.class) +public abstract class SwingFallDamageMixin { + @Inject(method = "computeFallDamage", at = @At("HEAD"), cancellable = true) + private void timeless$cancelFallDamageDuringSwing(float fallDistance, float damageMultiplier, CallbackInfoReturnable cir) { + LivingEntity self = (LivingEntity) (Object) this; + if (SwingFallGrace.handleFallDamage(self)) cir.setReturnValue(0); + } + + @Inject(method = "handleFallDamage", at = @At("HEAD"), cancellable = true) + private void timeless$cancelHandleFallDamage(float fallDistance, float damageMultiplier, DamageSource damageSource, CallbackInfoReturnable cir) { + LivingEntity self = (LivingEntity) (Object) this; + if (SwingFallGrace.handleFallDamage(self)) cir.setReturnValue(false); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/WallCrawlTravelMixin.java b/src/main/java/mc/duzo/timeless/mixin/WallCrawlTravelMixin.java new file mode 100644 index 00000000..221640ae --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/WallCrawlTravelMixin.java @@ -0,0 +1,100 @@ +package mc.duzo.timeless.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.MovementType; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import mc.duzo.timeless.power.PowerRegistry; +import mc.duzo.timeless.suit.Suit; +import mc.duzo.timeless.util.WallCrawlData; + +@Mixin(LivingEntity.class) +public abstract class WallCrawlTravelMixin { + @Inject(method = "travel", at = @At("HEAD"), cancellable = true) + private void timeless$wallCrawlTravel(Vec3d input, CallbackInfo ci) { + LivingEntity self = (LivingEntity) (Object) this; + + WallCrawlData data = WallCrawlData.get(self); + if (!data.enabled) return; + + if (Suit.findSuit(self).map(s -> s.hasPower(PowerRegistry.WALL_CLIMB)).orElse(false) == false) { + return; + } + + Direction face = data.direction(); + Vec3d toWall = Vec3d.of(face.getOpposite().getVector()); + + float strafe = (float) input.x; + float forward = (float) input.z; + + float yaw = self.getYaw() * 0.017453292F; + float sinYaw = MathHelper.sin(yaw); + float cosYaw = MathHelper.cos(yaw); + + double speed = 0.1; + + Vec3d v = Vec3d.ZERO; + + if (face == Direction.DOWN) { + float mag = strafe * strafe + forward * forward; + if (mag >= 1e-4F) { + mag = MathHelper.sqrt(mag); + if (mag < 1f) mag = 1f; + mag = (float) speed / mag; + strafe *= mag; + forward *= mag; + v = v.add( + -strafe * cosYaw - forward * sinYaw, + 0, + -strafe * sinYaw + forward * cosYaw + ); + } + } else { + float mag = strafe * strafe + forward * forward; + if (mag >= 1e-4F) { + mag = MathHelper.sqrt(mag); + if (mag < 1f) mag = 1f; + mag = (float) speed / mag; + strafe *= mag; + forward *= mag; + + float clampedPitch = MathHelper.clamp(self.getPitch() * 1.5f, -90f, 90f) * 0.017453292F; + float cosPitch = MathHelper.cos(clampedPitch); + float sinPitch = MathHelper.sin(clampedPitch); + + v = v.add( + strafe * cosYaw - cosPitch * forward * sinYaw, + -sinPitch * forward * 1.5, + cosPitch * forward * cosYaw + strafe * sinYaw + ); + } + } + + Vec3d existing = self.getVelocity(); + v = v.add(existing.multiply(0.5)); + + v = v.add(toWall.multiply(0.08)); + + double awayMag = -v.dotProduct(toWall); + if (awayMag > 0) { + v = v.add(toWall.multiply(awayMag)); + } + + double drag = 0.91; + double dragY = face == Direction.DOWN ? 0.98 : drag; + v = new Vec3d(v.x * drag, v.y * dragY, v.z * drag); + + self.setVelocity(v); + self.move(MovementType.SELF, self.getVelocity()); + self.fallDistance = 0; + + ci.cancel(); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/BipedSwingPoseMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/BipedSwingPoseMixin.java new file mode 100644 index 00000000..97d19024 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/BipedSwingPoseMixin.java @@ -0,0 +1,26 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.client.render.entity.model.BipedEntityModel; +import net.minecraft.entity.LivingEntity; + +import mc.duzo.timeless.client.render.WebSwingBodyTilt; + +@Mixin(BipedEntityModel.class) +public abstract class BipedSwingPoseMixin { + /** + * The vanilla biped applies a crouched pose (lowered pivots, body.pitch = 0.5) when its + * sneaking flag is true. The flag is set to {@code entity.isInSneakingPose()} just before + * setAngles runs, so we clear it whenever this entity is mid-swing. + */ + @Inject(method = "setAngles(Lnet/minecraft/entity/LivingEntity;FFFFF)V", at = @At("HEAD")) + private void timeless$clearSneakDuringSwing(LivingEntity entity, float limbAngle, float limbDistance, float animationProgress, float headYaw, float headPitch, CallbackInfo ci) { + if (WebSwingBodyTilt.isSwinging(entity)) { + ((BipedEntityModel) (Object) this).sneaking = false; + } + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/EntityGlowMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/EntityGlowMixin.java new file mode 100644 index 00000000..33f38e37 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/EntityGlowMixin.java @@ -0,0 +1,31 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.Entity; + +import mc.duzo.timeless.client.render.SpideySenseTracker; +import mc.duzo.timeless.power.impl.SpideySensePower; + +@Mixin(Entity.class) +public abstract class EntityGlowMixin { + @Inject(method = "isGlowing", at = @At("RETURN"), cancellable = true) + private void timeless$spideySenseGlow(CallbackInfoReturnable cir) { + if (cir.getReturnValueZ()) return; + + Entity self = (Entity) (Object) this; + if (self.getWorld() == null || !self.getWorld().isClient) return; + + ClientPlayerEntity local = MinecraftClient.getInstance().player; + if (local == null || local == self) return; + if (!SpideySensePower.isEnabled(local)) return; + if (SpideySenseTracker.intensityFor(self) <= 0f) return; + + cir.setReturnValue(true); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererInvoker.java b/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererInvoker.java new file mode 100644 index 00000000..d4e5aeac --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererInvoker.java @@ -0,0 +1,15 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.item.HeldItemRenderer; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Arm; + +@Mixin(HeldItemRenderer.class) +public interface HeldItemRendererInvoker { + @Invoker("renderArmHoldingItem") + void timeless$renderArmHoldingItem(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, float equipProgress, float swingProgress, Arm arm); +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererMixin.java new file mode 100644 index 00000000..39fdefba --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererMixin.java @@ -0,0 +1,43 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.item.HeldItemRenderer; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Arm; +import net.minecraft.util.Hand; + +import mc.duzo.timeless.client.render.WebSwingState; +import mc.duzo.timeless.entity.WebRopeEntity; + +@Mixin(HeldItemRenderer.class) +public abstract class HeldItemRendererMixin { + @Inject(method = "renderFirstPersonItem", at = @At("HEAD"), cancellable = true) + private void timeless$renderOffHandSwingArm(AbstractClientPlayerEntity player, float tickDelta, float pitch, Hand hand, float swingProgress, ItemStack item, float equipProgress, MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, CallbackInfo ci) { + if (hand != Hand.OFF_HAND) return; + if (!item.isEmpty()) return; + if (player.isInvisible()) return; + if (player.isUsingSpyglass()) return; + + WebRopeEntity rope = WebSwingState.findActiveRope(player); + if (rope == null) return; + + Arm offArm = player.getMainArm().getOpposite(); + boolean ropeOnOffArm = rope.isLeftArm() == (offArm == Arm.LEFT); + if (!ropeOnOffArm) return; + + matrices.push(); + // Empty off-hand decays equipProgressOffHand to 0, so the value passed in is ~1 + // (fully unequipped) which drops the arm 1.2 blocks below the camera. Force 0 so + // the arm sits in its normal rest position. + ((HeldItemRendererInvoker)(Object) this).timeless$renderArmHoldingItem(matrices, vertexConsumers, light, 0f, swingProgress, offArm); + matrices.pop(); + ci.cancel(); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/MouseMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/MouseMixin.java new file mode 100644 index 00000000..4cb4bf28 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/MouseMixin.java @@ -0,0 +1,34 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.Mouse; +import net.minecraft.client.network.ClientPlayerEntity; + +import mc.duzo.timeless.client.render.WebSwingState; +import mc.duzo.timeless.entity.WebRopeEntity; +import mc.duzo.timeless.network.c2s.WebRappelC2SPacket; + +@Mixin(Mouse.class) +public abstract class MouseMixin { + @Inject(method = "onMouseScroll", at = @At("HEAD"), cancellable = true) + private void timeless$consumeWebRappel(long window, double horizontal, double vertical, CallbackInfo ci) { + ClientPlayerEntity player = MinecraftClient.getInstance().player; + if (player == null) return; + if (player.isSpectator()) return; + + WebRopeEntity rope = WebSwingState.findActiveRope(player); + if (rope == null || rope.getMode() != WebRopeEntity.Mode.SWING) return; + if (Math.abs(vertical) < 0.001) return; + + int direction = vertical > 0 ? 1 : -1; + ClientPlayNetworking.send(new WebRappelC2SPacket(direction)); + ci.cancel(); + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/PlayerEntityRendererMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/PlayerEntityRendererMixin.java index 3033245d..cb89d616 100644 --- a/src/main/java/mc/duzo/timeless/mixin/client/PlayerEntityRendererMixin.java +++ b/src/main/java/mc/duzo/timeless/mixin/client/PlayerEntityRendererMixin.java @@ -54,7 +54,8 @@ public PlayerEntityRendererMixin(EntityRendererFactory.Context ctx, PlayerEntity } if (this.timeless$cachedArmModel == null) return; - boolean isRight = player.getMainArm() == Arm.RIGHT; + // arm.pivotX < 0 for the right bone (-5), > 0 for the left bone (+5). + boolean isRight = arm.pivotX < 0; this.timeless$cachedArmModel.renderArm(isRight, player, 0, matrices, vertexConsumers.getBuffer(RenderLayer.getEntityTranslucent(clientSuit.texture())), light, 1, 1, 1, 1); ci.cancel(); } diff --git a/src/main/java/mc/duzo/timeless/mixin/client/WallCrawlRendererMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/WallCrawlRendererMixin.java new file mode 100644 index 00000000..778c62a0 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/WallCrawlRendererMixin.java @@ -0,0 +1,30 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.RotationAxis; + +import mc.duzo.timeless.util.WallCrawlData; + +@Mixin(LivingEntityRenderer.class) +public abstract class WallCrawlRendererMixin { + @Inject(method = "setupTransforms", at = @At("TAIL")) + private void timeless$wallCrawlRotation(LivingEntity entity, MatrixStack matrices, float animationProgress, float bodyYaw, float tickDelta, CallbackInfo ci) { + WallCrawlData data = WallCrawlData.get(entity); + if (!data.enabled && data.invertTimer <= 0f && data.timer >= 1f) return; + + float invert = MathHelper.lerp(tickDelta, data.prevInvertTimer, data.invertTimer); + + if (invert > 0f) { + matrices.translate(0f, entity.getHeight() * invert, 0f); + matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(180f * invert)); + } + } +} diff --git a/src/main/java/mc/duzo/timeless/mixin/client/WebSwingRendererMixin.java b/src/main/java/mc/duzo/timeless/mixin/client/WebSwingRendererMixin.java new file mode 100644 index 00000000..3f4d8fd0 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/mixin/client/WebSwingRendererMixin.java @@ -0,0 +1,20 @@ +package mc.duzo.timeless.mixin.client; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.LivingEntity; + +import mc.duzo.timeless.client.render.WebSwingBodyTilt; + +@Mixin(LivingEntityRenderer.class) +public abstract class WebSwingRendererMixin { + @Inject(method = "setupTransforms", at = @At("TAIL")) + private void timeless$webSwingTilt(LivingEntity entity, MatrixStack matrices, float animationProgress, float bodyYaw, float tickDelta, CallbackInfo ci) { + WebSwingBodyTilt.apply(entity, matrices, tickDelta); + } +} diff --git a/src/main/java/mc/duzo/timeless/network/Network.java b/src/main/java/mc/duzo/timeless/network/Network.java index dc5812fd..43ff97a6 100644 --- a/src/main/java/mc/duzo/timeless/network/Network.java +++ b/src/main/java/mc/duzo/timeless/network/Network.java @@ -19,11 +19,13 @@ import mc.duzo.timeless.Timeless; import mc.duzo.timeless.network.c2s.UpdateInputC2SPacket; import mc.duzo.timeless.network.c2s.UsePowerC2SPacket; +import mc.duzo.timeless.network.c2s.WebRappelC2SPacket; public class Network { static { ServerPlayNetworking.registerGlobalReceiver(UsePowerC2SPacket.TYPE, UsePowerC2SPacket::handle); ServerPlayNetworking.registerGlobalReceiver(UpdateInputC2SPacket.TYPE, UpdateInputC2SPacket::handle); + ServerPlayNetworking.registerGlobalReceiver(WebRappelC2SPacket.TYPE, WebRappelC2SPacket::handle); } public static void init() { diff --git a/src/main/java/mc/duzo/timeless/network/c2s/WebRappelC2SPacket.java b/src/main/java/mc/duzo/timeless/network/c2s/WebRappelC2SPacket.java new file mode 100644 index 00000000..cc586d73 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/network/c2s/WebRappelC2SPacket.java @@ -0,0 +1,46 @@ +package mc.duzo.timeless.network.c2s; + +import net.fabricmc.fabric.api.networking.v1.FabricPacket; +import net.fabricmc.fabric.api.networking.v1.PacketSender; +import net.fabricmc.fabric.api.networking.v1.PacketType; + +import net.minecraft.entity.Entity; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.entity.WebRopeEntity; + +public record WebRappelC2SPacket(int direction) implements FabricPacket { + public static final PacketType TYPE = PacketType.create(new Identifier(Timeless.MOD_ID, "web_rappel"), WebRappelC2SPacket::new); + + public WebRappelC2SPacket(PacketByteBuf buf) { + this(buf.readByte()); + } + + @Override + public void write(PacketByteBuf buf) { + buf.writeByte(direction); + } + + @Override + public PacketType getType() { + return TYPE; + } + + public boolean handle(ServerPlayerEntity source, PacketSender response) { + if (source.getServerWorld() == null) return false; + for (Entity e : source.getServerWorld().iterateEntities()) { + if (e instanceof WebRopeEntity rope + && source.getUuid().equals(rope.getShooterUuid()) + && rope.isAnchored() + && !rope.isDisconnected() + && rope.getMode() == WebRopeEntity.Mode.SWING) { + rope.rappel(direction); + return true; + } + } + return false; + } +} diff --git a/src/main/java/mc/duzo/timeless/power/Power.java b/src/main/java/mc/duzo/timeless/power/Power.java index 0f67c068..f8576d0c 100644 --- a/src/main/java/mc/duzo/timeless/power/Power.java +++ b/src/main/java/mc/duzo/timeless/power/Power.java @@ -30,6 +30,15 @@ public void tick(AbstractClientPlayerEntity player) { } public abstract void onLoad(ServerPlayerEntity player); + @Environment(EnvType.CLIENT) + public boolean handlesRightClick(net.minecraft.client.network.AbstractClientPlayerEntity player) { + return false; + } + + @Environment(EnvType.CLIENT) + public void applyPose(net.minecraft.entity.LivingEntity entity, mc.duzo.timeless.suit.client.render.SuitModel model) { + } + @Override public String getTranslationKey() { return this.id().getNamespace() + ".power." + this.id().getPath(); diff --git a/src/main/java/mc/duzo/timeless/power/PowerRegistry.java b/src/main/java/mc/duzo/timeless/power/PowerRegistry.java index dd96e931..57bdca1c 100644 --- a/src/main/java/mc/duzo/timeless/power/PowerRegistry.java +++ b/src/main/java/mc/duzo/timeless/power/PowerRegistry.java @@ -54,6 +54,9 @@ public static T register(T entry) { }) .build().register(); public static Power SWIFT_SNEAK = Power.Builder.create(new Identifier(Timeless.MOD_ID, "swift_sneak")).build().register(); + public static SpideySensePower SPIDEY_SENSE = new SpideySensePower().register(); + public static WallClimbPower WALL_CLIMB = new WallClimbPower().register(); + public static WebSwingPower WEB_SWING = new WebSwingPower().register(); public static void init() {} } diff --git a/src/main/java/mc/duzo/timeless/power/impl/FlightPower.java b/src/main/java/mc/duzo/timeless/power/impl/FlightPower.java index 0673d1fb..060b3d26 100644 --- a/src/main/java/mc/duzo/timeless/power/impl/FlightPower.java +++ b/src/main/java/mc/duzo/timeless/power/impl/FlightPower.java @@ -5,6 +5,7 @@ import mc.duzo.timeless.network.Network; import mc.duzo.timeless.network.s2c.UpdateFlyingStatusS2CPacket; import mc.duzo.timeless.power.Power; +import mc.duzo.timeless.power.PowerRegistry; import mc.duzo.timeless.suit.Suit; import mc.duzo.timeless.suit.api.FlightSuit; import net.fabricmc.api.EnvType; @@ -119,14 +120,21 @@ public void onLoad(ServerPlayerEntity player) { } public static boolean hasFlight(PlayerEntity player) { + if (!suitHasFlightPower(player)) return false; return SuitItem.Data.getBoolean(player, "FlightEnabled"); } public static void setFlight(PlayerEntity player, boolean val) { SuitItem.Data.putBoolean(player, "FlightEnabled", val); } public static boolean isFlying(PlayerEntity player) { + if (!suitHasFlightPower(player)) return false; return SuitItem.Data.getBoolean(player, "IsFlying"); } + private static boolean suitHasFlightPower(PlayerEntity player) { + return Suit.findSuit(player) + .map(s -> s.hasPower(PowerRegistry.FLIGHT) || s.hasPower(PowerRegistry.BOOSTED_FLIGHT)) + .orElse(false); + } private static void setIsFlying(PlayerEntity player, boolean val) { SuitItem.Data.putBoolean(player, "IsFlying", val); diff --git a/src/main/java/mc/duzo/timeless/power/impl/SpideySensePower.java b/src/main/java/mc/duzo/timeless/power/impl/SpideySensePower.java new file mode 100644 index 00000000..2dd654af --- /dev/null +++ b/src/main/java/mc/duzo/timeless/power/impl/SpideySensePower.java @@ -0,0 +1,56 @@ +package mc.duzo.timeless.power.impl; + +import net.minecraft.entity.Entity; +import net.minecraft.entity.mob.HostileEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.projectile.ProjectileEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.sound.SoundCategory; +import net.minecraft.util.Identifier; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.core.TimelessSounds; +import mc.duzo.timeless.core.items.SuitItem; +import mc.duzo.timeless.power.Power; + +public class SpideySensePower extends Power { + public static final String DATA_KEY = "spideySenseEnabled"; + public static final double DETECT_RADIUS = 12.0; + + private final Identifier id = new Identifier(Timeless.MOD_ID, "spidey_sense"); + + @Override + public boolean run(ServerPlayerEntity player) { + boolean next = !isEnabled(player); + SuitItem.Data.putBoolean(player, DATA_KEY, next); + SuitItem.Data.sync(player); + player.playSound(TimelessSounds.SPIDERMAN_SHOOT, SoundCategory.PLAYERS, 0.4f, next ? 1.4f : 0.7f); + return true; + } + + @Override + public void tick(ServerPlayerEntity player) { + } + + @Override + public void onLoad(ServerPlayerEntity player) { + } + + @Override + public Identifier id() { + return this.id; + } + + public static boolean isEnabled(PlayerEntity player) { + if (!SuitItem.Data.contains(player, DATA_KEY)) return false; + return SuitItem.Data.getBoolean(player, DATA_KEY); + } + + /** Threats highlighted by spidey-sense: hostile mobs and live projectiles. */ + public static boolean isThreat(Entity entity) { + if (entity == null || entity.isRemoved()) return false; + if (entity instanceof HostileEntity h && h.isAlive()) return true; + if (entity instanceof ProjectileEntity p && !p.isRemoved()) return true; + return false; + } +} diff --git a/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java new file mode 100644 index 00000000..aaefc080 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java @@ -0,0 +1,152 @@ +package mc.duzo.timeless.power.impl; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.core.items.SuitItem; +import mc.duzo.timeless.power.Power; +import mc.duzo.timeless.util.ServerKeybind; +import mc.duzo.timeless.util.WallCrawlData; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.RaycastContext; + +public class WallClimbPower extends Power { + private static final double ATTACH_RANGE = 3.0; + private final Identifier id = new Identifier(Timeless.MOD_ID, "wall_climb"); + + @Override + public boolean run(ServerPlayerEntity player) { + return false; + } + + @Override + public void tick(ServerPlayerEntity player) { + tickShared(player); + } + + @Environment(EnvType.CLIENT) + @Override + public void tick(AbstractClientPlayerEntity player) { + WallCrawlData data = WallCrawlData.get(player); + data.tick(); + data.save(player); + } + + @Override + public void onLoad(ServerPlayerEntity player) { + } + + private static void tickShared(ServerPlayerEntity player) { + WallCrawlData data = WallCrawlData.get(player); + data.tick(); + + if (data.enabled) { + if (ServerKeybind.get(player).isJumping()) { + detach(player, data); + } else if (player.isOnGround() && data.timer >= 0.5f) { + detach(player, data); + } else if (!isStillOnWall(player, data)) { + detach(player, data); + } else { + player.setNoGravity(true); + player.fallDistance = 0; + } + } else { + tryAttach(player, data); + } + + data.save(player); + SuitItem.Data.sync(player); + } + + private static boolean isStillOnWall(PlayerEntity player, WallCrawlData data) { + Direction face = data.direction(); + Vec3d toWall = Vec3d.of(face.getOpposite().getVector()); + double cx = player.getX(); + double cz = player.getZ(); + double feetY = player.getY(); + double centerY = feetY + player.getHeight() * 0.5; + double eyeY = feetY + player.getStandingEyeHeight(); + return castWallProbe(player, new Vec3d(cx, eyeY, cz), toWall) + || castWallProbe(player, new Vec3d(cx, centerY, cz), toWall) + || castWallProbe(player, new Vec3d(cx, feetY + 0.1, cz), toWall); + } + + private static boolean castWallProbe(PlayerEntity player, Vec3d origin, Vec3d direction) { + Vec3d end = origin.add(direction.multiply(2.0)); + BlockHitResult hit = player.getWorld().raycast(new RaycastContext( + origin, end, + RaycastContext.ShapeType.COLLIDER, + RaycastContext.FluidHandling.NONE, + player + )); + return hit.getType() == HitResult.Type.BLOCK; + } + + private static void tryAttach(ServerPlayerEntity player, WallCrawlData data) { + if (!player.isSneaking()) return; + + Direction wall = findAttachableWall(player); + if (wall == null) return; + + data.setSide((byte) wall.getId()); + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0; + player.setNoGravity(true); + } + + private static void detach(ServerPlayerEntity player, WallCrawlData data) { + if (!data.enabled) return; + data.setSide((byte) Direction.UP.getId()); + player.fallDistance = 0; + player.setNoGravity(false); + } + + private static void applyStickyMotion(ServerPlayerEntity player, WallCrawlData data) { + Direction face = data.direction(); + Vec3d toWall = Vec3d.of(face.getOpposite().getVector()); + Vec3d v = player.getVelocity(); + + double awayMagnitude = -v.dotProduct(toWall); + if (awayMagnitude > 0) { + v = v.add(toWall.multiply(awayMagnitude)); + } + + double bias = 0.08; + v = v.add(toWall.multiply(bias)); + + player.setVelocity(v); + player.fallDistance = 0; + player.setNoGravity(true); + } + + private static Direction findAttachableWall(PlayerEntity player) { + Vec3d eye = player.getCameraPosVec(1f); + Vec3d look = player.getRotationVec(1f); + Vec3d end = eye.add(look.multiply(ATTACH_RANGE)); + BlockHitResult hit = player.getWorld().raycast(new RaycastContext( + eye, end, + RaycastContext.ShapeType.COLLIDER, + RaycastContext.FluidHandling.NONE, + player + )); + if (hit.getType() != HitResult.Type.BLOCK) return null; + Direction face = hit.getSide(); + if (face == Direction.UP) return null; + return face; + } + + @Override + public Identifier id() { + return this.id; + } +} diff --git a/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java new file mode 100644 index 00000000..74f4e55f --- /dev/null +++ b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java @@ -0,0 +1,208 @@ +package mc.duzo.timeless.power.impl; + +import java.util.UUID; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.model.ModelPart; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.client.render.WebSwingState; +import mc.duzo.timeless.core.TimelessSounds; +import mc.duzo.timeless.entity.WebRopeEntity; +import mc.duzo.timeless.power.Power; +import mc.duzo.timeless.suit.client.render.SuitModel; + +public class WebSwingPower extends Power { + private final Identifier id = new Identifier(Timeless.MOD_ID, "web_swing"); + + @Override + public boolean run(ServerPlayerEntity player) { + ServerWorld world = player.getServerWorld(); + + UUID owner = player.getUuid(); + + // If there is an active bind rope on a hooked entity, a second activation reels them in. + for (Entity e : world.iterateEntities()) { + if (e instanceof WebRopeEntity rope + && owner.equals(rope.getShooterUuid()) + && !rope.isDisconnected() + && rope.getMode() == WebRopeEntity.Mode.BIND + && rope.getHookedEntity() != null) { + rope.requestReel(); + world.playSound(null, player.getX(), player.getY(), player.getZ(), + TimelessSounds.SPIDERMAN_SHOOT, SoundCategory.PLAYERS, 0.6f, 1.4f); + return true; + } + } + + // Otherwise discard any older ropes and fire a new one. + for (Entity e : world.iterateEntities()) { + if (e instanceof WebRopeEntity rope && owner.equals(rope.getShooterUuid()) && !rope.isDisconnected()) { + rope.setDisconnected(true); + } + } + + WebRopeEntity.Mode mode = pickMode(player); + boolean isLeftArm = pickLeftArm(player); + WebRopeEntity rope = new WebRopeEntity(world, player, isLeftArm, mode); + world.spawnEntity(rope); + + world.playSound(null, player.getX(), player.getY(), player.getZ(), + TimelessSounds.SPIDERMAN_SHOOT, SoundCategory.PLAYERS, 0.8f, 1.0f + (world.random.nextFloat() - 0.5f) * 0.1f); + + return true; + } + + /** + * sneak + look-at-block -> pull-zip toward the block; + * look-at-hostile -> bind that mob in place; + * otherwise swing as normal. + */ + private static WebRopeEntity.Mode pickMode(ServerPlayerEntity player) { + Vec3d eye = player.getCameraPosVec(1f); + Vec3d look = player.getRotationVec(1f); + double range = 32.0; + Vec3d end = eye.add(look.multiply(range)); + net.minecraft.util.math.Box box = player.getBoundingBox().stretch(look.multiply(range)).expand(1.0, 1.0, 1.0); + net.minecraft.util.hit.EntityHitResult ehit = net.minecraft.entity.projectile.ProjectileUtil.raycast(player, eye, end, box, + e -> e instanceof net.minecraft.entity.mob.HostileEntity && e.isAlive(), range * range); + + if (ehit != null) return WebRopeEntity.Mode.BIND; + if (player.isSneaking()) return WebRopeEntity.Mode.ZIP; + return WebRopeEntity.Mode.SWING; + } + + private static boolean pickLeftArm(PlayerEntity player) { + float delta = MathHelper.wrapDegrees(player.bodyYaw - player.getYaw()); + double sideChance = 0.25 + Math.min(Math.abs(delta) / 40.0, 0.5); + if (Math.random() >= sideChance) return false; + return delta < 0; + } + + @Override + public void tick(ServerPlayerEntity player) { + } + + @Override + public void onLoad(ServerPlayerEntity player) { + } + + @Override + public Identifier id() { + return this.id; + } + + @Override + @Environment(EnvType.CLIENT) + public boolean handlesRightClick(AbstractClientPlayerEntity player) { + return player.getMainHandStack().isEmpty(); + } + + @Override + @Environment(EnvType.CLIENT) + public void applyPose(LivingEntity entity, SuitModel model) { + if (!(entity instanceof PlayerEntity player)) return; + if (!(player.getWorld() instanceof ClientWorld)) return; + + WebRopeEntity rope = WebSwingState.findActiveRope(player); + WebSwingState state = WebSwingState.get(player); + if (rope == null && !state.isPostSwing()) return; + + ModelPart leftArm = model.partLeftArm(); + ModelPart rightArm = model.partRightArm(); + if (leftArm == null || rightArm == null) return; + + MinecraftClient mc = MinecraftClient.getInstance(); + float tickDelta = mc.getTickDelta(); + float timer = state.interpolate(tickDelta); + if (timer <= 0.001f) return; + + // Speed drives velocity-scaled motion. Includes vertical so falling/rising affects swing. + double mx = entity.getX() - entity.prevX; + double my = entity.getY() - entity.prevY; + double mz = entity.getZ() - entity.prevZ; + float speed = (float) MathHelper.clamp(Math.sqrt(mx * mx + my * my + mz * mz) / 0.4, 0.0, 1.0); + float vertSpeed = (float) MathHelper.clamp(Math.abs(my) / 0.5, 0.0, 1.0); + float phase = ((float) entity.age + tickDelta) * 0.32f; + float swingOsc = MathHelper.sin(phase); + float legOsc = MathHelper.sin(phase + (float) Math.PI * 0.5f); + + // 45 degrees in radians, the upper bound the user requested for sideways spread / leg swing. + final float MAX_45 = (float) Math.toRadians(45.0); + + boolean leftIsGrip = rope != null && rope.isLeftArm(); + ModelPart gripArm = leftIsGrip ? leftArm : rightArm; + ModelPart trailArm = leftIsGrip ? rightArm : leftArm; + float trailMirror = leftIsGrip ? 1f : -1f; + + if (rope != null) { + Vec3d shoulderApprox = new Vec3d(entity.getX(), entity.getBodyY(0.85), entity.getZ()); + Vec3d toRope = rope.getPos().subtract(shoulderApprox); + if (toRope.lengthSquared() >= 1.0e-6) { + Vec3d local = toRope.rotateY((float) Math.toRadians(entity.bodyYaw)).normalize(); + double lx = local.x, ly = local.y, lz = local.z; + + float gripRoll = (float) -Math.asin(MathHelper.clamp(lx, -1.0, 1.0)); + float gripPitch = (float) Math.atan2(-lz, -ly); + + gripArm.pitch = MathHelper.lerp(timer, gripArm.pitch, gripPitch); + gripArm.yaw = MathHelper.lerp(timer, gripArm.yaw, 0f); + gripArm.roll = MathHelper.lerp(timer, gripArm.roll, gripRoll); + } + } else { + // Post-swing (airborne after release): both arms flow with the body, biased upward. + float fallReach = -MAX_45 * 0.5f * (1f + vertSpeed); + leftArm.pitch = MathHelper.lerp(timer, leftArm.pitch, fallReach + swingOsc * MAX_45 * 0.4f * speed); + rightArm.pitch = MathHelper.lerp(timer, rightArm.pitch, fallReach - swingOsc * MAX_45 * 0.4f * speed); + leftArm.roll = MathHelper.lerp(timer, leftArm.roll, MAX_45 * 0.4f * speed); + rightArm.roll = MathHelper.lerp(timer, rightArm.roll, -MAX_45 * 0.4f * speed); + } + + if (rope != null) { + // Trail arm: at rest pose pitch=0, roll=0 (hanging straight); ramp to pitch +/- swingOsc*45deg + // (front-back swing) and roll up to +/- 45deg (sideways spread) as speed grows. + float trailPitch = swingOsc * MAX_45 * speed - vertSpeed * MAX_45 * 0.4f; + float trailRoll = trailMirror * MAX_45 * speed; + trailArm.pitch = MathHelper.lerp(timer, trailArm.pitch, trailPitch); + trailArm.yaw = MathHelper.lerp(timer, trailArm.yaw, 0f); + trailArm.roll = MathHelper.lerp(timer, trailArm.roll, trailRoll); + } + + ModelPart body = model.partBody(); + if (body != null) { + body.pitch = MathHelper.lerp(timer, body.pitch, swingOsc * 0.05f * speed - vertSpeed * 0.15f); + body.yaw = MathHelper.lerp(timer, body.yaw, swingOsc * 0.08f * speed); + body.roll = MathHelper.lerp(timer, body.roll, 0f); + } + + // Legs swing front-back (alternating) and spread side-to-side, both 0-45deg with speed. + // Outward-roll sign: pivot at -X (right side) uses POSITIVE roll, pivot at +X (left side) + // uses NEGATIVE roll - same convention as the trail arm above. + ModelPart leftLeg = model.partLeftLeg(); + ModelPart rightLeg = model.partRightLeg(); + if (leftLeg != null && rightLeg != null) { + float legSwing = legOsc * MAX_45 * speed - vertSpeed * MAX_45 * 0.3f; + float legSpread = MAX_45 * 0.35f * speed; + leftLeg.pitch = MathHelper.lerp(timer, leftLeg.pitch, legSwing); + rightLeg.pitch = MathHelper.lerp(timer, rightLeg.pitch, -legSwing); + leftLeg.yaw = MathHelper.lerp(timer, leftLeg.yaw, 0f); + rightLeg.yaw = MathHelper.lerp(timer, rightLeg.yaw, 0f); + leftLeg.roll = MathHelper.lerp(timer, leftLeg.roll, -legSpread); + rightLeg.roll = MathHelper.lerp(timer, rightLeg.roll, legSpread); + } + } +} diff --git a/src/main/java/mc/duzo/timeless/suit/Suit.java b/src/main/java/mc/duzo/timeless/suit/Suit.java index 231ee962..87eb9af7 100644 --- a/src/main/java/mc/duzo/timeless/suit/Suit.java +++ b/src/main/java/mc/duzo/timeless/suit/Suit.java @@ -5,6 +5,7 @@ import mc.duzo.timeless.datagen.provider.lang.Translatable; import mc.duzo.timeless.power.Power; import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.suit.api.EquipSoundSupplier; import mc.duzo.timeless.suit.client.ClientSuit; import mc.duzo.timeless.suit.client.ClientSuitRegistry; import mc.duzo.timeless.suit.set.SuitSet; @@ -16,7 +17,7 @@ import java.util.Optional; -public abstract class Suit implements Identifiable, Translatable { +public abstract class Suit implements Identifiable, Translatable, EquipSoundSupplier { public static Optional findSuit(LivingEntity entity, EquipmentSlot slot) { if (!(entity.getEquippedStack(slot).getItem() instanceof SuitItem item)) return Optional.empty(); return Optional.ofNullable(item.getSuit()); @@ -37,12 +38,6 @@ public boolean hasPower(Power power) { return this.getPowers().contains(power); } public abstract SoundEvent getStepSound(); - public Optional getEquipSound() { - return Optional.empty(); - } - public Optional getUnequipSound() { - return Optional.empty(); - } /** * whether this suit is always visible on the player diff --git a/src/main/java/mc/duzo/timeless/suit/client/render/SuitFeature.java b/src/main/java/mc/duzo/timeless/suit/client/render/SuitFeature.java index 93ce2ddb..624dc64f 100644 --- a/src/main/java/mc/duzo/timeless/suit/client/render/SuitFeature.java +++ b/src/main/java/mc/duzo/timeless/suit/client/render/SuitFeature.java @@ -52,6 +52,7 @@ private void renderPart(MatrixStack matrixStack, VertexConsumerProvider vertexCo model.setVisibilityForSlot(slot); model.copyFrom(context); model.setAngles(livingEntity, f, g, j, k, l); + model.copyTo(context); model.getCape().ifPresent(cape -> { cape.visible = false; diff --git a/src/main/java/mc/duzo/timeless/suit/client/render/SuitModel.java b/src/main/java/mc/duzo/timeless/suit/client/render/SuitModel.java index 21f05faf..0cc553f0 100644 --- a/src/main/java/mc/duzo/timeless/suit/client/render/SuitModel.java +++ b/src/main/java/mc/duzo/timeless/suit/client/render/SuitModel.java @@ -44,8 +44,51 @@ public void render(MatrixStack matrices, VertexConsumer vertices, int light, int public void setAngles(LivingEntity entity, float limbAngle, float limbDistance, float animationProgress, float headYaw, float headPitch) { if (!(entity instanceof AbstractClientPlayerEntity player)) return; this.runAnimations(player, animationProgress); + if (mc.duzo.timeless.client.render.WebSwingBodyTilt.isSwinging(entity)) { + resetSneakPivots(); + } + applyPowerPoses(entity); + } + + private void resetSneakPivots() { + ModelPart head = partHead(); + ModelPart body = partBody(); + ModelPart la = partLeftArm(); + ModelPart ra = partRightArm(); + ModelPart ll = partLeftLeg(); + ModelPart rl = partRightLeg(); + if (head != null) head.pivotY = 0f; + if (body != null) { + body.pivotY = 0f; + body.pitch = 0f; + } + if (la != null) la.pivotY = 2f; + if (ra != null) ra.pivotY = 2f; + if (ll != null) { ll.pivotY = 12f; ll.pivotZ = 0f; } + if (rl != null) { rl.pivotY = 12f; rl.pivotZ = 0f; } + } + + protected void applyPowerPoses(LivingEntity entity) { + mc.duzo.timeless.suit.Suit suit = mc.duzo.timeless.suit.Suit.findSuit(entity).orElse(null); + if (suit == null) return; + for (mc.duzo.timeless.power.Power p : suit.getPowers()) { + p.applyPose(entity, this); + } } + /** + * Bone accessors for biped-shaped suit models. + * Subclasses with biped structure (Steve/Alex) override to return their fields; + * non-biped suits (Iron Man marks etc.) leave these null and just do their own rotateParts. + * Powers' applyPose() reads through these to apply procedural pose changes generically. + */ + public ModelPart partHead() { return null; } + public ModelPart partBody() { return null; } + public ModelPart partLeftArm() { return null; } + public ModelPart partRightArm() { return null; } + public ModelPart partLeftLeg() { return null; } + public ModelPart partRightLeg() { return null; } + protected void runAnimations(AbstractClientPlayerEntity player, float animationProgress) { SuitAnimationHolder anim = this.getAnimation(player).orElse(null); if (anim == null) return; diff --git a/src/main/java/mc/duzo/timeless/suit/client/render/generic/AlexSuitModel.java b/src/main/java/mc/duzo/timeless/suit/client/render/generic/AlexSuitModel.java index d1b1af60..43941b92 100644 --- a/src/main/java/mc/duzo/timeless/suit/client/render/generic/AlexSuitModel.java +++ b/src/main/java/mc/duzo/timeless/suit/client/render/generic/AlexSuitModel.java @@ -13,6 +13,7 @@ import net.minecraft.client.util.math.MatrixStack; import net.minecraft.entity.EquipmentSlot; import net.minecraft.entity.LivingEntity; +import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.RotationAxis; import net.minecraft.util.math.Vec3d; @@ -66,6 +67,26 @@ public static TexturedModelData getTexturedModelData() { return TexturedModelData.of(modelData, 64, 64); } + @Override + public void setAngles(LivingEntity entity, float limbAngle, float limbDistance, float animationProgress, float headYaw, float headPitch) { + this.head.yaw = headYaw * 0.017453292F; + this.head.pitch = headPitch * 0.017453292F; + this.rightArm.pitch = MathHelper.cos(limbAngle * 0.6662F + (float) Math.PI) * 2.0F * limbDistance * 0.5F; + this.leftArm.pitch = MathHelper.cos(limbAngle * 0.6662F) * 2.0F * limbDistance * 0.5F; + this.rightArm.roll = 0.0F; + this.leftArm.roll = 0.0F; + this.rightArm.yaw = 0.0F; + this.leftArm.yaw = 0.0F; + this.rightLeg.pitch = MathHelper.cos(limbAngle * 0.6662F) * 1.4F * limbDistance; + this.leftLeg.pitch = MathHelper.cos(limbAngle * 0.6662F + (float) Math.PI) * 1.4F * limbDistance; + this.rightLeg.yaw = 0.0F; + this.leftLeg.yaw = 0.0F; + this.rightLeg.roll = 0.0F; + this.leftLeg.roll = 0.0F; + + super.setAngles(entity, limbAngle, limbDistance, animationProgress, headYaw, headPitch); + } + @Override public void render(LivingEntity entity, float tickDelta, MatrixStack matrices, VertexConsumer vertexConsumers, int light, float r, float g, float b, float alpha) { matrices.push(); @@ -173,6 +194,19 @@ public void setVisibilityForSlot(EquipmentSlot slot) { } } + @Override + public ModelPart partHead() { return this.head; } + @Override + public ModelPart partBody() { return this.body; } + @Override + public ModelPart partLeftArm() { return this.leftArm; } + @Override + public ModelPart partRightArm() { return this.rightArm; } + @Override + public ModelPart partLeftLeg() { return this.leftLeg; } + @Override + public ModelPart partRightLeg() { return this.rightLeg; } + @Override public ClientSuit getSuit() { return suit; diff --git a/src/main/java/mc/duzo/timeless/suit/client/render/generic/SteveSuitModel.java b/src/main/java/mc/duzo/timeless/suit/client/render/generic/SteveSuitModel.java index 8c54ade4..86570ec2 100644 --- a/src/main/java/mc/duzo/timeless/suit/client/render/generic/SteveSuitModel.java +++ b/src/main/java/mc/duzo/timeless/suit/client/render/generic/SteveSuitModel.java @@ -13,6 +13,7 @@ import net.minecraft.client.util.math.MatrixStack; import net.minecraft.entity.EquipmentSlot; import net.minecraft.entity.LivingEntity; +import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.RotationAxis; import net.minecraft.util.math.Vec3d; @@ -66,6 +67,26 @@ public static TexturedModelData getTexturedModelData() { return TexturedModelData.of(modelData, 64, 64); } + @Override + public void setAngles(LivingEntity entity, float limbAngle, float limbDistance, float animationProgress, float headYaw, float headPitch) { + this.head.yaw = headYaw * 0.017453292F; + this.head.pitch = headPitch * 0.017453292F; + this.rightArm.pitch = MathHelper.cos(limbAngle * 0.6662F + (float) Math.PI) * 2.0F * limbDistance * 0.5F; + this.leftArm.pitch = MathHelper.cos(limbAngle * 0.6662F) * 2.0F * limbDistance * 0.5F; + this.rightArm.roll = 0.0F; + this.leftArm.roll = 0.0F; + this.rightArm.yaw = 0.0F; + this.leftArm.yaw = 0.0F; + this.rightLeg.pitch = MathHelper.cos(limbAngle * 0.6662F) * 1.4F * limbDistance; + this.leftLeg.pitch = MathHelper.cos(limbAngle * 0.6662F + (float) Math.PI) * 1.4F * limbDistance; + this.rightLeg.yaw = 0.0F; + this.leftLeg.yaw = 0.0F; + this.rightLeg.roll = 0.0F; + this.leftLeg.roll = 0.0F; + + super.setAngles(entity, limbAngle, limbDistance, animationProgress, headYaw, headPitch); + } + @Override public void render(LivingEntity entity, float tickDelta, MatrixStack matrices, VertexConsumer vertexConsumers, int light, float r, float g, float b, float alpha) { matrices.push(); @@ -173,6 +194,19 @@ public void setVisibilityForSlot(EquipmentSlot slot) { } } + @Override + public ModelPart partHead() { return this.head; } + @Override + public ModelPart partBody() { return this.body; } + @Override + public ModelPart partLeftArm() { return this.leftArm; } + @Override + public ModelPart partRightArm() { return this.rightArm; } + @Override + public ModelPart partLeftLeg() { return this.leftLeg; } + @Override + public ModelPart partRightLeg() { return this.rightLeg; } + @Override public ClientSuit getSuit() { return suit; diff --git a/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java b/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java index dfea107e..908c7e5f 100644 --- a/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java +++ b/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java @@ -4,6 +4,10 @@ import mc.duzo.timeless.suit.moonknight.jake.JakeSuit; import mc.duzo.timeless.suit.moonknight.marc.MarcSuit; import mc.duzo.timeless.suit.moonknight.steven.StevenSuit; +import mc.duzo.timeless.suit.spiderman.item.SpidermanSuitItem; +import mc.duzo.timeless.suit.spiderman.classic.SpidermanClassicSuit; +import mc.duzo.timeless.suit.spiderman.miles.MilesSuit; +import mc.duzo.timeless.suit.spiderman.spidernoir.SpiderNoirSuit; import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder; import net.minecraft.registry.Registry; @@ -32,6 +36,9 @@ public static T register(T suit) { public static SuitSet MARK_THREE; public static SuitSet MARK_TWO; public static SuitSet BATMAN_66; + public static SuitSet SPIDERMAN; + public static SuitSet MILES; + public static SuitSet SPIDER_NOIR; public static SuitSet MOON_KNIGHT_MARC; public static SuitSet MOON_KNIGHT_JAKE; public static SuitSet MOON_KNIGHT_STEVEN; @@ -45,6 +52,13 @@ public static void init() { // Batman BATMAN_66 = register(new RegisteringSuitSet(new Batman66Suit(), BatmanSuitItem::new)); + + // Spiderman + SPIDERMAN = register(new RegisteringSuitSet(new SpidermanClassicSuit(), SpidermanSuitItem::new)); + MILES = register(new RegisteringSuitSet(new MilesSuit(), SpidermanSuitItem::new)); + SPIDER_NOIR = register(new RegisteringSuitSet(new SpiderNoirSuit(), SpidermanSuitItem::new)); + + // Moon Knight MOON_KNIGHT_MARC = register(new RegisteringSuitSet(new MarcSuit(), MoonKnightSuitItem::new)); MOON_KNIGHT_JAKE = register(new RegisteringSuitSet(new JakeSuit(), MoonKnightSuitItem::new)); MOON_KNIGHT_STEVEN = register(new RegisteringSuitSet(new StevenSuit(), MoonKnightSuitItem::new)); diff --git a/src/main/java/mc/duzo/timeless/suit/set/SuitSet.java b/src/main/java/mc/duzo/timeless/suit/set/SuitSet.java index ec2a64c8..089216a0 100644 --- a/src/main/java/mc/duzo/timeless/suit/set/SuitSet.java +++ b/src/main/java/mc/duzo/timeless/suit/set/SuitSet.java @@ -1,17 +1,16 @@ package mc.duzo.timeless.suit.set; -import java.util.HashMap; -import java.util.function.BiFunction; - import mc.duzo.animation.registry.Identifiable; - +import mc.duzo.timeless.core.items.SuitItem; +import mc.duzo.timeless.suit.Suit; +import net.minecraft.entity.EquipmentSlot; import net.minecraft.entity.LivingEntity; import net.minecraft.item.ArmorItem; import net.minecraft.item.ItemStack; import net.minecraft.util.Identifier; -import mc.duzo.timeless.core.items.SuitItem; -import mc.duzo.timeless.suit.Suit; +import java.util.HashMap; +import java.util.function.BiFunction; public class SuitSet extends HashMap implements Identifiable { protected final Suit suit; @@ -70,6 +69,13 @@ public boolean isWearing(LivingEntity entity) { return count == target; } + public boolean isWearing(LivingEntity entity, EquipmentSlot slot) { + ItemStack stack = entity.getEquippedStack(slot); + if (stack == null || stack.isEmpty()) return false; + + return this.containsValue(stack.getItem()); + } + public boolean wear(LivingEntity entity, boolean playSounds) { if (hasArmor(entity)) return false; diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/SpidermanSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/SpidermanSuit.java new file mode 100644 index 00000000..32608aca --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/SpidermanSuit.java @@ -0,0 +1,68 @@ +package mc.duzo.timeless.suit.spiderman; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.datagen.provider.lang.AutomaticSuitEnglish; +import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.suit.Suit; +import net.minecraft.sound.SoundEvent; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.Identifier; + +import java.util.Optional; + +public abstract class SpidermanSuit extends Suit implements AutomaticSuitEnglish { + private final Identifier id; + protected final PowerList powers; + + protected SpidermanSuit(Identifier id, PowerList powers) { + this.id = id; + this.powers = powers; + } + + protected SpidermanSuit(String mark, String modid, PowerList powers) { + this(new Identifier(modid, "spiderman_" + mark), powers); + } + + /** + * For Timeless heroes ONLY + * Addon mods should use other constructor + */ + protected SpidermanSuit(String mark, PowerList powers) { + this(mark, Timeless.MOD_ID, powers); + } + + @Override + public PowerList getPowers() { + return powers; + } + + @Override + public boolean isBinding() { + return false; + } + + @Override + public boolean isAlwaysVisible() { + return false; + } + + @Override + public Identifier id() { + return this.id; + } + + @Override + public SoundEvent getStepSound() { + return SoundEvents.BLOCK_WOOL_STEP; + } + + @Override + public Optional getEquipSound() { + return Optional.of(SoundEvents.ITEM_ARMOR_EQUIP_LEATHER); + } + + @Override + public Optional getUnequipSound() { + return Optional.of(SoundEvents.ITEM_ARMOR_EQUIP_LEATHER); + } +} diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/classic/SpidermanClassicSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/classic/SpidermanClassicSuit.java new file mode 100644 index 00000000..c5e50ce4 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/classic/SpidermanClassicSuit.java @@ -0,0 +1,39 @@ +package mc.duzo.timeless.suit.spiderman.classic; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.power.PowerRegistry; +import mc.duzo.timeless.suit.client.ClientSuit; +import mc.duzo.timeless.suit.client.render.SuitModel; +import mc.duzo.timeless.suit.client.render.generic.SteveSuitModel; +import mc.duzo.timeless.suit.set.SetRegistry; +import mc.duzo.timeless.suit.set.SuitSet; +import mc.duzo.timeless.suit.spiderman.SpidermanSuit; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.Identifier; + +import java.util.function.Supplier; + +public class SpidermanClassicSuit extends SpidermanSuit { + public SpidermanClassicSuit() { + super(new Identifier(Timeless.MOD_ID, "spiderman"), + PowerList.of(PowerRegistry.WEB_SWING, PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SPIDEY_SENSE)); + } + + @Override + public SuitSet getSet() { + return SetRegistry.SPIDERMAN; + } + + @Environment(EnvType.CLIENT) + @Override + protected ClientSuit createClient() { + return new ClientSuit(this) { + @Override + public Supplier model() { + return () -> new SteveSuitModel(this); + } + }; + } +} diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/item/SpidermanSuitItem.java b/src/main/java/mc/duzo/timeless/suit/spiderman/item/SpidermanSuitItem.java new file mode 100644 index 00000000..daa5b4c1 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/item/SpidermanSuitItem.java @@ -0,0 +1,38 @@ +package mc.duzo.timeless.suit.spiderman.item; + +import mc.duzo.timeless.core.items.SuitItem; +import mc.duzo.timeless.core.items.SuitMaterial; +import mc.duzo.timeless.datagen.provider.lang.AutomaticSuitEnglish; +import mc.duzo.timeless.datagen.provider.model.AutomaticModel; +import mc.duzo.timeless.suit.Suit; +import net.fabricmc.fabric.api.item.v1.FabricItemSettings; +import net.minecraft.item.Item; +import net.minecraft.item.Items; +import net.minecraft.recipe.Ingredient; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.Util; + +import java.util.EnumMap; + +public class SpidermanSuitItem extends SuitItem implements AutomaticModel, AutomaticSuitEnglish { + public SpidermanSuitItem(Suit suit, Type type) { + super(suit, SpidermanMaterial.INSTANCE, type, new FabricItemSettings().maxCount(1)); + } + + private static class SpidermanMaterial extends SuitMaterial { + public SpidermanMaterial(Item repair) { + super("spiderman", 33, Util.make(new EnumMap(Type.class), map -> { + map.put(Type.BOOTS, 3); + map.put(Type.LEGGINGS, 6); + map.put(Type.CHESTPLATE, 8); + map.put(Type.HELMET, 3); + }), 10, SoundEvents.ITEM_ARMOR_EQUIP_LEATHER, 2.0F, 0.0F, () -> Ingredient.ofItems(repair)); + } + + public SpidermanMaterial() { + this(Items.STRING); // todo + } + + public static SpidermanMaterial INSTANCE = new SpidermanMaterial(); + } +} diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java new file mode 100644 index 00000000..ca15100d --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java @@ -0,0 +1,36 @@ +package mc.duzo.timeless.suit.spiderman.miles; + +import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.power.PowerRegistry; +import mc.duzo.timeless.suit.client.ClientSuit; +import mc.duzo.timeless.suit.client.render.SuitModel; +import mc.duzo.timeless.suit.client.render.generic.AlexSuitModel; +import mc.duzo.timeless.suit.set.SetRegistry; +import mc.duzo.timeless.suit.set.SuitSet; +import mc.duzo.timeless.suit.spiderman.SpidermanSuit; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +import java.util.function.Supplier; + +public class MilesSuit extends SpidermanSuit { + public MilesSuit() { + super("miles", PowerList.of(PowerRegistry.WEB_SWING, PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SPIDEY_SENSE)); + } + + @Override + public SuitSet getSet() { + return SetRegistry.MILES; + } + + @Environment(EnvType.CLIENT) + @Override + protected ClientSuit createClient() { + return new ClientSuit(this) { + @Override + public Supplier model() { + return () -> new AlexSuitModel(this); + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuit.java new file mode 100644 index 00000000..10f66217 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuit.java @@ -0,0 +1,35 @@ +package mc.duzo.timeless.suit.spiderman.spidernoir; + +import mc.duzo.timeless.power.PowerList; +import mc.duzo.timeless.power.PowerRegistry; +import mc.duzo.timeless.suit.client.ClientSuit; +import mc.duzo.timeless.suit.client.render.SuitModel; +import mc.duzo.timeless.suit.set.SetRegistry; +import mc.duzo.timeless.suit.set.SuitSet; +import mc.duzo.timeless.suit.spiderman.SpidermanSuit; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +import java.util.function.Supplier; + +public class SpiderNoirSuit extends SpidermanSuit { + public SpiderNoirSuit() { + super("spidernoir", PowerList.of(PowerRegistry.WEB_SWING, PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SPIDEY_SENSE)); + } + + @Override + public SuitSet getSet() { + return SetRegistry.SPIDER_NOIR; + } + + @Environment(EnvType.CLIENT) + @Override + protected ClientSuit createClient() { + return new ClientSuit(this) { + @Override + public Supplier model() { + return () -> new SpiderNoirSuitModel(this); + } + }; + } +} diff --git a/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuitModel.java b/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuitModel.java new file mode 100644 index 00000000..e84f23db --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuitModel.java @@ -0,0 +1,69 @@ +package mc.duzo.timeless.suit.spiderman.spidernoir; + +import mc.duzo.timeless.suit.client.ClientSuit; +import mc.duzo.timeless.suit.client.render.generic.AlexSuitModel; +import net.minecraft.client.model.Dilation; +import net.minecraft.client.model.ModelData; +import net.minecraft.client.model.ModelPartBuilder; +import net.minecraft.client.model.ModelPartData; +import net.minecraft.client.model.ModelTransform; +import net.minecraft.client.model.TexturedModelData; + +public class SpiderNoirSuitModel extends AlexSuitModel { + public SpiderNoirSuitModel(ClientSuit suit) { + super(suit, getTexturedModelData().createModel()); + } + + public static TexturedModelData getTexturedModelData() { + ModelData modelData = new ModelData(); + ModelPartData root = modelData.getRoot(); + + ModelPartData head = root.addChild("head", ModelPartBuilder.create() + .uv(0, 0).cuboid(-4f, -8f, -4f, 8f, 8f, 8f, new Dilation(0f)) + .uv(32, 0).cuboid(-4f, -8f, -4f, 8f, 8f, 8f, new Dilation(0.5f)), + ModelTransform.pivot(0f, 0f, 0f)); + + // Fedora crown (8x3x8) sits on top of head. Bottom-half UVs at offset (0, 64). + head.addChild("fedora_crown", ModelPartBuilder.create() + .uv(0, 64).cuboid(-3.80003f, -9.60349f, -4f, 8f, 3f, 8f, new Dilation(0.1f)), + ModelTransform.pivot(0f, 0f, 0f)); + + // Crown overlay (slightly larger inflate) at offset (32, 64). + head.addChild("fedora_crown_overlay", ModelPartBuilder.create() + .uv(32, 64).cuboid(-3.80701f, -9.20355f, -4f, 8f, 3f, 8f, new Dilation(0.2f)), + ModelTransform.pivot(0f, 0f, 0f)); + + // Brim (11x1x11) tilted -2.5deg around X. UVs at offset (0, 80). + head.addChild("fedora_brim", ModelPartBuilder.create() + .uv(0, 80).cuboid(-6f, 0.6f, -6f, 11f, 1f, 11f, new Dilation(0f)), + ModelTransform.of(0.60402f, -7.68874f, 0.45154f, + (float) Math.toRadians(-2.5), 0f, 0f)); + + root.addChild("body", ModelPartBuilder.create() + .uv(16, 16).cuboid(-4f, 0f, -2f, 8f, 12f, 4f, new Dilation(0f)) + .uv(16, 32).cuboid(-4f, 0f, -2f, 8f, 12f, 4f, new Dilation(0.25f)), + ModelTransform.pivot(0f, 0f, 0f)); + + root.addChild("right_arm", ModelPartBuilder.create() + .uv(40, 16).cuboid(-2f, -2f, -2f, 3f, 12f, 4f, new Dilation(0f)) + .uv(40, 32).cuboid(-2f, -2f, -2f, 3f, 12f, 4f, new Dilation(0.25f)), + ModelTransform.pivot(-5f, 2f, 0f)); + + root.addChild("left_arm", ModelPartBuilder.create() + .uv(32, 48).cuboid(-1f, -2f, -2f, 3f, 12f, 4f, new Dilation(0f)) + .uv(48, 48).cuboid(-1f, -2f, -2f, 3f, 12f, 4f, new Dilation(0.25f)), + ModelTransform.pivot(5f, 2f, 0f)); + + root.addChild("right_leg", ModelPartBuilder.create() + .uv(0, 16).cuboid(-2f, 0f, -2f, 4f, 12f, 4f, new Dilation(0f)) + .uv(0, 32).cuboid(-2f, 0f, -2f, 4f, 12f, 4f, new Dilation(0.25f)), + ModelTransform.pivot(-1.9f, 12f, 0f)); + + root.addChild("left_leg", ModelPartBuilder.create() + .uv(16, 48).cuboid(-2f, 0f, -2f, 4f, 12f, 4f, new Dilation(0f)) + .uv(0, 48).cuboid(-2f, 0f, -2f, 4f, 12f, 4f, new Dilation(0.25f)), + ModelTransform.pivot(1.9f, 12f, 0f)); + + return TexturedModelData.of(modelData, 64, 128); + } +} diff --git a/src/main/java/mc/duzo/timeless/util/SwingFallGrace.java b/src/main/java/mc/duzo/timeless/util/SwingFallGrace.java new file mode 100644 index 00000000..348ac111 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/util/SwingFallGrace.java @@ -0,0 +1,58 @@ +package mc.duzo.timeless.util; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; + +import net.minecraft.entity.LivingEntity; +import net.minecraft.server.network.ServerPlayerEntity; + +/** + * Per-player grace period that suppresses fall damage during and shortly after web-swinging, + * web-zipping, or any other rope-driven motion. The rope code calls {@link #grant} when a + * shooter releases or when their rope ticks; {@link #handleFallDamage} returns {@code true} + * to cancel a pending fall-damage event. + */ +public final class SwingFallGrace { + private static final int RELEASE_GRACE_TICKS = 80; + private static final Map GRACE = new ConcurrentHashMap<>(); + + private SwingFallGrace() {} + + public static void init() { + ServerTickEvents.END_SERVER_TICK.register(server -> { + if (GRACE.isEmpty()) return; + // ConcurrentHashMap entry views hand out immutable entries from removeIf, so we + // can't setValue inside it. Use atomic merge to decrement, returning null to + // implicitly remove the key when it would drop below 1. + GRACE.replaceAll((uuid, ticks) -> ticks - 1); + GRACE.values().removeIf(v -> v <= 0); + + for (ServerPlayerEntity p : server.getPlayerManager().getPlayerList()) { + if (GRACE.containsKey(p.getUuid())) { + p.fallDistance = 0; + } + } + }); + + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> GRACE.remove(handler.getPlayer().getUuid())); + } + + /** Refresh the grace window for this player; cheap to call every tick while a rope is active. */ + public static void grant(LivingEntity entity) { + GRACE.put(entity.getUuid(), RELEASE_GRACE_TICKS); + entity.fallDistance = 0; + } + + public static boolean isInGrace(LivingEntity entity) { + return GRACE.containsKey(entity.getUuid()); + } + + /** Returns true if a fall-damage event for this entity should be cancelled. */ + public static boolean handleFallDamage(LivingEntity entity) { + return GRACE.containsKey(entity.getUuid()); + } +} diff --git a/src/main/java/mc/duzo/timeless/util/WallCrawlData.java b/src/main/java/mc/duzo/timeless/util/WallCrawlData.java new file mode 100644 index 00000000..68240f7b --- /dev/null +++ b/src/main/java/mc/duzo/timeless/util/WallCrawlData.java @@ -0,0 +1,96 @@ +package mc.duzo.timeless.util; + +import net.minecraft.entity.LivingEntity; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.util.math.Direction; + +public class WallCrawlData { + public static final String NBT_KEY = "WallCrawl"; + private static final String SIDE_KEY = "Side"; + private static final String INVERTED_KEY = "Inverted"; + + public boolean enabled; + public boolean inverted; + public byte side = (byte) Direction.UP.getId(); + public byte prevSide = (byte) Direction.UP.getId(); + public float timer; + public float prevTimer; + public float ceilTimer; + public float prevCeilTimer; + public float invertTimer; + public float prevInvertTimer; + + public void setSide(byte newSide) { + if (newSide == this.side) return; + + this.prevSide = this.side; + this.timer = 0.0f; + + boolean nowCeiling = newSide == Direction.DOWN.getId(); + boolean wasCeiling = this.side == Direction.DOWN.getId(); + + if (nowCeiling && !wasCeiling) { + this.inverted = true; + } + + this.side = newSide; + this.enabled = newSide != Direction.UP.getId(); + if (this.side == Direction.UP.getId()) { + this.inverted = false; + } + } + + public void tick() { + this.prevTimer = this.timer; + this.prevCeilTimer = this.ceilTimer; + this.prevInvertTimer = this.invertTimer; + + if (this.timer < 1.0f) this.timer += 0.1f; + + boolean ceiling = this.enabled && this.side == Direction.DOWN.getId(); + if (this.ceilTimer < 1.0f && ceiling) this.ceilTimer += 0.05f; + else if (this.ceilTimer > 0.0f && !ceiling) this.ceilTimer -= 0.05f; + + if (this.invertTimer < 1.0f && this.inverted) this.invertTimer += 0.16666667f; + else if (this.invertTimer > 0.0f && !this.inverted) this.invertTimer -= 0.16666667f; + } + + public Direction direction() { + return Direction.byId(this.side); + } + + public NbtCompound toNbt() { + NbtCompound nbt = new NbtCompound(); + nbt.putByte(SIDE_KEY, this.side); + nbt.putBoolean(INVERTED_KEY, this.inverted); + return nbt; + } + + public static WallCrawlData fromNbt(NbtCompound nbt) { + WallCrawlData data = new WallCrawlData(); + if (nbt == null) return data; + if (nbt.contains(SIDE_KEY)) { + byte s = nbt.getByte(SIDE_KEY); + data.side = s; + data.enabled = s != Direction.UP.getId(); + } + if (nbt.contains(INVERTED_KEY)) { + data.inverted = nbt.getBoolean(INVERTED_KEY); + } + return data; + } + + public static WallCrawlData get(LivingEntity entity) { + if (!(entity instanceof SuitDataAccess access)) return new WallCrawlData(); + NbtCompound suitData = access.timeless$getSuitData(); + if (suitData == null || !suitData.contains(NBT_KEY)) return new WallCrawlData(); + return fromNbt(suitData.getCompound(NBT_KEY)); + } + + public void save(LivingEntity entity) { + if (!(entity instanceof SuitDataAccess access)) return; + NbtCompound suitData = access.timeless$getSuitData(); + if (suitData == null) return; + suitData.put(NBT_KEY, this.toNbt()); + } +} diff --git a/src/main/resources/assets/timeless/sounds/spiderman_shoot.ogg b/src/main/resources/assets/timeless/sounds/spiderman_shoot.ogg new file mode 100644 index 00000000..d05bd55e Binary files /dev/null and b/src/main/resources/assets/timeless/sounds/spiderman_shoot.ogg differ diff --git a/src/main/resources/assets/timeless/textures/suit/spiderman.png b/src/main/resources/assets/timeless/textures/suit/spiderman.png new file mode 100644 index 00000000..9963f21f Binary files /dev/null and b/src/main/resources/assets/timeless/textures/suit/spiderman.png differ diff --git a/src/main/resources/assets/timeless/textures/suit/spiderman_miles.png b/src/main/resources/assets/timeless/textures/suit/spiderman_miles.png new file mode 100644 index 00000000..a9f67e50 Binary files /dev/null and b/src/main/resources/assets/timeless/textures/suit/spiderman_miles.png differ diff --git a/src/main/resources/assets/timeless/textures/suit/spiderman_spidernoir.png b/src/main/resources/assets/timeless/textures/suit/spiderman_spidernoir.png new file mode 100644 index 00000000..237fc5a4 Binary files /dev/null and b/src/main/resources/assets/timeless/textures/suit/spiderman_spidernoir.png differ diff --git a/src/main/resources/timeless.mixins.json b/src/main/resources/timeless.mixins.json index 73cf6e44..43219e61 100644 --- a/src/main/resources/timeless.mixins.json +++ b/src/main/resources/timeless.mixins.json @@ -6,13 +6,22 @@ "EnchantmentHelperMixin", "EntityMixin", "PlayerEntityMixin", - "ServerPlayerEntityMixin" + "ServerPlayerEntityMixin", + "SwingFallDamageMixin", + "WallCrawlTravelMixin" ], "client": [ "client.ArmorFeatureRendererMixin", "client.ArmorStandEntityRendererMixin", "client.ClientPlayerEntityMixin", - "client.PlayerEntityRendererMixin" + "client.BipedSwingPoseMixin", + "client.EntityGlowMixin", + "client.HeldItemRendererInvoker", + "client.HeldItemRendererMixin", + "client.MouseMixin", + "client.PlayerEntityRendererMixin", + "client.WallCrawlRendererMixin", + "client.WebSwingRendererMixin" ], "injectors": { "defaultRequire": 1