Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
afe5929
update cloth config version to 20.0.149 to fix crash bug while click …
RCUTANF Dec 11, 2025
51f7266
fix:Refactor CustomPacketCodecs to use OPTIONAL_STREAM_CODEC for Item…
RCUTANF Dec 11, 2025
0af75b7
feat: Implement effects synchronization for players in SpectatorPlus
RCUTANF Dec 11, 2025
b93d50f
fix: effect sync and render
RCUTANF Dec 12, 2025
3751651
fix: hand height calculations bug
RCUTANF Dec 13, 2025
d243f9c
fix: handle null viewingEntity in sync data reset, which case a clien…
RCUTANF Dec 14, 2025
532e1fc
fix: handle null ItemStack serialization in CustomPacketCodecs,which …
RCUTANF Dec 14, 2025
6db7fdf
fix: prevent incorrect arm orientation when spectating by skipping mu…
RCUTANF Dec 17, 2025
3116679
fix: update fabric-loom plugin version to 1.13-SNAPSHOT
RCUTANF Jan 2, 2026
8ae3bc6
update to 1.21.11
RCUTANF Jan 3, 2026
ff06b36
fix: replace ResourceLocation with Identifier for packet type definit…
RCUTANF Jan 4, 2026
a37be0f
beautiful code style
RCUTANF Jan 4, 2025
ccc204d
Migrate to 1.21.11
RCUTANF Jan 4, 2025
058dbba
migrate entityInteractionRange() form gameRendererMixin to new localP…
RCUTANF Jan 4, 2025
b326e00
fix: replace ResourceLocation with Identifier in effect handling
RCUTANF Jan 30, 2026
7506b4b
fix: renderItemInHand follow the update of source code
RCUTANF Jan 30, 2026
6d94a62
close shadowJar of paper build
RCUTANF Jan 30, 2026
e42b4a8
feat: configure run directories for client and server
RCUTANF Jan 30, 2026
0f095c0
fix: integrate server will be correctly use original effects data ins…
RCUTANF Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions fabric/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id("fabric-loom") version "1.11.7"
id("fabric-loom") version "1.14-SNAPSHOT"
id("spectatorplus.platform")
}

Expand All @@ -22,6 +22,20 @@ loom {
}
}

runs {
getByName("client") {
client()
ideConfigGenerated(true)
runDir("run/client")
}

getByName("server") {
server()
ideConfigGenerated(true)
runDir("run")
}
}

accessWidenerPath = file("src/main/resources/spectatorplus.accesswidener")
}

Expand All @@ -32,7 +46,6 @@ dependencies {
officialMojangMappings()
parchment("org.parchmentmc.data:parchment-${property("parchment_minecraft_version")}:${property("parchment_version")}@zip")
})

modImplementation("net.fabricmc.fabric-api:fabric-api:${property("fabric_version")}")

include(modImplementation("me.lucko:fabric-permissions-api:${property("fabric_permissions_api_version")}")!!)
Expand Down
18 changes: 9 additions & 9 deletions fabric/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
minecraft_version=1.21.10
yarn_mappings=1.21.10+build.1
loader_version=0.17.2
minecraft_version=1.21.11
yarn_mappings=1.21.11+build.3
loader_version=0.18.4

# Fabric API
fabric_version=0.138.3+1.21.10
fabric_version=0.140.2+1.21.11

parchment_minecraft_version=1.21.10
parchment_version=2025.10.12
parchment_minecraft_version=1.21.11
parchment_version=2025.12.20

fabric_permissions_api_version=0.4.0
cloth_config_version=19.0.147
modmenu_version=16.0.0-rc.1
fabric_permissions_api_version=0.6.1
cloth_config_version=21.11.153
modmenu_version=17.0.0-beta.1
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
import net.minecraft.util.Util;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.spectator.SpectatorGui;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.GameType;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Matrix4fc;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
Expand Down Expand Up @@ -45,12 +47,24 @@ public abstract class GameRendererMixin {
@Unique private float xBobO;
@Unique private float yBobO;

// @Redirect(method = "renderItemInHand", at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/vertex/PoseStack;mulPose(Lorg/joml/Matrix4fc;)V", ordinal = 0))
// private void redirectMulPose(PoseStack poseStack, Matrix4fc matrix) {
// // In spectator mode, the projection matrix contains rotation data from the spectated player,
// // while arm rendering calculations are based on the localPlayer's viewpoint.
// // We must skip this mulPose operation when spectating to avoid rendering arms with incorrect orientation.
// if (this.minecraft.player == null
// || this.minecraft.player.gameMode() != GameType.SPECTATOR
// || !this.minecraft.options.getCameraType().isFirstPerson()) {
// poseStack.mulPose(matrix);
// }
// }

@Inject(method = "renderItemInHand", at = @At(value = "INVOKE", target = "Lorg/joml/Matrix4fStack;popMatrix()Lorg/joml/Matrix4fStack;", remap = false))
public void spectatorplus$renderItemInHand(float partialTicks, boolean sleeping, Matrix4f projectionMatrix, CallbackInfo ci, @Local PoseStack poseStackIn) {
if (SpectatorClientMod.config.renderArms && this.minecraft.player != null && this.minecraft.options.getCameraType().isFirstPerson() && !this.minecraft.options.hideGui) {
final AbstractClientPlayer spectated = SpecUtil.getCameraPlayer(this.minecraft);
if (spectated != null && !spectated.isSpectator()) {
this.lightTexture.turnOnLightLayer();
//this.lightTexture.turnOnLightLayer();

float attackAnim = spectated.getAttackAnim(partialTicks);
final InteractionHand interactionHand = MoreObjects.firstNonNull(spectated.swingingArm, InteractionHand.MAIN_HAND);
Expand All @@ -67,7 +81,7 @@ public abstract class GameRendererMixin {

if (handRenderSelection.renderMainHand) {
final float swingProgress = interactionHand == InteractionHand.MAIN_HAND ? attackAnim : 0.0F;
final float equippedProgress = 1F - Mth.lerp(partialTicks, accessor.getOMainHandHeight(), accessor.getMainHandHeight());
final float equippedProgress = accessor.getItemModelResolver().swapAnimationScale(accessor.getMainHandItem()) * (1F - Mth.lerp(partialTicks, accessor.getOMainHandHeight(), accessor.getMainHandHeight()));

accessor.invokeRenderArmWithItem(spectated, partialTicks,
pitch, InteractionHand.MAIN_HAND, swingProgress, accessor.getMainHandItem(), equippedProgress,
Expand All @@ -76,14 +90,15 @@ public abstract class GameRendererMixin {

if (handRenderSelection.renderOffHand) {
final float swingProgress = interactionHand == InteractionHand.OFF_HAND ? attackAnim : 0.0F;
final float equippedProgress = 1F - Mth.lerp(partialTicks, accessor.getOOffHandHeight(), accessor.getOffHandHeight());
final float equippedProgress = accessor.getItemModelResolver().swapAnimationScale(accessor.getOffHandItem()) * (1F - Mth.lerp(partialTicks, accessor.getOOffHandHeight(), accessor.getOffHandHeight()));

accessor.invokeRenderArmWithItem(spectated, partialTicks,
pitch, InteractionHand.OFF_HAND, swingProgress, accessor.getOffHandItem(), equippedProgress,
poseStackIn, submitNodeCollector, packedLightCoords);
}

this.lightTexture.turnOffLightLayer();
//this.lightTexture.turnOffLightLayer();
this.minecraft.gameRenderer.getFeatureRenderDispatcher().renderAllFeatures();
this.renderBuffers.bufferSource().endBatch();
}
}
Expand Down Expand Up @@ -161,25 +176,4 @@ private static ItemInHandRenderer.HandRenderSelection evaluateWhichHandsToRender
if (minecraft.getCameraEntity() == this.minecraft.player) return instance.getInterpolatedBob(partialTick);
return Mth.lerp(partialTick, this.bobO, this.bob);
}

@ModifyExpressionValue(method = "pick(F)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;blockInteractionRange()D"))
private double spectatorplus$modifyBlockInteractionRange(double original) {
final AbstractClientPlayer spectated = SpecUtil.getCameraPlayer(this.minecraft);
if (spectated != null) {
return spectated.blockInteractionRange();
}

return original;
}

@ModifyExpressionValue(method = "pick(F)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;entityInteractionRange()D"))
private double spectatorplus$modifyEntityInteractionRange(double original) {
final AbstractClientPlayer spectated = SpecUtil.getCameraPlayer(this.minecraft);
if (spectated != null) {
return spectated.entityInteractionRange();
}

return original;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import org.spongepowered.asm.mixin.injection.modify.LocalVariableDiscriminator.Context.Local;

import net.minecraft.resources.ResourceLocation;
import net.minecraft.resources.Identifier;
import net.minecraft.core.Holder;

@Mixin(Gui.class)
Expand All @@ -54,15 +54,15 @@ public abstract class GuiMixin {
@Shadow @Final private SpectatorGui spectatorGui;


// Use correct ResourceLocations for vanilla empty armor slot icons from the GUI atlas
private static final ResourceLocation EMPTY_ARMOR_SLOT_HELMET = ResourceLocation.withDefaultNamespace("container/slot/helmet");
private static final ResourceLocation EMPTY_ARMOR_SLOT_CHESTPLATE = ResourceLocation.withDefaultNamespace("container/slot/chestplate");
private static final ResourceLocation EMPTY_ARMOR_SLOT_LEGGINGS = ResourceLocation.withDefaultNamespace("container/slot/leggings");
private static final ResourceLocation EMPTY_ARMOR_SLOT_BOOTS = ResourceLocation.withDefaultNamespace("container/slot/boots");
private static final ResourceLocation EFFECT_BACKGROUND_AMBIENT_SPRITE = ResourceLocation.withDefaultNamespace("hud/effect_background_ambient");
private static final ResourceLocation EFFECT_BACKGROUND_SPRITE = ResourceLocation.withDefaultNamespace("hud/effect_background");
// Use correct Identifiers for vanilla empty armor slot icons from the GUI atlas
private static final Identifier EMPTY_ARMOR_SLOT_HELMET = Identifier.withDefaultNamespace("container/slot/helmet");
private static final Identifier EMPTY_ARMOR_SLOT_CHESTPLATE = Identifier.withDefaultNamespace("container/slot/chestplate");
private static final Identifier EMPTY_ARMOR_SLOT_LEGGINGS = Identifier.withDefaultNamespace("container/slot/leggings");
private static final Identifier EMPTY_ARMOR_SLOT_BOOTS = Identifier.withDefaultNamespace("container/slot/boots");
private static final Identifier EFFECT_BACKGROUND_AMBIENT_SPRITE = Identifier.withDefaultNamespace("hud/effect_background_ambient");
private static final Identifier EFFECT_BACKGROUND_SPRITE = Identifier.withDefaultNamespace("hud/effect_background");

private static final ResourceLocation[] TEXTURE_EMPTY_SLOTS = new ResourceLocation[]{
private static final Identifier[] TEXTURE_EMPTY_SLOTS = new Identifier[]{
EMPTY_ARMOR_SLOT_BOOTS, EMPTY_ARMOR_SLOT_LEGGINGS, EMPTY_ARMOR_SLOT_CHESTPLATE, EMPTY_ARMOR_SLOT_HELMET
};

Expand Down Expand Up @@ -178,20 +178,19 @@ public abstract class GuiMixin {
int effectBaseY = baseY + slots.length * (itemHeight + spacing) + spacing; // start below armor

// Render all active effect icons down the right side below armor
LocalPlayer player = this.minecraft.player;
if (player != null && player.getActiveEffects() != null && !player.getActiveEffects().isEmpty()) {
if (ClientSyncController.syncData.effects != null && !ClientSyncController.syncData.effects.isEmpty()) {
int effectIndex = 0;
for (var effectInstance : player.getActiveEffects()) {
for (var effectInstance : ClientSyncController.syncData.effects) {
int y = effectBaseY + effectIndex * (itemWidth + spacing);

// Draw vanilla effect background
guiGraphics.blitSprite(RenderPipelines.GUI_TEXTURED, EFFECT_BACKGROUND_SPRITE, baseX, y, itemWidth, itemHeight);

ResourceLocation effectIcon = Gui.getMobEffectSprite(effectInstance.getEffect());
Identifier effectIcon = GuiMixin.getEffectIcon(effectInstance.effectKey);
guiGraphics.blitSprite(RenderPipelines.GUI_TEXTURED, effectIcon, baseX + 2, y + 2, itemWidth - 4, itemHeight - 4);

// Draw effect level as a small white number on the top right of the icon
int level = effectInstance.getAmplifier() + 1;
int level = effectInstance.amplifier + 1;
String levelText = String.valueOf(level);
int levelTextWidth = this.minecraft.font.width(levelText);
int levelTextX = baseX + itemWidth - (int)(levelTextWidth * 0.4F) - 3; // right-align inside top-right corner
Expand All @@ -202,7 +201,7 @@ public abstract class GuiMixin {
guiGraphics.pose().popMatrix();

// Draw duration bar (1px wide) to the left of the effect icon, color changes with percent
int duration = effectInstance.getDuration();
int duration = effectInstance.duration;
int maxDuration = 3600; // 3 minutes, adjust as needed
float percent = maxDuration > 0 ? (duration / (float)maxDuration) : 1.0F;
int maxBarHeight = itemHeight - 2;
Expand Down Expand Up @@ -365,8 +364,8 @@ public abstract class GuiMixin {
}
return instance;
}
// Map EffectType to vanilla effect icon ResourceLocation
private static ResourceLocation getEffectIcon(String effectKey) {
// Map EffectType to vanilla effect icon Identifier
private static Identifier getEffectIcon(String effectKey) {
// If effectKey contains a namespace (e.g., minecraft:nausea), strip it
String key = effectKey;
int colonIdx = key.indexOf(":");
Expand All @@ -375,7 +374,7 @@ private static ResourceLocation getEffectIcon(String effectKey) {
}
// Vanilla effect icons are in the GUI atlas as effect/<effectKey>
// The effectKey should be lowercase, matching the registry name
return ResourceLocation.withDefaultNamespace("mob_effect/" + key.toLowerCase());
return Identifier.withDefaultNamespace("mob_effect/" + key.toLowerCase());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.ItemInHandRenderer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
Expand Down Expand Up @@ -35,6 +36,9 @@ void invokeRenderArmWithItem(AbstractClientPlayer player, float partialTick, flo
@Accessor
ItemStack getOffHandItem();

@Accessor
ItemModelResolver getItemModelResolver();

@Invoker("isChargedCrossbow")
static boolean invokeIsChargedCrossbow(ItemStack stack) {
throw new AssertionError();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ public abstract class ItemInHandRendererMixin {

if (this.spectated == spectated) {
float f = spectated.getAttackStrengthScale(1.0F);
this.mainHandHeight += Mth.clamp((this.mainHandItem == mainHandItem ? f * f * f : 0.0F) - this.mainHandHeight, -0.4F, 0.4F);
this.offHandHeight += Mth.clamp((float) (this.offHandItem == offHandItem ? 1 : 0) - this.offHandHeight, -0.4F, 0.4F);
float g = this.mainHandItem != mainHandItem ? 0.0F : f * f * f;
float h = this.offHandItem != offHandItem ? 0.0F : 1.0F;
this.mainHandHeight = this.mainHandHeight + Mth.clamp(g - this.mainHandHeight, -0.4F, 0.4F);
this.offHandHeight = this.offHandHeight + Mth.clamp(h - this.offHandHeight, -0.4F, 0.4F);

if (this.mainHandHeight < 0.1F) {
this.mainHandItem = mainHandItem;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.hpfxd.spectatorplus.fabric.client.mixin;

import net.minecraft.core.Holder;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.LivingEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;

import java.util.Map;

@Mixin(LivingEntity.class)
public interface LivingEntityAccessor {
@Accessor("activeEffects")
Map<Holder<MobEffect>, MobEffectInstance> spectatorplus$getActiveEffects();
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
package com.hpfxd.spectatorplus.fabric.client.mixin;

import com.hpfxd.spectatorplus.fabric.client.util.EffectUtil;
import com.hpfxd.spectatorplus.fabric.client.util.SpecUtil;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.core.Holder;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.HitResult;

import java.util.Collection;

import org.jetbrains.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

import java.util.Map;

@Mixin(LivingEntity.class)
public abstract class LivingEntityMixin extends Entity {
Expand Down Expand Up @@ -52,6 +50,20 @@ private boolean isLookingAtBlock() {
return false;
}

return ((GameRendererAccessor) Minecraft.getInstance().gameRenderer).invokePick(this, player.blockInteractionRange(), player.entityInteractionRange(), 1F).getType() == HitResult.Type.BLOCK;
var spectated = SpecUtil.getCameraPlayer(Minecraft.getInstance());
var blockRange = spectated == null ? player.blockInteractionRange() : spectated.blockInteractionRange();
var entityRange = spectated == null ? player.entityInteractionRange() : spectated.entityInteractionRange();
return LocalPlayerAccessor.invokePick(this, blockRange, entityRange, 1F).getType() == HitResult.Type.BLOCK;
}

@Redirect(method = {"hasEffect", "getEffect", "getActiveEffects", "tickEffects"},
at = @At(value = "FIELD", target = "Lnet/minecraft/world/entity/LivingEntity;activeEffects:Ljava/util/Map;"))
private Map<Holder<MobEffect>, MobEffectInstance> spectatorplus$redirectActiveEffects(LivingEntity instance) {
// 只对玩家且满足条件时才重定向
if (instance.level().isClientSide() && instance instanceof Player && EffectUtil.shouldUseSpectatorData()) {
return EffectUtil.getActiveEffectsMap();
}
return ((LivingEntityAccessor) instance).spectatorplus$getActiveEffects();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.hpfxd.spectatorplus.fabric.client.mixin;

import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.HitResult;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;

@Mixin(LocalPlayer.class)
public interface LocalPlayerAccessor {
@Invoker
static HitResult invokePick(Entity cameraEntity, double blockRange, double entityRange, float partialTick) {
throw new AssertionError();
}
}
Loading