Skip to content
2 changes: 1 addition & 1 deletion fabric/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ parchment_minecraft_version=1.21.10
parchment_version=2025.10.12

fabric_permissions_api_version=0.4.0
cloth_config_version=19.0.147
cloth_config_version=20.0.149
modmenu_version=16.0.0-rc.1
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
import net.minecraft.core.Direction;
import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.animal.CowVariant;
import net.minecraft.world.entity.player.PlayerModelType;
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,6 +49,18 @@ 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) {
Expand Down
Original file line number Diff line number Diff line change
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());
ResourceLocation 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
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 @@ -54,4 +52,15 @@ private boolean isLookingAtBlock() {

return ((GameRendererAccessor) Minecraft.getInstance().gameRenderer).invokePick(this, player.blockInteractionRange(), player.entityInteractionRange(), 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 instanceof Player && EffectUtil.shouldUseSpectatorData()) {
return EffectUtil.getActiveEffectsMap();
}
return ((LivingEntityAccessor) instance).spectatorplus$getActiveEffects();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
public abstract class MinecraftMixin {
@Inject(method = "setCameraEntity(Lnet/minecraft/world/entity/Entity;)V", at = @At(value = "TAIL"))
private void spectatorplus$resetSyncDataOnCameraSwitch(Entity viewingEntity, CallbackInfo ci) {
if (ClientSyncController.syncData != null && !ClientSyncController.syncData.playerId.equals(viewingEntity.getUUID())) {
ClientSyncController.setSyncData(null);
if (ClientSyncController.syncData != null) {
if (viewingEntity == null || !ClientSyncController.syncData.playerId.equals(viewingEntity.getUUID())) {
ClientSyncController.setSyncData(null);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.hpfxd.spectatorplus.fabric.client.sync;

import com.hpfxd.spectatorplus.fabric.client.sync.screen.ScreenSyncController;
import com.hpfxd.spectatorplus.fabric.client.util.EffectUtil;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket;
Expand Down Expand Up @@ -42,32 +43,8 @@ public static void init() {

private static void handle(ClientboundEffectsSyncPacket packet, ClientPlayNetworking.Context context) {
setSyncData(packet.playerId());
syncData.effects = packet.effects(); // Now List<SyncedEffect>
System.out.println("[SpectatorPlus] Synced effects: " + syncData.effects);

// var client = Minecraft.getInstance();
// if (client.player != null) {
// // Remove all current effects from the client player
// List<Holder<MobEffect>> toRemove = new ArrayList<>(client.player.getActiveEffectsMap().keySet());
// for (Holder<MobEffect> effect : toRemove) {
// System.out.println("[SpectatorPlus] Removing effect: " + BuiltInRegistries.MOB_EFFECT.getKey(effect.value()));
// client.player.removeEffect(effect);
// }
// // Add all synced effects to the client player
// for (SyncedEffect synced : syncData.effects) {
// System.out.println("[SpectatorPlus] Syncing effect: " + synced.effectKey + " duration=" + synced.duration + " amplifier=" + synced.amplifier);
// java.util.Optional<Holder.Reference<MobEffect>> optHolder = BuiltInRegistries.MOB_EFFECT.get(ResourceLocation.tryParse(synced.effectKey));
// if (optHolder.isPresent()) {
// MobEffect effect = optHolder.get().value();
// Holder<MobEffect> holder = Holder.direct(effect);
// MobEffectInstance instance = new MobEffectInstance(holder, synced.duration, synced.amplifier);
// client.player.forceAddEffect(instance, client.player);
// System.out.println("[SpectatorPlus] Added effect: " + synced.effectKey);
// } else {
// System.out.println("[SpectatorPlus] Effect not found in registry: " + synced.effectKey);
// }
// }
// }
syncData.effects = packet.effects();
EffectUtil.updateEffectInstances(packet.effects());
}

private static void handle(ClientboundExperienceSyncPacket packet, ClientPlayNetworking.Context context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.hpfxd.spectatorplus.fabric.client.util;


import com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController;
import com.hpfxd.spectatorplus.fabric.sync.SyncedEffect;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;

import java.util.*;

public class EffectUtil {
private static final Map<Holder<MobEffect>, MobEffectInstance> activeEffects = new HashMap<>();

public static void updateEffectInstances(List<SyncedEffect> effects) {
// 收集新的效果
Set<Holder<MobEffect>> newEffects = new HashSet<>();

for (SyncedEffect syncedEffect : effects) {
Holder<MobEffect> effect = BuiltInRegistries.MOB_EFFECT.get(ResourceLocation.parse(syncedEffect.effectKey))
.orElseThrow(() -> new IllegalArgumentException("Unknown effect: " + syncedEffect.effectKey));
newEffects.add(effect);
}

// 移除不再存在的效果
activeEffects.entrySet().removeIf(entry -> !newEffects.contains(entry.getKey()));

// 添加新效果(保持现有实例的BlendState)
for (SyncedEffect syncedEffect : effects) {
Holder<MobEffect> effect = BuiltInRegistries.MOB_EFFECT.get(ResourceLocation.parse(syncedEffect.effectKey))
.orElseThrow(() -> new IllegalArgumentException("Unknown effect: " + syncedEffect.effectKey));

if (!activeEffects.containsKey(effect)) {
MobEffectInstance instance = new MobEffectInstance(effect, syncedEffect.duration,
syncedEffect.amplifier, false, true, true);
activeEffects.put(effect, instance);
}
}
}

public static boolean hasValidSyncData() {
return ClientSyncController.syncData != null &&
ClientSyncController.syncData.effects != null;
}

public static boolean shouldUseSpectatorData() {
Minecraft mc = Minecraft.getInstance();
return mc.player != null &&
SpecUtil.getCameraPlayer(mc) != null &&
hasValidSyncData();
}

// 直接返回原版格式的activeEffects
public static Map<Holder<MobEffect>, MobEffectInstance> getActiveEffectsMap() {
return activeEffects;
}
}
5 changes: 4 additions & 1 deletion fabric/src/client/resources/spectatorplus.client.mixins.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@
],
"injectors": {
"defaultRequire": 1
}
},
"mixins": [
"LivingEntityAccessor"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.hpfxd.spectatorplus.fabric.mixin;

import com.hpfxd.spectatorplus.fabric.sync.handler.EffectsSyncHandler;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
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 org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

import java.util.Collection;

@Mixin(LivingEntity.class)
public class LivingEntityMixin {

@Inject(method = "onEffectAdded", at = @At("TAIL"))
private void onEffectAdded(MobEffectInstance effectInstance, @Nullable Entity entity, CallbackInfo ci) {
if ((Object) this instanceof ServerPlayer player) {
EffectsSyncHandler.onEffectChanged(player);
}
}

@Inject(method = "onEffectUpdated", at = @At("TAIL"))
private void onEffectUpdated(MobEffectInstance effectInstance, boolean forced, @Nullable Entity entity, CallbackInfo ci) {
if ((Object) this instanceof ServerPlayer player) {
EffectsSyncHandler.onEffectChanged(player);
}
}

@Inject(method = "onEffectsRemoved", at = @At("TAIL"))
private void onEffectsRemoved(Collection<MobEffectInstance> effects, CallbackInfo ci) {
if ((Object) this instanceof ServerPlayer player) {
EffectsSyncHandler.onEffectChanged(player);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.google.common.collect.Lists;
import com.hpfxd.spectatorplus.fabric.SpectatorMod;
import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController;
import com.hpfxd.spectatorplus.fabric.sync.handler.EffectsSyncHandler;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket;
import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket;
Expand Down Expand Up @@ -66,6 +67,7 @@ public ServerPlayerMixin(Level level, GameProfile gameProfile) {
ServerSyncController.sendPacket(spectator, ClientboundFoodSyncPacket.initializing(target));
ServerSyncController.sendPacket(spectator, ClientboundHotbarSyncPacket.initializing(target));
ServerSyncController.sendPacket(spectator, ClientboundSelectedSlotSyncPacket.initializing(target));
EffectsSyncHandler.onStartSpectating(spectator, target);

// Send initial map data patch packet if the target has a map in inventory
for (final ItemStack stack : target.getInventory().getNonEquipmentItems()) {
Expand Down
Loading