v0.4.5: panel-center audio, preload chat feedback, defensive BE check
Some checks failed
build / build (push) Has been cancelled
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user