diff --git a/README.md b/README.md index 30183f9..1a620a6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드. - 모드 ID: `video_player` -- 현재 버전: **0.4.4** +- 현재 버전: **0.4.5** - 마인크래프트 버전: **26.1.2** - 필요 Java: **25** (마인크래프트 26.x 가 요구함) @@ -53,7 +53,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 - 받은 `fabric-api-0.149.0+26.1.2.jar` 를 `mods` 폴더에 넣습니다. 2. **video_player** (이 모드) - 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases - - `video_player-0.4.4.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다. + - `video_player-0.4.5.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다. 이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. @@ -135,7 +135,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면: - 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘). -- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.4` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.) +- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.5` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.) --- @@ -187,7 +187,11 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 - 한 파일당 상한 512 MB - 캐시 폴더가 너무 커지면 직접 `.minecraft/video_player_cache/` 안의 파일을 삭제해도 됩니다 (그러면 다음 사용 시 다시 받아옴) -> 영상 삭제 시 소리가 안 멎던 문제는 0.4.4 에서 수정되었습니다 (앵커 블록이 사라지면 디코더 / 오디오 라인을 즉시 강제 종료). +> 영상 삭제 시 소리가 안 멎던 문제는 0.4.4 에서 수정되었습니다 (앵커 블록이 사라지면 디코더 / 오디오 라인을 즉시 강제 종료). 0.4.5 에서는 `BLOCK_ENTITY_UNLOAD` 이벤트가 누락되는 엣지케이스를 대비해 매 틱마다 BE 존재를 한 번 더 검증합니다. + +> 0.4.5 부터 다운로드 시작 / 완료 / 실패가 채팅창에 표시됩니다. 커맨드블럭으로 `/videopreload` 후 `/videoplace` 를 이어 실행할 때는 `[videopreload] 완료` 메시지를 본 뒤에 재생해야 로컬 파일에서 재생됩니다 (그 전에 재생하면 일반 스트리밍으로 떨어집니다). + +> 0.4.5 부터 오디오 거리 감쇠가 **판때기 중앙**을 기준으로 계산됩니다. 예전엔 앵커 블록(보통 화면 모서리)을 기준으로 측정해서 큰 화면일수록 소리가 한쪽에서 들리는 느낌이었습니다. --- @@ -204,7 +208,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build ``` -산출물: `build/libs/video_player-0.4.4.jar` +산출물: `build/libs/video_player-0.4.5.jar` JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표: ``` diff --git a/gradle.properties b/gradle.properties index 48ea921..f1d3081 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.configuration-cache=false # Mod mod_id=video_player -mod_version=0.4.4 +mod_version=0.4.5 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java index 653afb4..c208190 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java @@ -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 panel center, 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); diff --git a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java index 935b5ce..720d336 100644 --- a/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java +++ b/src/main/java/com/ejclaw/videoplayer/block/VideoAnchorBlockEntity.java @@ -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(); diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java index 3dc20c9..bec767e 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoCache.java @@ -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; diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java index 3f076e3..8ab2cf8 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java @@ -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> it = ENTRIES.entrySet().iterator(); while (it.hasNext()) { Map.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; diff --git a/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java b/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java index 719d0f4..27e5f95 100644 --- a/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java +++ b/src/main/java/com/ejclaw/videoplayer/command/VideoPreloadCommand.java @@ -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; } }