1 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
8 changed files with 129 additions and 54 deletions

View File

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

View File

@@ -33,9 +33,20 @@ public class VideoPlayerClient implements ClientModInitializer {
);
AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (level.isClientSide()
&& player.getMainHandItem().getItem() instanceof VideoStickItem
&& level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
if (!level.isClientSide()) return InteractionResult.PASS;
if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
// 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));
return InteractionResult.SUCCESS;
}

View File

@@ -10,13 +10,26 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
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.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
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 static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new);
@@ -34,6 +47,31 @@ public class VideoAnchorBlock extends BaseEntityBlock {
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
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
BlockPos pos, Player player, InteractionHand hand,

View File

@@ -108,9 +108,14 @@ public class JavaCvBackend implements VideoBackend {
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
// mp4/http(s) network tuning
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {}
// HTTP(S) tuning for streaming URLs (e.g. webm via Range / chunked transfer).
try { setOpt.invoke(grabber, "rw_timeout", "10000000"); } 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) {}
@@ -162,7 +167,10 @@ public class JavaCvBackend implements VideoBackend {
if (audioLine == null) Thread.sleep(15);
}
} 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) {
Thread.currentThread().interrupt();
} catch (Throwable t) {

View File

@@ -20,15 +20,19 @@ import net.minecraft.world.phys.Vec3;
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
* {@link State} via {@link #extractRenderState}, then drawn via
* {@link SubmitNodeCollector#submitCustomGeometry} during {@link #submit}.
* <p>The anchor BE lives in the air block adjacent to the clicked wall. Its {@code facing}
* field is the surface normal of the wall (= the {@link Direction} the player clicked). The
* 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)
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) {
// no-op
}
@@ -44,8 +48,7 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth();
state.height = be.getHeight();
Direction facing = be.getFacing();
state.yaw = facing.getAxis().isHorizontal() ? facing.toYRot() : 0F;
state.facing = be.getFacing();
state.textureId = VideoPlayback.getOrStart(be);
}
@@ -57,25 +60,26 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
final float w = state.width;
final float h = state.height;
final int light = state.lightCoords;
final Direction f = state.facing == null ? Direction.NORTH : state.facing;
pose.pushPose();
// Center quad on the anchor's top face, rotated to face the configured direction.
pose.translate(0.5F, 1.01F, 0.5F);
pose.mulPose(Axis.YP.rotationDegrees(-state.yaw));
pose.translate(-w / 2.0F, 0F, 0F);
// 1) Move to the anchor block's center.
pose.translate(0.5F, 0.5F, 0.5F);
// 2) Rotate local +Z to align with the wall's outward normal.
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());
RenderType rt = RenderTypes.entityCutout(tex);
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, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 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, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
@@ -85,6 +89,18 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
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,
float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z)
@@ -110,6 +126,6 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
public Identifier textureId;
public int width = 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.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 VideoStickItem(Properties properties) {
super(properties);
@@ -33,25 +44,27 @@ public class VideoStickItem extends Item {
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor → edit
if (sl.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt()));
// Existing anchor on this face → reopen edit GUI.
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, existing.toNbt()));
return InteractionResult.SUCCESS;
}
// Empty face → place anchor on top of the clicked face
Direction side = ctx.getClickedFace();
BlockPos placeAt = hit.relative(side);
BlockState there = sl.getBlockState(placeAt);
// Need an empty / replaceable space in front of the clicked face.
BlockState there = sl.getBlockState(anchorPos);
if (!there.canBeReplaced()) return InteractionResult.PASS;
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) {
be.setFacing(ctx.getHorizontalDirection().getOpposite());
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt()));
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
// Surface normal of the wall we're painting on points outward in the same direction
// as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
}
return InteractionResult.SUCCESS;
}

View File

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

View File

@@ -1,21 +1,5 @@
{
"parent": "block/block",
"textures": {
"all": "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] }
}
}
]
}