package ar.com.companeros.horror.client; import ar.com.companeros.Companeros; import ar.com.companeros.horror.HorrorModule; import ar.com.companeros.horror.HorrorNetwork; import ar.com.companeros.horror.ParasiteEntity; import com.mojang.blaze3d.audio.OggAudioStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import net.minecraft.Util; import net.minecraft.client.Minecraft; import net.minecraft.client.resources.sounds.AbstractTickableSoundInstance; import net.minecraft.client.resources.sounds.Sound; import net.minecraft.client.sounds.AudioStream; import net.minecraft.client.sounds.SoundBufferLibrary; import net.minecraft.sounds.SoundSource; import net.minecraft.util.RandomSource; import net.minecraft.world.entity.Entity; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; import net.minecraftforge.event.TickEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; /** Reproduce voz generada en memoria; jamás abre archivos ni contacta servicios. */ @Mod.EventBusSubscriber(modid = Companeros.ID, value = Dist.CLIENT) public final class HorrorSpeechClient { private static final Map ACTIVE = new LinkedHashMap<>(); private static long lastPacketTime; private HorrorSpeechClient() {} // RECEPCIÓN: una voz por cuerpo y sólo para un jugador que acepta el modo de terror. public static void play(HorrorNetwork.Speech speech) { Minecraft minecraft = Minecraft.getInstance(); if (HorrorClientSafety.isEnabled() || minecraft.level == null || minecraft.player == null || minecraft.player.isCreative() || minecraft.player.isSpectator()) return; long now = Util.getMillis(); if (now - lastPacketTime < 500) return; ParasiteEntity body = null; for (Entity entity : minecraft.level.entitiesForRendering()) { if (entity instanceof ParasiteEntity parasite && entity.getUUID().equals(speech.body())) { body = parasite; break; } } if (body == null || !body.isAlive() || minecraft.player.distanceToSqr(body) > 48 * 48) return; // El relato fijo también usa este evento: se corta antes de la nueva frase. stopAll(); minecraft.getSoundManager().stop(HorrorModule.PARASITE_LORE.get().getLocation(), SoundSource.HOSTILE); lastPacketTime = now; GeneratedSpeech sound = new GeneratedSpeech(speech, body); ACTIVE.put(speech.body(), sound); minecraft.getSoundManager().play(sound); } public static void stopAll() { for (GeneratedSpeech sound : ACTIVE.values().toArray(GeneratedSpeech[]::new)) sound.cancel(); ACTIVE.clear(); lastPacketTime = 0; } @SubscribeEvent public static void disconnected(ClientPlayerNetworkEvent.LoggingOut event) { stopAll(); } // LIMPIEZA: StopSound del servidor y fin del audio también liberan sus bytes locales. @SubscribeEvent public static void clientTick(TickEvent.ClientTickEvent event) { if (event.phase != TickEvent.Phase.END || ACTIVE.isEmpty()) return; Minecraft minecraft = Minecraft.getInstance(); if (HorrorClientSafety.isEnabled() || minecraft.level == null) { stopAll(); return; } for (GeneratedSpeech sound : ACTIVE.values().toArray(GeneratedSpeech[]::new)) { if (sound.age > 20 && !minecraft.getSoundManager().isActive(sound)) sound.cancel(); } } private static final class GeneratedSpeech extends AbstractTickableSoundInstance { private final UUID bodyId; private final ParasiteEntity body; private byte[] audio; private volatile boolean cancelled; private int age; private GeneratedSpeech(HorrorNetwork.Speech speech, ParasiteEntity body) { super(HorrorModule.PARASITE_LORE.get(), SoundSource.HOSTILE, RandomSource.create()); this.bodyId = speech.body(); this.body = body; this.audio = speech.audio(); x = speech.x(); y = speech.y(); z = speech.z(); volume = .95F; pitch = 1; looping = false; relative = false; attenuation = Attenuation.LINEAR; } @Override public boolean canPlaySound() { return !cancelled && !HorrorClientSafety.isEnabled(); } @Override public void tick() { Minecraft minecraft = Minecraft.getInstance(); if (++age > 20 * 75 || cancelled || HorrorClientSafety.isEnabled() || !body.isAlive() || minecraft.level != body.level() || minecraft.player == null || minecraft.player.isCreative() || minecraft.player.isSpectator() || minecraft.player.distanceToSqr(body) > 64 * 64) { cancel(); return; } x = body.getX(); y = body.getEyeY(); z = body.getZ(); } // El constructor del decodificador corre fuera del hilo de renderizado. @Override public CompletableFuture getStream(SoundBufferLibrary buffers, Sound sound, boolean loop) { byte[] bytes = audio; return CompletableFuture.supplyAsync(() -> { try { if (cancelled || HorrorClientSafety.isEnabled()) throw new IOException("Speech cancelled"); OggAudioStream stream = new OggAudioStream(new ByteArrayInputStream(bytes)); if (cancelled || HorrorClientSafety.isEnabled()) { stream.close(); throw new IOException("Speech cancelled"); } return stream; } catch (IOException error) { throw new CompletionException(error); } }, Util.backgroundExecutor()); } private void cancel() { if (cancelled) return; cancelled = true; stop(); Minecraft.getInstance().getSoundManager().stop(this); ACTIVE.remove(bodyId, this); audio = new byte[0]; } } }