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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

+85 -12
View File
@@ -1,15 +1,15 @@
# Minecraft 26.3 port workspace
# Minecraft 26.3 beta port workspace
This is an **experimental alpha port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. It registers basic items and a runtime Age
prototype with persisted seeds. The operator-only `rivencraft_port age`
commands create and inspect separate vanilla-terrain levels. The current
development slice can name an unbound Descriptive Book to create a prototype
Age, then enter it from the book GUI; the player verified this interaction in game.
Named Linking Book binding/travel works as a limited probe. Authored
generation, most content, recipes, workstations, portal systems, most modifiers and
the destination preview renderer remain unported. Use a test world and keep a
backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
author Standard, Amplified, or Island terrain with supported biome-distribution
pages. Other terrain families, feature/material effects, portals, and much of
the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
The entries below record how the port was built, including earlier alpha-only
limitations that were addressed by later slices.
The creative tab contains the two Books, Crystal Block and Crystal Shard;
the player verified these in game. Unbound Linking Books use the Legacy
@@ -80,6 +80,79 @@ The local `1.0.1.15-alpha.1` Binder polish uses the Link Panel's actual 16×16
item icon for the missing-panel cue instead of a full-size page sprite. The
red pulse and tooltip remain confined to that icon.
The local `1.0.1.16-alpha.1` slice adds the bundled 1.21.1 symbol-page
identities to the 26.3 creative menu, grouped by category. Their names and
descriptions are reused from the 1.21.1 translations, and the Page item now
persists each symbol ID. These pages currently use the generic symbol-page
artwork; the Legacy-style runtime glyph compositor has not been ported.
They cannot yet be inserted into the Binder or change Age generation. This is
the page-data foundation for the next authored-writing slice.
The local `1.0.1.17-alpha.1` follow-up derives biome pages from the active
26.3 biome registry instead of only the older bundled list. It excludes the
TerraBlender staging placeholder and separates cave/Nether biomes. Full-block
materials and true source-fluid blocks also receive registry-derived pages,
covering eligible new and modded blocks. Five 26.3 feature symbols cover
poplar trees, fallen poplars, shelf mushrooms, red shrubs, and abandoned
camps. These feature pages are identities only until their Age-generation
handlers are ported. Biome, material, and feature pages still use generic
artwork and cannot yet be placed in the Binder.
The local `1.0.1.18-alpha.1` follow-up restores the 1.21.1 animated redaction
for Corrupted Page names and descriptions. It does not yet port the dynamic
glyph artwork used for other symbol pages.
The local `1.0.1.19-alpha.1` slice ports the runtime glyph compositor for
symbol Page items. It uses the shared Legacy component sheet and page
parchment with a bounded 256-texture client cache; generated textures are
released on resource reload. Registry-derived biome and material pages use
the same deterministic four-word poem pattern as 1.21.1. No per-symbol PNGs
are generated in the source tree. Binder insertion and authored Age generation
are still unported.
The local `1.0.1.20-alpha.1` visual follow-up enlarges symbol artwork on Page
items, slightly thickens the component strokes, and centers number glyphs at a
larger size so they remain readable in inventory and creative-menu icons.
The local `1.0.1.21-alpha.1` slice ports the Binder's ordered, scrollable
symbol-page strip. Pages persist in its block inventory and can be inserted,
removed, and moved via the cursor or shift-click. A Binder with symbol pages
cannot yet produce a book: authored Age compilation is the next port step, and
silently producing a random Age would consume the player's writing incorrectly.
The local `1.0.1.22-alpha.1` visual follow-up gives large Binder pages the
transparent, full-size Legacy glyph composition. Item icons retain their
separate parchment-backed, reduced-size artwork.
The local `1.0.1.23-alpha.1` follow-up places Binder glyph overlays in a later
26.3 GUI stratum than the page atlas, preventing texture batching from drawing
the opaque page backgrounds over the dynamic artwork.
The local `1.0.1.24-alpha.1` follow-up renders Binder page glyphs through
their working special item model, scaled to the page thumbnail. Direct GUI
blitting of the generated texture remained blank in the 26.3 client.
The local `1.0.1.25-alpha.1` follow-up marks temporary Binder preview stacks
for glyph-only special-model rendering. The full-size transparent artwork now
uses the working item pipeline without drawing a second, smaller page outline.
The local `1.0.1.26-alpha.1` corrects the temporary preview component's codec
registration, which NeoForge 26.3 requires during bootstrap.
The local `1.0.1.27-alpha.1` slice applies the first authored Age subset:
Standard terrain plus at most one Biome page. The selected biome is persisted
with the Age and used by a fixed-biome Overworld noise generator on creation
and reload. Ordered page IDs are stored on the finished book. Other page
combinations remain in the Binder and cannot yet be assembled, preventing
silent loss of unsupported writing. Multi-biome distribution, other terrain
profiles, and feature/material pages still need their 26.3 generation ports.
The `1.0.1.28`–`1.0.1.32` slices add Tiled, Grid, Native, and five scaled
biome distributions; multiple authored Biome pages; Amplified and Island
terrain; and saved terrain/biome plans across reload. A Binder or Ink Mixer
beside another block no longer hides that block's faces. Unsupported authoring
still stays in the Binder rather than silently producing an incorrect Age.
From the repository root:
```sh
@@ -94,7 +167,7 @@ Minecraft 1.21.1/Java 21 build:
./gradlew build
```
The alpha JAR is under `ports/mc263/build/libs`. Continue the port and its
The beta JAR is under `ports/mc263/build/libs`. Continue the port and its
full dual-version validation in issues #98–#104 before calling 26.3 stable.
The dedicated development server uses `build/run-server`, with its own world
@@ -0,0 +1,19 @@
# Rivencraft 26.3 beta 1.0.1.32-beta.1
This is the first 26.3 beta, focused on authored Age generation. It is not yet
feature-complete with the separate Minecraft 1.21.1 release.
- Ported the Book Binder's ordered symbol-page strip and Legacy-inspired GUI,
plus runtime symbol artwork, creative pages, and registry-derived biome pages.
- Ported the Ink Mixer, its additive modifiers, and one-paper/one-ink-unit Link
Panel creation.
- Added authored Standard, Amplified, and Island terrain; fixed, Tiled, Grid,
Native, and Tiny through Huge biome distributions; and persistent Age plans.
- Fixed workstation block-face occlusion beside the Book Binder and Ink Mixer.
Feature/material writing, Cave/Flat/Void terrain, portals, and other 1.21.1
systems are still being ported. Use a test world and keep a backup. Minecraft
1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and
Java 25.
+65
View File
@@ -59,12 +59,77 @@ def generateModMetadata = tasks.register('generateModMetadata', ProcessResources
sourceSets.main.resources.srcDir generateModMetadata
neoForge.ideSyncTask generateModMetadata
// Keep the bundled page list in step with the 1.21.1 symbol definitions.
abstract class GeneratePortSymbolIndex extends DefaultTask {
@InputDirectory abstract DirectoryProperty getSymbolsDir()
@InputDirectory abstract DirectoryProperty getPortSymbolsDir()
@OutputDirectory abstract DirectoryProperty getIndexDir()
@TaskAction void generate() {
File root = symbolsDir.get().asFile
def definitions = new LinkedHashMap<String, Object>()
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = root.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File portRoot = portSymbolsDir.get().asFile
java.nio.file.Files.walk(portRoot.toPath()).withCloseable { paths ->
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
.sorted().forEach { source ->
String path = portRoot.toPath().relativize(source).toString()
.replace('\\', '/').replaceAll(/\.json$/, '')
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
}
}
File index = new File(indexDir.get().asFile, 'assets/rivencraft/creative_symbol_pages.json')
index.parentFile.mkdirs()
index.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definitions)) + '\n'
}
}
def generatePortSymbolIndex = tasks.register('generatePortSymbolIndex', GeneratePortSymbolIndex) {
symbolsDir.set(layout.projectDirectory.dir('../../src/main/resources/data/rivencraft/rivencraft/symbols'))
portSymbolsDir.set(layout.projectDirectory.dir('src/main/resources/data/rivencraft/rivencraft/symbols'))
indexDir.set(layout.buildDirectory.dir('generated/resources/creativeSymbols'))
}
sourceSets.main.resources.srcDir generatePortSymbolIndex
// Use the established symbol names/descriptions without overwriting 26.3-only strings.
abstract class GeneratePortLanguage extends DefaultTask {
@InputFile abstract RegularFileProperty getBaseLanguage()
@InputFile abstract RegularFileProperty getPortLanguage()
@OutputDirectory abstract DirectoryProperty getOutputDir()
@TaskAction void generate() {
def base = new groovy.json.JsonSlurper().parse(baseLanguage.get().asFile) as Map
def port = new groovy.json.JsonSlurper().parse(portLanguage.get().asFile) as Map
base.putAll(port)
File result = new File(outputDir.get().asFile, 'assets/rivencraft/lang/en_us.json')
result.parentFile.mkdirs()
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(base)) + '\n'
}
}
def generatePortLanguage = tasks.register('generatePortLanguage', GeneratePortLanguage) {
baseLanguage.set(layout.projectDirectory.file('../../src/main/resources/assets/rivencraft/lang/en_us.json'))
portLanguage.set(layout.projectDirectory.file('port_en_us.json'))
outputDir.set(layout.buildDirectory.dir('generated/resources/portLanguage'))
}
sourceSets.main.resources.srcDir generatePortLanguage
tasks.named('processResources', ProcessResources).configure {
// Reuse the existing sprite and base model without copying binary assets.
from('../../src/main/resources') {
include 'assets/rivencraft/textures/item/crystal_shard.png'
include 'assets/rivencraft/textures/item/page.png'
include 'assets/rivencraft/textures/item/page_link_panel.png'
include 'assets/rivencraft/textures/item/page_symbol.png'
include 'assets/rivencraft/models/item/page_symbol.json'
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.15-alpha.1
mod_version=1.0.1.32-beta.1
@@ -5,6 +5,8 @@
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.unresolved": "Unknown Symbol Page",
"item.rivencraft.page.symbol.port_status": "Binder currently supports Standard terrain and one Biome page; other effects are still being ported.",
"item.rivencraft.link_property.disarm": "Disarm",
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
"item.rivencraft.link_property.intra_linking": "Intra-Linking",
@@ -16,6 +18,7 @@
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"screen.rivencraft.book_binder.missing_panel": "Link Panel required",
"screen.rivencraft.book_binder.authored_pending": "Writing not yet ported",
"block.rivencraft.ink_mixer": "Ink Mixer",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
@@ -28,5 +31,15 @@
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
"block.rivencraft.crystal_cluster": "Crystal Cluster",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages"
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
"symbol.rivencraft.feature.poplar_trees": "Poplar Trees",
"symbol.rivencraft.feature.poplar_trees.description": "Poplar tree generation symbol.",
"symbol.rivencraft.feature.fallen_poplar_trees": "Fallen Poplar Trees",
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Fallen poplar tree generation symbol.",
"symbol.rivencraft.feature.shelf_mushrooms": "Shelf Mushrooms",
"symbol.rivencraft.feature.shelf_mushrooms.description": "Shelf mushroom generation symbol.",
"symbol.rivencraft.feature.red_shrubs": "Red Shrubs",
"symbol.rivencraft.feature.red_shrubs.description": "Red shrub generation symbol.",
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Abandoned camp structure generation symbol."
}
@@ -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"}
}
}
}
@@ -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"
}