Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459b3249a4 | ||
|
|
2b50f56980 | ||
|
|
429244d820 |
@@ -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.1
|
||||||
maven_group=com.ejclaw.videoplayer
|
maven_group=com.ejclaw.videoplayer
|
||||||
archives_base_name=video_player
|
archives_base_name=video_player
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,28 @@ 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) Place the quad's local origin (0,0) at the bottom-left corner of the anchor block's
|
||||||
|
// wall face, so the clicked block becomes the lower-left and the video grows up & right.
|
||||||
|
// Push it onto the wall surface (-0.5 along local +Z, the outward normal) plus a tiny
|
||||||
|
// epsilon outward so the quad doesn't z-fight with the wall.
|
||||||
|
pose.translate(-0.5F, -0.5F, -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 +91,24 @@ 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},
|
||||||
|
* with local +X mapped to the natural "right" direction the player sees when looking at the face.
|
||||||
|
* Derivation: for each face {@code f}, pick the rotation that maps local +Z → f, +Y → world up
|
||||||
|
* (or a sensible substitute for top/bottom), so the quad lies flush against the wall, oriented
|
||||||
|
* the way the player intuits.
|
||||||
|
*/
|
||||||
|
private static void applyFaceRotation(PoseStack pose, Direction f) {
|
||||||
|
switch (f) {
|
||||||
|
case SOUTH -> { /* identity: local +Z = world +Z (south). +X = east, +Y = up. */ }
|
||||||
|
case NORTH -> pose.mulPose(Axis.YP.rotationDegrees(180F)); // +Z → -Z, +X → -X (west)
|
||||||
|
case EAST -> pose.mulPose(Axis.YP.rotationDegrees(90F)); // +Z → +X, +X → -Z (north)
|
||||||
|
case WEST -> pose.mulPose(Axis.YP.rotationDegrees(-90F)); // +Z → -X, +X → +Z (south)
|
||||||
|
case UP -> pose.mulPose(Axis.XP.rotationDegrees(-90F)); // +Z → +Y, +Y → -Z (north)
|
||||||
|
case DOWN -> pose.mulPose(Axis.XP.rotationDegrees(90F)); // +Z → -Y, +Y → +Z (south)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 +134,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"model": {
|
||||||
|
"type": "minecraft:model",
|
||||||
|
"model": "video_player:item/video_stick"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user