diff --git a/projects/common/src/client/java/dan200/computercraft/client/ClientRegistry.java b/projects/common/src/client/java/dan200/computercraft/client/ClientRegistry.java
index 405f810ab..b1560568c 100644
--- a/projects/common/src/client/java/dan200/computercraft/client/ClientRegistry.java
+++ b/projects/common/src/client/java/dan200/computercraft/client/ClientRegistry.java
@@ -13,6 +13,7 @@ import dan200.computercraft.api.client.turtle.RegisterTurtleUpgradeModeller;
import dan200.computercraft.api.client.turtle.TurtleUpgradeModeller;
import dan200.computercraft.client.gui.*;
import dan200.computercraft.client.pocket.ClientPocketComputers;
+import dan200.computercraft.client.render.CustomLecternRenderer;
import dan200.computercraft.client.render.RenderTypes;
import dan200.computercraft.client.render.TurtleBlockEntityRenderer;
import dan200.computercraft.client.render.monitor.MonitorBlockEntityRenderer;
@@ -73,6 +74,7 @@ public final class ClientRegistry {
BlockEntityRenderers.register(ModRegistry.BlockEntities.MONITOR_ADVANCED.get(), MonitorBlockEntityRenderer::new);
BlockEntityRenderers.register(ModRegistry.BlockEntities.TURTLE_NORMAL.get(), TurtleBlockEntityRenderer::new);
BlockEntityRenderers.register(ModRegistry.BlockEntities.TURTLE_ADVANCED.get(), TurtleBlockEntityRenderer::new);
+ BlockEntityRenderers.register(ModRegistry.BlockEntities.LECTERN.get(), CustomLecternRenderer::new);
}
/**
diff --git a/projects/common/src/client/java/dan200/computercraft/client/model/LecternPrintoutModel.java b/projects/common/src/client/java/dan200/computercraft/client/model/LecternPrintoutModel.java
new file mode 100644
index 000000000..5e9930094
--- /dev/null
+++ b/projects/common/src/client/java/dan200/computercraft/client/model/LecternPrintoutModel.java
@@ -0,0 +1,117 @@
+// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package dan200.computercraft.client.model;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import dan200.computercraft.api.ComputerCraftAPI;
+import dan200.computercraft.client.render.CustomLecternRenderer;
+import dan200.computercraft.shared.media.items.PrintoutItem;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.model.geom.PartPose;
+import net.minecraft.client.model.geom.builders.CubeListBuilder;
+import net.minecraft.client.model.geom.builders.MeshDefinition;
+import net.minecraft.client.resources.model.Material;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.inventory.InventoryMenu;
+
+import java.util.List;
+
+/**
+ * A model for {@linkplain PrintoutItem printouts} placed on a lectern.
+ *
+ * This provides two models, {@linkplain #renderPages(PoseStack, VertexConsumer, int, int, int) one for a variable
+ * number of pages}, and {@linkplain #renderBook(PoseStack, VertexConsumer, int, int) one for books}.
+ *
+ * @see CustomLecternRenderer
+ */
+public class LecternPrintoutModel {
+ public static final ResourceLocation TEXTURE = new ResourceLocation(ComputerCraftAPI.MOD_ID, "entity/printout");
+ public static final Material MATERIAL = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE);
+
+ private static final int TEXTURE_WIDTH = 32;
+ private static final int TEXTURE_HEIGHT = 32;
+
+ private static final String PAGE_1 = "page_1";
+ private static final String PAGE_2 = "page_2";
+ private static final String PAGE_3 = "page_3";
+ private static final List PAGES = List.of(PAGE_1, PAGE_2, PAGE_3);
+
+ private final ModelPart pagesRoot;
+ private final ModelPart bookRoot;
+ private final ModelPart[] pages;
+
+ public LecternPrintoutModel() {
+ pagesRoot = buildPages();
+ bookRoot = buildBook();
+ pages = PAGES.stream().map(pagesRoot::getChild).toArray(ModelPart[]::new);
+ }
+
+ private static ModelPart buildPages() {
+ var mesh = new MeshDefinition();
+ var parts = mesh.getRoot();
+ parts.addOrReplaceChild(
+ PAGE_1,
+ CubeListBuilder.create().texOffs(0, 0).addBox(-0.005f, -4.0f, -2.5f, 1f, 8.0f, 5.0f),
+ PartPose.ZERO
+ );
+
+ parts.addOrReplaceChild(
+ PAGE_2,
+ CubeListBuilder.create().texOffs(12, 0).addBox(-0.005f, -4.0f, -2.5f, 1f, 8.0f, 5.0f),
+ PartPose.offsetAndRotation(-0.125f, 0, 1.5f, (float) Math.PI * (1f / 16), 0, 0)
+ );
+ parts.addOrReplaceChild(
+ PAGE_3,
+ CubeListBuilder.create().texOffs(12, 0).addBox(-0.005f, -4.0f, -2.5f, 1f, 8.0f, 5.0f),
+ PartPose.offsetAndRotation(-0.25f, 0, -1.5f, (float) -Math.PI * (2f / 16), 0, 0)
+ );
+
+ return mesh.getRoot().bake(TEXTURE_WIDTH, TEXTURE_HEIGHT);
+ }
+
+ private static ModelPart buildBook() {
+ var mesh = new MeshDefinition();
+ var parts = mesh.getRoot();
+
+ parts.addOrReplaceChild(
+ "spine",
+ CubeListBuilder.create().texOffs(12, 15).addBox(-0.005f, -5.0f, -0.5f, 0, 10, 1.0f),
+ PartPose.ZERO
+ );
+
+ var angle = (float) Math.toRadians(5);
+ parts.addOrReplaceChild(
+ "left",
+ CubeListBuilder.create()
+ .texOffs(0, 10).addBox(0, -5.0f, -6.0f, 0, 10, 6.0f)
+ .texOffs(0, 0).addBox(0.005f, -4.0f, -5.0f, 1.0f, 8.0f, 5.0f),
+ PartPose.offsetAndRotation(-0.005f, 0, -0.5f, 0, -angle, 0)
+ );
+
+ parts.addOrReplaceChild(
+ "right",
+ CubeListBuilder.create()
+ .texOffs(14, 10).addBox(0, -5.0f, 0, 0, 10, 6.0f)
+ .texOffs(0, 0).addBox(0.005f, -4.0f, 0, 1.0f, 8.0f, 5.0f),
+ PartPose.offsetAndRotation(-0.005f, 0, 0.5f, 0, angle, 0)
+ );
+
+ return mesh.getRoot().bake(TEXTURE_WIDTH, TEXTURE_HEIGHT);
+ }
+
+ public void renderBook(PoseStack poseStack, VertexConsumer buffer, int packedLight, int packedOverlay) {
+ bookRoot.render(poseStack, buffer, packedLight, packedOverlay, 1, 1, 1, 1);
+ }
+
+ public void renderPages(PoseStack poseStack, VertexConsumer buffer, int packedLight, int packedOverlay, int pageCount) {
+ if (pageCount > pages.length) pageCount = pages.length;
+ var i = 0;
+ for (; i < pageCount; i++) pages[i].visible = true;
+ for (; i < pages.length; i++) pages[i].visible = false;
+
+ pagesRoot.render(poseStack, buffer, packedLight, packedOverlay, 1, 1, 1, 1);
+ }
+}
diff --git a/projects/common/src/client/java/dan200/computercraft/client/render/CustomLecternRenderer.java b/projects/common/src/client/java/dan200/computercraft/client/render/CustomLecternRenderer.java
new file mode 100644
index 000000000..1743d2bfd
--- /dev/null
+++ b/projects/common/src/client/java/dan200/computercraft/client/render/CustomLecternRenderer.java
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package dan200.computercraft.client.render;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.math.Axis;
+import dan200.computercraft.client.model.LecternPrintoutModel;
+import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
+import dan200.computercraft.shared.media.items.PrintoutItem;
+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.client.renderer.blockentity.LecternRenderer;
+import net.minecraft.world.level.block.LecternBlock;
+
+/**
+ * A block entity renderer for our {@linkplain CustomLecternBlockEntity lectern}.
+ *
+ * This largely follows {@link LecternRenderer}, but with support for multiple types of item.
+ */
+public class CustomLecternRenderer implements BlockEntityRenderer {
+ private final LecternPrintoutModel printoutModel;
+
+ public CustomLecternRenderer(BlockEntityRendererProvider.Context context) {
+ printoutModel = new LecternPrintoutModel();
+ }
+
+ @Override
+ public void render(CustomLecternBlockEntity lectern, float partialTick, PoseStack poseStack, MultiBufferSource buffer, int packedLight, int packedOverlay) {
+ poseStack.pushPose();
+ poseStack.translate(0.5f, 1.0625f, 0.5f);
+ poseStack.mulPose(Axis.YP.rotationDegrees(-lectern.getBlockState().getValue(LecternBlock.FACING).getClockWise().toYRot()));
+ poseStack.mulPose(Axis.ZP.rotationDegrees(67.5f));
+ poseStack.translate(0, -0.125f, 0);
+
+ var item = lectern.getItem();
+ if (item.getItem() instanceof PrintoutItem printout) {
+ var vertexConsumer = LecternPrintoutModel.MATERIAL.buffer(buffer, RenderType::entitySolid);
+ if (printout.getType() == PrintoutItem.Type.BOOK) {
+ printoutModel.renderBook(poseStack, vertexConsumer, packedLight, packedOverlay);
+ } else {
+ printoutModel.renderPages(poseStack, vertexConsumer, packedLight, packedOverlay, PrintoutItem.getPageCount(item));
+ }
+ }
+
+ poseStack.popPose();
+ }
+}
diff --git a/projects/common/src/client/java/dan200/computercraft/data/client/ClientDataProviders.java b/projects/common/src/client/java/dan200/computercraft/data/client/ClientDataProviders.java
index 76891e250..b318826d0 100644
--- a/projects/common/src/client/java/dan200/computercraft/data/client/ClientDataProviders.java
+++ b/projects/common/src/client/java/dan200/computercraft/data/client/ClientDataProviders.java
@@ -5,6 +5,7 @@
package dan200.computercraft.data.client;
import dan200.computercraft.client.gui.GuiSprites;
+import dan200.computercraft.client.model.LecternPrintoutModel;
import dan200.computercraft.data.DataProviders;
import dan200.computercraft.shared.turtle.inventory.UpgradeSlot;
import net.minecraft.client.renderer.texture.atlas.SpriteSource;
@@ -30,7 +31,8 @@ public final class ClientDataProviders {
generator.addFromCodec("Block atlases", PackType.CLIENT_RESOURCES, "atlases", SpriteSources.FILE_CODEC, out -> {
out.accept(new ResourceLocation("blocks"), List.of(
new SingleFile(UpgradeSlot.LEFT_UPGRADE, Optional.empty()),
- new SingleFile(UpgradeSlot.RIGHT_UPGRADE, Optional.empty())
+ new SingleFile(UpgradeSlot.RIGHT_UPGRADE, Optional.empty()),
+ new SingleFile(LecternPrintoutModel.TEXTURE, Optional.empty())
));
out.accept(GuiSprites.SPRITE_SHEET, Stream.of(
// Buttons
diff --git a/projects/common/src/generated/resources/assets/computercraft/blockstates/lectern.json b/projects/common/src/generated/resources/assets/computercraft/blockstates/lectern.json
new file mode 100644
index 000000000..c760753f3
--- /dev/null
+++ b/projects/common/src/generated/resources/assets/computercraft/blockstates/lectern.json
@@ -0,0 +1,8 @@
+{
+ "variants": {
+ "facing=east": {"model": "minecraft:block/lectern", "y": 90},
+ "facing=north": {"model": "minecraft:block/lectern", "y": 0},
+ "facing=south": {"model": "minecraft:block/lectern", "y": 180},
+ "facing=west": {"model": "minecraft:block/lectern", "y": 270}
+ }
+}
diff --git a/projects/common/src/generated/resources/assets/minecraft/atlases/blocks.json b/projects/common/src/generated/resources/assets/minecraft/atlases/blocks.json
index d9d236296..eb21f8f98 100644
--- a/projects/common/src/generated/resources/assets/minecraft/atlases/blocks.json
+++ b/projects/common/src/generated/resources/assets/minecraft/atlases/blocks.json
@@ -1,6 +1,7 @@
{
"sources": [
{"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_left"},
- {"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_right"}
+ {"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_right"},
+ {"type": "minecraft:single", "resource": "computercraft:entity/printout"}
]
}
diff --git a/projects/common/src/generated/resources/data/computercraft/loot_tables/blocks/lectern.json b/projects/common/src/generated/resources/data/computercraft/loot_tables/blocks/lectern.json
new file mode 100644
index 000000000..b5876ca42
--- /dev/null
+++ b/projects/common/src/generated/resources/data/computercraft/loot_tables/blocks/lectern.json
@@ -0,0 +1,12 @@
+{
+ "type": "minecraft:block",
+ "pools": [
+ {
+ "bonus_rolls": 0.0,
+ "conditions": [{"condition": "minecraft:survives_explosion"}],
+ "entries": [{"type": "minecraft:item", "name": "minecraft:lectern"}],
+ "rolls": 1.0
+ }
+ ],
+ "random_sequence": "computercraft:blocks/lectern"
+}
diff --git a/projects/common/src/main/java/dan200/computercraft/data/BlockModelProvider.java b/projects/common/src/main/java/dan200/computercraft/data/BlockModelProvider.java
index c0c9f3491..274557ad2 100644
--- a/projects/common/src/main/java/dan200/computercraft/data/BlockModelProvider.java
+++ b/projects/common/src/main/java/dan200/computercraft/data/BlockModelProvider.java
@@ -23,6 +23,7 @@ import net.minecraft.data.models.BlockModelGenerators;
import net.minecraft.data.models.blockstates.*;
import net.minecraft.data.models.model.*;
import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.level.block.state.properties.Property;
@@ -100,6 +101,11 @@ class BlockModelProvider {
registerTurtleUpgrade(generators, "block/turtle_speaker", "block/turtle_speaker_face");
registerTurtleModem(generators, "block/turtle_modem_normal", "block/wireless_modem_normal_face");
registerTurtleModem(generators, "block/turtle_modem_advanced", "block/wireless_modem_advanced_face");
+
+ generators.blockStateOutput.accept(MultiVariantGenerator.multiVariant(
+ ModRegistry.Blocks.LECTERN.get(),
+ Variant.variant().with(VariantProperties.MODEL, ModelLocationUtils.getModelLocation(Blocks.LECTERN))
+ ).with(createHorizontalFacingDispatch()));
}
private static void registerDiskDrive(BlockModelGenerators generators) {
diff --git a/projects/common/src/main/java/dan200/computercraft/data/LanguageProvider.java b/projects/common/src/main/java/dan200/computercraft/data/LanguageProvider.java
index 585f77b1e..2dc57e405 100644
--- a/projects/common/src/main/java/dan200/computercraft/data/LanguageProvider.java
+++ b/projects/common/src/main/java/dan200/computercraft/data/LanguageProvider.java
@@ -284,7 +284,9 @@ public final class LanguageProvider implements DataProvider {
return Stream.of(
RegistryWrappers.BLOCKS.stream()
.filter(x -> RegistryWrappers.BLOCKS.getKey(x).getNamespace().equals(ComputerCraftAPI.MOD_ID))
- .map(Block::getDescriptionId),
+ .map(Block::getDescriptionId)
+ // Exclude blocks that just reuse vanilla translations, such as the lectern.
+ .filter(x -> !x.startsWith("block.minecraft.")),
RegistryWrappers.ITEMS.stream()
.filter(x -> RegistryWrappers.ITEMS.getKey(x).getNamespace().equals(ComputerCraftAPI.MOD_ID))
.map(Item::getDescriptionId),
diff --git a/projects/common/src/main/java/dan200/computercraft/data/LootTableProvider.java b/projects/common/src/main/java/dan200/computercraft/data/LootTableProvider.java
index 33c9d239b..5f0f5b5ac 100644
--- a/projects/common/src/main/java/dan200/computercraft/data/LootTableProvider.java
+++ b/projects/common/src/main/java/dan200/computercraft/data/LootTableProvider.java
@@ -15,6 +15,7 @@ import dan200.computercraft.shared.peripheral.modem.wired.CableModemVariant;
import net.minecraft.advancements.critereon.StatePropertiesPredicate;
import net.minecraft.data.loot.LootTableProvider.SubProviderEntry;
import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.storage.loot.LootPool;
import net.minecraft.world.level.storage.loot.LootTable;
@@ -57,6 +58,8 @@ class LootTableProvider {
computerDrop(add, ModRegistry.Blocks.TURTLE_NORMAL);
computerDrop(add, ModRegistry.Blocks.TURTLE_ADVANCED);
+ blockDrop(add, ModRegistry.Blocks.LECTERN, LootItem.lootTableItem(Items.LECTERN), ExplosionCondition.survivesExplosion());
+
add.accept(ModRegistry.Blocks.CABLE.get().getLootTable(), LootTable
.lootTable()
.withPool(LootPool.lootPool()
diff --git a/projects/common/src/main/java/dan200/computercraft/data/TagProvider.java b/projects/common/src/main/java/dan200/computercraft/data/TagProvider.java
index 833287af6..795ecca15 100644
--- a/projects/common/src/main/java/dan200/computercraft/data/TagProvider.java
+++ b/projects/common/src/main/java/dan200/computercraft/data/TagProvider.java
@@ -102,8 +102,14 @@ class TagProvider {
ModRegistry.Items.MONITOR_ADVANCED.get()
);
+ // Allow printed books to be placed in bookshelves.
tags.tag(ItemTags.BOOKSHELF_BOOKS).add(ModRegistry.Items.PRINTED_BOOK.get());
+ // Allow any printout to be placed on lecterns. See also PrintoutItem and CustomLecternBlock.
+ tags.tag(ItemTags.LECTERN_BOOKS).add(
+ ModRegistry.Items.PRINTED_PAGE.get(), ModRegistry.Items.PRINTED_PAGES.get(), ModRegistry.Items.PRINTED_BOOK.get()
+ );
+
tags.tag(ComputerCraftTags.Items.TURTLE_CAN_PLACE)
.add(Items.GLASS_BOTTLE)
.addTag(ItemTags.BOATS);
diff --git a/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java b/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java
index 9d16b6ee3..5cb731e14 100644
--- a/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java
+++ b/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java
@@ -40,6 +40,8 @@ import dan200.computercraft.shared.data.PlayerCreativeLootCondition;
import dan200.computercraft.shared.details.BlockDetails;
import dan200.computercraft.shared.details.ItemDetails;
import dan200.computercraft.shared.integration.PermissionRegistry;
+import dan200.computercraft.shared.lectern.CustomLecternBlock;
+import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.media.PrintoutMenu;
import dan200.computercraft.shared.media.items.DiskItem;
import dan200.computercraft.shared.media.items.PrintoutItem;
@@ -100,10 +102,12 @@ import net.minecraft.world.item.crafting.CustomRecipe;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.SimpleCraftingRecipeSerializer;
import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.state.properties.NoteBlockInstrument;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.level.storage.loot.predicates.LootItemConditionType;
@@ -171,6 +175,10 @@ public final class ModRegistry {
public static final RegistryEntry WIRED_MODEM_FULL = REGISTRY.register("wired_modem_full",
() -> new WiredModemFullBlock(modemProperties().mapColor(MapColor.STONE)));
public static final RegistryEntry CABLE = REGISTRY.register("cable", () -> new CableBlock(modemProperties().mapColor(MapColor.STONE)));
+
+ public static final RegistryEntry LECTERN = REGISTRY.register("lectern", () -> new CustomLecternBlock(
+ BlockBehaviour.Properties.of().mapColor(MapColor.WOOD).instrument(NoteBlockInstrument.BASS).strength(2.5F).sound(SoundType.WOOD).ignitedByLava()
+ ));
}
public static class BlockEntities {
@@ -212,6 +220,8 @@ public final class ModRegistry {
ofBlock(Blocks.WIRELESS_MODEM_NORMAL, (p, s) -> new WirelessModemBlockEntity(BlockEntities.WIRELESS_MODEM_NORMAL.get(), p, s, false));
public static final RegistryEntry> WIRELESS_MODEM_ADVANCED =
ofBlock(Blocks.WIRELESS_MODEM_ADVANCED, (p, s) -> new WirelessModemBlockEntity(BlockEntities.WIRELESS_MODEM_ADVANCED.get(), p, s, true));
+
+ public static final RegistryEntry> LECTERN = ofBlock(Blocks.LECTERN, CustomLecternBlockEntity::new);
}
public static final class Items {
diff --git a/projects/common/src/main/java/dan200/computercraft/shared/container/BasicContainer.java b/projects/common/src/main/java/dan200/computercraft/shared/container/BasicContainer.java
index 6680725da..7dd51b29f 100644
--- a/projects/common/src/main/java/dan200/computercraft/shared/container/BasicContainer.java
+++ b/projects/common/src/main/java/dan200/computercraft/shared/container/BasicContainer.java
@@ -4,16 +4,17 @@
package dan200.computercraft.shared.container;
-import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.item.ItemStack;
+import java.util.List;
+
/**
* A basic implementation of {@link Container} which operates on a {@linkplain #getContents() list of stacks}.
*/
public interface BasicContainer extends Container {
- NonNullList getContents();
+ List getContents();
@Override
default int getContainerSize() {
diff --git a/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlock.java b/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlock.java
new file mode 100644
index 000000000..f79417f2f
--- /dev/null
+++ b/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlock.java
@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package dan200.computercraft.shared.lectern;
+
+import dan200.computercraft.shared.ModRegistry;
+import dan200.computercraft.shared.media.items.PrintoutItem;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.stats.Stats;
+import net.minecraft.util.RandomSource;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.context.UseOnContext;
+import net.minecraft.world.level.BlockGetter;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.LecternBlock;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.BlockHitResult;
+
+/**
+ * Extends {@link LecternBlock} with support for {@linkplain PrintoutItem printouts}.
+ *
+ * Unlike the vanilla lectern, this block is never empty. If the book is removed from the lectern, it converts back to
+ * its vanilla version (see {@link #clearLectern(Level, BlockPos, BlockState)}).
+ *
+ * @see PrintoutItem#useOn(UseOnContext) Placing books into a lectern.
+ */
+public class CustomLecternBlock extends LecternBlock {
+ public CustomLecternBlock(Properties properties) {
+ super(properties);
+ registerDefaultState(defaultBlockState().setValue(HAS_BOOK, true));
+ }
+
+ /**
+ * Replace a vanilla lectern with a custom one.
+ *
+ * @param level The current level.
+ * @param pos The position of the lectern.
+ * @param blockState The current state of the lectern.
+ * @param item The item to place in the custom lectern.
+ */
+ public static void replaceLectern(Level level, BlockPos pos, BlockState blockState, ItemStack item) {
+ level.setBlockAndUpdate(pos, ModRegistry.Blocks.LECTERN.get().defaultBlockState()
+ .setValue(HAS_BOOK, true)
+ .setValue(FACING, blockState.getValue(FACING))
+ .setValue(POWERED, blockState.getValue(POWERED)));
+
+ if (level.getBlockEntity(pos) instanceof CustomLecternBlockEntity be) be.setItem(item.split(1));
+ }
+
+ /**
+ * Remove a custom lectern and replace it with an empty vanilla one.
+ *
+ * @param level The current level.
+ * @param pos The position of the lectern.
+ * @param blockState The current state of the lectern.
+ */
+ static void clearLectern(Level level, BlockPos pos, BlockState blockState) {
+ level.setBlockAndUpdate(pos, Blocks.LECTERN.defaultBlockState()
+ .setValue(HAS_BOOK, false)
+ .setValue(FACING, blockState.getValue(FACING))
+ .setValue(POWERED, blockState.getValue(POWERED)));
+ }
+
+ @Override
+ @Deprecated
+ public ItemStack getCloneItemStack(BlockGetter level, BlockPos pos, BlockState state) {
+ return new ItemStack(Items.LECTERN);
+ }
+
+ @Override
+ public void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
+ // If we've no lectern, remove it.
+ if (level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern && lectern.getItem().isEmpty()) {
+ clearLectern(level, pos, state);
+ return;
+ }
+
+ super.tick(state, level, pos, random);
+ }
+
+ @Override
+ public void onRemove(BlockState state, Level level, BlockPos pos, BlockState newState, boolean isMoving) {
+ if (state.is(newState.getBlock())) return;
+
+ if (level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern) {
+ dropItem(level, pos, state, lectern.getItem().copy());
+ }
+
+ super.onRemove(state, level, pos, newState, isMoving);
+ }
+
+ private static void dropItem(Level level, BlockPos pos, BlockState state, ItemStack stack) {
+ if (stack.isEmpty()) return;
+
+ var direction = state.getValue(FACING);
+ var dx = 0.25 * direction.getStepX();
+ var dz = 0.25 * direction.getStepZ();
+ var entity = new ItemEntity(level, pos.getX() + 0.5 + dx, pos.getY() + 1, pos.getZ() + 0.5 + dz, stack);
+ entity.setDefaultPickUpDelay();
+ level.addFreshEntity(entity);
+ }
+
+ @Override
+ public String getDescriptionId() {
+ return Blocks.LECTERN.getDescriptionId();
+ }
+
+ @Override
+ public CustomLecternBlockEntity newBlockEntity(BlockPos pos, BlockState state) {
+ return new CustomLecternBlockEntity(pos, state);
+ }
+
+ @Override
+ public int getAnalogOutputSignal(BlockState blockState, Level level, BlockPos pos) {
+ return level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern ? lectern.getRedstoneSignal() : 0;
+ }
+
+ @Override
+ public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) {
+ if (!level.isClientSide && level.getBlockEntity(pos) instanceof CustomLecternBlockEntity lectern) {
+ if (player.isSecondaryUseActive()) {
+ // When shift+clicked with an empty hand, drop the item and replace with the normal lectern.
+ clearLectern(level, pos, state);
+ } else {
+ // Otherwise open the screen.
+ player.openMenu(lectern);
+ }
+
+ player.awardStat(Stats.INTERACT_WITH_LECTERN);
+ }
+
+ return InteractionResult.sidedSuccess(level.isClientSide);
+ }
+}
diff --git a/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlockEntity.java b/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlockEntity.java
new file mode 100644
index 000000000..ee124ed15
--- /dev/null
+++ b/projects/common/src/main/java/dan200/computercraft/shared/lectern/CustomLecternBlockEntity.java
@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package dan200.computercraft.shared.lectern;
+
+import dan200.computercraft.shared.ModRegistry;
+import dan200.computercraft.shared.container.BasicContainer;
+import dan200.computercraft.shared.container.SingleContainerData;
+import dan200.computercraft.shared.media.PrintoutMenu;
+import dan200.computercraft.shared.media.items.PrintoutItem;
+import dan200.computercraft.shared.util.BlockEntityHelpers;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.network.protocol.game.ClientGamePacketListener;
+import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
+import net.minecraft.util.Mth;
+import net.minecraft.world.Container;
+import net.minecraft.world.MenuProvider;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.inventory.ContainerData;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.LecternBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.entity.LecternBlockEntity;
+import net.minecraft.world.level.block.state.BlockState;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.AbstractList;
+import java.util.List;
+
+/**
+ * The block entity for our {@link CustomLecternBlock}.
+ *
+ * @see LecternBlockEntity
+ */
+public final class CustomLecternBlockEntity extends BlockEntity implements MenuProvider {
+ private static final String NBT_ITEM = "Item";
+ private static final String NBT_PAGE = "Page";
+
+ private ItemStack item = ItemStack.EMPTY;
+ private int page, pageCount;
+
+ public CustomLecternBlockEntity(BlockPos pos, BlockState blockState) {
+ super(ModRegistry.BlockEntities.LECTERN.get(), pos, blockState);
+ }
+
+ public ItemStack getItem() {
+ return item;
+ }
+
+ void setItem(ItemStack item) {
+ this.item = item;
+ itemChanged();
+ BlockEntityHelpers.updateBlock(this);
+ }
+
+ int getRedstoneSignal() {
+ if (item.getItem() instanceof PrintoutItem) {
+ var progress = pageCount > 1 ? (float) page / (pageCount - 1) : 1F;
+ return Mth.floor(progress * 14f) + 1;
+ }
+
+ return 15;
+ }
+
+ /**
+ * Called after the item has changed. This sets up the state for the new item.
+ */
+ private void itemChanged() {
+ if (item.getItem() instanceof PrintoutItem) {
+ pageCount = PrintoutItem.getPageCount(item);
+ page = Mth.clamp(page, 0, pageCount - 1);
+ } else {
+ pageCount = page = 0;
+ }
+ }
+
+ /**
+ * Set the current page, emitting a redstone pulse if needed.
+ *
+ * @param page The new page.
+ */
+ private void setPage(int page) {
+ if (this.page == page) return;
+
+ this.page = page;
+ setChanged();
+ if (getLevel() != null) LecternBlock.signalPageChange(getLevel(), getBlockPos(), getBlockState());
+ }
+
+ @Override
+ public void load(CompoundTag tag) {
+ super.load(tag);
+
+ item = tag.contains(NBT_ITEM, Tag.TAG_COMPOUND) ? ItemStack.of(tag.getCompound(NBT_ITEM)) : ItemStack.EMPTY;
+ page = tag.getInt(NBT_PAGE);
+ itemChanged();
+ }
+
+ @Override
+ protected void saveAdditional(CompoundTag tag) {
+ super.saveAdditional(tag);
+
+ if (!item.isEmpty()) tag.put(NBT_ITEM, item.save(new CompoundTag()));
+ if (item.getItem() instanceof PrintoutItem) tag.putInt(NBT_PAGE, page);
+ }
+
+ @Override
+ public Packet getUpdatePacket() {
+ return ClientboundBlockEntityDataPacket.create(this);
+ }
+
+ @Override
+ public CompoundTag getUpdateTag() {
+ var tag = super.getUpdateTag();
+ tag.put(NBT_ITEM, item.save(new CompoundTag()));
+ return tag;
+ }
+
+ @Nullable
+ @Override
+ public AbstractContainerMenu createMenu(int containerId, Inventory playerInventory, Player player) {
+ var item = getItem();
+ if (item.getItem() instanceof PrintoutItem) {
+ return new PrintoutMenu(
+ containerId, new LecternContainer(), 0,
+ p -> Container.stillValidBlockEntity(this, player, Container.DEFAULT_DISTANCE_LIMIT),
+ new PrintoutContainerData()
+ );
+ }
+
+ return null;
+ }
+
+ @Override
+ public Component getDisplayName() {
+ return getItem().getDisplayName();
+ }
+
+ /**
+ * A read-only container storing the lectern's contents.
+ */
+ private final class LecternContainer implements BasicContainer {
+ private final List itemView = new AbstractList<>() {
+ @Override
+ public ItemStack get(int index) {
+ if (index != 0) throw new IndexOutOfBoundsException("Inventory only has one slot");
+ return item;
+ }
+
+ @Override
+ public int size() {
+ return 1;
+ }
+ };
+
+ @Override
+ public List getContents() {
+ return itemView;
+ }
+
+ @Override
+ public void setChanged() {
+ // Should never happen, so a no-op.
+ }
+
+ @Override
+ public boolean stillValid(Player player) {
+ return !isRemoved();
+ }
+ }
+
+ /**
+ * {@link ContainerData} for a {@link PrintoutMenu}. This provides a read/write view of the current page.
+ */
+ private final class PrintoutContainerData implements SingleContainerData {
+ @Override
+ public int get() {
+ return page;
+ }
+
+ @Override
+ public void set(int index, int value) {
+ if (index == 0) setPage(value);
+ }
+ }
+}
diff --git a/projects/common/src/main/java/dan200/computercraft/shared/media/items/PrintoutItem.java b/projects/common/src/main/java/dan200/computercraft/shared/media/items/PrintoutItem.java
index 31d2a6e86..6aed3babf 100644
--- a/projects/common/src/main/java/dan200/computercraft/shared/media/items/PrintoutItem.java
+++ b/projects/common/src/main/java/dan200/computercraft/shared/media/items/PrintoutItem.java
@@ -6,6 +6,7 @@ package dan200.computercraft.shared.media.items;
import com.google.common.base.Strings;
import dan200.computercraft.shared.ModRegistry;
+import dan200.computercraft.shared.lectern.CustomLecternBlock;
import dan200.computercraft.shared.media.PrintoutMenu;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
@@ -16,7 +17,10 @@ import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
+import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.LecternBlock;
import javax.annotation.Nullable;
import java.util.List;
@@ -50,6 +54,22 @@ public class PrintoutItem extends Item {
if (title != null && !title.isEmpty()) list.add(Component.literal(title));
}
+ @Override
+ public InteractionResult useOn(UseOnContext context) {
+ var level = context.getLevel();
+ var blockPos = context.getClickedPos();
+ var blockState = level.getBlockState(blockPos);
+ if (blockState.is(Blocks.LECTERN) && !blockState.getValue(LecternBlock.HAS_BOOK)) {
+ // If we have an empty lectern, place our book into it.
+ if (!level.isClientSide) {
+ CustomLecternBlock.replaceLectern(level, blockPos, blockState, context.getItemInHand());
+ }
+ return InteractionResult.sidedSuccess(level.isClientSide);
+ } else {
+ return InteractionResult.PASS;
+ }
+ }
+
@Override
public InteractionResultHolder use(Level world, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
diff --git a/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png b/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png
new file mode 100644
index 000000000..204b0483f
Binary files /dev/null and b/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png differ
diff --git a/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png.license b/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png.license
new file mode 100644
index 000000000..1377dfd3b
--- /dev/null
+++ b/projects/common/src/main/resources/assets/computercraft/textures/entity/printout.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
+
+SPDX-License-Identifier: MPL-2.0
diff --git a/projects/fabric/src/generated/resources/data/minecraft/tags/items/lectern_books.json b/projects/fabric/src/generated/resources/data/minecraft/tags/items/lectern_books.json
new file mode 100644
index 000000000..818fe0ecf
--- /dev/null
+++ b/projects/fabric/src/generated/resources/data/minecraft/tags/items/lectern_books.json
@@ -0,0 +1,4 @@
+{
+ "replace": false,
+ "values": ["computercraft:printed_page", "computercraft:printed_pages", "computercraft:printed_book"]
+}
diff --git a/projects/forge/src/generated/resources/data/minecraft/tags/items/lectern_books.json b/projects/forge/src/generated/resources/data/minecraft/tags/items/lectern_books.json
new file mode 100644
index 000000000..80ec25824
--- /dev/null
+++ b/projects/forge/src/generated/resources/data/minecraft/tags/items/lectern_books.json
@@ -0,0 +1 @@
+{"values": ["computercraft:printed_page", "computercraft:printed_pages", "computercraft:printed_book"]}