Volver al índice
src/main/java/ar/com/companeros/horror/client/HorrorSpeechClient.java
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<UUID, GeneratedSpeech> 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<AudioStream> 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];
}
}
}