No files matched your search
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||