v0.4.5: panel-center audio, preload chat feedback, defensive BE check
Some checks failed
build / build (push) Has been cancelled

- audio: distance attenuation now uses the panel center (width/2, height/2
  offset from the anchor along the renderer's right/up axes) instead of the
  anchor block corner, so a 4x4 panel sounds like it's coming from the
  middle of the screen and not the bottom-left.
- preload: each client now posts a chat line on start / completion / failure
  / cache-hit, so a command-block sequence like /videopreload -> /videoplace
  can be timed against the visible "[videopreload] 완료" message.
- safety: VideoPlayback.tick() verifies the anchor BE still exists at each
  active position and forcibly stops playback if it doesn't — covers any
  edge case where BLOCK_ENTITY_UNLOAD doesn't fire.
- /videopreload feedback now explicitly states "완료 알림 후 재생하세요".
This commit is contained in:
tkrmagid
2026-05-15 21:53:21 +09:00
parent 4fc7cf46b7
commit e6faae3f39
7 changed files with 88 additions and 14 deletions

View File

@@ -76,17 +76,20 @@ public class VideoPlayerClient implements ClientModInitializer {
VideoPlayerMod.LOG.info("[{}] client initialized", VideoPlayerMod.MOD_ID);
}
/** SPEC §6 — recompute per-anchor audio gain from player distance every tick. */
/**
* SPEC §6 — recompute per-anchor audio gain from player distance every tick.
* Distance is measured from the player's eye to the <em>panel center</em>, not the anchor
* block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually
* sits, which made the audio feel like it was off to the side.
*/
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
double dx = (pos.getX() + 0.5) - eye.x;
double dy = (pos.getY() + 0.5) - eye.y;
double dz = (pos.getZ() + 0.5) - eye.z;
double d = Math.sqrt(dx * dx + dy * dy + dz * dz);
Vec3 center = be.panelCenter();
double d = center.distanceTo(eye);
float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0));
float gain = be.isMuted() ? 0F : be.getVolume() * attenuation;
VideoPlayback.setGain(pos, gain);

View File

@@ -8,6 +8,7 @@ import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/**
* Anchor BE — holds per-block config that drives playback.
@@ -52,6 +53,37 @@ public class VideoAnchorBlockEntity extends BlockEntity {
setChanged();
}
/**
* World-space center of the rendered panel. Used for distance-based audio attenuation so
* the sound source feels like it's coming from the middle of the screen, not the corner
* (anchor block). Geometry mirrors {@code VideoAnchorRenderer#submit}:
* the panel's bottom-left corner sits on the wall surface at
* {@code anchorCenter + (-0.5)*facing + (-0.5)*right + (-0.5)*up}, and the panel extends
* {@code width × height} blocks along {@code right} and {@code up}. So the center is
* {@code anchorCenter + (w/2 - 0.5)*right + (h/2 - 0.5)*up + (-0.5)*facing}.
*/
public Vec3 panelCenter() {
Vec3 right, up;
switch (facing) {
case NORTH -> { right = new Vec3(-1, 0, 0); up = new Vec3(0, 1, 0); }
case SOUTH -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 1, 0); }
case EAST -> { right = new Vec3( 0, 0, -1); up = new Vec3(0, 1, 0); }
case WEST -> { right = new Vec3( 0, 0, 1); up = new Vec3(0, 1, 0); }
case UP -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 0, -1); }
case DOWN -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 0, 1); }
default -> { right = new Vec3( 1, 0, 0); up = new Vec3(0, 1, 0); }
}
Vec3 facingVec = new Vec3(facing.getStepX(), facing.getStepY(), facing.getStepZ());
BlockPos p = getBlockPos();
Vec3 anchorCenter = new Vec3(p.getX() + 0.5, p.getY() + 0.5, p.getZ() + 0.5);
double offR = width / 2.0 - 0.5;
double offU = height / 2.0 - 0.5;
return anchorCenter
.add(right.scale(offR))
.add(up.scale(offU))
.add(facingVec.scale(-0.5));
}
/** Wire-format NBT used by SaveConfig/SyncAnchor payloads. */
public CompoundTag toNbt() {
CompoundTag nbt = new CompoundTag();

View File

@@ -3,7 +3,9 @@ package com.ejclaw.videoplayer.client.playback;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import java.io.InputStream;
import java.io.OutputStream;
@@ -48,12 +50,15 @@ public final class VideoCache {
if (!(url.startsWith("http://") || url.startsWith("https://"))) return;
if (READY.containsKey(url)) {
VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY);
return;
}
if (!IN_FLIGHT.add(url)) {
VideoPlayerMod.LOG.info("[{}] preload: already downloading {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 다운로드 중: " + url, ChatFormatting.GRAY);
return;
}
notifyChat("[videopreload] 다운로드 시작: " + url, ChatFormatting.YELLOW);
Thread t = new Thread(() -> download(url), "video_player-preload");
t.setDaemon(true);
t.start();
@@ -101,6 +106,7 @@ public final class VideoCache {
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
return;
}
@@ -114,6 +120,7 @@ public final class VideoCache {
if (code >= 400) {
VideoPlayerMod.LOG.warn("[{}] preload: HTTP {} for {}",
VideoPlayerMod.MOD_ID, code, url);
notifyChat("[videopreload] 실패 (HTTP " + code + "): " + url, ChatFormatting.RED);
return;
}
}
@@ -130,6 +137,7 @@ public final class VideoCache {
"[{}] preload: {} exceeded {} MB cap; aborting",
VideoPlayerMod.MOD_ID, url, MAX_BYTES / (1024 * 1024));
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
notifyChat("[videopreload] 실패 (512MB 초과): " + url, ChatFormatting.RED);
return;
}
out.write(buf, 0, n);
@@ -141,14 +149,28 @@ public final class VideoCache {
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}",
VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName());
long mb = Math.max(1, total / (1024 * 1024));
notifyChat("[videopreload] 완료 (" + mb + " MB): " + url, ChatFormatting.GREEN);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] preload failed for {}: {}",
VideoPlayerMod.MOD_ID, url, t.toString());
notifyChat("[videopreload] 실패 (" + t.getClass().getSimpleName() + "): " + url,
ChatFormatting.RED);
} finally {
IN_FLIGHT.remove(url);
}
}
/** Post a status line to the local player's chat. No-op if there's no client/player yet. */
private static void notifyChat(String text, ChatFormatting color) {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
mc.execute(() -> {
if (mc.player == null) return;
mc.player.sendSystemMessage(Component.literal(text).withStyle(color));
});
}
private static Path cacheDir() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.gameDirectory == null) return null;

View File

@@ -95,11 +95,22 @@ public final class VideoPlayback {
/** Called every client tick to upload new frames into the GPU texture. */
public static void tick() {
if (Minecraft.getInstance() == null) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<BlockPos, Entry> me = it.next();
BlockPos pos = me.getKey();
Entry e = me.getValue();
// Belt-and-suspenders for the audio-on-delete bug: if BLOCK_ENTITY_UNLOAD didn't
// fire for some edge case (dimension change, chunk torn down before event runs,
// etc.), the BE will be gone from the level but our Entry still holds an open
// audio line. Catch it here and stop next tick.
if (mc.level == null || !(mc.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) {
e.close();
it.remove();
continue;
}
if (!e.backend.isReady()) continue;
ByteBuffer buf = e.backend.pollFrame();
if (buf == null) continue;

View File

@@ -64,9 +64,11 @@ public final class VideoPreloadCommand {
}
final int sentFinal = sent;
// Use sendSuccess(..., false) so the chat noise is local-only and command blocks don't
// spam every operator on each tick.
// spam every operator on each tick. Be explicit that this is fire-and-forget: each
// client posts its own "[videopreload] 완료" chat line when the download finishes.
src.sendSuccess(() -> Component.literal(
"preload requested for " + sentFinal + " client(s): " + payload.url()), false);
"preload 요청을 " + sentFinal + " 클라이언트에 전송: " + payload.url()
+ " (완료 알림 후 재생하세요)"), false);
return sent;
}
}