diff --git a/dev images/Pasted image (2).png b/dev images/Pasted image (2).png index fbcc2fe..cfb83c0 100644 Binary files a/dev images/Pasted image (2).png and b/dev images/Pasted image (2).png differ diff --git a/dev images/Pasted image (3).png b/dev images/Pasted image (3).png deleted file mode 100644 index b9655da..0000000 Binary files a/dev images/Pasted image (3).png and /dev/null differ diff --git a/dev images/Pasted image (4).png b/dev images/Pasted image (4).png deleted file mode 100644 index dc8dfbb..0000000 Binary files a/dev images/Pasted image (4).png and /dev/null differ diff --git a/dev images/Pasted image (5).png b/dev images/Pasted image (5).png deleted file mode 100644 index 51be084..0000000 Binary files a/dev images/Pasted image (5).png and /dev/null differ diff --git a/dev images/Pasted image (6).png b/dev images/Pasted image (6).png deleted file mode 100644 index abefcbd..0000000 Binary files a/dev images/Pasted image (6).png and /dev/null differ diff --git a/dev images/Pasted image (7).png b/dev images/Pasted image (7).png deleted file mode 100644 index 7789370..0000000 Binary files a/dev images/Pasted image (7).png and /dev/null differ diff --git a/dev images/Pasted image (8).png b/dev images/Pasted image (8).png deleted file mode 100644 index ecd5523..0000000 Binary files a/dev images/Pasted image (8).png and /dev/null differ diff --git a/dev images/Pasted image.png b/dev images/Pasted image.png deleted file mode 100644 index 647b315..0000000 Binary files a/dev images/Pasted image.png and /dev/null differ diff --git a/ports/mc263/README.md b/ports/mc263/README.md index 3783590..b001a69 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -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 diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md new file mode 100644 index 0000000..2fd264a --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md @@ -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. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 04e58c5..e43f6e3 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -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() + 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' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 426a7eb..de0e5b9 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -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 diff --git a/ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json b/ports/mc263/port_en_us.json similarity index 60% rename from ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json rename to ports/mc263/port_en_us.json index 0079c26..62d40bf 100644 --- a/ports/mc263/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/ports/mc263/port_en_us.json @@ -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." } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index ebd8070..9a83455 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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> LINK_PANEL_PAGE = COMPONENTS.registerComponentType( "link_panel_page", builder -> builder.persistent(Codec.BOOL) .networkSynchronized(ByteBufCodecs.BOOL)); + public static final Supplier> 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>> BOOK_SYMBOLS = COMPONENTS.registerComponentType( + "book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf()) + .networkSynchronized(ByteBufCodecs.list(252) + .apply(Identifier.STREAM_CODEC))); + /** Set only on temporary GUI stacks; NeoForge 26.3 requires a registered codec. */ + public static final Supplier> PAGE_GLYPH_ONLY = COMPONENTS.registerComponentType( + "page_glyph_only", builder -> builder.persistent(Codec.BOOL) + .networkSynchronized(ByteBufCodecs.BOOL)); public static final Supplier> 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 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 BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER); public static final DeferredBlock 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 INK_MIXER_ITEM = ITEMS.registerSimpleBlockItem(INK_MIXER); public static final Supplier> 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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java index ebf2ac2..0adefec 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java @@ -17,7 +17,14 @@ final class AgeLevelIndex extends SavedData { private static final Codec 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 TYPE = new SavedDataType<>( @@ -28,15 +35,27 @@ final class AgeLevelIndex extends SavedData { private final Map ages; private final Set retired; + private final Map biomeOverrides; + private final Map biomePlans; + private final Map 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 ages, List retired) { + private AgeLevelIndex(Map ages, List retired, + Map biomeOverrides, + Map biomePlans, + Map 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 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(); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTerrain.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTerrain.java new file mode 100644 index 0000000..99f5826 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTerrain.java @@ -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 +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomePlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomePlan.java new file mode 100644 index 0000000..4334a2f --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomePlan.java @@ -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 biomes) { + public enum Distribution { NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE } + + public static final Codec 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"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortScaledBiomeSource.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortScaledBiomeSource.java new file mode 100644 index 0000000..328b0ff --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortScaledBiomeSource.java @@ -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 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 biomes; + private final long seed; + private final int scale; + private final int cellWidth; + + public PortScaledBiomeSource(HolderSet 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 codec() { + return CODEC; + } + + @Override + protected Stream> collectPossibleBiomes() { + return biomes.stream(); + } + + @Override + public BiomeResolver createResolver(Climate.Sampler sampler) { + return this; + } + + @Override + public Holder 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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index e55fafc..0f7e0bd 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -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 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 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> biomes = biomePlan.biomes().stream().map(biome -> + (Holder) 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()), diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java index 02f6d09..1ecc950 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/PortBookBinderBlockEntity.java @@ -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 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 symbolPages() { + List 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 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java index f4ae23b..b4d3895 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java @@ -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()); + } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderScreen.java index 2db6c82..3c863fa 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderScreen.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderScreen.java @@ -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= 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= 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 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= 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()) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageRenderer.java new file mode 100644 index 0000000..e738433 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageRenderer.java @@ -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 { + @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 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 { + public static final Unbaked INSTANCE = new Unbaked(); + public static final MapCodec MAP_CODEC = MapCodec.unit(INSTANCE); + + @Override public MapCodec type() { return MAP_CODEC; } + @Override public SpecialModelRenderer bake(BakingContext context) { + return new PortSymbolPageRenderer(); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java new file mode 100644 index 0000000..02c5e24 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortSymbolPageTextures.java @@ -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 CACHE = new LinkedHashMap<>(16, .75F, true); + private static final Map 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 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"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 4274e88..642c4cd 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -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 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/"); + } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java index ed87ce8..a9200f0 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/PortBookBinderMenu.java @@ -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); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 2baffb3..ed0799b 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -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 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; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index f8b85a7..fd17698 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -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() { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortBiomeSources.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortBiomeSources.java new file mode 100644 index 0000000..2fa0870 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortBiomeSources.java @@ -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> 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java new file mode 100644 index 0000000..e6b3ff1 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java @@ -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 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 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 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 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); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java new file mode 100644 index 0000000..3303def --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java @@ -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 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 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 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 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; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java new file mode 100644 index 0000000..7449d60 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolNumerals.java @@ -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}; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java new file mode 100644 index 0000000..da7ef80 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java @@ -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 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 categories() { + LinkedHashSet 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 pages(String category, HolderLookup.Provider registries) { + List 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 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 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); + } + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/page.json b/ports/mc263/src/main/resources/assets/rivencraft/items/page.json index 67de65e..b2596c3 100644 --- a/ports/mc263/src/main/resources/assets/rivencraft/items/page.json +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/page.json @@ -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"} + } } } diff --git a/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/abandoned_camps.json b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/abandoned_camps.json new file mode 100644 index 0000000..d9bc250 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/abandoned_camps.json @@ -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" +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/fallen_poplar_trees.json b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/fallen_poplar_trees.json new file mode 100644 index 0000000..5c97658 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/fallen_poplar_trees.json @@ -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" +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/poplar_trees.json b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/poplar_trees.json new file mode 100644 index 0000000..82bfc10 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/poplar_trees.json @@ -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" +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/red_shrubs.json b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/red_shrubs.json new file mode 100644 index 0000000..3cd1f34 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/red_shrubs.json @@ -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" +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/shelf_mushrooms.json b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/shelf_mushrooms.json new file mode 100644 index 0000000..a3ea97c --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/rivencraft/symbols/feature/shelf_mushrooms.json @@ -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" +}