Advance Minecraft 26.3 port to authored Age beta
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-31 02:38:22 -04:00
1 parent 9ab8d3de4a
commit c23b057f51
39 files changed
+1416 -77

No files matched your search

@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.PortBiomeSources;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
@@ -71,6 +72,18 @@ public final class Rivencraft {
public static final Supplier<DataComponentType<Boolean>> LINK_PANEL_PAGE = COMPONENTS.registerComponentType(
"link_panel_page", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Identifier>> SYMBOL_PAGE_ID = COMPONENTS.registerComponentType(
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
/** Ordered authored page IDs remain on the finished book for later port slices. */
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
.apply(Identifier.STREAM_CODEC)));
/** Set only on temporary GUI stacks; NeoForge 26.3 requires a registered codec. */
public static final Supplier<DataComponentType<Boolean>> PAGE_GLYPH_ONLY = COMPONENTS.registerComponentType(
"page_glyph_only", builder -> builder.persistent(Codec.BOOL)
.networkSynchronized(ByteBufCodecs.BOOL));
public static final Supplier<DataComponentType<Integer>> INK_REMAINING = COMPONENTS.registerComponentType(
"ink_remaining", builder -> builder.persistent(Codec.INT)
.networkSynchronized(ByteBufCodecs.VAR_INT));
@@ -82,11 +95,11 @@ public final class Rivencraft {
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE));
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE));
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> INK_MIXER_ITEM = ITEMS.registerSimpleBlockItem(INK_MIXER);
public static final Supplier<BlockEntityType<PortBookBinderBlockEntity>> BOOK_BINDER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_binder", () ->
@@ -138,6 +151,7 @@ public final class Rivencraft {
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
@@ -17,7 +17,14 @@ final class AgeLevelIndex extends SavedData {
private static final Codec<AgeLevelIndex> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages),
Identifier.CODEC.listOf().optionalFieldOf("retired", List.of())
.forGetter(index -> List.copyOf(index.retired))
.forGetter(index -> List.copyOf(index.retired)),
Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC).optionalFieldOf("biome_overrides", Map.of())
.forGetter(index -> index.biomeOverrides),
Codec.unboundedMap(Identifier.CODEC, PortBiomePlan.CODEC).optionalFieldOf("biome_plans", Map.of())
.forGetter(index -> index.biomePlans),
Codec.unboundedMap(Identifier.CODEC,
Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name))
.optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
@@ -28,15 +35,27 @@ final class AgeLevelIndex extends SavedData {
private final Map<Identifier, Long> ages;
private final Set<Identifier> retired;
private final Map<Identifier, Identifier> biomeOverrides;
private final Map<Identifier, PortBiomePlan> biomePlans;
private final Map<Identifier, PortAgeTerrain> terrain;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
this.retired = new LinkedHashSet<>();
this.biomeOverrides = new LinkedHashMap<>();
this.biomePlans = new LinkedHashMap<>();
this.terrain = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired) {
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
Map<Identifier, Identifier> biomeOverrides,
Map<Identifier, PortBiomePlan> biomePlans,
Map<Identifier, PortAgeTerrain> terrain) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
this.biomePlans = new LinkedHashMap<>(biomePlans);
this.terrain = new LinkedHashMap<>(terrain);
}
Map<Identifier, Long> entries() {
@@ -51,23 +70,42 @@ final class AgeLevelIndex extends SavedData {
return ages.get(id);
}
void add(Identifier id, long seed) {
PortBiomePlan biomePlan(Identifier id) {
PortBiomePlan plan = biomePlans.get(id);
if (plan != null) return plan;
Identifier oldBiome = biomeOverrides.get(id);
return oldBiome == null ? null : new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, List.of(oldBiome));
}
PortAgeTerrain terrain(Identifier id) {
return terrain.getOrDefault(id, PortAgeTerrain.STANDARD);
}
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
setDirty();
}
void retire(Identifier id) {
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
biomeOverrides.remove(id);
biomePlans.remove(id);
terrain.remove(id);
retired.add(id);
setDirty();
}
void restoreAfterFailedRetirement(Identifier id, long seed) {
void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan,
PortAgeTerrain ageTerrain) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
setDirty();
}
@@ -0,0 +1,8 @@
package net.dnorberg.rivencraft.age;
/** Terrain variants currently backed by 26.3 vanilla noise settings. */
public enum PortAgeTerrain {
STANDARD,
AMPLIFIED,
ISLAND
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.minecraft.resources.Identifier;
/** Frozen authored biome choice; repeated entries retain their selection weight. */
public record PortBiomePlan(Distribution distribution, List<Identifier> biomes) {
public enum Distribution { NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE }
public static final Codec<PortBiomePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.xmap(Distribution::valueOf, Distribution::name).fieldOf("distribution")
.forGetter(PortBiomePlan::distribution),
Identifier.CODEC.listOf().fieldOf("biomes").forGetter(PortBiomePlan::biomes)
).apply(instance, PortBiomePlan::new));
public PortBiomePlan {
biomes = List.copyOf(biomes);
if (distribution == Distribution.NATIVE && !biomes.isEmpty()
|| distribution == Distribution.SINGLE && biomes.size() != 1
|| distribution != Distribution.NATIVE && distribution != Distribution.SINGLE && biomes.size() < 2)
throw new IllegalArgumentException("Invalid authored biome distribution");
}
}
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
/** 26.3 resolver port of the seeded 1.21.1 Mystcraft-style region distribution. */
public final class PortScaledBiomeSource extends BiomeSource implements BiomeResolver {
public static final MapCodec<PortScaledBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
Biome.LIST_CODEC.fieldOf("biomes").forGetter(source -> source.biomes),
Codec.LONG.fieldOf("seed").forGetter(source -> source.seed),
Codec.intRange(0, 4).fieldOf("scale").forGetter(source -> source.scale)
).apply(instance, PortScaledBiomeSource::new));
private final HolderSet<Biome> biomes;
private final long seed;
private final int scale;
private final int cellWidth;
public PortScaledBiomeSource(HolderSet<Biome> biomes, long seed, int scale) {
if (biomes.size() < 2 || scale < 0 || scale > 4)
throw new IllegalArgumentException("Scaled regions need two biomes and scale 0..4");
this.biomes = biomes;
this.seed = seed;
this.scale = scale;
// Quart coordinates; Tiny starts at 16 blocks and each scale doubles.
this.cellWidth = 4 << scale;
}
@Override
protected MapCodec<? extends BiomeSource> codec() {
return CODEC;
}
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
return biomes.stream();
}
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
return this;
}
@Override
public Holder<Biome> getNoiseBiome(int x, int y, int z) {
int baseX = Math.floorDiv(x, cellWidth);
int baseZ = Math.floorDiv(z, cellWidth);
int closestX = baseX;
int closestZ = baseZ;
double closestDistance = Double.POSITIVE_INFINITY;
for (int cellX = baseX - 1; cellX <= baseX + 1; cellX++) {
for (int cellZ = baseZ - 1; cellZ <= baseZ + 1; cellZ++) {
long cellSeed = mix(seed, cellX, cellZ);
double centerX = (cellX + 0.5D + jitter(cellSeed) * 0.8D) * cellWidth;
double centerZ = (cellZ + 0.5D + jitter(mix64(cellSeed)) * 0.8D) * cellWidth;
double deltaX = x - centerX;
double deltaZ = z - centerZ;
double distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance < closestDistance) {
closestDistance = distance;
closestX = cellX;
closestZ = cellZ;
}
}
}
return biomes.get(Math.floorMod((int) mix(seed ^ 0x42494F4D45534CL,
closestX, closestZ), biomes.size()));
}
private static double jitter(long value) {
return ((value >>> 11) * 0x1.0p-53) - 0.5D;
}
private static long mix(long seed, int x, int z) {
return mix64(seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL);
}
private static long mix64(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -11,6 +11,8 @@ import java.util.Map;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
@@ -18,7 +20,12 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.TicketStorage;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
@@ -41,7 +48,8 @@ public final class RuntimeAgeBackend {
AgeLevelIndex index = index(server);
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
try {
load(server, age.getKey(), age.getValue());
load(server, age.getKey(), age.getValue(), index.biomePlan(age.getKey()),
index.terrain(age.getKey()));
} catch (RuntimeException exception) {
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
}
@@ -50,12 +58,21 @@ public final class RuntimeAgeBackend {
/** Creates a server level, then commits its identity only after successful construction. */
public static ServerLevel create(MinecraftServer server, Identifier id, long seed) {
return create(server, id, seed, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed, PortBiomePlan biomePlan) {
return create(server, id, seed, biomePlan, PortAgeTerrain.STANDARD);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed);
index.add(id, seed);
ServerLevel level = load(server, id, seed, biomePlan, terrain);
index.add(id, seed, biomePlan, terrain);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
@@ -75,7 +92,7 @@ public final class RuntimeAgeBackend {
public static ServerLevel reload(MinecraftServer server, Identifier id) {
checkServerThread(server);
long seed = requireSeed(server, id);
ServerLevel level = load(server, id, seed);
ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id));
AgeCatalogSync.onCatalogChanged(server);
return level;
}
@@ -85,18 +102,20 @@ public final class RuntimeAgeBackend {
checkServerThread(server);
AgeLevelIndex index = index(server);
long previousSeed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
if (previousSeed == seed) return;
boolean loaded = server.getLevel(dimensionKey(id)) != null;
if (loaded) unloadLoaded(server, id);
try {
if (loaded) load(server, id, seed);
if (loaded) load(server, id, seed, biomePlan, terrain);
index.reseed(id, seed);
server.overworld().getDataStorage().saveAndJoin();
if (loaded) AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException exception) {
if (loaded) {
try {
load(server, id, previousSeed);
load(server, id, previousSeed, biomePlan, terrain);
AgeCatalogSync.onCatalogChanged(server);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
@@ -112,6 +131,8 @@ public final class RuntimeAgeBackend {
String name = ageName(id);
AgeLevelIndex index = index(server);
long seed = requireSeed(server, id);
PortBiomePlan biomePlan = index.biomePlan(id);
PortAgeTerrain terrain = index.terrain(id);
boolean loaded = server.getLevel(dimensionKey(id)) != null;
ArchivePaths paths = archivePaths(server, name);
if (loaded) unloadLoaded(server, id);
@@ -130,14 +151,14 @@ public final class RuntimeAgeBackend {
}
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed);
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
if (loaded) {
try {
load(server, id, seed);
load(server, id, seed, biomePlan, terrain);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
@@ -233,17 +254,49 @@ public final class RuntimeAgeBackend {
private record ArchivePaths(Path live, Path archive) {
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed) {
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
// This prototype uses vanilla Overworld terrain until issue #98 ports
// authored ChunkGenerators. LevelStem.seedOverride controls chunk random
// state as well as ServerLevel.getSeed() in NeoForge 26.3.
// Noise terrains reuse the Overworld biome source unless a
// non-Native authored distribution replaces it.
// LevelStem.seedOverride controls chunk random state and getSeed().
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
LevelStem stem = new LevelStem(overworldStem.type(), overworldStem.generator(), OptionalLong.of(seed));
var generator = overworldStem.generator();
if ((biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE)
|| terrain != PortAgeTerrain.STANDARD) {
if (!(generator instanceof NoiseBasedChunkGenerator noise))
throw new IllegalStateException("Overworld generator cannot accept authored noise settings");
var source = generator.getBiomeSource();
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = biomePlan.biomes().stream().map(biome ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, biome))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome)))
.toList();
source = switch (biomePlan.distribution()) {
case NATIVE -> throw new IllegalStateException("Native is not an override");
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
biomePlan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
case TINY, SMALL, MEDIUM, LARGE, HUGE -> new PortScaledBiomeSource(
HolderSet.direct(biomes), seed, biomePlan.distribution().ordinal() -
PortBiomePlan.Distribution.TINY.ordinal());
};
}
var settings = switch (terrain) {
case STANDARD -> noise.generatorSettings();
case AMPLIFIED -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.AMPLIFIED);
case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS);
};
generator = new NoiseBasedChunkGenerator(source, settings);
}
LevelStem stem = new LevelStem(overworldStem.type(), generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
@@ -1,7 +1,10 @@
package net.dnorberg.rivencraft.block.entity;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.resources.Identifier;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
@@ -14,12 +17,14 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** Persistent, server-owned inputs and output for the first 26.3 Binder slice. */
/** Persistent, server-owned Binder inputs. */
public final class PortBookBinderBlockEntity extends BlockEntity implements Container {
public static final int COVER = 0;
public static final int PANEL = 1;
public static final int OUTPUT = 2;
public static final int SIZE = 3;
public static final int FIRST_PAGE = 3;
public static final int PAGE_COUNT = 252;
public static final int SIZE = FIRST_PAGE + PAGE_COUNT;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private String title = "";
@@ -61,11 +66,61 @@ public final class PortBookBinderBlockEntity extends BlockEntity implements Cont
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return switch (slot) {
case COVER -> stack.is(Items.LEATHER);
case PANEL -> PageItem.isLinkPanel(stack);
default -> false;
};
if (slot == COVER) return stack.is(Items.LEATHER);
if (slot == PANEL) return PageItem.isLinkPanel(stack);
return slot >= FIRST_PAGE && slot < SIZE && validSymbolPage(stack);
}
private static boolean validSymbolPage(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get()) && !PageItem.isLinkPanel(stack)
&& stack.has(Rivencraft.SYMBOL_PAGE_ID.get());
}
public boolean hasSymbolPages() {
for (int slot = FIRST_PAGE; slot < SIZE; slot++)
if (!items.get(slot).isEmpty()) return true;
return false;
}
public List<Identifier> symbolPages() {
List<Identifier> pages = new ArrayList<>();
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
Identifier symbol = items.get(slot).get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbol != null) pages.add(symbol);
}
return List.copyOf(pages);
}
public void clearSymbolPages() {
for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY);
setChanged();
}
public int nextPageIndex() {
for (int index = PAGE_COUNT - 1; index >= 0; index--)
if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1;
return 0;
}
public boolean insertPage(int index, ItemStack page) {
int end = nextPageIndex();
if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1
|| !validSymbolPage(page)) return false;
for (int current = end; current > index; current--)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1));
items.set(FIRST_PAGE + index, page.copy());
setChanged();
return true;
}
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end) return ItemStack.EMPTY;
ItemStack removed = items.get(FIRST_PAGE + index);
for (int current = index; current < end - 1; current++)
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1));
items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY);
setChanged();
return removed;
}
@Override public boolean stillValid(Player player) {
return level != null && level.getBlockEntity(worldPosition) == this
@@ -5,6 +5,10 @@ import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.RegisterSpecialModelRendererEvent;
import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookBinderClientEvents {
@@ -15,4 +19,16 @@ public final class PortBookBinderClientEvents {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
}
@SubscribeEvent
public static void registerPageRenderer(RegisterSpecialModelRendererEvent event) {
event.register(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page"),
PortSymbolPageRenderer.Unbaked.MAP_CODEC);
}
@SubscribeEvent
public static void registerPageTextureReload(AddClientReloadListenersEvent event) {
event.addListener(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page_textures"),
(ResourceManagerReloadListener) manager -> PortSymbolPageTextures.clear());
}
}
@@ -2,11 +2,13 @@ package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.network.AssembleBinderPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.SetBinderTitlePayload;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
@@ -28,12 +30,19 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
private static final int PAGE_Y = 48;
private static final int PAGE_WIDTH = 26;
private static final int PAGE_HEIGHT = 34;
private static final int PAGE_STEP = 28;
private static final int VISIBLE_PAGES = 5;
private static final int STRIP_X = 7;
private static final int STRIP_Y = 45;
private static final int STRIP_WIDTH = 162;
private static final int STRIP_HEIGHT = 40;
private static final int MISSING_PANEL_X = 27;
private static final int MISSING_PANEL_Y = 26;
private static final int MISSING_PANEL_SIZE = 16;
private static final ItemStack MISSING_PANEL_ICON = PageItem.createLinkPanelPage();
private EditBox titleInput;
private boolean stripClickPendingRelease;
private int firstVisible;
public PortBookBinderScreen(PortBookBinderMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 181);
@@ -65,19 +74,43 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top, 0, 0,
imageWidth, imageHeight, 256, 256);
graphics.fill(left + 7, top + 9, left + 123, top + 23, 0xFF000000);
// The strip grows only when a page is present or the player is carrying
// a page to insert, just as the 1.21.1 Binder does.
boolean panelPresent = !menu.getSlot(1).getItem().isEmpty();
if (panelPresent || PageItem.isLinkPanel(menu.getCarried())) {
int x = left + 16;
firstVisible = Math.min(firstVisible, maxFirstVisible());
int visibleCount = Math.min(VISIBLE_PAGES, visiblePageCount() - firstVisible);
for (int visible = 0; visible < visibleCount; visible++) {
int x = left + 16 + visible * PAGE_STEP;
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS,
x, top + PAGE_Y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!panelPresent)
graphics.fill(x, top + PAGE_Y, x + PAGE_WIDTH, top + PAGE_Y + PAGE_HEIGHT,
0xAA000000);
else graphics.fill(x + 5, top + PAGE_Y + 6, x + 22, top + PAGE_Y + 17,
0xFF000000);
}
// GUI extraction batches textures within a stratum. The glyphs need a
// later stratum so another atlas page cannot cover their transparent quad.
graphics.nextStratum();
for (int visible = 0; visible < visibleCount; visible++) {
int index = firstVisible + visible;
int x = left + 16 + visible * PAGE_STEP;
int y = top + PAGE_Y;
ItemStack page = pageAt(index);
if (page.isEmpty()) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000);
} else if (PageItem.isLinkPanel(page)) {
graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
} else {
// Render the full-size transparent glyph through the working
// item pipeline, without its small parchment-backed icon.
ItemStack glyphOnly = page.copy();
glyphOnly.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 5);
graphics.pose().scale(1.5F, 1.5F);
graphics.fakeItem(glyphOnly, 0, 0);
graphics.pose().popMatrix();
}
}
graphics.fill(left + STRIP_X, top + STRIP_Y, left + STRIP_X + 8,
top + STRIP_Y + STRIP_HEIGHT, firstVisible == 0 ? 0x33000000 : 0xAA000000);
graphics.fill(left + STRIP_X + STRIP_WIDTH - 8, top + STRIP_Y,
left + STRIP_X + STRIP_WIDTH, top + STRIP_Y + STRIP_HEIGHT,
firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000);
boolean panelPresent = !menu.getSlot(PortBookBinderBlockEntity.PANEL).getItem().isEmpty();
if (titleInput == null || titleInput.getValue().isBlank()) {
graphics.fill(left + 7, top + 9, left + 123, top + 10, 0xFFFF3030);
graphics.fill(left + 7, top + 22, left + 123, top + 23, 0xFFFF3030);
@@ -92,7 +125,10 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
top + MISSING_PANEL_Y + MISSING_PANEL_SIZE,
(alpha << 24) | 0x00B02020);
}
if (menu.getSlot(2).getItem().isEmpty() && !menu.getSlot(0).getItem().isEmpty()
if (hasUnsupportedPages()) graphics.text(font,
Component.translatable("screen.rivencraft.book_binder.authored_pending"),
left + 75, top + 31, 0xFFB84B42, false);
if (!hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty() && !menu.getSlot(0).getItem().isEmpty()
&& !menu.getSlot(1).getItem().isEmpty() && titleInput != null
&& !titleInput.getValue().isBlank()) {
graphics.fakeItem(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), left + 152, top + 27);
@@ -112,32 +148,74 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
&& menu.getSlot(1).getItem().isEmpty()) {
graphics.setTooltipForNextFrame(font,
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
} else if (x >= 16 && x < 16 + PAGE_WIDTH && y >= PAGE_Y && y < PAGE_Y + PAGE_HEIGHT
&& !menu.getSlot(1).getItem().isEmpty()) {
graphics.setTooltipForNextFrame(font, menu.getSlot(1).getItem(), mouseX, mouseY);
} else {
int index = pageIndexAt(mouseX, mouseY);
if (index >= 0 && !pageAt(index).isEmpty())
graphics.setTooltipForNextFrame(font, pageAt(index), mouseX, mouseY);
}
}
private ItemStack pageAt(int index) {
return menu.getSlot(index == 0 ? PortBookBinderBlockEntity.PANEL
: PortBookBinderBlockEntity.FIRST_PAGE + index - 1).getItem();
}
private int displayedPages() {
for (int index = PortBookBinderBlockEntity.PAGE_COUNT; index >= 1; index--)
if (!pageAt(index).isEmpty()) return index + 1;
return pageAt(0).isEmpty() ? 0 : 1;
}
private boolean hasUnsupportedPages() {
java.util.List<Identifier> pages = new java.util.ArrayList<>();
for (int index = 1; index <= PortBookBinderBlockEntity.PAGE_COUNT; index++) {
Identifier id = pageAt(index).get(Rivencraft.SYMBOL_PAGE_ID.get());
if (id != null) pages.add(id);
}
return !PortAuthoredWriting.supports(pages);
}
private int visiblePageCount() {
return Math.min(PortBookBinderBlockEntity.PAGE_COUNT + 1,
displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1));
}
private int maxFirstVisible() { return Math.max(0, visiblePageCount() - VISIBLE_PAGES); }
private int pageIndexAt(double mouseX, double mouseY) {
int x = (int) mouseX - leftPos;
int y = (int) mouseY - topPos;
if (x < 16 || x >= STRIP_X + STRIP_WIDTH - 8 || y < STRIP_Y || y >= STRIP_Y + STRIP_HEIGHT)
return -1;
int visible = (x - 16) / PAGE_STEP;
return visible < VISIBLE_PAGES ? firstVisible + visible : -1;
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
int left = (width - imageWidth) / 2;
int top = (height - imageHeight) / 2;
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
&& event.x() >= left + 16 && event.x() < left + 16 + PAGE_WIDTH
&& event.y() >= top + PAGE_Y && event.y() < top + PAGE_Y + PAGE_HEIGHT) {
boolean hasPanel = !menu.getSlot(1).getItem().isEmpty();
if (hasPanel && menu.getCarried().isEmpty()
|| !hasPanel && PageItem.isLinkPanel(menu.getCarried())) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
PortBookBinderMenu.STRIP_CLICK_PANEL);
stripClickPendingRelease = true;
return true;
&& event.x() >= left + STRIP_X && event.x() < left + STRIP_X + STRIP_WIDTH
&& event.y() >= top + STRIP_Y && event.y() < top + STRIP_Y + STRIP_HEIGHT) {
stripClickPendingRelease = true;
if (event.x() < left + STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1);
else if (event.x() >= left + STRIP_X + STRIP_WIDTH - 8)
firstVisible = Math.min(maxFirstVisible(), firstVisible + 1);
else {
int index = pageIndexAt(event.x(), event.y());
if (index >= 0 && index <= displayedPages()
&& (index < displayedPages() || !menu.getCarried().isEmpty()))
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
PortBookBinderMenu.STRIP_CLICK_BASE + index);
}
return true;
}
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT
&& event.x() >= left + 151 && event.x() < left + 170
&& event.y() >= top + 26 && event.y() < top + 45
&& menu.getSlot(2).getItem().isEmpty() && menu.getCarried().isEmpty()) {
&& !hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty()
&& menu.getCarried().isEmpty()) {
String name = titleInput.getValue().strip();
if (!name.isEmpty()) ClientPacketDistributor.sendToServer(new AssembleBinderPayload(name));
return true;
@@ -153,6 +231,15 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
return super.mouseReleased(event);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH
&& mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible());
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override public boolean keyPressed(KeyEvent event) {
if (titleInput != null && titleInput.isFocused()) {
if (event.isEscape()) {
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.serialization.MapCodec;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.special.SpecialModelRenderer;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import org.joml.Vector3f;
/** Flat item geometry textured with a page composed from its symbol stack. */
public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSymbolPageRenderer.PageArt> {
@Override
public PageArt extractArgument(ItemStack stack) {
Identifier symbol = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
return symbol == null ? null : new PageArt(symbol,
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false));
}
@Override
public void submit(PageArt art, PoseStack pose, SubmitNodeCollector collector,
int light, int overlay, boolean hasFoil, int outlineColor) {
if (art == null) return;
Identifier texture = art.glyphOnly()
? PortSymbolPageTextures.forPage(art.symbol())
: PortSymbolPageTextures.forItem(art.symbol());
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture),
(matrix, vertices) -> {
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
});
}
@Override
public void getExtents(Consumer<org.joml.Vector3fc> extents) {
extents.accept(new Vector3f(0, 0, 0.5F));
extents.accept(new Vector3f(1, 1, 0.5F));
}
private static void vertex(VertexConsumer vertices, PoseStack.Pose pose,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(pose, x, y, 0.5F).setColor(255, 255, 255, 255)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(pose, 0, 0, 1);
}
public record PageArt(Identifier symbol, boolean glyphOnly) {}
public record Unbaked() implements SpecialModelRenderer.Unbaked<PageArt> {
public static final Unbaked INSTANCE = new Unbaked();
public static final MapCodec<Unbaked> MAP_CODEC = MapCodec.unit(INSTANCE);
@Override public MapCodec<Unbaked> type() { return MAP_CODEC; }
@Override public SpecialModelRenderer<PageArt> bake(BakingContext context) {
return new PortSymbolPageRenderer();
}
}
}
@@ -0,0 +1,189 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolNumerals;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.ARGB;
/** The 1.21.1 Legacy-style glyph compositor adapted to 26.3's texture API. */
public final class PortSymbolPageTextures {
private static final int SIZE = 128;
private static final int GLYPH = 51;
private static final int ITEM_SYMBOL_SIZE = 15;
private static final int NUMBER_GLYPH_SIZE = 64;
private static final int CACHE_LIMIT = 256;
private static final Identifier PAGE = texture("item/page");
private static final Identifier COMPONENTS = texture("gui/symbolcomponents");
private static final Identifier FALLBACK = texture("item/page_symbol");
private static final Map<String, Identifier> CACHE = new LinkedHashMap<>(16, .75F, true);
private static final Map<String, int[]> WORD_CACHE = new LinkedHashMap<>(16, .75F, true);
private static NativeImage page;
private static NativeImage components;
private static int nextId;
// WordData from Mystcraft Legacy 0.13.7.06, matching the 1.21.1 compositor.
private static final Map<String, int[]> WORDS = Map.ofEntries(
Map.entry("Terrain", new int[] {6,10,12,13,16,19,24,25,27}),
Map.entry("Form", new int[] {4,6,8,9,10,11,13,14,16,23}),
Map.entry("Tradition", new int[] {4,6,7,8,9,14,17,18,20}),
Map.entry("Flow", new int[] {4,7,12,13,17,18}),
Map.entry("Constraint", new int[] {5,6,9,11,13,20,22,23}),
Map.entry("Nature", new int[] {5,6,8,10,11,12,15,16,17,22}),
Map.entry("Sustain", new int[] {6,16,19,20,21,23}),
Map.entry("Ethereal", new int[] {4,6,8,13,14,16,18,21,23}),
Map.entry("Dynamic", new int[] {4,5,6,12,13,17,18,22,23}),
Map.entry("Cycle", new int[] {4,5,7,16,17,18}),
Map.entry("Balance", new int[] {4,6,9,10,16,19}),
Map.entry("Transform", new int[] {4,5,6,8,11,14,18,20,21}),
Map.entry("Time", new int[] {4,5,7,12,13,19,20,21,22,23}),
Map.entry("System", new int[] {10,12,14,15,16,20,21,22}),
Map.entry("Void", new int[] {4,5,6,16,17,18,23})
);
private PortSymbolPageTextures() {}
public static Identifier forItem(Identifier id) {
return forSymbol(id, true);
}
/** Transparent, full-size glyphs layered over the Binder's page atlas. */
public static Identifier forPage(Identifier id) {
return forSymbol(id, false);
}
private static Identifier forSymbol(Identifier id, boolean item) {
PortSymbolPages.Entry entry = PortSymbolPages.find(id);
if (entry == null) return FALLBACK;
String poem = entry.poem();
if (!poem.matches("[A-Za-z]+(?: [A-Za-z]+){3}")) poem = "";
String key = id + "|" + poem + (item ? "|item" : "|page");
Identifier cached = CACHE.get(key);
if (cached != null) return cached;
try {
loadSources(Minecraft.getInstance().getResourceManager());
NativeImage image = compose(id, poem, item);
Identifier location = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"generated/page_" + nextId++);
Minecraft.getInstance().getTextureManager().register(location,
new DynamicTexture(() -> "Rivencraft symbol page " + id, image));
CACHE.put(key, location);
if (CACHE.size() > CACHE_LIMIT) {
String eldest = CACHE.keySet().iterator().next();
Minecraft.getInstance().getTextureManager().release(CACHE.remove(eldest));
}
return location;
} catch (IOException exception) {
Rivencraft.LOGGER.error("Could not compose symbol page {}", id, exception);
return FALLBACK;
}
}
public static void clear() {
var textures = Minecraft.getInstance().getTextureManager();
CACHE.values().forEach(textures::release);
CACHE.clear();
WORD_CACHE.clear();
if (page != null) page.close();
if (components != null) components.close();
page = null;
components = null;
}
private static void loadSources(ResourceManager manager) throws IOException {
if (page != null && components != null) return;
try (InputStream pageStream = manager.getResourceOrThrow(PAGE).open();
InputStream componentsStream = manager.getResourceOrThrow(COMPONENTS).open()) {
page = NativeImage.read(pageStream);
components = NativeImage.read(componentsStream);
}
}
private static NativeImage compose(Identifier id, String poem, boolean item) {
NativeImage image = new NativeImage(SIZE, SIZE, true);
if (item) {
for (int y = 0; y < SIZE; y++) for (int x = 0; x < SIZE; x++) {
image.setPixel(x, y, page.getPixel(x * page.getWidth() / SIZE,
y * page.getHeight() / SIZE));
}
}
String numberPath = id.getPath().startsWith("number/")
? id.getPath().substring("number/".length()) : "";
if (id.getNamespace().equals(Rivencraft.MOD_ID)
&& numberPath.matches("(?:[0-9]|1[0-9]|2[0-4])")) {
int digit = Integer.parseInt(numberPath);
int glyphSize = item ? NUMBER_GLYPH_SIZE : GLYPH;
int origin = item ? (SIZE - glyphSize) / 2 : 38;
for (int component : PortSymbolNumerals.componentsForDigit(digit))
blendComponent(image, component, origin, origin, glyphSize);
return image;
}
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
int[] offsetsX = {38, 77, 38, 0};
int[] offsetsY = {0, 38, 77, 38};
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / 16 : GLYPH;
for (int word = 0; word < 4; word++) {
int x = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
int y = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
for (int component : glyphs(words[word]))
blendComponent(image, component, x, y, glyphSize);
}
return image;
}
private static void blendComponent(NativeImage image, int component, int x0, int y0, int size) {
int cellX = (component % 8) * 64;
int cellY = (component / 8) * 64;
for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) {
int sourceX = cellX + x * 64 / size;
int sourceY = cellY + y * 64 / size;
// One source-pixel of coverage keeps the thin Legacy strokes legible
// when Minecraft shrinks this 128px composite to a 16px item icon.
int alpha = ARGB.alpha(components.getPixel(sourceX, sourceY));
if (sourceX > cellX) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX - 1, sourceY)));
if (sourceX < cellX + 63) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX + 1, sourceY)));
if (sourceY > cellY) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX, sourceY - 1)));
if (sourceY < cellY + 63) alpha = Math.max(alpha, ARGB.alpha(components.getPixel(sourceX, sourceY + 1)));
if (alpha != 0) image.setPixel(x0 + x, y0 + y,
ARGB.alphaBlend(image.getPixel(x0 + x, y0 + y),
(alpha << 24) | 0x001A212B));
}
}
private static int scaleForItem(int coordinate) {
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / 16F);
}
private static String[] generatedWords(Identifier id) {
String stem = id.toString();
return new String[] {stem + 'a', stem + 'b', stem + 'c', stem + 'd'};
}
private static int[] glyphs(String word) {
int[] known = WORDS.get(word);
if (known != null) return known;
String key = word.toLowerCase(Locale.ROOT);
int[] cached = WORD_CACHE.get(key);
if (cached != null) return cached;
Random random = new Random(key.hashCode());
int[] result = new int[random.nextInt(10) + 3];
for (int i = 0; i < result.length; i++) result[i] = random.nextInt(20) + 4;
WORD_CACHE.put(key, result);
if (WORD_CACHE.size() > CACHE_LIMIT) WORD_CACHE.remove(WORD_CACHE.keySet().iterator().next());
return result;
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
}
@@ -2,8 +2,11 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
@@ -30,10 +33,28 @@ public final class PageItem extends Item {
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
}
public static ItemStack createSymbolPage(Identifier id) {
if (PortSymbolPages.find(id) == null)
throw new IllegalArgumentException("Unknown bundled symbol " + id);
ItemStack stack = new ItemStack(Rivencraft.PAGE.get());
stack.set(Rivencraft.SYMBOL_PAGE_ID.get(), id);
return stack;
}
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
if (customName != null) return customName;
Identifier symbolId = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbolId != null) {
PortSymbolPages.Entry entry = PortSymbolPages.find(symbolId);
if (entry != null) {
Component name = Component.translatableWithFallback(
entry.nameKey(), PortSymbolPages.fallbackName(symbolId));
return isCorrupted(symbolId) ? name.copy().withStyle(ChatFormatting.OBFUSCATED) : name;
}
return Component.translatableWithFallback("item.rivencraft.page.unresolved", "Unknown Symbol Page");
}
return Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel" : "item.rivencraft.page.blank");
}
@@ -41,6 +62,20 @@ public final class PageItem extends Item {
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier symbolId = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (symbolId != null) {
PortSymbolPages.Entry entry = PortSymbolPages.find(symbolId);
if (entry != null) {
Component description = Component.translatableWithFallback(
entry.descriptionKey(), PortSymbolPages.fallbackName(symbolId));
tooltip.accept(isCorrupted(symbolId)
? description.copy().withStyle(ChatFormatting.GRAY, ChatFormatting.OBFUSCATED)
: description.copy().withStyle(ChatFormatting.GRAY));
}
tooltip.accept(Component.translatable("item.rivencraft.page.symbol.port_status")
.withStyle(ChatFormatting.DARK_GRAY));
return;
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
@@ -52,4 +87,8 @@ public final class PageItem extends Item {
? "item.rivencraft.page.link_panel.port_status"
: "item.rivencraft.page.blank.port_status"));
}
private static boolean isCorrupted(Identifier id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("corrupted/");
}
}
@@ -4,6 +4,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
@@ -19,7 +20,7 @@ import net.minecraft.world.item.Items;
/** Binder inputs persist in the block; assembling places the finished book on the cursor. */
public final class PortBookBinderMenu extends AbstractContainerMenu {
public static final int STRIP_CLICK_PANEL = 3000;
public static final int STRIP_CLICK_BASE = 3000;
private final Container binder;
private final PortBookBinderBlockEntity blockEntity;
private final String initialTitle;
@@ -48,6 +49,14 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
addSlot(new Slot(binder, PortBookBinderBlockEntity.OUTPUT, 152, 27) {
@Override public boolean mayPlace(ItemStack stack) { return false; }
});
for (int page = 0; page < PortBookBinderBlockEntity.PAGE_COUNT; page++) {
addSlot(new Slot(binder, PortBookBinderBlockEntity.FIRST_PAGE + page, -1000, -1000) {
@Override public boolean mayPlace(ItemStack stack) {
return binder.canPlaceItem(getContainerSlot(), stack);
}
@Override public int getMaxStackSize() { return 1; }
});
}
addStandardInventorySlots(inventory, 8, 99);
}
@@ -58,19 +67,26 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
public String title() { return initialTitle; }
@Override public boolean clickMenuButton(Player player, int id) {
if (id != STRIP_CLICK_PANEL || !stillValid(player)) return false;
if (id < STRIP_CLICK_BASE || id > STRIP_CLICK_BASE + PortBookBinderBlockEntity.PAGE_COUNT
|| !stillValid(player)) return false;
if (player.level().isClientSide()) return true;
int index = id - STRIP_CLICK_BASE;
ItemStack carried = getCarried();
if (carried.isEmpty()) {
ItemStack panel = binder.removeItem(PortBookBinderBlockEntity.PANEL, 1);
if (panel.isEmpty()) return false;
setCarried(panel);
} else {
if (!PageItem.isLinkPanel(carried)
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty()) return false;
binder.setItem(PortBookBinderBlockEntity.PANEL, carried.copyWithCount(1));
if (!carried.isEmpty()) {
if (index == 0) {
if (!PageItem.isLinkPanel(carried)
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty()) return false;
binder.setItem(PortBookBinderBlockEntity.PANEL, carried.copyWithCount(1));
} else if (!blockEntity.insertPage(index - 1, carried.copyWithCount(1))) return false;
ItemStack remainder = carried.copy();
remainder.shrink(1);
setCarried(remainder);
} else {
ItemStack removed = index == 0
? binder.removeItem(PortBookBinderBlockEntity.PANEL, 1)
: blockEntity.takePage(index - 1);
if (removed.isEmpty()) return false;
setCarried(removed);
}
broadcastChanges();
return true;
@@ -84,22 +100,26 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
public void assemble(ServerPlayer player, String title) {
if (!stillValid(player) || title.isBlank() || title.length() > PortBookNetworking.MAX_BOOK_TITLE_LENGTH
|| !binder.getItem(PortBookBinderBlockEntity.OUTPUT).isEmpty()
|| !PortAuthoredWriting.supports(blockEntity.symbolPages())
|| !getCarried().isEmpty()) return;
ItemStack cover = binder.getItem(PortBookBinderBlockEntity.COVER);
ItemStack panel = binder.getItem(PortBookBinderBlockEntity.PANEL);
if (!cover.is(Items.LEATHER) || !PageItem.isLinkPanel(panel)) return;
blockEntity.setTitle(title);
ItemStack book = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
var id = PortBookNetworking.createAge(player, title);
var id = PortBookNetworking.createAge(player, title, blockEntity.symbolPages());
if (id == null) return;
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
if (blockEntity.hasSymbolPages())
book.set(Rivencraft.BOOK_SYMBOLS.get(), blockEntity.symbolPages());
var modifiers = panel.get(Rivencraft.LINK_PROPERTIES.get());
if (modifiers != null) book.set(Rivencraft.LINK_PROPERTIES.get(), modifiers);
cover.shrink(1);
panel.shrink(1);
binder.setItem(PortBookBinderBlockEntity.COVER, cover);
binder.setItem(PortBookBinderBlockEntity.PANEL, panel);
blockEntity.clearSymbolPages();
setCarried(book);
broadcastChanges();
}
@@ -108,6 +128,15 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
if (index >= PortBookBinderBlockEntity.FIRST_PAGE && index < PortBookBinderBlockEntity.SIZE) {
ItemStack page = slot.getItem().copy();
ItemStack remainder = page.copy();
if (!moveItemStackTo(remainder, PortBookBinderBlockEntity.SIZE, slots.size(), true)
|| !remainder.isEmpty()) return ItemStack.EMPTY;
blockEntity.takePage(index - PortBookBinderBlockEntity.FIRST_PAGE);
broadcastChanges();
return page;
}
ItemStack stack = slot.getItem();
ItemStack original = stack.copy();
boolean moved;
@@ -119,6 +148,9 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
} else if (PageItem.isLinkPanel(stack)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.PANEL,
PortBookBinderBlockEntity.PANEL + 1, false);
} else if (stack.is(Rivencraft.PAGE.get()) && stack.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.FIRST_PAGE,
PortBookBinderBlockEntity.SIZE, false);
} else return ItemStack.EMPTY;
if (!moved) return ItemStack.EMPTY;
if (stack.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
@@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
@@ -19,6 +20,7 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import java.util.Locale;
import java.util.List;
/** Server-authoritative handheld Linking Book binding and travel. */
public final class PortBookNetworking {
@@ -99,7 +101,18 @@ public final class PortBookNetworking {
/** Shared name resolution and Age creation for handheld books and the Binder. */
public static Identifier createAge(ServerPlayer player, String title) {
return createAge(player, title, List.of());
}
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
try {
biomePlan = PortAuthoredWriting.biomePlan(pages, player.registryAccess());
} catch (IllegalArgumentException exception) {
player.sendOverlayMessage(Component.literal("Age writing unavailable: " + exception.getMessage()));
return null;
}
String base = title.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_")
.replaceAll("^[_\\.-]+|[_\\.-]+$", "");
if (base.isEmpty()) base = "age";
@@ -112,7 +125,8 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong());
RuntimeAgeBackend.create(server, id, player.getRandom().nextLong(), biomePlan,
PortAuthoredWriting.terrain(pages));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
@@ -47,6 +48,18 @@ public final class ModCreativeTabs {
}
})
.build());
for (String category : PortSymbolPages.categories()) {
String path = category.substring(category.indexOf(':') + 1);
TABS.register("rivencraft_pages_" + path, () -> CreativeModeTab.builder()
.title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + path,
"Rivencraft " + PortSymbolPages.fallbackName(
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path))
+ " Pages"))
.icon(() -> PortSymbolPages.icon(category))
.displayItems((parameters, output) ->
PortSymbolPages.pages(category, parameters.holders()).forEach(output::accept))
.build());
}
}
private ModCreativeTabs() {
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortScaledBiomeSource;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.biome.BiomeSource;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Serializes the custom biome resolver in saved 26.3 Age level stems. */
public final class PortBiomeSources {
private static final DeferredRegister<MapCodec<? extends BiomeSource>> SOURCES =
DeferredRegister.create(Registries.BIOME_SOURCE, Rivencraft.MOD_ID);
static {
SOURCES.register("scaled", () -> PortScaledBiomeSource.CODEC);
}
private PortBiomeSources() {}
public static void register(IEventBus bus) {
SOURCES.register(bus);
}
}
@@ -0,0 +1,104 @@
package net.dnorberg.rivencraft.writing;
import java.util.List;
import java.util.ArrayList;
import net.dnorberg.rivencraft.age.PortBiomePlan;
import net.dnorberg.rivencraft.age.PortAgeTerrain;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
/** Authored noise terrain and biome distribution supported by the 26.3 backend. */
public final class PortAuthoredWriting {
private static final int MAX_AUTHORED_BIOMES = 252;
private static final Identifier STANDARD_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/standard");
private static final Identifier AMPLIFIED_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/amplified");
private static final Identifier ISLAND_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/island");
private PortAuthoredWriting() {}
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
boolean terrain = false;
int biomes = 0;
int controllers = 0;
for (Identifier page : pages) {
if ((page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN)) && !terrain) {
terrain = true;
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
} else if (page.getNamespace().equals(Rivencraft.MOD_ID)
&& page.getPath().startsWith("biome/") && biomes < MAX_AUTHORED_BIOMES
&& PortSymbolPages.find(page) != null
&& PortSymbolPages.find(page).category().equals("rivencraft:biome")
&& PortRegistryPages.biomeId(page) != null) {
biomes++;
} else return false;
}
if (controllers != 0) {
PortBiomePlan.Distribution distribution = pages.stream().map(PortAuthoredWriting::controller)
.filter(value -> value != null).findFirst().orElseThrow();
return switch (distribution) {
case NATIVE -> biomes == 0;
case SINGLE -> biomes == 1;
default -> biomes >= 2;
};
}
return true;
}
private static PortBiomePlan.Distribution controller(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "biome/native" -> PortBiomePlan.Distribution.NATIVE;
case "biome/single" -> PortBiomePlan.Distribution.SINGLE;
case "biome/tiled" -> PortBiomePlan.Distribution.TILED;
case "biome/grid" -> PortBiomePlan.Distribution.GRID;
case "biome/tiny" -> PortBiomePlan.Distribution.TINY;
case "biome/small" -> PortBiomePlan.Distribution.SMALL;
case "biome/medium" -> PortBiomePlan.Distribution.MEDIUM;
case "biome/large" -> PortBiomePlan.Distribution.LARGE;
case "biome/huge" -> PortBiomePlan.Distribution.HUGE;
default -> null;
};
}
public static PortAgeTerrain terrain(List<Identifier> pages) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
if (pages.contains(AMPLIFIED_TERRAIN)) return PortAgeTerrain.AMPLIFIED;
if (pages.contains(ISLAND_TERRAIN)) return PortAgeTerrain.ISLAND;
return PortAgeTerrain.STANDARD;
}
/** Resolves all authored biomes from the active registry before creating a level. */
public static PortBiomePlan biomePlan(List<Identifier> pages, HolderLookup.Provider registries) {
if (!supports(pages)) throw new IllegalArgumentException(
"Only Standard/Amplified/Island terrain with supported Biome pages is available in this port slice");
List<Identifier> biomes = new ArrayList<>();
PortBiomePlan.Distribution distribution = null;
for (Identifier page : pages) {
if (page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN)) continue;
PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; }
Identifier biome = PortRegistryPages.biomeId(page);
var registry = registries.lookupOrThrow(Registries.BIOME);
if (registry.get(ResourceKey.create(Registries.BIOME, biome)).isEmpty())
throw new IllegalArgumentException("Unknown authored biome: " + biome);
biomes.add(biome);
}
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new PortBiomePlan(distribution, List.of());
if (biomes.isEmpty()) return null;
if (distribution == null) distribution = biomes.size() == 1
? PortBiomePlan.Distribution.SINGLE : biomes.size() == 2
? PortBiomePlan.Distribution.TILED : PortBiomePlan.Distribution.MEDIUM;
return new PortBiomePlan(distribution, biomes);
}
}
@@ -0,0 +1,136 @@
package net.dnorberg.rivencraft.writing;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.EmptyBlockGetter;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.EntityBlock;
/** Registry-derived 26.3 biome and material pages, with the same IDs as 1.21.1. */
public final class PortRegistryPages {
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes"));
private static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
"terrablender", "deferred_placeholder");
private PortRegistryPages() {}
public static Identifier biomePageId(Identifier biome) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/"
+ (biome.getNamespace().equals("minecraft") ? "" : biome.getNamespace() + "/")
+ biome.getPath());
}
public static Identifier blockPageId(Identifier block) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"block/" + block.getNamespace() + "/" + block.getPath());
}
public static PortSymbolPages.Entry find(Identifier symbol) {
if (!symbol.getNamespace().equals(Rivencraft.MOD_ID)) return null;
String path = symbol.getPath();
if (path.startsWith("biome/")) {
Identifier biome = biomeId(symbol);
if (biome == null) return null;
String key = biome.getNamespace().equals("minecraft")
? "symbol.rivencraft.biome." + biome.getPath()
: "biome." + biome.getNamespace() + "." + biome.getPath().replace('/', '.');
return new PortSymbolPages.Entry(symbol, "rivencraft:biome", key,
"symbol.rivencraft.biome.generated.description",
"Nature Form " + glyphWord(biome.getPath(), "World") + " Biome");
}
if (path.startsWith("block/")) {
Identifier block = blockId(symbol);
if (block == null) return null;
String category = materialCategory(BuiltInRegistries.BLOCK.getValue(block));
if (category == null) return null;
return new PortSymbolPages.Entry(symbol, category,
"block." + block.getNamespace() + "." + block.getPath().replace('/', '_'),
"symbol.rivencraft.block.generated.description",
"Modifier Constraint " + glyphWord(block.getPath(), "Stone") + " Form");
}
return null;
}
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
return registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
.filter(holder -> isCave(holder) == caves)
.map(holder -> PageItem.createSymbolPage(biomePageId(holder.key().identifier())))
.sorted((left, right) -> left.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()
.compareTo(right.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()))
.toList();
}
public static List<ItemStack> materialPages(boolean fluids) {
String category = fluids ? "rivencraft:fluid" : "rivencraft:block";
return BuiltInRegistries.BLOCK.keySet().stream().sorted()
.filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id))))
.map(id -> PageItem.createSymbolPage(blockPageId(id))).toList();
}
private static boolean isCave(net.minecraft.core.Holder.Reference<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.key().identifier().getPath();
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|| path.equals("deep_dark") || path.endsWith("_caves");
}
private static String materialCategory(Block block) {
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
var state = block.defaultBlockState();
var fluid = state.getFluidState();
if (fluid.isSource() && fluid.createLegacyBlock().getBlock() == block) return "rivencraft:fluid";
return fluid.isEmpty() && state.isCollisionShapeFullBlock(EmptyBlockGetter.INSTANCE, BlockPos.ZERO)
? "rivencraft:block" : null;
}
public static Identifier biomeId(Identifier symbol) {
String path = symbol.getPath().substring("biome/".length());
int slash = path.indexOf('/');
if (slash < 0) return Identifier.fromNamespaceAndPath("minecraft", path);
return Identifier.fromNamespaceAndPath(path.substring(0, slash), path.substring(slash + 1));
}
public static Identifier blockId(Identifier symbol) {
String path = symbol.getPath().substring("block/".length());
int slash = path.indexOf('/');
if (slash < 1) return null;
return Identifier.fromNamespaceAndPath(path.substring(0, slash), path.substring(slash + 1));
}
public static String fallbackName(Identifier symbol) {
boolean biome = symbol.getPath().startsWith("biome/");
Identifier source = biome ? biomeId(symbol) : blockId(symbol);
if (source == null) return symbol.toString();
String words = Arrays.stream(source.getPath().replace('/', '_').split("_"))
.filter(word -> !word.isEmpty())
.map(word -> word.substring(0, 1).toUpperCase(Locale.ROOT) + word.substring(1))
.reduce((a, b) -> a + " " + b).orElse("Unknown");
return words + (biome ? " Biome" : "rivencraft:fluid".equals(find(symbol).category())
? " Fluid" : " Material")
+ (source.getNamespace().equals("minecraft") ? "" : " (" + source.getNamespace() + ")");
}
private static String glyphWord(String path, String fallback) {
String word = Arrays.stream(path.replace('/', '_').split("_"))
.map(part -> part.replaceAll("[^A-Za-z]", ""))
.filter(part -> !part.isEmpty())
.map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1))
.reduce("", String::concat);
if (word.isEmpty()) word = fallback;
return word.length() > 48 ? word.substring(0, 48) : word;
}
}
@@ -0,0 +1,16 @@
package net.dnorberg.rivencraft.writing;
/** Mystcraft Legacy's layered base-25 numeral components, shared with 1.21.1. */
public final class PortSymbolNumerals {
private PortSymbolNumerals() {}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Base-25 digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
int fifths = digit / 5;
int remainder = digit % 5;
if (fifths == 0) return new int[] {55 + remainder};
if (remainder == 0) return new int[] {59 + fifths};
return new int[] {59 + fifths, 55 + remainder};
}
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.writing;
import com.google.gson.JsonParser;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.item.ItemStack;
import net.dnorberg.rivencraft.item.PageItem;
/** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */
public final class PortSymbolPages {
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {}
private static final Map<Identifier, Entry> ENTRIES = load();
private PortSymbolPages() {}
public static Entry find(Identifier id) {
Entry bundled = ENTRIES.get(id);
return bundled != null ? bundled : PortRegistryPages.find(id);
}
public static List<String> categories() {
LinkedHashSet<String> categories = new LinkedHashSet<>();
ENTRIES.values().stream().map(Entry::category).forEach(categories::add);
categories.add("rivencraft:cave_biome");
categories.add("rivencraft:block");
categories.add("rivencraft:fluid");
return List.copyOf(categories);
}
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
List<ItemStack> pages = new ArrayList<>();
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
.filter(entry -> !category.equals("rivencraft:biome")
|| !entry.id().getPath().startsWith("biome/"))
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id())));
if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
if (category.equals("rivencraft:fluid")) pages.addAll(PortRegistryPages.materialPages(true));
return List.copyOf(pages);
}
public static ItemStack icon(String category) {
if (category.equals("rivencraft:cave_biome"))
return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
if (category.equals("rivencraft:block") || category.equals("rivencraft:fluid"))
return PageItem.createSymbolPage(PortRegistryPages.blockPageId(
Identifier.fromNamespaceAndPath("minecraft",
category.equals("rivencraft:fluid") ? "water" : "stone")));
return ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
.findFirst().map(entry -> PageItem.createSymbolPage(entry.id()))
.orElseGet(PageItem::createLinkPanelPage);
}
public static String fallbackName(Identifier id) {
if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
return PortRegistryPages.fallbackName(id);
String path = id.getPath();
String stem = path.substring(path.lastIndexOf('/') + 1).replace('_', ' ');
if (stem.isEmpty()) return id.toString();
return Character.toUpperCase(stem.charAt(0)) + stem.substring(1);
}
private static Map<Identifier, Entry> load() {
String resource = "/assets/rivencraft/creative_symbol_pages.json";
try (var stream = PortSymbolPages.class.getResourceAsStream(resource)) {
if (stream == null) throw new IllegalStateException("Missing generated symbol index " + resource);
var json = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8))
.getAsJsonObject();
Map<Identifier, Entry> entries = new LinkedHashMap<>();
json.entrySet().forEach(item -> {
String[] parts = item.getKey().split(":", 2);
if (parts.length != 2 || !parts[0].equals(Rivencraft.MOD_ID))
throw new IllegalStateException("Unexpected bundled symbol " + item.getKey());
Identifier id = Identifier.fromNamespaceAndPath(parts[0], parts[1]);
var definition = item.getValue().getAsJsonObject();
String category = definition.get("category").getAsString();
if (definition.has("semantics") && definition.getAsJsonObject("semantics")
.get("role").getAsString().equals("biome_controller"))
category = "rivencraft:biome_generation";
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
definition.get("description").getAsString(),
definition.has("poem") ? definition.get("poem").getAsString() : ""));
});
return Collections.unmodifiableMap(entries);
} catch (java.io.IOException exception) {
throw new IllegalStateException("Could not read bundled symbol index", exception);
}
}
}
@@ -4,6 +4,16 @@
"property": "minecraft:has_component",
"component": "rivencraft:link_panel_page",
"on_true": {"type": "minecraft:model", "model": "rivencraft:item/page_link_panel"},
"on_false": {"type": "minecraft:model", "model": "rivencraft:item/page"}
"on_false": {
"type": "minecraft:condition",
"property": "minecraft:has_component",
"component": "rivencraft:symbol_page_id",
"on_true": {
"type": "minecraft:special",
"base": "rivencraft:item/page",
"model": {"type": "rivencraft:symbol_page"}
},
"on_false": {"type": "minecraft:model", "model": "rivencraft:item/page"}
}
}
}
@@ -1,32 +0,0 @@
{
"item.rivencraft.crystal_shard": "Crystal Shard",
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.link_property.disarm": "Disarm",
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
"item.rivencraft.link_property.intra_linking": "Intra-Linking",
"item.rivencraft.ink_vial": "Ink Vial",
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand, or place in an Ink Mixer.",
"item.rivencraft.page.blank.port_status": "Base for future symbol writing; not yet usable in this port.",
"item.rivencraft.page.link_panel.port_status": "Use with leather in a Book Binder to make a Descriptive Book.",
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required",
"block.rivencraft.ink_mixer": "Ink Mixer",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
"screen.rivencraft.ink_mixer.additive_hint": "Click with an additive to mix it in",
"screen.rivencraft.ink_mixer.chance": "%s: %s%%",
"block.rivencraft.crystal_block": "Crystal Block",
"block.rivencraft.budding_crystal": "Budding Crystal",
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
"block.rivencraft.crystal_cluster": "Crystal Cluster",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.abandoned_camps",
"description": "symbol.rivencraft.feature.abandoned_camps.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:abandoned_camps" },
"poem": "Terrain Form Abandoned Camp"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.fallen_poplar_trees",
"description": "symbol.rivencraft.feature.fallen_poplar_trees.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:fallen_poplar_trees" },
"poem": "Nature Form Poplar Fallen"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.poplar_trees",
"description": "symbol.rivencraft.feature.poplar_trees.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:poplar_trees" },
"poem": "Nature Form Poplar Growth"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.red_shrubs",
"description": "symbol.rivencraft.feature.red_shrubs.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:red_shrubs" },
"poem": "Nature Form Red Growth"
}
@@ -0,0 +1,8 @@
{
"schema_version": 2,
"name": "symbol.rivencraft.feature.shelf_mushrooms",
"description": "symbol.rivencraft.feature.shelf_mushrooms.description",
"category": "rivencraft:feature",
"semantics": { "role": "feature", "value": "rivencraft:shelf_mushrooms" },
"poem": "Nature Form Shelf Fungi"
}