2 Commits

Author SHA1 Message Date
tkrmagid
2b50f56980 render: paint video on the clicked wall face (no visible anchor block)
Some checks failed
build / build (push) Has been cancelled
The anchor block becomes invisible and non-collidable; it exists only as a
BlockEntity host in the air block adjacent to the clicked wall. The renderer
now translates and rotates the textured quad so it sits flush against the
surface of the wall the user actually clicked, on any of the six faces.

Stick interaction:
  right-click face → place anchor at hit.relative(face), facing=face, open GUI
  right-click face with anchor already there → reopen the GUI
  sneak + left-click face with stick → delete the anchor on that face
The anchor's selection outline / collision / occlusion are all empty, so the
player can target the wall block behind it without interference.

JavaCV / streaming polish:
- Bump missing-JavaCV log to WARN so users notice when the runtime jar is
  not installed (previously buried at INFO).
- Add HTTP resilience options: `timeout`, `reconnect`, `reconnect_streamed`,
  `reconnect_at_eof`, and a `user_agent` so picky servers don't 403 us.
2026-05-15 20:08:33 +09:00
tkrmagid
429244d820 audio: route JavaCV samples through SourceDataLine with live gain
Some checks failed
build / build (push) Has been cancelled
setVolume/Mute previously stored gain without affecting audible output: the
backend only called grabImage() and never opened an audio sink. Switch to
grab() (interleaved video+audio frames), force AV_SAMPLE_FMT_S16 on the
grabber so samples are always interleaved signed 16-bit PCM, open a matching
JavaSound SourceDataLine and write scaled samples per-frame. gain is read
on every block so /videoMute, GUI Mute and the per-tick distance attenuation
now take effect immediately. SourceDataLine.write blocking provides natural
A/V pacing, so the legacy 15ms sleep is dropped when an audio line is open;
sleep is retained as a 60fps cap when there is no audio device.

bump version to 0.3.1.
2026-05-15 19:45:42 +09:00
8 changed files with 231 additions and 66 deletions

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod # Mod
mod_id=video_player mod_id=video_player
mod_version=0.3.0 mod_version=0.4.0
maven_group=com.ejclaw.videoplayer maven_group=com.ejclaw.videoplayer
archives_base_name=video_player archives_base_name=video_player

View File

@@ -33,9 +33,20 @@ public class VideoPlayerClient implements ClientModInitializer {
); );
AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> { AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (level.isClientSide() if (!level.isClientSide()) return InteractionResult.PASS;
&& player.getMainHandItem().getItem() instanceof VideoStickItem if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
&& level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { // The anchor itself is invisible / non-collidable so the player cannot left-click it
// directly. Sneak + left-click on the wall the video sits on → delete the anchor in
// the adjacent air block.
if (player.isShiftKeyDown()) {
BlockPos anchorPos = pos.relative(direction);
if (level.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(anchorPos));
return InteractionResult.SUCCESS;
}
}
// Legacy / safety: if the player somehow targets the anchor block directly.
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }

View File

@@ -10,13 +10,26 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock; import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
/**
* Anchor block — invisible, non-collidable host for {@link VideoAnchorBlockEntity}.
*
* <p>The block exists only so a {@link BlockEntity} can be attached to a position; visually it is
* completely empty (no model, no selection outline, no collision). The video itself is drawn by
* {@link com.ejclaw.videoplayer.client.render.VideoAnchorRenderer} flush against the wall the
* player clicked, not as a textured surface on this block.
*/
public class VideoAnchorBlock extends BaseEntityBlock { public class VideoAnchorBlock extends BaseEntityBlock {
public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new); public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new);
@@ -34,6 +47,31 @@ public class VideoAnchorBlock extends BaseEntityBlock {
return new VideoAnchorBlockEntity(pos, state); return new VideoAnchorBlockEntity(pos, state);
} }
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.INVISIBLE;
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getCollisionShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getOcclusionShape(BlockState state) {
return Shapes.empty();
}
@Override
protected boolean propagatesSkylightDown(BlockState state) {
return true;
}
@Override @Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
BlockPos pos, Player player, InteractionHand hand, BlockPos pos, Player player, InteractionHand hand,

View File

@@ -4,24 +4,34 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.SourceDataLine;
import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
/** /**
* SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber. * SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber.
* *
* JavaCV is referenced entirely through reflection so that the mod jar stays loadable when the * <p>Video frames are decoded through {@code grab()}, audio samples are forced to interleaved
* (large) JavaCV dependency isn't bundled — the backend just reports {@code !isReady()} until the * signed 16-bit PCM ({@code AV_SAMPLE_FMT_S16}) and written to a {@link SourceDataLine} so that
* runtime classpath contains org.bytedeco.javacv.FFmpegFrameGrabber. * {@link #setVolume(float)} actually mutes / attenuates audible output. JavaCV is referenced
* entirely through reflection so that the mod jar stays loadable when the (large) JavaCV
* dependency isn't bundled — the backend just reports {@code !isReady()} until the runtime
* classpath contains {@code org.bytedeco.javacv.FFmpegFrameGrabber}.
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class JavaCvBackend implements VideoBackend { public class JavaCvBackend implements VideoBackend {
private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber"; private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber";
private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame"; private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame";
private static final String CONVERTER_CLASS = "org.bytedeco.javacv.Java2DFrameConverter"; private static final String CONVERTER_CLASS = "org.bytedeco.javacv.Java2DFrameConverter";
/** {@code AV_SAMPLE_FMT_S16} from {@code org.bytedeco.ffmpeg.global.avutil}. */
private static final int AV_SAMPLE_FMT_S16 = 1;
private final Object lock = new Object(); private final Object lock = new Object();
private Thread worker; private Thread worker;
@@ -84,28 +94,46 @@ public class JavaCvBackend implements VideoBackend {
/** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */ /** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */
private void runLoop(String url) { private void runLoop(String url) {
Object grabber = null; Object grabber = null;
SourceDataLine audioLine = null;
try { try {
Class<?> grabberCls = Class.forName(GRABBER_CLASS); Class<?> grabberCls = Class.forName(GRABBER_CLASS);
grabber = grabberCls.getConstructor(String.class).newInstance(url); grabber = grabberCls.getConstructor(String.class).newInstance(url);
Method start = grabberCls.getMethod("start"); Method start = grabberCls.getMethod("start");
Method stop = grabberCls.getMethod("stop"); Method stop = grabberCls.getMethod("stop");
Method grab = grabberCls.getMethod("grabImage"); Method grab = grabberCls.getMethod("grab");
Method getW = grabberCls.getMethod("getImageWidth"); Method getW = grabberCls.getMethod("getImageWidth");
Method getH = grabberCls.getMethod("getImageHeight"); Method getH = grabberCls.getMethod("getImageHeight");
Method getSampleRate = grabberCls.getMethod("getSampleRate");
Method getAudioChannels = grabberCls.getMethod("getAudioChannels");
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class); Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
// mp4/http(s) network tuning // HTTP(S) tuning for streaming URLs (e.g. webm via Range / chunked transfer).
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {} try { setOpt.invoke(grabber, "rw_timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {} try { setOpt.invoke(grabber, "timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_streamed", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_at_eof", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "user_agent",
"video_player/" + com.ejclaw.videoplayer.VideoPlayerMod.MOD_ID); } catch (Throwable ignored) {}
// Force interleaved signed 16-bit PCM so the audio sink path is single-shape.
try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {}
start.invoke(grabber); start.invoke(grabber);
this.width = (int) getW.invoke(grabber); this.width = (int) getW.invoke(grabber);
this.height = (int) getH.invoke(grabber); this.height = (int) getH.invoke(grabber);
this.ready = (width > 0 && height > 0); this.ready = (width > 0 && height > 0);
int sampleRate = safeInt(getSampleRate, grabber);
int audioChannels = safeInt(getAudioChannels, grabber);
audioLine = openLine(sampleRate, audioChannels);
Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples");
Class<?> convCls = Class.forName(CONVERTER_CLASS); Class<?> convCls = Class.forName(CONVERTER_CLASS);
Object converter = convCls.getDeclaredConstructor().newInstance(); Object converter = convCls.getDeclaredConstructor().newInstance();
Method toImage = convCls.getMethod("getBufferedImage", Class.forName(FRAME_CLASS)); Method toImage = convCls.getMethod("getBufferedImage", frameCls);
while (running.get() && !closed) { while (running.get() && !closed) {
if (paused.get()) { Thread.sleep(20); continue; } if (paused.get()) { Thread.sleep(20); continue; }
@@ -118,26 +146,96 @@ public class JavaCvBackend implements VideoBackend {
} }
break; break;
} }
java.awt.image.BufferedImage img = (java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img == null) continue; Object[] samples = (Object[]) samplesField.get(frame);
if (samples != null && samples.length > 0 && audioLine != null) {
writeAudio(audioLine, samples, this.gain);
}
Object[] images = (Object[]) imageField.get(frame);
if (images != null && images.length > 0) {
java.awt.image.BufferedImage img =
(java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img != null) {
ByteBuffer buf = toRgba(img); ByteBuffer buf = toRgba(img);
if (buf != null) latest.set(buf); if (buf != null) latest.set(buf);
Thread.sleep(15); // ~60fps cap }
}
// If we have an open audio line, SourceDataLine.write() blocks for backpressure
// and provides natural A/V pacing; otherwise tick ~60fps so we don't busy-loop.
if (audioLine == null) Thread.sleep(15);
} }
} catch (ClassNotFoundException cnf) { } catch (ClassNotFoundException cnf) {
VideoPlayerMod.LOG.info("[{}] JavaCV not on classpath; backend inactive", VideoPlayerMod.MOD_ID); VideoPlayerMod.LOG.warn(
"[{}] JavaCV not on classpath — install org.bytedeco:javacv-platform (or javacv + ffmpeg natives)" +
" to enable video/audio playback. Anchor placeholder will remain visible.",
VideoPlayerMod.MOD_ID);
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Throwable t) { } catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString()); VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString());
} finally { } finally {
ready = false; ready = false;
if (audioLine != null) {
try { audioLine.drain(); } catch (Throwable ignored) {}
try { audioLine.stop(); } catch (Throwable ignored) {}
try { audioLine.close(); } catch (Throwable ignored) {}
}
if (grabber != null) { if (grabber != null) {
try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {} try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {}
} }
} }
} }
/** Open a JavaSound output line for the stream's sample rate / channel count, or null. */
private static SourceDataLine openLine(int sampleRate, int channels) {
if (sampleRate <= 0 || channels <= 0) return null;
try {
AudioFormat fmt = new AudioFormat(sampleRate, 16, channels, true, false); // signed 16-bit LE
SourceDataLine line = AudioSystem.getSourceDataLine(fmt);
line.open(fmt);
line.start();
return line;
} catch (Throwable t) {
VideoPlayerMod.LOG.info("[{}] no audio sink ({} Hz x{}): {}",
VideoPlayerMod.MOD_ID, sampleRate, channels, t.toString());
return null;
}
}
/** Scale & write interleaved S16 PCM samples to the audio line. */
private static void writeAudio(SourceDataLine line, Object[] samples, float gain) {
if (!(samples[0] instanceof ShortBuffer sb)) return; // sample format forcing failed
int remaining = sb.remaining();
if (remaining <= 0) return;
byte[] pcm = new byte[remaining * 2];
int idx = 0;
if (gain >= 0.999F) {
while (sb.hasRemaining()) {
short s = sb.get();
pcm[idx++] = (byte) (s & 0xFF);
pcm[idx++] = (byte) ((s >> 8) & 0xFF);
}
} else if (gain <= 0F) {
// Mute: consume but emit silence so the line keeps cadence.
sb.position(sb.limit());
} else {
while (sb.hasRemaining()) {
int scaled = (int) (sb.get() * gain);
if (scaled > 32767) scaled = 32767;
if (scaled < -32768) scaled = -32768;
pcm[idx++] = (byte) (scaled & 0xFF);
pcm[idx++] = (byte) ((scaled >> 8) & 0xFF);
}
}
line.write(pcm, 0, pcm.length);
}
private static int safeInt(Method m, Object target) {
try { return (int) m.invoke(target); } catch (Throwable t) { return 0; }
}
private static ByteBuffer toRgba(java.awt.image.BufferedImage img) { private static ByteBuffer toRgba(java.awt.image.BufferedImage img) {
int w = img.getWidth(), h = img.getHeight(); int w = img.getWidth(), h = img.getHeight();
int[] argb = img.getRGB(0, 0, w, h, null, 0, w); int[] argb = img.getRGB(0, 0, w, h, null, 0, w);

View File

@@ -20,15 +20,19 @@ import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f; import org.joml.Matrix4f;
/** /**
* SPEC §5.2 — submits a width×height textured quad in front of the anchor, oriented by facing. * Draws the video as a textured quad <em>on the surface of the block the user clicked</em>.
* *
* <p>Ported to 26.1.2's render-state pipeline: per-frame BE state is captured in * <p>The anchor BE lives in the air block adjacent to the clicked wall. Its {@code facing}
* {@link State} via {@link #extractRenderState}, then drawn via * field is the surface normal of the wall (= the {@link Direction} the player clicked). The
* {@link SubmitNodeCollector#submitCustomGeometry} during {@link #submit}. * quad is rotated so its normal aligns with that direction and shifted so it sits flush against
* the wall surface, with a tiny outward offset to avoid z-fighting.
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> { public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Tiny outward offset so the quad doesn't z-fight with the wall. */
private static final float SURFACE_EPSILON = 0.001F;
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) { public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op // no-op
} }
@@ -44,8 +48,7 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
BlockEntityRenderState.extractBase(be, state, crumbling); BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth(); state.width = be.getWidth();
state.height = be.getHeight(); state.height = be.getHeight();
Direction facing = be.getFacing(); state.facing = be.getFacing();
state.yaw = facing.getAxis().isHorizontal() ? facing.toYRot() : 0F;
state.textureId = VideoPlayback.getOrStart(be); state.textureId = VideoPlayback.getOrStart(be);
} }
@@ -57,25 +60,26 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
final float w = state.width; final float w = state.width;
final float h = state.height; final float h = state.height;
final int light = state.lightCoords; final int light = state.lightCoords;
final Direction f = state.facing == null ? Direction.NORTH : state.facing;
pose.pushPose(); pose.pushPose();
// Center quad on the anchor's top face, rotated to face the configured direction. // 1) Move to the anchor block's center.
pose.translate(0.5F, 1.01F, 0.5F); pose.translate(0.5F, 0.5F, 0.5F);
pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); // 2) Rotate local +Z to align with the wall's outward normal.
pose.translate(-w / 2.0F, 0F, 0F); applyFaceRotation(pose, f);
// 3) Center the quad on origin (local XY) and push it back 0.5 - ε so it lands on the
// wall surface (the boundary face between the anchor's air block and the wall block).
pose.translate(-w / 2.0F, -h / 2.0F, -0.5F + SURFACE_EPSILON);
// Snapshot the matrix so the callback's matrix-aware addVertex works even though
// submitCustomGeometry hands us a fresh Pose (its `pose` parameter).
final Matrix4f mat = new Matrix4f(pose.last().pose()); final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex); RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> { collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from the direction the anchor faces) // Front face (visible from outside, looking back at the wall)
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light); emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light); emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light); emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light); emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
// Back face (visible from behind) // Back face (in case the player ends up on the other side, e.g. clipping into the wall)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light); emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light); emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light); emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
@@ -85,6 +89,18 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
pose.popPose(); pose.popPose();
} }
/** Rotate so local +Z (the quad's outward normal in its base orientation) becomes world {@code f}. */
private static void applyFaceRotation(PoseStack pose, Direction f) {
switch (f) {
case SOUTH -> { /* identity: local +Z already faces world +Z (south) */ }
case NORTH -> pose.mulPose(Axis.YP.rotationDegrees(180F));
case EAST -> pose.mulPose(Axis.YP.rotationDegrees(-90F));
case WEST -> pose.mulPose(Axis.YP.rotationDegrees(90F));
case UP -> pose.mulPose(Axis.XP.rotationDegrees(-90F));
case DOWN -> pose.mulPose(Axis.XP.rotationDegrees(90F));
}
}
private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat, private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v, int light) { float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z) vc.addVertex(mat, x, y, z)
@@ -110,6 +126,6 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
public Identifier textureId; public Identifier textureId;
public int width = 1; public int width = 1;
public int height = 1; public int height = 1;
public float yaw = 0F; public Direction facing = Direction.NORTH;
} }
} }

View File

@@ -16,7 +16,18 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
/** Right-click empty face → place anchor + open GUI. Right-click existing anchor → edit. */ /**
* Right-click a block's face with the video stick:
* <ul>
* <li>If a video anchor already exists in the adjacent air (= an anchor already drawn on this
* face), reopen its config GUI.</li>
* <li>Otherwise place an invisible anchor in the adjacent air block, set its facing to the
* clicked face direction (so the renderer draws the quad flush against this face), and
* open the config GUI.</li>
* </ul>
* The anchor block itself is invisible / non-collidable, so visually no new block appears —
* the video just shows up on the face the user clicked.
*/
public class VideoStickItem extends Item { public class VideoStickItem extends Item {
public VideoStickItem(Properties properties) { public VideoStickItem(Properties properties) {
super(properties); super(properties);
@@ -33,25 +44,27 @@ public class VideoStickItem extends Item {
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS; if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos(); BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor → edit // Existing anchor on this face → reopen edit GUI.
if (sl.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) { if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt())); ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, existing.toNbt()));
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }
// Empty face → place anchor on top of the clicked face // Need an empty / replaceable space in front of the clicked face.
Direction side = ctx.getClickedFace(); BlockState there = sl.getBlockState(anchorPos);
BlockPos placeAt = hit.relative(side);
BlockState there = sl.getBlockState(placeAt);
if (!there.canBeReplaced()) return InteractionResult.PASS; if (!there.canBeReplaced()) return InteractionResult.PASS;
Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR; Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR;
sl.setBlock(placeAt, anchor.defaultBlockState(), Block.UPDATE_ALL); sl.setBlock(anchorPos, anchor.defaultBlockState(), Block.UPDATE_ALL);
if (sl.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) { if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
be.setFacing(ctx.getHorizontalDirection().getOpposite()); // Surface normal of the wall we're painting on points outward in the same direction
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt())); // as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
} }
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }

View File

@@ -14,7 +14,12 @@ public final class VideoPlayerBlocks {
public static final Block VIDEO_ANCHOR = register( public static final Block VIDEO_ANCHOR = register(
"video_anchor", "video_anchor",
BlockBehaviour.Properties.of().strength(1.0F).noOcclusion(), BlockBehaviour.Properties.of()
.noCollision()
.noOcclusion()
.instabreak()
.replaceable()
.strength(0F),
VideoAnchorBlock::new VideoAnchorBlock::new
); );

View File

@@ -1,21 +1,5 @@
{ {
"parent": "block/block",
"textures": { "textures": {
"all": "video_player:block/video_anchor",
"particle": "video_player:block/video_anchor" "particle": "video_player:block/video_anchor"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 2, 16],
"faces": {
"down": { "texture": "#all", "uv": [0, 0, 16, 16] },
"up": { "texture": "#all", "uv": [0, 0, 16, 16] },
"north": { "texture": "#all", "uv": [0, 0, 16, 2] },
"south": { "texture": "#all", "uv": [0, 0, 16, 2] },
"east": { "texture": "#all", "uv": [0, 0, 16, 2] },
"west": { "texture": "#all", "uv": [0, 0, 16, 2] }
} }
}
]
} }