From 0fc9aabe7dfb1dfb33e3832e2a2f87bf33d39862 Mon Sep 17 00:00:00 2001 From: James Hall Date: Mon, 7 Jul 2025 00:02:18 +0100 Subject: [PATCH 01/10] feat: spiderman base - miles suit --- .../duzo/timeless/suit/set/SetRegistry.java | 18 +++-- .../mc/duzo/timeless/suit/set/SuitSet.java | 18 +++-- .../suit/spiderman/SpidermanSuit.java | 68 ++++++++++++++++++ .../spiderman/item/SpidermanSuitItem.java | 38 ++++++++++ .../suit/spiderman/miles/MilesSuit.java | 52 ++++++++++++++ .../textures/suit/spiderman_miles.png | Bin 0 -> 2533 bytes 6 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/SpidermanSuit.java create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/item/SpidermanSuitItem.java create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java create mode 100644 src/main/resources/assets/timeless/textures/suit/spiderman_miles.png 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 54741ebf..10f822ca 100644 --- a/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java +++ b/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java @@ -1,12 +1,5 @@ package mc.duzo.timeless.suit.set; -import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder; - -import net.minecraft.registry.Registry; -import net.minecraft.registry.RegistryKey; -import net.minecraft.registry.SimpleRegistry; -import net.minecraft.util.Identifier; - import mc.duzo.timeless.Timeless; import mc.duzo.timeless.suit.batman.item.BatmanSuitItem; import mc.duzo.timeless.suit.batman.sixer.Batman66Suit; @@ -15,6 +8,13 @@ import mc.duzo.timeless.suit.ironman.mk3.MarkThreeSuit; import mc.duzo.timeless.suit.ironman.mk5.MarkFiveSuit; import mc.duzo.timeless.suit.ironman.mk7.MarkSevenSuit; +import mc.duzo.timeless.suit.spiderman.item.SpidermanSuitItem; +import mc.duzo.timeless.suit.spiderman.miles.MilesSuit; +import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder; +import net.minecraft.registry.Registry; +import net.minecraft.registry.RegistryKey; +import net.minecraft.registry.SimpleRegistry; +import net.minecraft.util.Identifier; public class SetRegistry { public static final SimpleRegistry REGISTRY = FabricRegistryBuilder.createSimple(RegistryKey.ofRegistry(new Identifier(Timeless.MOD_ID, "suit_set"))).buildAndRegister(); @@ -28,6 +28,7 @@ public static T register(T suit) { public static SuitSet MARK_THREE; public static SuitSet MARK_TWO; public static SuitSet BATMAN_66; + public static SuitSet MILES; public static void init() { // Iron Man @@ -38,5 +39,8 @@ public static void init() { // Batman BATMAN_66 = register(new RegisteringSuitSet(new Batman66Suit(), BatmanSuitItem::new)); + + // Spiderman + MILES = register(new RegisteringSuitSet(new MilesSuit(), SpidermanSuitItem::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/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..d1b0ed30 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java @@ -0,0 +1,52 @@ +package mc.duzo.timeless.suit.spiderman.miles; + +import mc.duzo.animation.generic.AnimationInfo; +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 net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; + +import java.util.Optional; +import java.util.function.Supplier; + +public class MilesSuit extends SpidermanSuit { + public MilesSuit() { + super("miles", PowerList.of(PowerRegistry.SUPER_STRENGTH, PowerRegistry.SWIFT_SNEAK)); + } + + @Override + public SuitSet getSet() { + return SetRegistry.MILES; + } + + @Environment(EnvType.CLIENT) + @Override + protected ClientSuit createClient() { + AnimationInfo info = new AnimationInfo(AnimationInfo.RenderType.FIRST_LAYER, null, AnimationInfo.Movement.ALLOW, null); + + return new ClientSuit(this) { + @Override + public Supplier model() { + return () -> new AlexSuitModel(this); + } + + @Override + public Optional emission() { + return Optional.empty(); + } + + @Override + public AnimationInfo getAnimationInfo(LivingEntity entity) { + return info; + } + }; + } +} \ No newline at end of file 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 0000000000000000000000000000000000000000..a9f67e50fa8a9513f3612cee6ce2968cf3ab0537 GIT binary patch literal 2533 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D33^FHK~#8N?V4L? zTtyhiCuJkr(c3xw3Y z1;#y-U;7zzeZ2ek--N=rr(c&u$3!S9Gsyt+08~DZR zZrW4tNmv3@{)3hR;d`c^>z!hWBMa!{1cCRlS-1MzsKr=X`L%G~s39N%6!v)c8n5f_ zAMivO^N=JUqazJq%R9Vli3{lLoZ;t`6uVNOtRKGqyiVmK%lXy9J3ti=3H=43 zD*t?|>Bx+ZY|{jr2T!4~snHJ?h!c>}#iH-SuM+79(sUC+MisTs!Tv$Fp*QDx2KKov zJ-uE}+_&;bKnA2?L!5Wg@&a^M<_rNDT`VZ)%iaBXx;z0{^{N&bHZuYhBhCWmg=ur$ z-m5Qlr{8j|+j_-9pYF$PQgL*oVS_F2@UA7QyrtvIp)ZQHs|3neHnYi3*aT#BwLsl@ z7hayHQd+-5L~((5^QkSx;X%tnF0@I@gYFJlo2N~SnaIvwaB)7Z{95cZGqy5R90J7A zD-fq}#w`Ik%|_(OOX=zb)*cxstr8#_mS3xF_LxlYb|R*tQo%2`wD|X5?O5O)0q6nu zc5U|Zi*0B6XDL+5|ITbSKPSnzy}7~t{pXN7X?BCVd)>APIV5T+O!p(i0`d@MEkQs= zCn&!##6aW$-={AoRus~cJ=fWgbAXq(wMJ=&I>Pb%Qt@GS~Bw!jdRaTJ(#-meR%)L zNzA!Dhqt&DolD*A8`rz18yfuNW|z9GD;^sfaw~UkD|UO4e`ia#cfj*Avpz1|Hdt7n z>=>DI-pJ*m=7fx9WZOa^lqOpXk%y21M0)utldrunp1OcjS|Vy>*mL6L6;V*vcLqX$0*cDUa1AG9ZpjI(C3K zHnGU4bs=M=$+w>lbirFY-8cKm=GZYJpr;{)1;RjusWQ@ zgtvUhFMh2K=+2O*%hGDr{;F+j-N)^PuZQwfI!>}_VL9p@(&Kc>Ccf$C@1oWLemks! zfieSD4@k2G0#yc3xMX!n^#odmlVWS5%G!zp_~zhd%baaMVhW7lA4T z(5XNRc^`!7w&K=7A(XcWY8Yq?lLy1#yy=A<j@k=Q8G`j@ zsmfy`dl3i$d9_ezLtJ^QJbH-k6Qs)?Zg#zU`-(CFJg_@glu1WM2c-Yc;=P57^RKja zRJDf@YQ7yl+W6w~!bMK4aNIe7?w-r^70&|xmtY7aV%4bnJfPac$=4Un%a@w5l?LI~ z8O`ph?+Twb;x}(tJ?0(kIUTyMw=23%#UT*xt1W7E0-FXFNmM+E7J*J_kcB+sUvmI@ z`yR3mNUgpSGLS2G9w72Ot!$rZShM7YVkNAc28jN6(RD=|WOP710OB}7y`U6u!gx-I zM;U5W$SO(~CLu|NOgZAhH=UjNrfG#^vi^Nr+|rF3%f9nOM@Rm~nHPBZ!`*A$Q(f`h zxEhT53fT2CavISX@&d#OF@Sv6pMUruM;r@``3%Ocp(|lvaJp}G0&*olJU5*40r^;A z(yRj@o~_UNfc(G1D~;LWBxvOmIDr#5fuo6}xH3=M zE2QC=dbwCr12{kd+CJMe0*00jP5gO)W@bI@nSf*} zm}blNnKWSE)6>3>ZD<2T-a=R40uEYDOtAM<%K;28K`&ZmRjj*fNC*j5+vjBTp$t0K$Y+}wq3x95(I97iIXNfbdZp(T3%^@PbSU@@Bh)oeokXiwFA`pwqp9fK*K<3 z*rFq-D~r5Ec*id%Xo%GkRZk@0{{;eZVAG@n))vTyUcs_i>1p}m(4#g9ju(~~Ps!-R zH0cP^X~ Date: Mon, 7 Jul 2025 00:17:13 +0100 Subject: [PATCH 02/10] feat: wall climb power --- .../mc/duzo/timeless/core/items/SuitItem.java | 12 +++-- .../java/mc/duzo/timeless/power/Power.java | 7 +++ .../mc/duzo/timeless/power/PowerRegistry.java | 1 + .../timeless/power/impl/WallClimbPower.java | 48 +++++++++++++++++++ src/main/java/mc/duzo/timeless/suit/Suit.java | 9 +--- .../suit/spiderman/miles/MilesSuit.java | 4 +- 6 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java diff --git a/src/main/java/mc/duzo/timeless/core/items/SuitItem.java b/src/main/java/mc/duzo/timeless/core/items/SuitItem.java index 334a34cb..38467fd8 100644 --- a/src/main/java/mc/duzo/timeless/core/items/SuitItem.java +++ b/src/main/java/mc/duzo/timeless/core/items/SuitItem.java @@ -2,6 +2,7 @@ import mc.duzo.animation.registry.Identifiable; +import net.minecraft.client.network.AbstractClientPlayerEntity; import net.minecraft.entity.Entity; import net.minecraft.entity.EquipmentSlot; import net.minecraft.entity.LivingEntity; @@ -52,12 +53,15 @@ public TypedActionResult equipAndSwap(Item item, World world, PlayerE public void inventoryTick(ItemStack stack, World world, Entity entity, int slot, boolean selected) { super.inventoryTick(stack, world, entity, slot, selected); - if (world.isClient()) return; - - if (!(entity instanceof ServerPlayerEntity player)) return; + if (!(entity instanceof PlayerEntity player)) return; if (!stack.equals(player.getEquippedStack(EquipmentSlot.CHEST))) return; // return if this stack isnt being worn in chest - this.parent.getPowers().forEach(power -> power.tick(player)); + if (world.isClient()) { + this.parent.getPowers().forEach(power -> power.tick((AbstractClientPlayerEntity) player)); + return; + } + + this.parent.getPowers().forEach(power -> power.tick((ServerPlayerEntity) player)); } public static class Data { diff --git a/src/main/java/mc/duzo/timeless/power/Power.java b/src/main/java/mc/duzo/timeless/power/Power.java index 87221aa1..142e4b14 100644 --- a/src/main/java/mc/duzo/timeless/power/Power.java +++ b/src/main/java/mc/duzo/timeless/power/Power.java @@ -3,7 +3,10 @@ import mc.duzo.animation.registry.Identifiable; import mc.duzo.timeless.datagen.provider.lang.Translatable; import mc.duzo.timeless.suit.Suit; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.client.network.AbstractClientPlayerEntity; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.util.Identifier; @@ -21,6 +24,10 @@ public abstract class Power implements Identifiable, Translatable { public abstract boolean run(ServerPlayerEntity player); public abstract void tick(ServerPlayerEntity player); + @Environment(EnvType.CLIENT) + public void tick(AbstractClientPlayerEntity player) { + + } public abstract void onLoad(ServerPlayerEntity player); @Override diff --git a/src/main/java/mc/duzo/timeless/power/PowerRegistry.java b/src/main/java/mc/duzo/timeless/power/PowerRegistry.java index 1ae09a3b..dd3fa53f 100644 --- a/src/main/java/mc/duzo/timeless/power/PowerRegistry.java +++ b/src/main/java/mc/duzo/timeless/power/PowerRegistry.java @@ -45,6 +45,7 @@ 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 WallClimbPower WALL_CLIMB = new WallClimbPower().register(); public static void init() {} } 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..f15ac4e5 --- /dev/null +++ b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java @@ -0,0 +1,48 @@ +package mc.duzo.timeless.power.impl; + +import mc.duzo.timeless.Timeless; +import mc.duzo.timeless.power.TogglePower; +import mc.duzo.timeless.suit.api.EquipSoundSupplier; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; + +import java.util.Optional; + +public class WallClimbPower extends TogglePower { + public WallClimbPower() { + super(Timeless.MOD_ID, "wall_climb"); + } + + @Environment(EnvType.CLIENT) + @Override + public void tick(AbstractClientPlayerEntity player) { + if (player != MinecraftClient.getInstance().player) return; + + if (!player.horizontalCollision || !this.getValue(player)) return; + + Vec3d velocity = player.getVelocity(); + float change = (player.isSneaking()) ? 0 : 0.2f; + + player.setVelocity(velocity.x, change, velocity.z); + } + + @Override + protected Class getSuitClass() { + return EquipSoundSupplier.class; + } + + @Override + public Identifier getAnimation(boolean isOpening, EquipSoundSupplier suit) { + return null; + } + + @Override + public Optional getSound(EquipSoundSupplier suit) { + return Optional.empty(); + } +} 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/spiderman/miles/MilesSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java index d1b0ed30..6854a458 100644 --- a/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java @@ -19,7 +19,7 @@ public class MilesSuit extends SpidermanSuit { public MilesSuit() { - super("miles", PowerList.of(PowerRegistry.SUPER_STRENGTH, PowerRegistry.SWIFT_SNEAK)); + super("miles", PowerList.of(PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SWIFT_SNEAK)); } @Override @@ -30,7 +30,7 @@ public SuitSet getSet() { @Environment(EnvType.CLIENT) @Override protected ClientSuit createClient() { - AnimationInfo info = new AnimationInfo(AnimationInfo.RenderType.FIRST_LAYER, null, AnimationInfo.Movement.ALLOW, null); + AnimationInfo info = new AnimationInfo(AnimationInfo.RenderType.TORSO_HEAD, null, AnimationInfo.Movement.ALLOW, null); return new ClientSuit(this) { @Override From 87897b729ab82db50d46266c3b3ee14778efae14 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sat, 2 May 2026 21:46:13 +0100 Subject: [PATCH 03/10] feat: wall crawl - sneak attach, jump detach, side-aware sticky physics, ceiling invert replaces the placeholder wallclimbpower with the proper sneak-to-attach pattern: - new util/wallcrawldata: serializable per-player state (side direction, attach timer, ceiling invert timer) stored on the suit-data attachment so it auto-syncs to clients via the existing s2c chain - wallclimbpower now extends power directly (was togglepower which didnt fit). server tick: detect attach via ground+sneak+raycast against 5 candidate sides (n/s/e/w/ceiling, range 1.5b); detect detach via jump (synced through the existing keybind packet) or landing on the floor. while attached, applies a small velocity bias toward the wall to keep the player stuck. - new mixin/client/wallcrawlrenderermixin injects at tail of livingentityrenderer.setuptransforms. while inverttimer > 0 (player attached to a ceiling), interpolates a 180-deg rotation around z plus a height translation so the player flips upside-down smoothly. wall-side tilt (player rotated 90 to lie against vertical walls) is mechanical-only for now; visual tilt for vertical walls is a follow-up. - mixin registered in timeless.mixins.json --- .../mixin/client/WallCrawlRendererMixin.java | 30 ++++ .../timeless/power/impl/WallClimbPower.java | 129 ++++++++++++++---- .../mc/duzo/timeless/util/WallCrawlData.java | 96 +++++++++++++ src/main/resources/timeless.mixins.json | 3 +- 4 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/WallCrawlRendererMixin.java create mode 100644 src/main/java/mc/duzo/timeless/util/WallCrawlData.java 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/power/impl/WallClimbPower.java b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java index f15ac4e5..5057a617 100644 --- a/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java +++ b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java @@ -1,48 +1,117 @@ package mc.duzo.timeless.power.impl; import mc.duzo.timeless.Timeless; -import mc.duzo.timeless.power.TogglePower; -import mc.duzo.timeless.suit.api.EquipSoundSupplier; +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.MinecraftClient; + import net.minecraft.client.network.AbstractClientPlayerEntity; -import net.minecraft.sound.SoundEvent; +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 = 1.5; + 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(); -import java.util.Optional; + if (data.enabled) { + if (ServerKeybind.get(player).isJumping()) { + detach(player, data); + } else if (player.isOnGround()) { + detach(player, data); + } else { + applyStickyMotion(player, data); + } + } else { + tryAttach(player, data); + } -public class WallClimbPower extends TogglePower { - public WallClimbPower() { - super(Timeless.MOD_ID, "wall_climb"); - } + data.save(player); + SuitItem.Data.sync(player); + } - @Environment(EnvType.CLIENT) - @Override - public void tick(AbstractClientPlayerEntity player) { - if (player != MinecraftClient.getInstance().player) return; + private static void tryAttach(ServerPlayerEntity player, WallCrawlData data) { + if (player.isOnGround() || !player.isSneaking()) return; + if (!player.horizontalCollision && !player.verticalCollision) return; - if (!player.horizontalCollision || !this.getValue(player)) return; + Direction wall = findAttachableWall(player); + if (wall == null) return; - Vec3d velocity = player.getVelocity(); - float change = (player.isSneaking()) ? 0 : 0.2f; + data.setSide((byte) wall.getId()); + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0; + } - player.setVelocity(velocity.x, change, velocity.z); - } + private static void detach(ServerPlayerEntity player, WallCrawlData data) { + if (!data.enabled) return; + data.setSide((byte) Direction.UP.getId()); + player.fallDistance = 0; + } - @Override - protected Class getSuitClass() { - return EquipSoundSupplier.class; - } + private static void applyStickyMotion(ServerPlayerEntity player, WallCrawlData data) { + Direction wall = data.direction(); + Vec3d normal = Vec3d.of(wall.getVector()); + Vec3d v = player.getVelocity(); + double bias = 0.08; + player.setVelocity(v.x - normal.x * bias, v.y - normal.y * bias, v.z - normal.z * bias); + player.fallDistance = 0; + } - @Override - public Identifier getAnimation(boolean isOpening, EquipSoundSupplier suit) { - return null; - } + private static Direction findAttachableWall(PlayerEntity player) { + Direction[] dirs = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, Direction.DOWN}; + Vec3d eye = player.getCameraPosVec(1f); + for (Direction d : dirs) { + Vec3d offset = Vec3d.of(d.getVector()).multiply(ATTACH_RANGE); + BlockHitResult hit = player.getWorld().raycast(new RaycastContext( + eye, eye.add(offset), + RaycastContext.ShapeType.COLLIDER, + RaycastContext.FluidHandling.NONE, + player + )); + if (hit.getType() == HitResult.Type.BLOCK && hit.getSide() == d.getOpposite()) { + return d; + } + } + return null; + } - @Override - public Optional getSound(EquipSoundSupplier suit) { - return Optional.empty(); - } + @Override + public Identifier id() { + return this.id; + } } 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/timeless.mixins.json b/src/main/resources/timeless.mixins.json index 73cf6e44..378f3330 100644 --- a/src/main/resources/timeless.mixins.json +++ b/src/main/resources/timeless.mixins.json @@ -12,7 +12,8 @@ "client.ArmorFeatureRendererMixin", "client.ArmorStandEntityRendererMixin", "client.ClientPlayerEntityMixin", - "client.PlayerEntityRendererMixin" + "client.PlayerEntityRendererMixin", + "client.WallCrawlRendererMixin" ], "injectors": { "defaultRequire": 1 From 0570f87f4cefbce1332cc5e3e7d35dc08a6136b4 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sat, 2 May 2026 21:47:48 +0100 Subject: [PATCH 04/10] ci: bump discord-webhook-upload-action v0.2 -> v0.3 v0.2 only treats http 200 as success. discord webhooks with file attachments return 204 (no content) which the action raised as `Failed to send webhook: 204`. half the recent runs flagged failed for this reason. v0.3 explicitly accepts 204 (one-line change in webhook.ts: `== 200` -> `== 200 || == 204`). pinned to its commit sha for the same supply-chain reason as the v0.2 pin. --- .github/workflows/publish-devbuilds.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From db7c9c114eb3254e0b1450eab7518abfe3137524 Mon Sep 17 00:00:00 2001 From: James Hall Date: Mon, 4 May 2026 15:32:38 +0100 Subject: [PATCH 05/10] feat: phase 1 wall crawl - travel mixin, sticky physics, look-direction climb builds on the initial wall-crawl scaffolding with the missing pieces from the reference: - mixin/WallCrawlTravelMixin: head-injects livingentity.travel, cancels vanilla when wall-crawl is enabled and the worn suit has wall_climb. ports the moveEntityWithHeading math from the reference - look pitch projects onto the wall plane (forward * sinPitch * 1.5 = vertical climb), strafe goes perpendicular along the wall surface using yaw. for ceilings, flat 2d motion in the ceiling plane. sticky bias (0.08 toward wall), away-component zero, 0.91 drag. - wallclimbpower: tryAttach now uses look-direction raycast at range 3.0 (not cardinal-iteration at 1.5), stores side as the hit face, no longer requires airborne. tickShared sets nogravity while attached and runs three checks for detach: jump pressed, on ground past the 0.5-timer grace period, or the multi-point wall-probe raycast (eye + center + feet) misses entirely. that last one catches cresting the top of a wall - all three probes go horizontally past empty space, all miss, immediate detach. - flightpower: gated hasflight/isflying on the worn suit having flight or boosted_flight. fixes a stale-flag bug where switching from iron man (which sets isflying=true on the suit-data attachment) to spider man (alex model) caused alexsuitmodel.rotateparts to apply the iron-man flight pose every frame on miles, overriding the walk cycle. - stevesuitmodel + alexsuitmodel: explicit walk-cycle limb math (cos(limbAngle * 0.6662) * 1.4 * limbDistance for legs etc) in setAngles before super, so walk pose is guaranteed regardless of copyfrom propagation. animations layered on top. - suitfeature: copyto(context) after setangles so post-animation pose pushes back to the player biped. - timeless.mixins.json: registered wallcrawltravelmixin (common-side mixin) --- .../timeless/mixin/WallCrawlTravelMixin.java | 100 ++++++++++++++++++ .../duzo/timeless/power/impl/FlightPower.java | 8 ++ .../timeless/power/impl/WallClimbPower.java | 79 ++++++++++---- .../suit/client/render/SuitFeature.java | 1 + .../client/render/generic/AlexSuitModel.java | 21 ++++ .../client/render/generic/SteveSuitModel.java | 21 ++++ src/main/resources/timeless.mixins.json | 3 +- 7 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 src/main/java/mc/duzo/timeless/mixin/WallCrawlTravelMixin.java 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/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/WallClimbPower.java b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java index 5057a617..aaefc080 100644 --- a/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java +++ b/src/main/java/mc/duzo/timeless/power/impl/WallClimbPower.java @@ -20,7 +20,7 @@ import net.minecraft.world.RaycastContext; public class WallClimbPower extends Power { - private static final double ATTACH_RANGE = 1.5; + private static final double ATTACH_RANGE = 3.0; private final Identifier id = new Identifier(Timeless.MOD_ID, "wall_climb"); @Override @@ -52,10 +52,13 @@ private static void tickShared(ServerPlayerEntity player) { if (data.enabled) { if (ServerKeybind.get(player).isJumping()) { detach(player, data); - } else if (player.isOnGround()) { + } else if (player.isOnGround() && data.timer >= 0.5f) { + detach(player, data); + } else if (!isStillOnWall(player, data)) { detach(player, data); } else { - applyStickyMotion(player, data); + player.setNoGravity(true); + player.fallDistance = 0; } } else { tryAttach(player, data); @@ -65,9 +68,32 @@ private static void tickShared(ServerPlayerEntity 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.isOnGround() || !player.isSneaking()) return; - if (!player.horizontalCollision && !player.verticalCollision) return; + if (!player.isSneaking()) return; Direction wall = findAttachableWall(player); if (wall == null) return; @@ -75,39 +101,48 @@ private static void tryAttach(ServerPlayerEntity player, WallCrawlData data) { 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 wall = data.direction(); - Vec3d normal = Vec3d.of(wall.getVector()); + 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; - player.setVelocity(v.x - normal.x * bias, v.y - normal.y * bias, v.z - normal.z * bias); + v = v.add(toWall.multiply(bias)); + + player.setVelocity(v); player.fallDistance = 0; + player.setNoGravity(true); } private static Direction findAttachableWall(PlayerEntity player) { - Direction[] dirs = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, Direction.DOWN}; Vec3d eye = player.getCameraPosVec(1f); - for (Direction d : dirs) { - Vec3d offset = Vec3d.of(d.getVector()).multiply(ATTACH_RANGE); - BlockHitResult hit = player.getWorld().raycast(new RaycastContext( - eye, eye.add(offset), - RaycastContext.ShapeType.COLLIDER, - RaycastContext.FluidHandling.NONE, - player - )); - if (hit.getType() == HitResult.Type.BLOCK && hit.getSide() == d.getOpposite()) { - return d; - } - } - return null; + 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 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/generic/AlexSuitModel.java b/src/main/java/mc/duzo/timeless/suit/client/render/generic/AlexSuitModel.java index d1b1af60..43898a71 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(); 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..943e72f2 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(); diff --git a/src/main/resources/timeless.mixins.json b/src/main/resources/timeless.mixins.json index 378f3330..26ec26b3 100644 --- a/src/main/resources/timeless.mixins.json +++ b/src/main/resources/timeless.mixins.json @@ -6,7 +6,8 @@ "EnchantmentHelperMixin", "EntityMixin", "PlayerEntityMixin", - "ServerPlayerEntityMixin" + "ServerPlayerEntityMixin", + "WallCrawlTravelMixin" ], "client": [ "client.ArmorFeatureRendererMixin", From 3bd806a3d5560cee712b7b7af449b80e35159b4a Mon Sep 17 00:00:00 2001 From: James Hall Date: Mon, 4 May 2026 22:27:43 +0100 Subject: [PATCH 06/10] feat: spider-man kit - web swing/zip/bind, scroll rappel, spidey sense, sfx - web rope entity with swing (pendulum), zip (eased pull + ground snap), bind (immobilise + reel) modes - right-click fires; mode picked from look-target (hostile -> bind, sneak+block -> zip, else swing) - scroll wheel rappels rope length 1.5-48 with smooth lerp + click sfx - left-click during swing releases with forward+up boost - procedural arm pose: gripping arm tracks rope, trail arm + legs scale 0-45 deg with horizontal speed - whole-body tilt via livingentityrenderer mixin, smoothed pitch/roll based on motion + rope direction - biped sneak pose suppressed during swing; held-item renderer extended so off-hand renders when rope is on off-arm - swing fall grace: 80-tick window cancels fall damage during and after rope use - spidey sense toggle power: client-only outline on hostile/projectile threats, hud vignette pulses with proximity, threat tracker scores velocity-toward-player - spiderman_shoot sfx pulled from archive --- src/main/java/mc/duzo/timeless/Timeless.java | 2 + .../duzo/timeless/client/TimelessClient.java | 5 + .../timeless/client/gui/SpideySenseHud.java | 51 ++ .../keybind/PowerRightClickHandler.java | 37 ++ .../client/keybind/TimelessKeybinds.java | 1 + .../client/render/SpideySenseTracker.java | 117 +++++ .../client/render/WebSwingBodyTilt.java | 42 ++ .../timeless/client/render/WebSwingState.java | 145 ++++++ .../client/render/entity/WebRopeRenderer.java | 124 +++++ .../timeless/core/TimelessEntityTypes.java | 7 + .../mc/duzo/timeless/core/TimelessSounds.java | 1 + .../datagen/TimelessDataGenerator.java | 1 + .../duzo/timeless/entity/WebRopeEntity.java | 488 ++++++++++++++++++ .../timeless/mixin/SwingFallDamageMixin.java | 26 + .../mixin/client/BipedSwingPoseMixin.java | 26 + .../mixin/client/EntityGlowMixin.java | 31 ++ .../mixin/client/HeldItemRendererInvoker.java | 15 + .../mixin/client/HeldItemRendererMixin.java | 43 ++ .../timeless/mixin/client/MouseMixin.java | 34 ++ .../client/PlayerEntityRendererMixin.java | 3 +- .../mixin/client/WebSwingRendererMixin.java | 20 + .../mc/duzo/timeless/network/Network.java | 2 + .../network/c2s/WebRappelC2SPacket.java | 46 ++ .../java/mc/duzo/timeless/power/Power.java | 9 + .../mc/duzo/timeless/power/PowerRegistry.java | 2 + .../timeless/power/impl/SpideySensePower.java | 56 ++ .../timeless/power/impl/WebSwingPower.java | 194 +++++++ .../suit/client/render/SuitModel.java | 43 ++ .../client/render/generic/AlexSuitModel.java | 13 + .../client/render/generic/SteveSuitModel.java | 13 + .../suit/spiderman/miles/MilesSuit.java | 2 +- .../mc/duzo/timeless/util/SwingFallGrace.java | 58 +++ .../timeless/sounds/spiderman_shoot.ogg | Bin 0 -> 8316 bytes src/main/resources/timeless.mixins.json | 9 +- 34 files changed, 1663 insertions(+), 3 deletions(-) create mode 100644 src/main/java/mc/duzo/timeless/client/gui/SpideySenseHud.java create mode 100644 src/main/java/mc/duzo/timeless/client/keybind/PowerRightClickHandler.java create mode 100644 src/main/java/mc/duzo/timeless/client/render/SpideySenseTracker.java create mode 100644 src/main/java/mc/duzo/timeless/client/render/WebSwingBodyTilt.java create mode 100644 src/main/java/mc/duzo/timeless/client/render/WebSwingState.java create mode 100644 src/main/java/mc/duzo/timeless/client/render/entity/WebRopeRenderer.java create mode 100644 src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/SwingFallDamageMixin.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/BipedSwingPoseMixin.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/EntityGlowMixin.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererInvoker.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/HeldItemRendererMixin.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/MouseMixin.java create mode 100644 src/main/java/mc/duzo/timeless/mixin/client/WebSwingRendererMixin.java create mode 100644 src/main/java/mc/duzo/timeless/network/c2s/WebRappelC2SPacket.java create mode 100644 src/main/java/mc/duzo/timeless/power/impl/SpideySensePower.java create mode 100644 src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java create mode 100644 src/main/java/mc/duzo/timeless/util/SwingFallGrace.java create mode 100644 src/main/resources/assets/timeless/sounds/spiderman_shoot.ogg 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..2c1462fb --- /dev/null +++ b/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java @@ -0,0 +1,145 @@ +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; + + private WebSwingState() {} + + 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 swinging = rope != null && !player.isOnGround(); + 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 (rope != null) { + 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); + + 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); + } + } + + 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..efcf5b5c --- /dev/null +++ b/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java @@ -0,0 +1,488 @@ +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 && shooter.isOnGround()) { + this.setDisconnected(true); + return; + } + + 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/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/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 f22ab110..57bdca1c 100644 --- a/src/main/java/mc/duzo/timeless/power/PowerRegistry.java +++ b/src/main/java/mc/duzo/timeless/power/PowerRegistry.java @@ -54,7 +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/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/WebSwingPower.java b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java new file mode 100644 index 00000000..86a1b16b --- /dev/null +++ b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java @@ -0,0 +1,194 @@ +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); + if (rope == null) 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 = WebSwingState.get(player).interpolate(tickDelta); + if (timer <= 0.001f) return; + + Vec3d shoulderApprox = new Vec3d(entity.getX(), entity.getBodyY(0.85), entity.getZ()); + Vec3d toRope = rope.getPos().subtract(shoulderApprox); + if (toRope.lengthSquared() < 1.0e-6) return; + + 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); + + boolean leftIsGrip = rope.isLeftArm(); + ModelPart gripArm = leftIsGrip ? leftArm : rightArm; + ModelPart trailArm = leftIsGrip ? rightArm : leftArm; + float trailMirror = leftIsGrip ? 1f : -1f; + + // Horizontal speed drives all velocity-scaled motion. Normalised so 1.0 ~= sprint. + double mx = entity.getX() - entity.prevX; + double mz = entity.getZ() - entity.prevZ; + float horizSpeed = (float) MathHelper.clamp(Math.sqrt(mx * mx + mz * mz) / 0.4, 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); + + gripArm.pitch = MathHelper.lerp(timer, gripArm.pitch, gripPitch); + gripArm.yaw = MathHelper.lerp(timer, gripArm.yaw, 0f); + gripArm.roll = MathHelper.lerp(timer, gripArm.roll, gripRoll); + + // 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 horizSpeed grows. + float trailPitch = swingOsc * MAX_45 * horizSpeed; + float trailRoll = trailMirror * MAX_45 * horizSpeed; + 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 * horizSpeed); + body.yaw = MathHelper.lerp(timer, body.yaw, swingOsc * 0.08f * horizSpeed); + 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 * horizSpeed; + float legSpread = MAX_45 * 0.35f * horizSpeed; + 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/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 43898a71..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 @@ -194,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 943e72f2..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 @@ -194,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/spiderman/miles/MilesSuit.java b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java index b4c712cb..ca15100d 100644 --- a/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java +++ b/src/main/java/mc/duzo/timeless/suit/spiderman/miles/MilesSuit.java @@ -15,7 +15,7 @@ public class MilesSuit extends SpidermanSuit { public MilesSuit() { - super("miles", PowerList.of(PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SWIFT_SNEAK)); + super("miles", PowerList.of(PowerRegistry.WEB_SWING, PowerRegistry.WALL_CLIMB, PowerRegistry.SUPER_STRENGTH, PowerRegistry.SPIDEY_SENSE)); } @Override 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/resources/assets/timeless/sounds/spiderman_shoot.ogg b/src/main/resources/assets/timeless/sounds/spiderman_shoot.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d05bd55e64536a555130896de18be6349d782276 GIT binary patch literal 8316 zcmaiY2|SeF_y05Yootb9h!{&5*``v~FbKogr7-p-WJ$I%){tc^*|Lp&--@!7jD1NA zB5SCmltdB#XZU=+-_QU1`@O!u=XG88x#!+22%mLz;qwJ^O2kKjdie6rxYs`LBnxlSJV&-JD@jKm7lmKJsHmvLM~e`IggJ zeQ!soo3pLS5qqdMR7P4xT3SI`7Al6q*!$W!dpJThJp7!!Jw4np9=Bm6ArQ)A9xzok z69_;9&cJn{-C?7NXaHaVfE&UvrW6Nl7#^1|b|(X;MH+QW$7JAQy1DG);DoKcEk71Yy4Wl_F02)YH zOE7{RMe1y$1tYn@E{fN?SFj}8n@+qm?>?POFEm_N=3`OHwF@6h3llDU5^Ee)rI%_P zL+ZjOOzf)LsH}s0h7oJoe|5A+dO*Rms8Vs;v#8RL*hj;PGrU1o$7(@=Fi=fEGl^Zh zj(wnxcj&dS{&z`3Ho4d5HH?h)(BR{1ZR&@a^z)tc3pUS)uzVV9{xrgJD#Cgv;wD$* zUu`#deuvbKREI)w1bQ&PfIkWhGGzarE%>Uh#oqELqWzd#AUc5bC!Wt;y9&btNBMbKe zpih-ilHK02nm)2UK9|6zd0V{?r#tjRm-%leklYRc%5dIpFWx@T8YrnZ-g2fsN|Qc% zlb|U`7Wwbby(7GUiU{U7q`8FAoKL)YWR@_HEmuB`8pE+9a6xqbduZlp=GG8`DRU*S zj6Lhkix}f9(U!7|tRoM)4RbGpzJ&EZ>dt#Kn&~sF&5(&d9^GNhqOK|%1LwHcvY80n zH^FtzW0}-t(`Mja^U=)J(iV1b6obnWZE7(-=JyvY%C2V*zd$U4J7V5uxwg<+gQH*e z*bD=IrpK#2~@W1g2jR<{I?%6rR2eTVMYA$30IM2;RYyfQT*-u zN18(c0LyZO;=j8iC?BJ^C^=TVU#wwJWT^;Cua#Hu8?ReCH>F=O^QBPDcJGu>KV}02nmbqnO0m#=(2C!>_5* z9y|E&$nj$DN#gBG64t2~)*F)?`Yx-#CHtC9!%$Ywh{O6dhwmFsjFG(c8#&AyG-kpN zGu@1_d=hNl{8zvnht2$??>{1k6e2>1gk`N{ihoB=o>;z%0Tp=3q_UlpdzM^NRz;=-1&oM%8V3f)dr5mkDcjOb$6T0n_$ zsOyHuizp;O8DAD#X|E{KfN75cFa&@wcrW}Fm-n!CU!M9foHhs7ix9=9jA_H9(zwpU zM2cDMma_}TwEK#1TspBiMXYu)Rp2Io8vLWrA5G+dD6<2AEdz=U7J)+z!&Vbf{qPlD z6cSOAZ8VIa;luXBsdB{=5b#e`Ox9ilU=altZV8X1 z$m+0*=&)&wuwy3K^d?hfhh*gpjnM;a=m}%=45uEtSwxRbPw$5@noR@Efqs*whi-Bo zk;P0Kqu+6&Cz>%coFS%ZzVqkx%)wV8NcEje3$`J;TW9>mN{{`Bl{|W~SaVZOs;*dX{z~5^x!!uKyt=xg{xgZ%TD4B_T5pvODuxXd zSA1$MYi;#dZ}l2%cIPcD|I{u%@UphQ{_A?{xkg_>L$vQZob|FPsE50af3D?DM7{gP zj;g(Wkipn4V#JfT1>Dx)!2`|_wj{3HR7}7-W`ZXzugWT~Sg-ex=&D>-HADwnr()(6 ztj$k)%qt{VPR19Me`*yUXa@zsX9=FX&!l;+XXHUbPJI=9uwCyC?TG6P!E5ir@=xEw z8V1^)haff}By6ztE9VPJkcBBi04l*Xb|V(?Zs=_m)F)_>F`P=!=Nht`7iWNU5yXv) z6QITk+P&E+tVkyqZVW}8k2gTN=HrMc*Sg|y+YM+DC(>C8H)i2dMi{q$;h2h6SrMy|c3Bh13SY#jgM>G;TYA8Wyx@)^ zR!eOdf!)YmijaAl00mj~k%S+~SjoVFtR96YBav09$*95X6cVfO1f({sIT@)7o(hSq zi$Gbp!HL-@H#zWBAgg4gn-syqQn$&M#e)9mR8>tTt}aysP`C)5O9xrk#Odk*Up<9% zt|AF9>*lmf0EHX5$`un`NCZoFC1R!zvWhs4iU$dH?rjs9cfbCvxvh&7foNMr5-whs znhXkGeQObiz>V30tddc2;0{Z-mf|!QkdTRb(6sv2$4&0%xJ4yNh7S0J@Ojv>Z31SU zFmgaSj24qeN+G1MRf$FoYdf=e4Z&TTiWA_l`6AjM%3EJ$q;JXq<{@q+c7+bGVA7d+5$9=X9_%K(pr-ZqvIKY`Un zik962En4LZLIK}BXyH=TIHgQe6+x8I5dop#Q1vRqLIMCjg9rM2lt)B>L~1~ps}Pin zaAdI^M10_t1v4WrmPwqD9nPdm!vP)x{8kMjz>Y-~B!Kl^emL0SabqL`3i%NV&cX1v zNdz2<4gkb&ffZ4$p}o)jq&zTZk^y%}ITMMX&c|Ja5umta6etc-D2)TcDfN}>N zHE>AHW1^`DJ4G)^(vgJ;EcCiEz!OvPVIyH5im;%2^3{+CF%b|YFc=6QFrsl5$VWaX zl(+~!jg&gZEs#q_NueNb=ktlFU_mpiV zyl4ys#wV9uB^W>!uHeyKrC?1&+qfps{$Pe6+CGpYTF?Uv9=Cunty-_h)S`x$uNRJd zEeICdsz=r)T?|m5FY-ue5mZNls3KuR5DU8Dcn+KRuQ`?rYsb(60kw9#4ricx1TC6; za1O=?f=ujaBm$0Tj|;dXFk)H3QVs`lKAyv|f-;WEI~(VNBg>gfy@UBXn2~sTS{+M@VliwhCwS>|D(kz1K?z`S1ujnRR#JKJO5Gp~pHqU2n0+FwR+B)d`iL3M5kpR(iA@uxb9dI$d7_*)HH-Uqd zFM^>ETQZ2kde6SP0AJu2k4#__H8o;(b42-rnWGPip^I!E?#n-uR(;SBwrm;9BXkBT zJeU4w;nfSTpoLvL8ERXT;#s!rsx~^s!J1t6!geXv{8Ok|QSep`?JIVgmxU+%uk=m5 z-j&n5=Q$bvJAWc5@IuZL`ilVX)dvQQ4ONGN-roif-YADr-P@@ulQTIRkf!*QY)W+d z>K`_NZLfDRbbu8q-^OLRKa`y{Q0w3tS)Dax!CgOd+pDVb+~biGHw4%C@3XnT=qTkn z#jZMV`;_EX=mX_&w1A2pUs%a{+68=ZV{7)=PA+G)Mad4mYh>ek{(+xIQ~od~&3#H0 z?j*mpx#CJ(5cCWgJd6N}Qj-z9l}&6^pS5e~{HC^cZEjV-PAZqf0hLO{8=`pKcBMzG z%2wi4;>{T|5OoKCCuROWHhnwupDJkKfL2Xq*J~rr}V+{rT$LlB9gL#9}@ZLi16U!*6R0v?g40nQ)a z1EVG#sYL+55Y)!0<&nRbn5AT8P!hV@?Ag^&pBR3^x_m==#}Yl?5;DhCx%{rH`R-Sy z_U4u8wnHY&+kN5gLQ27Lp{I4hIdm`9Uv>xVk;y6kzDTZD*AjB~CQZU~iAl3(#Osu- zB_gj+35?4Bu}Ge@%C$~P+cnuvLr~JM{C2bT>W?noEf$yefJ-TS^?1YxTs}1fmq8uw z>2lDQM>*o!@DN^_5@&{?Jhck|a4m{IBY5y!=&x@3bMt7b>ACj1uIIKjT`J=H>^|!H zwKys5cRWAXcYHhG$Gq_ZNv^QFQ;HONSoBCGJK*aJ)iXVWr@8I65M_4-`!VzZ;r1bQ z>YPl4SDDQTovrc)Y{ON__#cFMlzsiO(My-QLWQvjO=6Vd@3+J60yE=h$&m!9%C<+p z$bfe4AtR9wq136YjmxH*-kpubsTAvO*Zs#JK<$QOe<%;L_4PCnp7t^O?<08kd7`Qe-VG zF}3t=d2u!c5VAAK&N2{{yQ1jEI^fbbk&$s}H>@7$YyhU%M1Y_#K%OxWhMN5bOw~Ge zGUz7pYlTx&0t-I{XH3rwR7Hh6&kEjH?DodI@i1uL>~pYinJaWqs!UQhCvdUmUHe4i zw%3^&YetV5%DaZ5rh6UFW~Py^MMF!bljjB)o^!#8)T*OspbO2^3k*`GyioNB@jNGW zNJi2Pd##W~jCl38I>V)vx+knd1dmzk%e%pCLUd9Qn_qUiR#Y&d_oqHn0J%N0ij}W^ zb*Q9bf3viOoK3sKm_fAXQ2qcc+*N@90h_yoE&9F8CdQgJGyN~Kb29t2!b>=jz)!yd z8wM3;s@T-O62$D#24(ln++_>Cjj!$V&q5weK8? zE$fjM%KB!6S1G5d?_tyG1SWJB5bA9eu^Ap-i4nt-JP#Yc_>;BsoF_|hEAV_W*xRsk z@G?bOwVTTNEf2}nd>2jsfd1FKirV8F=>bi~U)nhGs=US&6o1gs0iEIV zma1CQBUhXoQXVAj*FN+AV6%+7zFvfnIq$ZXJ|Y<&pK(XK@~}fkD?2E1YRkT{-(_i_ zfn9(bKfm?XPM%xwrI`B~x^8@=jx+pS8Rl1~f=y=Q6lNjQNwgPw=Pkc;pb(kCjah!Y zz~jcVAv$g3)YLPO;7?cYK7n(wU7#K@RAFhh+lT~0{G0Yi&rL3>Q7+;#M_v)|G>Hzh zK)_{Yz!(6K%*v3wo?D@OGu_$#K9&kb({jpS4G932tWlo>gWj_CjlchO<)rsl+3k14 z6kpmh3MoA)$ceBgK+$PCM3Q)89QM9ePMvUV7^QM4wXL%82h_aqsi(I9VY0ApWxcSg z#$lIJePrquvphe4?)V+jKz$uUEokrwzYsx)jO|1&v8h6Wd|19grS%~ENL$w`?~cpz zMq5!8e(8#gxu)+D2N)$HOPxGzxMa~qppO8%p`Txyqndg&68U( zzp7TC$#!YxhJDVGGY2*<*eR4#L*(viz#P+Nto&W2S^??r`{ixPnm|$!xgv{+SB% z4)F|^2FBIHbSWk3y_-vSoyMFj45FPkFu?i`@{v zHa7oB^T)aE!sYP>kxPoQs4J0$JXhgb`$0`gf$9^x6hyhx4?|A*oC&|LA1GPQGt$#x zrC$Pn(uGb~aw}CaYai2HQggXWryl+FeUj@^yd&#GW{XZGz2cL4NN&k2%iE-lj)xU4 zQ9pha)0h8YDy^d*I3EvyY4lH}bb!nD;92e&O=n95=7V!PHSU4*StZ1R3)&$*M(0eZ zQPkFE-y_wZ^mdf*x%J+CZZdw#MPTB-#}nb^vz@<%qSxNrr#IytIz16${4Fn|d!J^q z0Dsx#LW%?%^TT)P*aNQ|W?P5c`nqc^VcAWFQ8hvFWO45&-@8OrQw>5-P%Jd$ zBq4m#yD`sCJ~)P9oO)yEbKHgUEjs_ZF)8vN8%$i$PkO3CoGNET%Ph@oJb7H>R)2q) z=_;r=+m*>uxIvK{HCsx-J$Xp0$D?33zqoxv;^L`M6Ft?2vJnmNx={Fp|9X!_v&Wa1 zwV2%Rr&FJKEjyRHnRFy(&lNngE6(){N%!=38|W#LM~Y!amzXwRV&ZWXOk`ICV`ts5 zb}M)J@5nsp`!;1u6NS`j9nAafr7rNQ=DK2%pOky^YNWs^6MGB)b6p2-uy^k#otZm} zKRv3G^fT2~R5p(?VU~DE9TGjL@sdny@9WHM?^^w&83(oQg|%>(^8lNiU921cEcE^H z*Y`j8`q+}^kf`nb^N?`z`Bq`}pGOzc^lqn{s zV(dJdNsWFhQ@nNJ2+y{ZX-l9$+4WIzZRyU$&p!gG9$%rJi{{1MdcSuVL&Q<+@n4s_ z(Il#jZpt@z3;|R4!R(<~Xvn2tZ_(|wBn0d#;Somc<|ozNq%Uul-)}Cy>+np^ogQ{% z5UB6?bNdETpNU^BaO6r=yM~$7+xHWmzWW|*lLhbZTDN=nNFXH(H>Q1e!qugScVhOk zOFcB%(LX3R6a`|aqD!}vWfN{X>*de{cVB;*6N{W6P~2Kdr{CNdiTAtSPjO1*rasE_-LpW& z3@*m5-L_Sa5@=s!2BKkfcTgsaC3$D9Zn61wy9;OlBl4c~>p9kr4K-g)Bg zpN|2LJD?M*Yw^iW1a0GTjM}B$(ZwPY=c&innrvjwyO&o{*4-%#crrsrUUhni+RTdL zsTBSjW@3718VX%h@8F;@CtsNRn8>vubZxD$BU!RcCq{9{LbHOENzJ6COsbn`uDSZ%_Z`*H)xfll_Y*UAIycUEoU zFxTyvU6LYnz6$tMd=6Cna^~z+lb3e$ZgyJ)kKcXW?IyRZH~GbWtb7g_a64@$bZbL| zN9<3j{LU8N&`pk&xVM}&5mWP6vtjm+ zrI_)V-^HKpMZoMM(k1M)_r@@WK-gY7B|sq`!}R&fawstI-Jg{PU@vG6BF`2}YB8a# z?}=ZQ?McJuOgAu+&B7_w314Pxgq>0b=OLvqWfeVceMBUu>DEU`XKv&qE8eMF;@&o% z?7dvf)y$h?HqypH9W%<#org=czCF6uM7}FbD2cBd_^e+$p`jw9T+wAqlN4Av+JdC3 zbEE2eeGnYKYV1a+)%-)y-!SmzD)^Z}qX!b$zQ%CD>19R0^?T5xo<&`Xle{QYne?RE+-*f7)*G=BGP0?RpAI#qI zb-T%l;Gt_tpbg0Rh*69(nqXfuVrKWUzFd&quY>$_A5x?*0KBl7az3FaZegVk0Gs}N zoXGZ1a|K|HRmpkBrM&3u0^DK{j=N-lq}5$CGuX$(QeQb^RUG!L_u#(Fb|!EjBcfj{ z=G3zHR8dF$L`p7j^t%RyrzNRK0|3(R8sy;rn6cz6i11v*{@SD6O}@>DOd%TfMeqk8 WKv^%ZcBUo|BDTdj4U7hT0RA8UxrTiJ literal 0 HcmV?d00001 diff --git a/src/main/resources/timeless.mixins.json b/src/main/resources/timeless.mixins.json index 26ec26b3..43219e61 100644 --- a/src/main/resources/timeless.mixins.json +++ b/src/main/resources/timeless.mixins.json @@ -7,14 +7,21 @@ "EntityMixin", "PlayerEntityMixin", "ServerPlayerEntityMixin", + "SwingFallDamageMixin", "WallCrawlTravelMixin" ], "client": [ "client.ArmorFeatureRendererMixin", "client.ArmorStandEntityRendererMixin", "client.ClientPlayerEntityMixin", + "client.BipedSwingPoseMixin", + "client.EntityGlowMixin", + "client.HeldItemRendererInvoker", + "client.HeldItemRendererMixin", + "client.MouseMixin", "client.PlayerEntityRendererMixin", - "client.WallCrawlRendererMixin" + "client.WallCrawlRendererMixin", + "client.WebSwingRendererMixin" ], "injectors": { "defaultRequire": 1 From 199c8f83d59c3093ab1e4acece6346c6f73e76e1 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 5 May 2026 23:01:42 +0100 Subject: [PATCH 07/10] chore: ignore claude.md and .claude --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From c9b363b3e42d25ba4e1309d9deddf0458c1e9383 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 5 May 2026 23:01:53 +0100 Subject: [PATCH 08/10] feat(spider-man): web fires from ground, persists on landing, anchors any surface --- src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java b/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java index efcf5b5c..3f2ffe2f 100644 --- a/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java +++ b/src/main/java/mc/duzo/timeless/entity/WebRopeEntity.java @@ -290,11 +290,6 @@ private static Vec3d getShooterAnchorPos(LivingEntity shooter) { private void tickSwing(PlayerEntity shooter) { this.ticksAnchored++; - if (!this.getWorld().isClient && shooter.isOnGround()) { - this.setDisconnected(true); - return; - } - if (!this.getWorld().isClient && this.ticksAnchored > RELEASE_GRACE_TICKS && shooter.handSwinging From dc85565cd0f37ac7b21956506f818f8109bf68da Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 5 May 2026 23:02:04 +0100 Subject: [PATCH 09/10] feat(spider-man): swing pose persists until ground, vertical velocity drives pose --- .../timeless/client/render/WebSwingState.java | 37 +++++++-- .../timeless/power/impl/WebSwingPower.java | 80 +++++++++++-------- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java b/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java index 2c1462fb..29fc414f 100644 --- a/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java +++ b/src/main/java/mc/duzo/timeless/client/render/WebSwingState.java @@ -32,8 +32,14 @@ public final class WebSwingState { 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; @@ -62,7 +68,13 @@ public static void init() { private static void tickPlayer(ClientWorld world, PlayerEntity player, WebSwingState state) { WebRopeEntity rope = findActiveRopeIn(world, player); - boolean swinging = rope != null && !player.isOnGround(); + 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); @@ -71,7 +83,7 @@ private static void tickPlayer(ClientWorld world, PlayerEntity player, WebSwingS float targetPitch = 0f; float targetRoll = 0f; - if (rope != null) { + if (swinging) { double mx = player.getX() - player.prevX; double my = player.getY() - player.prevY; double mz = player.getZ() - player.prevZ; @@ -85,12 +97,21 @@ private static void tickPlayer(ClientWorld world, PlayerEntity player, WebSwingS 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); - 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); + 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); + } } } diff --git a/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java index 86a1b16b..74f4e55f 100644 --- a/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java +++ b/src/main/java/mc/duzo/timeless/power/impl/WebSwingPower.java @@ -119,7 +119,8 @@ public void applyPose(LivingEntity entity, SuitModel model) { if (!(player.getWorld() instanceof ClientWorld)) return; WebRopeEntity rope = WebSwingState.findActiveRope(player); - if (rope == null) return; + WebSwingState state = WebSwingState.get(player); + if (rope == null && !state.isPostSwing()) return; ModelPart leftArm = model.partLeftArm(); ModelPart rightArm = model.partRightArm(); @@ -127,28 +128,15 @@ public void applyPose(LivingEntity entity, SuitModel model) { MinecraftClient mc = MinecraftClient.getInstance(); float tickDelta = mc.getTickDelta(); - float timer = WebSwingState.get(player).interpolate(tickDelta); + float timer = state.interpolate(tickDelta); if (timer <= 0.001f) return; - Vec3d shoulderApprox = new Vec3d(entity.getX(), entity.getBodyY(0.85), entity.getZ()); - Vec3d toRope = rope.getPos().subtract(shoulderApprox); - if (toRope.lengthSquared() < 1.0e-6) return; - - 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); - - boolean leftIsGrip = rope.isLeftArm(); - ModelPart gripArm = leftIsGrip ? leftArm : rightArm; - ModelPart trailArm = leftIsGrip ? rightArm : leftArm; - float trailMirror = leftIsGrip ? 1f : -1f; - - // Horizontal speed drives all velocity-scaled motion. Normalised so 1.0 ~= sprint. + // 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 horizSpeed = (float) MathHelper.clamp(Math.sqrt(mx * mx + mz * mz) / 0.4, 0.0, 1.0); + 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); @@ -156,22 +144,48 @@ public void applyPose(LivingEntity entity, SuitModel model) { // 45 degrees in radians, the upper bound the user requested for sideways spread / leg swing. final float MAX_45 = (float) Math.toRadians(45.0); - gripArm.pitch = MathHelper.lerp(timer, gripArm.pitch, gripPitch); - gripArm.yaw = MathHelper.lerp(timer, gripArm.yaw, 0f); - gripArm.roll = MathHelper.lerp(timer, gripArm.roll, gripRoll); + 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); - // 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 horizSpeed grows. - float trailPitch = swingOsc * MAX_45 * horizSpeed; - float trailRoll = trailMirror * MAX_45 * horizSpeed; - trailArm.pitch = MathHelper.lerp(timer, trailArm.pitch, trailPitch); - trailArm.yaw = MathHelper.lerp(timer, trailArm.yaw, 0f); - trailArm.roll = MathHelper.lerp(timer, trailArm.roll, trailRoll); + 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 * horizSpeed); - body.yaw = MathHelper.lerp(timer, body.yaw, swingOsc * 0.08f * horizSpeed); + 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); } @@ -181,8 +195,8 @@ public void applyPose(LivingEntity entity, SuitModel model) { ModelPart leftLeg = model.partLeftLeg(); ModelPart rightLeg = model.partRightLeg(); if (leftLeg != null && rightLeg != null) { - float legSwing = legOsc * MAX_45 * horizSpeed; - float legSpread = MAX_45 * 0.35f * horizSpeed; + 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); From 7d2ccb78564e4526d5ff2cc7f66af4f5ed042c4a Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 5 May 2026 23:02:16 +0100 Subject: [PATCH 10/10] feat(spider-man): add classic and noir variants, noir fedora model --- scripts/remap_spiderman.py | 104 ++++++++++ scripts/remap_spidernoir.py | 184 ++++++++++++++++++ .../duzo/timeless/suit/set/SetRegistry.java | 6 + .../classic/SpidermanClassicSuit.java | 39 ++++ .../spiderman/spidernoir/SpiderNoirSuit.java | 35 ++++ .../spidernoir/SpiderNoirSuitModel.java | 69 +++++++ .../timeless/textures/suit/spiderman.png | Bin 0 -> 2000 bytes .../textures/suit/spiderman_spidernoir.png | Bin 0 -> 2312 bytes 8 files changed, 437 insertions(+) create mode 100644 scripts/remap_spiderman.py create mode 100644 scripts/remap_spidernoir.py create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/classic/SpidermanClassicSuit.java create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuit.java create mode 100644 src/main/java/mc/duzo/timeless/suit/spiderman/spidernoir/SpiderNoirSuitModel.java create mode 100644 src/main/resources/assets/timeless/textures/suit/spiderman.png create mode 100644 src/main/resources/assets/timeless/textures/suit/spiderman_spidernoir.png 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/suit/set/SetRegistry.java b/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java index cb45603c..908c7e5f 100644 --- a/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java +++ b/src/main/java/mc/duzo/timeless/suit/set/SetRegistry.java @@ -5,7 +5,9 @@ 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; @@ -34,7 +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; @@ -50,7 +54,9 @@ public static void init() { 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)); 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/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/resources/assets/timeless/textures/suit/spiderman.png b/src/main/resources/assets/timeless/textures/suit/spiderman.png new file mode 100644 index 0000000000000000000000000000000000000000..9963f21fd577d222837125bfb4991d7810ebb347 GIT binary patch literal 2000 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