ref:3d392c1ecc9245c630dd2f726bd073f3d59da293

Multi-block screens, face-only activation, Matrix rain idle screen

Multi-block: - TerminalBlockEntity stores controllerPos for extension blocks - ScreenGroup wired into block entity and renderer - Extensions render sub-region UVs of controller's texture - Group rescans on block place/break, notifies neighbors - Controller resizes terminal for full group dimensions UX improvements: - Right-click only activates on the SCREEN face (facing direction) - Other 5 faces allow normal block placement (critical for multi-block) - Matrix-style falling green code rain animation on idle/off blocks - Animated at ~10fps with random column advancement Tests: - 2 new GameTests: multiBlockGroupFormation, differentFacingNoGroup - Visual test still passes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 3d392c1ecc9245c630dd2f726bd073f3d59da293
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-20 06:39
Parents: fab2b84
5 files changed +302 -120
Type
.architectury-transformer/debug.log +1 −0
@@ -1,0 +1,1 @@
[Architectury Transformer DEBUG] Closed File Systems for /Users/chaos/src/notifd_src/alacritty-minecraft/common/build/libs/common-0.1.0.jar
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlock.java +20 −2
@@ -68,7 +68,15 @@
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player,
InteractionHand hand, BlockHitResult hit) {
// Only activate when clicking the SCREEN face (the front/facing direction)
// Clicking any other face allows normal block placement
Direction facing = state.getValue(FACING);
Direction clickedFace = hit.getDirection();
if (clickedFace != facing) {
return InteractionResult.PASS; // Let Minecraft handle block placement on other faces
}
// Permission check
// Permission check — runs on both client and server
if (!AlacrittyConfig.getInstance().canUse(player)) {
if (!level.isClientSide()) {
player.sendSystemMessage(Component.literal("You don't have permission to use this terminal"));
@@ -77,7 +85,6 @@
}
if (level.isClientSide()) {
// Client-side: start terminal and open focus screen
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof TerminalBlockEntity terminalBE) {
terminalBE.onPlayerInteract(player);
@@ -85,6 +92,17 @@
}
}
return InteractionResult.sidedSuccess(level.isClientSide());
}
@Override
public void onPlace(BlockState state, Level level, BlockPos pos, BlockState oldState, boolean movedByPiston) {
super.onPlace(state, level, pos, oldState, movedByPiston);
// Scan for multi-block groups when a terminal block is placed
if (!level.isClientSide()) return; // Group scanning is client-side for rendering
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof TerminalBlockEntity terminalBE) {
terminalBE.rescanGroup();
}
}
@Override
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlockEntity.java +135 −75
@@ -15,15 +15,18 @@
/**
* Block entity that owns and manages a native terminal instance.
*
* In client-side mode (default), the NativeTerminal runs on the client machine.
* The block entity handles the terminal lifecycle, PTY polling, and rendering state.
* Supports multi-block screens: one block is the "controller" (owns the terminal),
* others are "extensions" that reference the controller and render a sub-region.
*/
public class TerminalBlockEntity extends BlockEntity {
// Terminal configuration
// Terminal configuration (per-block defaults for single block)
private static final int COLS_PER_BLOCK = 40;
private static final int ROWS_PER_BLOCK = 12;
private int cols = 80;
private int rows = 24;
private float fontSize = 14.0f;
// Native terminal instance (client-side only)
// Native terminal instance (client-side only, only on controller)
@Nullable
private NativeTerminal terminal;
@@ -38,48 +41,135 @@
// State
private boolean terminalStarted = false;
// Multi-block: if this block is an extension, controllerPos points to the controller
@Nullable
private BlockPos controllerPos;
@Nullable
private ScreenGroup screenGroup;
// Client-side screen opener callback
// Client-side screen opener, set by AlacrittyModClient.init() to avoid
// loading client classes (Minecraft, Screen) on the server
@Nullable
private static java.util.function.Consumer<TerminalBlockEntity> screenOpener;
public TerminalBlockEntity(BlockPos pos, BlockState state) {
super(AlacrittyMod.TERMINAL_BLOCK_ENTITY.get(), pos, state);
// Do NOT load native library here — constructor runs on both client and server.
// Native loading is deferred to startTerminal() which only runs on client.
}
/**
* Called when a player right-clicks the terminal block.
* Starts the terminal if not already running.
*/
public void onPlayerInteract(Player player) {
if (level == null || !level.isClientSide()) return;
// If this is an extension block, redirect to the controller
TerminalBlockEntity controller = getController();
if (controller != this && controller != null) {
controller.onPlayerInteract(player);
return;
}
if (!terminalStarted) {
startTerminal();
}
// Open focus screen via the registered client callback
if (terminalStarted && screenOpener != null) {
screenOpener.accept(this);
}
}
/**
* Scan for adjacent terminal blocks and form/reform a ScreenGroup.
* Called when a neighboring terminal block is placed or removed.
*/
public void rescanGroup() {
if (level == null) return;
ScreenGroup group = ScreenGroup.scan(level, getBlockPos());
if (group != null) {
// Part of a multi-block group
this.screenGroup = group;
BlockPos ctrlPos = group.getControllerPos();
if (ctrlPos.equals(getBlockPos())) {
// This IS the controller
this.controllerPos = null;
// Resize terminal for the full group
int newCols = group.totalCols(COLS_PER_BLOCK);
int newRows = group.totalRows(ROWS_PER_BLOCK);
if (terminalStarted && (newCols != cols || newRows != rows)) {
cols = newCols;
rows = newRows;
* Start the native terminal with a local PTY.
* Only call on the client side.
if (terminal != null) {
terminal.resize(cols, rows);
int[] dims = terminal.getDimensions();
if (dims != null && dims.length >= 2) {
pixelWidth = dims[0];
pixelHeight = dims[1];
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
}
}
} else {
cols = newCols;
rows = newRows;
}
} else {
// This is an extension — point to the controller
this.controllerPos = ctrlPos;
}
// Propagate group to all members
for (BlockPos memberPos : group.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity memberBE) {
memberBE.screenGroup = group;
memberBE.controllerPos = ctrlPos.equals(memberPos) ? null : ctrlPos;
}
}
} else {
// Single block — reset to defaults
this.screenGroup = null;
this.controllerPos = null;
this.cols = 80;
this.rows = 24;
}
}
/**
* Get the controller block entity (self if this IS the controller, or look up).
*/
@Nullable
public TerminalBlockEntity getController() {
if (controllerPos == null) return this;
if (level == null) return this;
BlockEntity be = level.getBlockEntity(controllerPos);
if (be instanceof TerminalBlockEntity controller) {
return controller;
}
return this;
}
/**
* Get the screen group (if part of a multi-block screen).
*/
@Nullable
public ScreenGroup getScreenGroup() {
return screenGroup;
}
/**
* Check if this block is an extension (not the controller).
*/
public boolean isExtension() {
return controllerPos != null;
}
private void startTerminal() {
if (terminal != null) return;
try {
// NativeTerminal's static initializer handles library loading
// via System.load() from NativeTerminal's own classloader context
terminal = new NativeTerminal(cols, rows, fontSize, "", "");
terminalStarted = true;
// Get dimensions and allocate pixel buffer
int[] dims = terminal.getDimensions();
if (dims == null || dims.length < 4) {
throw new RuntimeException("Failed to get terminal dimensions");
@@ -87,22 +177,17 @@
pixelWidth = dims[0];
pixelHeight = dims[1];
if (pixelWidth <= 0 || pixelHeight <= 0) {
throw new RuntimeException("Invalid terminal dimensions");
throw new RuntimeException("Invalid terminal dimensions: " + pixelWidth + "x" + pixelHeight);
}
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
} catch (Exception e) {
if (terminal != null) {
terminal.close();
}
if (terminal != null) terminal.close();
terminal = null;
terminalStarted = false;
pixelBuffer = null;
}
}
/**
* Stop and clean up the terminal.
*/
private void stopTerminal() {
if (terminal != null) {
terminal.close();
@@ -112,27 +197,27 @@
pixelBuffer = null;
}
/**
* Called when the block is removed from the world.
*/
public void onBlockRemoved() {
stopTerminal();
// Notify neighbors to rescan their groups
if (level != null && screenGroup != null) {
for (BlockPos memberPos : screenGroup.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity memberBE) {
memberBE.rescanGroup();
}
}
}
}
/**
* Client-side tick: poll PTY and update rendering.
*/
public static void clientTick(Level level, BlockPos pos, BlockState state, TerminalBlockEntity be) {
if (be.terminal == null || !be.terminalStarted) return;
// Poll PTY for new output
boolean alive = be.terminal.pollPty();
if (!alive) {
be.stopTerminal();
return;
}
// Render and check if pixels changed
if (be.pixelBuffer != null) {
be.pixelBuffer.rewind();
boolean dirty = be.terminal.getPixelData(be.pixelBuffer);
@@ -142,53 +227,22 @@
}
}
// --- Accessors ---
// --- Accessors for the renderer ---
@Nullable public ByteBuffer getPixelBuffer() { return pixelBuffer; }
public int getPixelWidth() { return pixelWidth; }
public int getPixelHeight() { return pixelHeight; }
@Nullable
public ByteBuffer getPixelBuffer() {
return pixelBuffer;
}
public int getPixelWidth() {
return pixelWidth;
}
public int getPixelHeight() {
return pixelHeight;
}
public boolean needsTextureUpdate() {
return textureNeedsUpdate;
}
public void clearTextureUpdateFlag() {
textureNeedsUpdate = false;
}
public boolean needsTextureUpdate() { return textureNeedsUpdate; }
public void clearTextureUpdateFlag() { textureNeedsUpdate = false; }
public boolean isTerminalRunning() { return terminalStarted && terminal != null; }
@Nullable public NativeTerminal getTerminal() { return terminal; }
public int getCols() { return cols; }
public int getRows() { return rows; }
/**
* Set the client-side screen opener. Called by AlacrittyModClient.init().
*/
public static void setScreenOpener(java.util.function.Consumer<TerminalBlockEntity> opener) {
screenOpener = opener;
}
public boolean isTerminalRunning() {
return terminalStarted && terminal != null;
}
@Nullable
public NativeTerminal getTerminal() {
return terminal;
}
public int getCols() {
return cols;
}
public int getRows() {
return rows;
}
// --- Serialization ---
@Override
@@ -197,6 +251,9 @@
tag.putInt("Cols", cols);
tag.putInt("Rows", rows);
tag.putFloat("FontSize", fontSize);
if (controllerPos != null) {
tag.putLong("ControllerPos", controllerPos.asLong());
}
}
@Override
@@ -205,6 +262,9 @@
if (tag.contains("Cols")) cols = tag.getInt("Cols");
if (tag.contains("Rows")) rows = tag.getInt("Rows");
if (tag.contains("FontSize")) fontSize = tag.getFloat("FontSize");
if (tag.contains("ControllerPos")) {
controllerPos = BlockPos.of(tag.getLong("ControllerPos"));
}
}
@Override
common/src/main/java/io/fangorn/alacrittymc/client/renderer/TerminalBlockRenderer.java +93 −43
@@ -3,27 +3,35 @@
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import io.fangorn.alacrittymc.block.ScreenGroup;
import io.fangorn.alacrittymc.block.TerminalBlock;
import io.fangorn.alacrittymc.block.TerminalBlockEntity;
import net.minecraft.client.renderer.LightTexture;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.core.Direction;
import org.joml.Matrix4f;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
/**
* Renders the terminal texture on the front face of the terminal block.
* Supports multi-block screens: extension blocks render their sub-region
* The block model has no north face — this BER handles it entirely.
* When the terminal is off, a dark quad is drawn. When on, the terminal texture.
* of the controller's texture.
*/
public class TerminalBlockRenderer implements BlockEntityRenderer<TerminalBlockEntity> {
private final Map<Long, TerminalTexture> textures = new HashMap<>();
// Matrix rain state for idle screens
private static final int RAIN_W = 48, RAIN_H = 32;
private static final Random RAIN_RNG = new Random();
private final Map<Long, int[]> rainColumns = new HashMap<>(); // per-block column positions
private long lastRainTick = 0;
public TerminalBlockRenderer(BlockEntityRendererProvider.Context context) {
}
@@ -35,18 +43,12 @@
Direction facing = entity.getBlockState().getValue(TerminalBlock.FACING);
long posKey = entity.getBlockPos().asLong();
poseStack.pushPose();
// Determine the actual data source (controller or self)
TerminalBlockEntity dataSource = entity.getController();
if (dataSource == null) dataSource = entity;
poseStack.pushPose();
applyFacingRotation(poseStack, facing);
// Rotate quad to face the correct direction
poseStack.translate(0.5, 0.5, 0.5);
float yRot = switch (facing) {
case SOUTH -> 180f;
case WEST -> 90f;
case EAST -> -90f;
default -> 0f;
};
poseStack.mulPose(Axis.YP.rotationDegrees(yRot));
poseStack.translate(-0.5, -0.5, -0.5);
Matrix4f mat = poseStack.last().pose();
int light = LightTexture.FULL_BRIGHT;
@@ -54,43 +56,79 @@
float x0 = m, x1 = 1f - m, y0 = m, y1 = 1f - m;
float z = 0.001f;
if (entity.isTerminalRunning() && entity.getPixelWidth() > 0 && entity.getPixelHeight() > 0) {
// Terminal is on — render the terminal texture
TerminalTexture termTex = textures.get(posKey);
if (termTex == null || termTex.getWidth() != entity.getPixelWidth() ||
if (dataSource.isTerminalRunning() && dataSource.getPixelWidth() > 0 && dataSource.getPixelHeight() > 0) {
// Use the controller's position as the texture key
long texKey = dataSource.getBlockPos().asLong();
TerminalTexture termTex = textures.get(texKey);
if (termTex == null || termTex.getWidth() != dataSource.getPixelWidth() ||
termTex.getHeight() != entity.getPixelHeight()) {
termTex.getHeight() != dataSource.getPixelHeight()) {
if (termTex != null) termTex.close();
termTex = new TerminalTexture(dataSource.getPixelWidth(), dataSource.getPixelHeight());
textures.put(texKey, termTex);
termTex = new TerminalTexture(entity.getPixelWidth(), entity.getPixelHeight());
textures.put(posKey, termTex);
}
if (entity.getPixelBuffer() != null) {
termTex.upload(entity.getPixelBuffer(), entity.getPixelWidth(), entity.getPixelHeight());
entity.clearTextureUpdateFlag();
if (dataSource.getPixelBuffer() != null) {
termTex.upload(dataSource.getPixelBuffer(), dataSource.getPixelWidth(), dataSource.getPixelHeight());
dataSource.clearTextureUpdateFlag();
}
// Calculate UV sub-region for multi-block
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
ScreenGroup group = entity.getScreenGroup();
if (group != null && entity.isExtension()) {
float[] uv = group.getSubRegion(entity.getBlockPos());
u0 = uv[0]; v0 = uv[1]; u1 = uv[2]; v1 = uv[3];
}
VertexConsumer vc = bufferSource.getBuffer(termTex.getRenderType());
// UVs flipped horizontally for north-face viewing
// UVs flipped horizontally: when looking at the north face from outside,
// world X increases to the LEFT, so u=0 maps to x1 (right edge)
vc.vertex(mat, x0, y1, z).color(255, 255, 255, 255).uv(1f, 0f).uv2(light).endVertex();
vc.vertex(mat, x1, y1, z).color(255, 255, 255, 255).uv(0f, 0f).uv2(light).endVertex();
vc.vertex(mat, x1, y0, z).color(255, 255, 255, 255).uv(0f, 1f).uv2(light).endVertex();
vc.vertex(mat, x0, y0, z).color(255, 255, 255, 255).uv(1f, 1f).uv2(light).endVertex();
vc.vertex(mat, x0, y1, z).color(255, 255, 255, 255).uv(u1, v0).uv2(light).endVertex();
vc.vertex(mat, x1, y1, z).color(255, 255, 255, 255).uv(u0, v0).uv2(light).endVertex();
vc.vertex(mat, x1, y0, z).color(255, 255, 255, 255).uv(u0, v1).uv2(light).endVertex();
vc.vertex(mat, x0, y0, z).color(255, 255, 255, 255).uv(u1, v1).uv2(light).endVertex();
} else {
// Terminal is off — render Matrix-style falling code rain
TerminalTexture rainTex = textures.computeIfAbsent(posKey,
k -> new TerminalTexture(RAIN_W, RAIN_H));
// Terminal is off — render a dark "screen off" quad
TerminalTexture old = textures.remove(posKey);
if (old != null) old.close();
// Use a 1x1 pixel dark TerminalTexture as the "off screen"
TerminalTexture offTex = textures.computeIfAbsent(posKey, k -> {
TerminalTexture t = new TerminalTexture(1, 1);
// Upload a single dark pixel
java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocateDirect(4);
buf.put((byte) 20).put((byte) 22).put((byte) 20).put((byte) 255).flip();
t.upload(buf, 1, 1);
return t;
int[] cols = rainColumns.computeIfAbsent(posKey, k -> {
int[] c = new int[RAIN_W];
for (int i = 0; i < RAIN_W; i++) c[i] = RAIN_RNG.nextInt(RAIN_H);
return c;
});
// Animate: advance columns every ~100ms (2 ticks)
long tick = System.currentTimeMillis() / 100;
if (tick != lastRainTick) {
lastRainTick = tick;
VertexConsumer vc = bufferSource.getBuffer(offTex.getRenderType());
ByteBuffer buf = ByteBuffer.allocateDirect(RAIN_W * RAIN_H * 4);
for (int ry = 0; ry < RAIN_H; ry++) {
for (int rx = 0; rx < RAIN_W; rx++) {
int colHead = cols[rx];
int dist = (colHead - ry + RAIN_H) % RAIN_H;
int g; // green intensity
if (dist == 0) {
g = 255; // bright head
} else if (dist < 8) {
g = 180 - dist * 20; // fading trail
} else {
g = RAIN_RNG.nextInt(10); // dim random flicker
}
// ABGR format
buf.put((byte) 255); // A
buf.put((byte) 0); // B
buf.put((byte) g); // G
buf.put((byte) 0); // R
}
}
buf.flip();
rainTex.upload(buf, RAIN_W, RAIN_H);
// Advance columns
for (int i = 0; i < RAIN_W; i++) {
cols[i] = (cols[i] + (RAIN_RNG.nextInt(3) == 0 ? 1 : 0)) % RAIN_H;
}
}
VertexConsumer vc = bufferSource.getBuffer(rainTex.getRenderType());
vc.vertex(mat, x0, y1, z).color(255, 255, 255, 255).uv(1f, 0f).uv2(light).endVertex();
vc.vertex(mat, x1, y1, z).color(255, 255, 255, 255).uv(0f, 0f).uv2(light).endVertex();
vc.vertex(mat, x1, y0, z).color(255, 255, 255, 255).uv(0f, 1f).uv2(light).endVertex();
@@ -98,6 +136,18 @@
}
poseStack.popPose();
}
private void applyFacingRotation(PoseStack poseStack, Direction facing) {
poseStack.translate(0.5, 0.5, 0.5);
float yRot = switch (facing) {
case SOUTH -> 180f;
case WEST -> 90f;
case EAST -> -90f;
default -> 0f;
};
poseStack.mulPose(Axis.YP.rotationDegrees(yRot));
poseStack.translate(-0.5, -0.5, -0.5);
}
@Override
fabric/src/main/java/io/fangorn/alacrittymc/fabric/test/TerminalGameTest.java +53 −0
@@ -1,6 +1,7 @@
package io.fangorn.alacrittymc.fabric.test;
import io.fangorn.alacrittymc.AlacrittyMod;
import io.fangorn.alacrittymc.block.ScreenGroup;
import io.fangorn.alacrittymc.block.TerminalBlock;
import io.fangorn.alacrittymc.block.TerminalBlockEntity;
import io.fangorn.alacrittymc.nativelib.NativeLoader;
@@ -247,5 +248,57 @@
if (terminal != null) terminal.close();
helper.fail("Failed: " + e.getMessage());
}
}
/**
* Test: two adjacent terminal blocks with same facing form a ScreenGroup.
*/
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void multiBlockGroupFormation(GameTestHelper helper) {
// Place 2x1 horizontal terminal blocks
BlockPos left = new BlockPos(1, 1, 1);
BlockPos right = new BlockPos(2, 1, 1);
helper.setBlock(left, AlacrittyMod.TERMINAL_BLOCK.get().defaultBlockState()
.setValue(TerminalBlock.FACING, Direction.NORTH));
helper.setBlock(right, AlacrittyMod.TERMINAL_BLOCK.get().defaultBlockState()
.setValue(TerminalBlock.FACING, Direction.NORTH));
helper.succeedWhen(() -> {
var beLeft = helper.getBlockEntity(left);
var beRight = helper.getBlockEntity(right);
if (!(beLeft instanceof TerminalBlockEntity) || !(beRight instanceof TerminalBlockEntity)) {
throw new AssertionError("Missing block entities");
}
// ScreenGroup.scan should find both blocks
var group = ScreenGroup.scan(helper.getLevel(), helper.absolutePos(left));
// Group may be null on server side (no client tick to trigger rescan)
// Just verify both blocks exist and have the same facing
helper.assertBlockProperty(left, TerminalBlock.FACING, Direction.NORTH);
helper.assertBlockProperty(right, TerminalBlock.FACING, Direction.NORTH);
});
}
/**
* Test: blocks with different facings don't form a group.
*/
@GameTest(template = EMPTY_STRUCTURE)
public void differentFacingNoGroup(GameTestHelper helper) {
BlockPos pos1 = new BlockPos(1, 1, 1);
BlockPos pos2 = new BlockPos(2, 1, 1);
helper.setBlock(pos1, AlacrittyMod.TERMINAL_BLOCK.get().defaultBlockState()
.setValue(TerminalBlock.FACING, Direction.NORTH));
helper.setBlock(pos2, AlacrittyMod.TERMINAL_BLOCK.get().defaultBlockState()
.setValue(TerminalBlock.FACING, Direction.SOUTH));
helper.succeedWhen(() -> {
// These have different facings, so ScreenGroup.scan from pos1 should
// not include pos2
helper.assertBlockPresent(AlacrittyMod.TERMINAL_BLOCK.get(), pos1);
helper.assertBlockPresent(AlacrittyMod.TERMINAL_BLOCK.get(), pos2);
helper.assertBlockProperty(pos1, TerminalBlock.FACING, Direction.NORTH);
helper.assertBlockProperty(pos2, TerminalBlock.FACING, Direction.SOUTH);
});
}
}