diff --git a/README.md b/README.md index 2fb8d16..a635c71 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,22 @@ right-clicking with an empty hand. Sneak-right-click with an empty hand to take the book back and restore the vanilla lectern. Breaking a Book Stand drops both the stored book and a vanilla lectern. -Bound Linking Books request a server-validated 32-by-32-by-32 snapshot around -their destination. The book keeps its target-centered close-up framing as a -rotating Ponder scene on the left page, backed by the -destination biome's sky color. The snapshot is visual only; the server -independently validates every link. +Bound Linking Books show a rotating Ponder snapshot when Create is installed. +Without Create, they use a built-in animated linking panel tinted by the +destination sky. The server independently validates every link. ## Descriptive Book Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft -assigns the book a permanent Age UUID, creates a dedicated vanilla-like runtime -dimension, and prepares a five-by-five cobblestone arrival platform at the +assigns the book a permanent Age UUID, selects a deterministic base terrain, +creates a dedicated runtime dimension, and prepares a five-by-five cobblestone +arrival platform at the highest suitable nearby terrain. Solid blocks and tree leaves are valid footing. Candidate columns are scanned from the top down so cave floors are not selected, and fluid-covered columns are excluded. If no suitable surface is available, the platform is generated at Y=63. Reopen the book and click its preview to travel. +The book cannot reopen while its Age is being generated, whether held or on a +Book Stand. Once creation finishes, reopening shows the written book. Copies of the written book continue to resolve to the same persistent Age across server restarts. @@ -50,6 +51,10 @@ New Age dimension folders use a Minecraft-safe lowercase form of the assigned name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing Age folders are never renamed. +Page-free Ages deterministically select one of six base terrains inspired by +Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen +profile is saved with the Age and does not change when the server restarts. + Linking creates a bound Return Link to the exact source position and orientation according to the configured Return Link policy. @@ -61,9 +66,18 @@ server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`. - A Java 21 JDK - Minecraft 1.21.1 - NeoForge 21.1.251 or newer in the 21.1 line -- Ponder 1.0.82 for Minecraft 1.21.1 (client) - Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server) +Create is optional. When installed, Rivencraft uses its Ponder renderer for +book previews when Immersive Portals is absent. Immersive Portals is also +optional and takes priority in the default `AUTO` mode, providing a live +rotating destination view. Its Explore button enables WASD and jump/sneak +camera movement plus left-drag camera look; right-click returns to the rotating +view. Without either integration, Rivencraft uses its built-in animated linking +panel. Clients can set `book_preview.mode` in +`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. +Unavailable optional renderers safely fall back to `LEGACY`. + ## Build and run ```shell diff --git a/build.gradle b/build.gradle index f5c1da7..c81103c 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,10 @@ repositories { content { includeGroup 'curse.maven' } } maven { url = 'https://maven.bai.lol' } + maven { + url = 'https://api.modrinth.com/maven' + content { includeGroup 'maven.modrinth' } + } } base { @@ -121,7 +125,10 @@ configurations { } dependencies { - implementation "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}" + // Create bundles the Ponder APIs used by the optional snapshot preview. + // Keep them on the compile classpath without forcing either mod at runtime. + compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}" + compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}" implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}" implementation "lol.bai:badpackets:neo-${badpackets_version}" // Example optional mod dependency with JEI diff --git a/dev images/Immersive Portals.png b/dev images/Immersive Portals.png new file mode 100644 index 0000000..d88363d Binary files /dev/null and b/dev images/Immersive Portals.png differ diff --git a/dev images/expected behavior.png b/dev images/expected behavior.png new file mode 100644 index 0000000..8bb3dc5 Binary files /dev/null and b/dev images/expected behavior.png differ diff --git a/docs/PORTING.md b/docs/PORTING.md index 55736e3..96baac4 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -215,6 +215,92 @@ player-assigned name. Names are converted to safe lowercase resource paths, and folder conflicts receive `_2`, `_3`, and later numeric suffixes. Existing UUID-based dimension folders remain unchanged for save compatibility. +Version 0.2.10.6 prevents a Descriptive Book from reopening while its Age is +being generated. Screen opening now waits for the server's current copy of the +held or displayed book, so a delayed interaction opens only the completed Age. + +Version 0.2.11.0 adds a dependency-free, legacy-style portal bloom as the +fallback destination preview for Linking and Descriptive Books. Clients with +Create installed retain the rotating Ponder snapshot; clients without Create +use the destination-tinted animation with the original effect's paired noisy +spirals. + +Version 0.2.11.1 makes Create genuinely optional. Ponder remains a compile-time +API for the Create integration, while generated mod metadata no longer requires +Ponder and marks Create as an optional client dependency. + +Version 0.2.12.0 introduces the first legacy-inspired Age generator profiles. +New page-free Ages deterministically select and persist Standard, Amplified, +Island, Cave, Flat, or Void base terrain. Existing `random_vanilla` Ages remain +mapped to their original Overworld generator for save compatibility. Terrain +alterations such as Skylands, Tendrils, Spheres, and Floating Islands remain +future Age-grammar work layered above these base profiles. + +Version 0.2.12.1 displays the persisted terrain generator on the description +side of written Descriptive Books. Newly written books receive the synchronized +profile immediately, while older books are backfilled from the server-owned Age +registry when opened. + +Version 0.2.13.0 constructs Standard, Amplified, Cave, and Island terrain from +Minecraft's worldgen registries instead of borrowing generator instances from +the vanilla levels. Noise settings and biome-source parameter lists therefore +honor the server's loaded data packs. Automated coverage also verifies that a +live Age's seed equals its immutable saved definition; Dynamic Dimensions +derives both from the Overworld seed and the Age dimension identifier. + +Version 0.2.14.0 restores the legacy Linking and Descriptive Book artwork for +inventory items and gives each book its matching open-book texture when placed +on a Book Stand. Return Linking Books created inside another Age also retain and +display that Age's saved name instead of exposing its dimension identifier. + +Version 0.2.14.1 gives every newly bound Linking Book a friendly destination +label. Standard books and Return Links use saved Age names inside Rivencraft +Ages and the familiar Overworld, The Nether, and The End names in vanilla +dimensions. Existing vanilla links receive the same presentation at display +time without requiring item migration. + +Version 0.2.15.0 adds the client-side book-preview mode configuration and the +selection policy for `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, and `LEGACY`. +`AUTO` uses Ponder when Create is present and otherwise uses the built-in +legacy animation. Explicit optional modes fall back to `LEGACY` when their +renderer is unavailable. + +Version 0.2.16.0 implements the optional Immersive Portals GUI renderer. Its +default view orbits the live destination like the Ponder snapshot. The Explore +control switches to a movable remote camera using movement keys and mouse drag; +right-click resets the camera and restores the passive book GUI. A validated +per-player chunk loader exists only while the screen is open and is released +on close. `AUTO` now prefers Immersive Portals, then Ponder, then `LEGACY`. + +Version 0.2.16.1 corrects Immersive Portals detection to use its NeoForge mod +ID, `immersive_portals_core`. Rivencraft now also refreshes Immersive Portals' +integer dimension map whenever saved or newly created Ages are loaded, before +their chunks can be synchronized to a player. + +Version 0.2.16.2 routes book travel through Immersive Portals' seamless +teleport API when that mod is installed, preserving its client-world registry +during links between Ages and vanilla dimensions. The Explore control is also +positioned fully below the live snapshot window on both book screens. + +Version 0.2.16.3 restores the full window viewport immediately after drawing +an Immersive Portals snapshot. Book titles, destination details, and controls +therefore render in their normal page positions instead of being compressed +inside the snapshot rectangle. + +Version 0.2.16.4 clears the Immersive Portals GUI framebuffer between remote +world renders, preventing moving camera frames from accumulating into a sky +smear. The framebuffer now follows Immersive Portals' shared-target lifecycle +so a queued end-of-frame render cannot target a texture destroyed on GUI close. + +Version 0.2.16.5 expands live-preview chunk loading to Immersive Portals' +reference eight-chunk radius. Rivencraft prepares the central 3x3 destination +chunks and requests an immediate tracking update when the GUI opens, providing +terrain for the first useful snapshot while the remaining chunks stream in. + +Version 0.2.16.6 makes Linking and Descriptive Book screens non-pausing. The +integrated server now continues ticking in single-player while a book is open, +allowing live preview chunks to generate and stream into the snapshot. + The preview code is separated behind a renderer interface and uses an isolated Ponder level populated from server-authoritative destination snapshots. diff --git a/gradle.properties b/gradle.properties index f7e98e3..be399a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,8 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.2.10.5 +mod_version=0.2.16.6 +immersive_portals_version=6.0.7 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index a445fe1..e259173 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -10,6 +10,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.event.BookStandConversionHandler; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.config.RivencraftConfig; +import net.dnorberg.rivencraft.config.RivencraftClientConfig; import net.dnorberg.rivencraft.book.AgeDimensionService; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; @@ -26,6 +27,7 @@ public final class Rivencraft { public Rivencraft(IEventBus modEventBus, ModContainer modContainer) { AgeDimensionService.register(); modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC); + modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC); modEventBus.addListener(ModNetworking::register); ModDataComponents.register(modEventBus); ModBlocks.register(modEventBus); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java index 96337c5..a0d6263 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDefinition.java @@ -20,7 +20,7 @@ public record AgeDefinition( public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath( Rivencraft.MOD_ID, "random_vanilla" ); - public static final int CURRENT_GENERATION_VERSION = 1; + public static final int CURRENT_GENERATION_VERSION = 2; public AgeDefinition { Objects.requireNonNull(id, "id"); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index ac7c80b..b51bf3e 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -4,10 +4,14 @@ import dev.galacticraft.dynamicdimensions.api.DynamicDimensionRegistry; import dev.galacticraft.dynamicdimensions.api.event.DynamicDimensionLoadCallback; import java.util.Optional; import java.util.OptionalLong; +import java.util.List; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -15,9 +19,19 @@ import net.minecraft.tags.BlockTags; import net.minecraft.util.valueproviders.UniformInt; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.biome.MultiNoiseBiomeSource; +import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists; +import net.minecraft.world.level.biome.TheEndBiomeSource; +import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.dimension.BuiltinDimensionTypes; import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.FlatLevelSource; import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; +import net.neoforged.fml.ModList; /** Creates, reloads, and resolves Rivencraft's runtime Age dimensions. */ public final class AgeDimensionService { @@ -34,10 +48,11 @@ public final class AgeDimensionService { for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) { loader.loadDynamicDimension( age.dimensionId(), - overworld.getChunkSource().getGenerator(), - createDimensionType() + createGenerator(server, age), + createDimensionType(server, age) ); } + synchronizeImmersivePortals(server); }); } @@ -70,15 +85,23 @@ public final class AgeDimensionService { ServerLevel overworld = server.overworld(); ServerLevel loaded = DynamicDimensionRegistry.from(server).loadDynamicDimension( definition.dimensionId(), - overworld.getChunkSource().getGenerator(), - createDimensionType() + createGenerator(server, definition), + createDimensionType(server, definition) ); if (loaded == null) { Rivencraft.LOGGER.error("Failed to load Age {} as {}", definition.id(), definition.dimensionId()); + } else { + synchronizeImmersivePortals(server); } return loaded; } + private static void synchronizeImmersivePortals(MinecraftServer server) { + if (ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.dimensionsChanged(server); + } + } + public static ResourceKey dimensionKey(AgeDefinition definition) { return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId()); } @@ -166,7 +189,60 @@ public final class AgeDimensionService { return new BlockPos(0, floorY, 0); } - private static DimensionType createDimensionType() { + private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) { + ResourceLocation profile = age.generationProfile(); + if (profile.equals(AgeGenerationProfiles.FLAT)) { + return new FlatLevelSource(FlatLevelGeneratorSettings.getDefault( + server.registryAccess().lookupOrThrow(Registries.BIOME), + server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET), + server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) + )); + } + if (profile.equals(AgeGenerationProfiles.VOID)) { + FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings( + Optional.empty(), + server.registryAccess().lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.THE_VOID), + List.of() + ); + settings.updateLayers(); + return new FlatLevelSource(settings); + } + + var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME); + var biomeParameters = server.registryAccess() + .lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST); + var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + if (profile.equals(AgeGenerationProfiles.CAVE)) { + return new NoiseBasedChunkGenerator( + MultiNoiseBiomeSource.createFromPreset( + biomeParameters.getOrThrow(MultiNoiseBiomeSourceParameterLists.NETHER)), + noiseSettings.getOrThrow(NoiseGeneratorSettings.NETHER) + ); + } + if (profile.equals(AgeGenerationProfiles.ISLAND)) { + return new NoiseBasedChunkGenerator( + TheEndBiomeSource.create(biomes), + noiseSettings.getOrThrow(NoiseGeneratorSettings.END) + ); + } + ResourceKey settingsKey = profile.equals(AgeGenerationProfiles.AMPLIFIED) + ? NoiseGeneratorSettings.AMPLIFIED + : NoiseGeneratorSettings.OVERWORLD; + // random_vanilla is retained as a migration alias for existing Ages. + return new NoiseBasedChunkGenerator( + MultiNoiseBiomeSource.createFromPreset( + biomeParameters.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD)), + noiseSettings.getOrThrow(settingsKey) + ); + } + + private static DimensionType createDimensionType(MinecraftServer server, AgeDefinition age) { + if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) { + return copyDimensionType(server.getLevel(Level.NETHER).dimensionType()); + } + if (age.generationProfile().equals(AgeGenerationProfiles.ISLAND)) { + return copyDimensionType(server.getLevel(Level.END).dimensionType()); + } return new DimensionType( OptionalLong.empty(), true, @@ -185,4 +261,34 @@ public final class AgeDimensionService { new DimensionType.MonsterSettings(false, true, UniformInt.of(0, 7), 0) ); } + + /** Dynamic Dimensions registers one dimension-type value per Age, so each needs a distinct instance graph. */ + private static DimensionType copyDimensionType(DimensionType type) { + DimensionType.MonsterSettings monsters = type.monsterSettings(); + return new DimensionType( + type.fixedTime(), + type.hasSkyLight(), + type.hasCeiling(), + type.ultraWarm(), + type.natural(), + type.coordinateScale(), + type.bedWorks(), + type.respawnAnchorWorks(), + type.minY(), + type.height(), + type.logicalHeight(), + type.infiniburn(), + type.effectsLocation(), + type.ambientLight(), + new DimensionType.MonsterSettings( + monsters.piglinSafe(), + monsters.hasRaids(), + UniformInt.of( + monsters.monsterSpawnLightTest().getMinValue(), + monsters.monsterSpawnLightTest().getMaxValue() + ), + monsters.monsterSpawnBlockLightLimit() + ) + ); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationProfiles.java b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationProfiles.java new file mode 100644 index 0000000..1ff3c20 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeGenerationProfiles.java @@ -0,0 +1,39 @@ +package net.dnorberg.rivencraft.book; + +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.ResourceLocation; + +/** Stable base-terrain families inspired by Mystcraft Legacy's terrain symbols. */ +public final class AgeGenerationProfiles { + public static final ResourceLocation STANDARD = id("standard"); + public static final ResourceLocation AMPLIFIED = id("amplified"); + public static final ResourceLocation ISLAND = id("island"); + public static final ResourceLocation CAVE = id("cave"); + public static final ResourceLocation FLAT = id("flat"); + public static final ResourceLocation VOID = id("void"); + + private static final List RANDOM_PROFILES = List.of( + STANDARD, AMPLIFIED, ISLAND, CAVE, FLAT, VOID + ); + + private AgeGenerationProfiles() { + } + + /** Selects one stable base terrain from the Age's already-persisted seed. */ + public static ResourceLocation select(long seed) { + return RANDOM_PROFILES.get(Math.floorMod(Long.hashCode(seed), RANDOM_PROFILES.size())); + } + + public static boolean isSupported(ResourceLocation profile) { + return RANDOM_PROFILES.contains(profile); + } + + public static List all() { + return RANDOM_PROFILES; + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java index c4fcaeb..f7355b5 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeRegistrySavedData.java @@ -50,6 +50,12 @@ public final class AgeRegistrySavedData extends SavedData { return Optional.ofNullable(ages.get(ageId)); } + public Optional findByDimension(ResourceLocation dimensionId) { + return ages.values().stream() + .filter(age -> age.dimensionId().equals(dimensionId)) + .findFirst(); + } + public List definitions() { return List.copyOf(ages.values()); } @@ -73,11 +79,12 @@ public final class AgeRegistrySavedData extends SavedData { String name = normalizedName(reference.name()); ResourceLocation dimensionId = availableDimensionId(name); + long seed = ageSeed(worldSeed, dimensionId); AgeDefinition created = new AgeDefinition( reference.id(), name, - ageSeed(worldSeed, dimensionId), - AgeDefinition.RANDOM_VANILLA_PROFILE, + seed, + AgeGenerationProfiles.select(seed), AgeDefinition.CURRENT_GENERATION_VERSION, createdGameTime, dimensionId, diff --git a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java index 3abf979..96c1696 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/LinkingService.java @@ -4,6 +4,7 @@ import java.util.Comparator; import java.util.Optional; import java.util.stream.IntStream; import java.util.stream.Stream; +import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; @@ -12,6 +13,7 @@ import net.minecraft.tags.BlockTags; import net.minecraft.world.level.border.WorldBorder; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.phys.Vec3; +import net.neoforged.fml.ModList; /** Server-side validation and execution for book travel. */ public final class LinkingService { @@ -39,7 +41,7 @@ public final class LinkingService { } Vec3 position = destination.get(); - player.teleportTo(destinationLevel, position.x, position.y, position.z, target.yaw(), target.pitch()); + teleport(player, destinationLevel, position, target); player.resetFallDistance(); return LinkResult.SUCCESS; } @@ -56,18 +58,32 @@ public final class LinkingService { } Vec3 position = destination.get(); - player.teleportTo( - destinationLevel, - position.x, - position.y, - position.z, - target.yaw(), - target.pitch() - ); + teleport(player, destinationLevel, position, target); player.resetFallDistance(); return LinkResult.SUCCESS; } + private static void teleport( + ServerPlayer player, + ServerLevel destinationLevel, + Vec3 position, + LinkTarget target + ) { + if (ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.teleport( + player, destinationLevel, position, target.yaw(), target.pitch()); + } else { + player.teleportTo( + destinationLevel, + position.x, + position.y, + position.z, + target.yaw(), + target.pitch() + ); + } + } + static Optional findSafeDestination(ServerLevel level, BlockPos origin) { return findSafeDestination(level, origin, false); } diff --git a/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java index b107be3..dcfe104 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java +++ b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java @@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.math.Axis; import net.dnorberg.rivencraft.block.BookStandBlock; import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.client.model.BookModel; import net.minecraft.client.model.geom.ModelLayers; import net.minecraft.client.Minecraft; @@ -13,13 +14,17 @@ import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; -import net.minecraft.client.renderer.blockentity.EnchantTableRenderer; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.block.state.BlockState; import org.joml.Matrix4f; public final class BookStandRenderer implements BlockEntityRenderer { + private static final ResourceLocation LINKING_BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath( + "rivencraft", "textures/entity/linking_book.png"); + private static final ResourceLocation DESCRIPTIVE_BOOK_TEXTURE = ResourceLocation.fromNamespaceAndPath( + "rivencraft", "textures/entity/descriptive_book.png"); private final BookModel bookModel; private final Font font; private final EntityRenderDispatcher entityRenderer; @@ -51,7 +56,10 @@ public final class BookStandRenderer implements BlockEntityRenderer { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player == null || minecraft.level == null || minecraft.screen != null + || !minecraft.level.dimension().equals(payload.source().dimension())) { + return; + } + + InteractionHand hand = payload.offhand() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; + ItemStack currentBook; + if (payload.fromStand()) { + if (minecraft.player.distanceToSqr(Vec3.atCenterOf(payload.source().pos())) > 64.0D + || !(minecraft.level.getBlockEntity(payload.source().pos()) instanceof BookStandBlockEntity stand)) { + return; + } + currentBook = stand.getBook(); + } else { + currentBook = minecraft.player.getItemInHand(hand); + } + // Ignore delayed replies if the player has moved or replaced the book. + if (!currentBook.is(ModItems.DESCRIPTIVE_BOOK.get()) + || !ItemStack.isSameItemSameComponents(currentBook, payload.book())) { + return; + } + minecraft.setScreen(payload.fromStand() + ? new DescriptiveBookScreen(payload.source().pos(), payload.book()) + : new DescriptiveBookScreen(hand, payload.book())); + }); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java index 8f13e16..0b34b3d 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java +++ b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderer.java @@ -10,6 +10,12 @@ public interface DestinationPreviewRenderer extends AutoCloseable { void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick); + default boolean supportsExploration() { return false; } + default boolean isExploring() { return false; } + default void setExploring(boolean exploring) { } + default void rotate(double deltaX, double deltaY) { } + default void resetView() { } + @Override default void close() { } diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderers.java b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderers.java new file mode 100644 index 0000000..6a0d1ba --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/preview/DestinationPreviewRenderers.java @@ -0,0 +1,44 @@ +package net.dnorberg.rivencraft.client.preview; + +import net.dnorberg.rivencraft.config.RivencraftClientConfig; +import net.neoforged.fml.ModList; + +/** Selects the configured destination preview while keeping optional integrations optional. */ +public final class DestinationPreviewRenderers { + private enum Backend { + IMMERSIVE_PORTALS, + PONDER, + LEGACY + } + + private DestinationPreviewRenderers() { + } + + public static DestinationPreviewRenderer create() { + boolean ponderAvailable = ModList.get().isLoaded("create"); + // The mode and priority are established before the optional IP renderer + // lands, so selecting it currently follows the documented safe fallback. + boolean immersivePortalsAvailable = ModList.get().isLoaded("immersive_portals_core"); + return switch (selectBackend( + RivencraftClientConfig.previewMode(), immersivePortalsAvailable, ponderAvailable)) { + case IMMERSIVE_PORTALS -> new ImmersivePortalsDestinationPreviewRenderer(); + case PONDER -> new PonderDestinationPreviewRenderer(); + case LEGACY -> new LegacyDestinationPreviewRenderer(); + }; + } + + private static Backend selectBackend( + RivencraftClientConfig.PreviewMode mode, + boolean immersivePortalsAvailable, + boolean ponderAvailable + ) { + return switch (mode) { + case AUTO -> immersivePortalsAvailable + ? Backend.IMMERSIVE_PORTALS + : ponderAvailable ? Backend.PONDER : Backend.LEGACY; + case IMMERSIVE_PORTALS -> immersivePortalsAvailable ? Backend.IMMERSIVE_PORTALS : Backend.LEGACY; + case PONDER -> ponderAvailable ? Backend.PONDER : Backend.LEGACY; + case LEGACY -> Backend.LEGACY; + }; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/ImmersivePortalsDestinationPreviewRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/preview/ImmersivePortalsDestinationPreviewRenderer.java new file mode 100644 index 0000000..0326fd0 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/preview/ImmersivePortalsDestinationPreviewRenderer.java @@ -0,0 +1,152 @@ +package net.dnorberg.rivencraft.client.preview; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import net.dnorberg.rivencraft.network.LinkPreviewPayload; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; +import qouteall.imm_ptl.core.ClientWorldLoader; +import qouteall.imm_ptl.core.render.GuiPortalRendering; +import qouteall.imm_ptl.core.render.MyRenderHelper; +import qouteall.imm_ptl.core.render.context_management.WorldRenderInfo; + +/** Live remote-world preview rendered through Immersive Portals' GUI API. */ +public final class ImmersivePortalsDestinationPreviewRenderer implements DestinationPreviewRenderer { + private static final double ORBIT_RADIUS = 11.0D; + // Immersive Portals keeps GUI render tasks until the end of the frame. A + // shared target follows its API example and cannot be destroyed while a + // queued task may still reference it. + private static final RenderTarget FRAMEBUFFER = new TextureTarget(2, 2, true, true); + private final Minecraft minecraft = Minecraft.getInstance(); + private LinkPreviewPayload destination; + private Vec3 cameraPosition; + private float yaw; + private float pitch; + private int ticks; + private boolean exploring; + private boolean hasCompletedFrame; + + @Override + public void accept(LinkPreviewPayload payload) { + destination = payload; + resetView(); + } + + @Override + public void tick() { + ticks++; + if (!exploring || cameraPosition == null) { + return; + } + double forward = (minecraft.options.keyUp.isDown() ? 1.0D : 0.0D) + - (minecraft.options.keyDown.isDown() ? 1.0D : 0.0D); + double strafe = (minecraft.options.keyRight.isDown() ? 1.0D : 0.0D) + - (minecraft.options.keyLeft.isDown() ? 1.0D : 0.0D); + double vertical = (minecraft.options.keyJump.isDown() ? 1.0D : 0.0D) + - (minecraft.options.keyShift.isDown() ? 1.0D : 0.0D); + double radians = Math.toRadians(yaw); + Vec3 motion = new Vec3( + -Math.sin(radians) * forward + Math.cos(radians) * strafe, + vertical, + Math.cos(radians) * forward + Math.sin(radians) * strafe + ); + if (motion.lengthSqr() > 0.0D) { + cameraPosition = cameraPosition.add(motion.normalize().scale(0.22D)); + } + } + + @Override + public void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick) { + graphics.fill(left, top, left + width, top + height, 0xFF080C18); + if (destination == null || !ClientWorldLoader.getIsInitialized()) { + return; + } + ClientLevel world = ClientWorldLoader.getWorld(destination.dimension()); + if (world == null) { + return; + } + + Vec3 renderPosition = cameraPosition; + float renderYaw = yaw; + float renderPitch = pitch; + if (!exploring) { + double angle = (ticks + partialTick) * 0.012D; + Vec3 center = Vec3.atCenterOf(destination.origin()).add(0.0D, 2.0D, 0.0D); + renderPosition = center.add(Math.sin(angle) * ORBIT_RADIUS, 5.0D, Math.cos(angle) * ORBIT_RADIUS); + renderYaw = (float)Math.toDegrees(angle) + 180.0F; + renderPitch = 20.0F; + } + + Matrix4f cameraTransform = new Matrix4f() + .rotateX((float)Math.toRadians(renderPitch)) + .rotateY((float)Math.toRadians(renderYaw + 180.0F)); + WorldRenderInfo info = new WorldRenderInfo.Builder() + .setWorld(world) + .setCameraPos(renderPosition) + .setCameraTransformation(cameraTransform) + .setOverwriteCameraTransformation(true) + .setRenderDistance(Math.min(8, minecraft.options.getEffectiveRenderDistance())) + .setDoRenderHand(false) + .setEnableViewBobbing(false) + .setDoRenderSky(true) + .setHasFog(true) + .build(); + if (hasCompletedFrame) { + double scale = minecraft.getWindow().getGuiScale(); + MyRenderHelper.drawFramebufferWithBounds(FRAMEBUFFER, false, false, + (int)(left * scale), (int)((left + width) * scale), + (int)(top * scale), (int)((top + height) * scale)); + // Immersive Portals draws by changing the raw OpenGL viewport. Restore + // the window viewport before the screen renders its text and widgets. + MyRenderHelper.restoreViewPort(); + } + + // The API does not clear targets when sky rendering is enabled. Clear + // color and depth after displaying the completed frame, then render the + // next view into a clean target at the end of this frame. + int sky = destination.skyColor(); + FRAMEBUFFER.setClearColor( + ((sky >> 16) & 0xFF) / 255.0F, + ((sky >> 8) & 0xFF) / 255.0F, + (sky & 0xFF) / 255.0F, + 1.0F + ); + FRAMEBUFFER.clear(true); + minecraft.getMainRenderTarget().bindWrite(true); + MyRenderHelper.restoreViewPort(); + GuiPortalRendering.submitNextFrameRendering(info, FRAMEBUFFER); + hasCompletedFrame = true; + graphics.renderOutline(left, top, width, height, exploring ? 0xFFE0C36A : 0xFF7B6AA8); + } + + @Override public boolean supportsExploration() { return true; } + @Override public boolean isExploring() { return exploring; } + @Override public void setExploring(boolean exploring) { this.exploring = exploring; } + + @Override + public void rotate(double deltaX, double deltaY) { + if (exploring) { + yaw += (float)deltaX * 0.35F; + pitch = Mth.clamp(pitch + (float)deltaY * 0.35F, -89.0F, 89.0F); + } + } + + @Override + public void resetView() { + exploring = false; + if (destination != null) { + cameraPosition = Vec3.atCenterOf(destination.origin()).add(0.0D, 2.0D, ORBIT_RADIUS); + yaw = destination.yaw(); + pitch = destination.pitch(); + } + } + + @Override + public void close() { + hasCompletedFrame = false; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/preview/LegacyDestinationPreviewRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/preview/LegacyDestinationPreviewRenderer.java new file mode 100644 index 0000000..a02fc03 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/preview/LegacyDestinationPreviewRenderer.java @@ -0,0 +1,75 @@ +package net.dnorberg.rivencraft.client.preview; + +import net.dnorberg.rivencraft.network.LinkPreviewPayload; +import net.minecraft.client.gui.GuiGraphics; + +/** + * Built-in linking-panel animation inspired by Mystcraft Legacy's portal bloom. + * + *

The original effect used a GLSL noise shader over an optional live world + * view. This renderer keeps its two expanding, noisy spirals and destination + * tint, but draws them with the normal GUI API so it needs no renderer mod.

+ */ +public final class LegacyDestinationPreviewRenderer implements DestinationPreviewRenderer { + private static final int CELL_SIZE = 3; + private static final int BORDER_COLOR = 0xFF7B6AA8; + private int ticks; + private int linkColor = 0xFF006666; + + @Override + public void accept(LinkPreviewPayload payload) { + linkColor = 0xFF000000 | payload.skyColor() & 0x00FFFFFF; + } + + @Override + public void tick() { + ticks++; + } + + @Override + public void render(GuiGraphics graphics, int left, int top, int width, int height, float partialTick) { + graphics.fill(left, top, left + width, top + height, 0xFF000011); + + float time = (ticks + partialTick) * 0.035F; + float reveal = Math.min(1.0F, time / 1.8F); + for (int y = 1; y < height - 1; y += CELL_SIZE) { + for (int x = 1; x < width - 1; x += CELL_SIZE) { + float normalizedX = (x - width * 0.5F) / height; + float normalizedY = (y - height * 0.5F) / height; + float first = spiral(normalizedX, normalizedY, time, 0.0F); + float second = spiral(normalizedX, normalizedY, time, 2.35F); + float intensity = Math.max(first, second) * reveal; + if (intensity <= 0.035F) { + continue; + } + + int color = scaledColor(linkColor, Math.min(1.0F, 0.12F + intensity)); + graphics.fill(left + x, top + y, + left + Math.min(width - 1, x + CELL_SIZE), + top + Math.min(height - 1, y + CELL_SIZE), color); + } + } + graphics.renderOutline(left, top, width, height, BORDER_COLOR); + } + + private static float spiral(float x, float y, float time, float phase) { + float distance = (float)Math.sqrt(x * x + y * y); + float angle = (float)Math.atan2(y, x); + float arm = 0.5F + 0.5F * (float)Math.cos(angle * 3.0F - distance * 25.0F + time * 4.0F + phase); + float pulse = 0.72F + 0.28F * noise(x * 31.0F, y * 31.0F, time + phase); + float core = Math.max(0.0F, 1.0F - distance * 2.15F); + return core * (0.28F + arm * 0.72F) * pulse; + } + + private static float noise(float x, float y, float time) { + double value = Math.sin(x * 12.9898D + y * 78.233D + time * 19.19D) * 43758.5453D; + return (float)(value - Math.floor(value)); + } + + private static int scaledColor(int color, float scale) { + int red = Math.min(255, Math.round(((color >> 16) & 0xFF) * scale + 18.0F * scale)); + int green = Math.min(255, Math.round(((color >> 8) & 0xFF) * scale + 24.0F * scale)); + int blue = Math.min(255, Math.round((color & 0xFF) * scale + 42.0F * scale)); + return 0xFF000000 | red << 16 | green << 8 | blue; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java index 56948ef..c740d10 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/DescriptiveBookScreen.java @@ -6,9 +6,10 @@ import net.dnorberg.rivencraft.network.BindDescriptiveBookPayload; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload; import net.dnorberg.rivencraft.network.LinkPreviewPayload; +import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer; -import net.dnorberg.rivencraft.client.preview.PonderDestinationPreviewRenderer; +import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers; import net.minecraft.core.BlockPos; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; @@ -36,14 +37,17 @@ public final class DescriptiveBookScreen extends Screen { private static final int LINK_PANEL_HEIGHT = 86; private final AgeReference age; + private final ResourceLocation generationProfile; private final InteractionHand hand; private final BlockPos bookStandPos; private DestinationPreviewRenderer preview; private EditBox titleInput; + private Button exploreButton; public DescriptiveBookScreen(InteractionHand hand, ItemStack book) { super(book.getHoverName()); age = book.get(ModDataComponents.AGE_REFERENCE.get()); + generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); this.hand = hand; bookStandPos = null; } @@ -51,6 +55,7 @@ public final class DescriptiveBookScreen extends Screen { public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book) { super(book.getHoverName()); age = book.get(ModDataComponents.AGE_REFERENCE.get()); + generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get()); hand = InteractionHand.MAIN_HAND; this.bookStandPos = bookStandPos.immutable(); } @@ -74,12 +79,18 @@ public final class DescriptiveBookScreen extends Screen { .bounds(rightPage + 65, top + 100, 59, 20) .build()); } else { - preview = new PonderDestinationPreviewRenderer(); + preview = DestinationPreviewRenderers.create(); + if (preview.supportsExploration()) { + exploreButton = addRenderableWidget(Button.builder( + Component.translatable("screen.rivencraft.book.explore"), button -> toggleExploration()) + .bounds(left + 61, top + 145, 60, 20).build()); + } PacketDistributor.sendToServer(new RequestLinkPreviewPayload( true, bookStandPos != null, bookStandPos == null && hand == InteractionHand.OFF_HAND, - bookStandPos == null ? BlockPos.ZERO : bookStandPos + bookStandPos == null ? BlockPos.ZERO : bookStandPos, + preview.supportsExploration() )); } } @@ -96,6 +107,11 @@ public final class DescriptiveBookScreen extends Screen { // Like Linking Books, the parchment is the full screen background. } + @Override + public boolean isPauseScreen() { + return false; + } + @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { int left = (width - BOOK_WIDTH) / 2; @@ -109,6 +125,14 @@ public final class DescriptiveBookScreen extends Screen { } else { preview.render(graphics, left + LINK_PANEL_X, top + LINK_PANEL_Y, LINK_PANEL_WIDTH, LINK_PANEL_HEIGHT, partialTick); drawWrappedWithoutShadow(graphics, Component.literal(age.name()), left + 177, top + 43, 124); + if (generationProfile != null) { + Component profileName = generationProfile.getNamespace().equals("rivencraft") + ? Component.translatable("generator.rivencraft." + generationProfile.getPath()) + : Component.literal(generationProfile.toString()); + drawWrappedWithoutShadow(graphics, + Component.translatable("screen.rivencraft.age.generator", profileName), + left + 177, top + 72, 124); + } } super.render(graphics, mouseX, mouseY, partialTick); @@ -117,6 +141,9 @@ public final class DescriptiveBookScreen extends Screen { @Override public void removed() { if (preview != null) { + if (preview.supportsExploration()) { + PacketDistributor.sendToServer(new ReleaseLinkPreviewPayload()); + } preview.close(); } } @@ -129,6 +156,10 @@ public final class DescriptiveBookScreen extends Screen { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (preview != null && preview.isExploring() && button == 1) { + resetExploration(); + return true; + } if (age != null && button == 0) { int panelLeft = (width - BOOK_WIDTH) / 2 + LINK_PANEL_X; int panelTop = (height - BOOK_HEIGHT) / 2 + LINK_PANEL_Y; @@ -141,6 +172,26 @@ public final class DescriptiveBookScreen extends Screen { return super.mouseClicked(mouseX, mouseY, button); } + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) { + if (preview != null && preview.isExploring() && button == 0) { + preview.rotate(dragX, dragY); + return true; + } + return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); + } + + private void toggleExploration() { + preview.setExploring(!preview.isExploring()); + exploreButton.setMessage(Component.translatable(preview.isExploring() + ? "screen.rivencraft.book.exploring" : "screen.rivencraft.book.explore")); + } + + private void resetExploration() { + preview.resetView(); + exploreButton.setMessage(Component.translatable("screen.rivencraft.book.explore")); + } + private void link() { PacketDistributor.sendToServer(new UseDescriptiveBookPayload( bookStandPos != null, diff --git a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java index 7e9f2e6..48b00ef 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java +++ b/src/main/java/net/dnorberg/rivencraft/client/screen/LinkingBookScreen.java @@ -2,14 +2,16 @@ package net.dnorberg.rivencraft.client.screen; import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer; -import net.dnorberg.rivencraft.client.preview.PonderDestinationPreviewRenderer; +import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers; import net.dnorberg.rivencraft.network.BindLinkingBookPayload; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.network.UseLinkingBookPayload; import net.dnorberg.rivencraft.network.UseBookStandPayload; import net.dnorberg.rivencraft.network.RequestLinkPreviewPayload; import net.dnorberg.rivencraft.network.LinkPreviewPayload; +import net.dnorberg.rivencraft.network.ReleaseLinkPreviewPayload; import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.item.LinkingBookItem; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; @@ -42,6 +44,7 @@ public final class LinkingBookScreen extends Screen { private final BlockPos bookStandPos; private DestinationPreviewRenderer preview; private EditBox titleInput; + private Button exploreButton; public LinkingBookScreen(InteractionHand hand, ItemStack book) { super(book.getHoverName()); @@ -78,12 +81,18 @@ public final class LinkingBookScreen extends Screen { .bounds(rightPage + 65, top + 100, 59, 20) .build()); } else { - preview = new PonderDestinationPreviewRenderer(); + preview = DestinationPreviewRenderers.create(); + if (preview.supportsExploration()) { + exploreButton = addRenderableWidget(Button.builder( + Component.translatable("screen.rivencraft.book.explore"), button -> toggleExploration()) + .bounds(left + 61, top + 145, 60, 20).build()); + } PacketDistributor.sendToServer(new RequestLinkPreviewPayload( false, bookStandPos != null, bookStandPos == null && hand == InteractionHand.OFF_HAND, - bookStandPos == null ? BlockPos.ZERO : bookStandPos + bookStandPos == null ? BlockPos.ZERO : bookStandPos, + preview.supportsExploration() )); } } @@ -101,6 +110,11 @@ public final class LinkingBookScreen extends Screen { // default full-screen world blur behind it. } + @Override + public boolean isPauseScreen() { + return false; + } + @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { int left = (width - BOOK_WIDTH) / 2; @@ -120,7 +134,7 @@ public final class LinkingBookScreen extends Screen { drawWrappedWithoutShadow(graphics, title, left + 177, top + 43, 124); BlockPos pos = target.location().pos(); drawCenteredWithoutShadow(graphics, - Component.literal(target.location().dimension().location().toString()), rightCenter, top + 76); + LinkingBookItem.destinationName(book, target), rightCenter, top + 76); drawCenteredWithoutShadow(graphics, Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()), rightCenter, top + 89); drawCenteredWithoutShadow(graphics, @@ -132,6 +146,10 @@ public final class LinkingBookScreen extends Screen { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (preview != null && preview.isExploring() && button == 1) { + resetExploration(); + return true; + } if (target != null && button == 0) { int left = (width - BOOK_WIDTH) / 2 + PREVIEW_X; int top = (height - BOOK_HEIGHT) / 2 + PREVIEW_Y; @@ -147,10 +165,33 @@ public final class LinkingBookScreen extends Screen { @Override public void removed() { if (preview != null) { + if (preview.supportsExploration()) { + PacketDistributor.sendToServer(new ReleaseLinkPreviewPayload()); + } preview.close(); } } + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) { + if (preview != null && preview.isExploring() && button == 0) { + preview.rotate(dragX, dragY); + return true; + } + return super.mouseDragged(mouseX, mouseY, button, dragX, dragY); + } + + private void toggleExploration() { + preview.setExploring(!preview.isExploring()); + exploreButton.setMessage(Component.translatable(preview.isExploring() + ? "screen.rivencraft.book.exploring" : "screen.rivencraft.book.explore")); + } + + private void resetExploration() { + preview.resetView(); + exploreButton.setMessage(Component.translatable("screen.rivencraft.book.explore")); + } + public void acceptPreview(LinkPreviewPayload payload) { if (preview != null) { preview.accept(payload); diff --git a/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java new file mode 100644 index 0000000..e595ed1 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/ImmersivePortalsChunkLoaders.java @@ -0,0 +1,65 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.Vec3; +import qouteall.imm_ptl.core.api.PortalAPI; +import qouteall.imm_ptl.core.chunk_loading.ChunkLoader; +import qouteall.imm_ptl.core.chunk_loading.ImmPtlChunkTracking; +import qouteall.q_misc_util.dimension.DimensionIntId; + +/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */ +public final class ImmersivePortalsChunkLoaders { + private static final Map LOADERS = new WeakHashMap<>(); + + private ImmersivePortalsChunkLoaders() { + } + + public static void open(ServerPlayer player, ServerLevel level, BlockPos center) { + close(player); + int centerChunkX = center.getX() >> 4; + int centerChunkZ = center.getZ() >> 4; + // Ensure the camera has useful terrain immediately while the wider + // Immersive Portals loader continues generating and streaming outward. + for (int x = centerChunkX - 1; x <= centerChunkX + 1; x++) { + for (int z = centerChunkZ - 1; z <= centerChunkZ + 1; z++) { + level.getChunk(x, z); + } + } + + ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8); + PortalAPI.addChunkLoaderForPlayer(player, loader); + LOADERS.put(player, loader); + ImmPtlChunkTracking.immediatelyUpdateForPlayer(player); + } + + public static void close(ServerPlayer player) { + ChunkLoader loader = LOADERS.remove(player); + if (loader != null) { + PortalAPI.removeChunkLoaderForPlayer(player, loader); + } + } + + /** Registers runtime dimensions and sends the revised ID map to connected clients. */ + public static void dimensionsChanged(MinecraftServer server) { + DimensionIntId.onServerDimensionChanged(server); + } + + /** Uses Immersive Portals' seamless transfer so its client-world registry stays active. */ + public static void teleport( + ServerPlayer player, + ServerLevel destination, + Vec3 position, + float yaw, + float pitch + ) { + player.setYRot(yaw); + player.setYHeadRot(yaw); + player.setXRot(pitch); + PortalAPI.teleportEntity(player, destination, position); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftClientConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftClientConfig.java new file mode 100644 index 0000000..3c30b74 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftClientConfig.java @@ -0,0 +1,40 @@ +package net.dnorberg.rivencraft.config; + +import net.neoforged.neoforge.common.ModConfigSpec; + +/** Client-only presentation preferences. */ +public final class RivencraftClientConfig { + public enum PreviewMode { + /** Prefer Immersive Portals, then Ponder, then Rivencraft's built-in animation. */ + AUTO, + /** Request the optional Immersive Portals destination renderer. */ + IMMERSIVE_PORTALS, + /** Request Create's Ponder destination renderer. */ + PONDER, + /** Always use Rivencraft's built-in legacy-style animation. */ + LEGACY + } + + public static final ModConfigSpec SPEC; + private static final ModConfigSpec.EnumValue PREVIEW_MODE; + + static { + ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); + builder.push("book_preview"); + PREVIEW_MODE = builder.comment( + "Selects the renderer used by Linking and Descriptive Book destination panels.", + "AUTO prefers Immersive Portals, then Create/Ponder, then LEGACY.", + "Requesting an unavailable optional renderer falls back to LEGACY." + ) + .defineEnum("mode", PreviewMode.AUTO); + builder.pop(); + SPEC = builder.build(); + } + + private RivencraftClientConfig() { + } + + public static PreviewMode previewMode() { + return PREVIEW_MODE.get(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 337a726..1c54fee 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -3,12 +3,14 @@ package net.dnorberg.rivencraft.gametest; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.book.AgeDefinition; import net.dnorberg.rivencraft.book.AgeDimensionService; +import net.dnorberg.rivencraft.book.AgeGenerationProfiles; import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.book.AgeVisitSavedData; import net.dnorberg.rivencraft.item.DescriptiveBookItem; +import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.core.BlockPos; @@ -60,12 +62,21 @@ public final class LinkingGameTests { -12.0F ); - ItemStack returnBook = DescriptiveBookItem.createReturnBook(source); + ItemStack returnBook = DescriptiveBookItem.createReturnBook(source, "Atrus's Study"); helper.assertTrue(returnBook.is(ModItems.LINKING_BOOK.get()), "Descriptive Book travel must create a Linking Book"); helper.assertValueEqual(returnBook.get(ModDataComponents.LINK_TARGET.get()), source, "The Return Link must retain the source dimension, location, and orientation"); + helper.assertValueEqual(returnBook.get(ModDataComponents.LINK_DESTINATION_NAME.get()), "Atrus's Study", + "The Return Link must retain the source Age's display name"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void vanillaDimensionUsesFriendlyLinkName(GameTestHelper helper) { + helper.assertValueEqual(LinkingBookItem.destinationName(helper.getLevel()), "Overworld", + "Standard Linking Books must use the friendly vanilla dimension name"); helper.succeed(); } @@ -103,8 +114,8 @@ public final class LinkingGameTests { "A new Age dimension folder must derive from the player-assigned name"); helper.assertValueEqual(conflicting.dimensionId().getPath(), first.dimensionId().getPath() + "_2", "A conflicting Age dimension folder must receive an incrementing suffix"); - helper.assertValueEqual(first.generationProfile(), AgeDefinition.RANDOM_VANILLA_PROFILE, - "A page-free book must use the random vanilla generation profile"); + helper.assertTrue(AgeGenerationProfiles.isSupported(first.generationProfile()), + "A page-free book must select a supported legacy-inspired terrain profile"); helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION, "New Ages must record the current generation algorithm version"); helper.assertTrue(first.seed() != another.seed(), "Distinct Age UUIDs must receive distinct random seeds"); @@ -121,6 +132,9 @@ public final class LinkingGameTests { helper.runAfterDelay(1, () -> { var ageLevel = player.getServer().getLevel(target.location().dimension()); helper.assertTrue(ageLevel != null, "The generated Age must be registered with the server"); + AgeDefinition definition = AgeRegistrySavedData.get(helper.getLevel()).find(reference.id()).orElseThrow(); + helper.assertValueEqual(ageLevel.getSeed(), definition.seed(), + "The runtime dimension seed must match the persisted Age definition"); BlockPos spawn = target.location().pos(); helper.assertTrue(ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE), "The generated Age must have a cobblestone arrival platform"); @@ -130,6 +144,34 @@ public final class LinkingGameTests { }); } + @GameTest(template = "empty", timeoutTicks = 200) + public static void legacyBaseTerrainProfilesLoadRuntimeDimensions(GameTestHelper helper) { + for (var profile : AgeGenerationProfiles.all()) { + UUID id = UUID.randomUUID(); + AgeDefinition definition = new AgeDefinition( + id, + "Profile " + profile.getPath(), + id.getMostSignificantBits(), + profile, + AgeDefinition.CURRENT_GENERATION_VERSION, + helper.getLevel().getGameTime(), + net.minecraft.resources.ResourceLocation.fromNamespaceAndPath( + Rivencraft.MOD_ID, "gametest/" + profile.getPath() + "_" + id + ), + null + ); + var level = AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), definition); + helper.assertTrue(level != null, "The " + profile + " profile must load a runtime dimension"); + if (level == null) { + continue; + } + + helper.assertTrue(level.dimension().location().equals(definition.dimensionId()), + "The " + profile + " profile must retain its assigned dimension id"); + } + helper.succeed(); + } + @GameTest(template = "empty") public static void travelUsesLeafyDestinationWithoutFloor(GameTestHelper helper) { helper.setBlock(ORIGIN.below(), Blocks.AIR); diff --git a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index 43f6d73..60e3a49 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -67,7 +67,7 @@ public final class DescriptiveBookItem extends Item { case DISABLED -> false; }; if (createReturnLink) { - ItemStack returnBook = createReturnBook(returnTarget); + ItemStack returnBook = createReturnBook(returnTarget, LinkingBookItem.destinationName(sourceLevel)); if (!player.getInventory().add(returnBook)) { player.drop(returnBook, false); } @@ -84,8 +84,15 @@ public final class DescriptiveBookItem extends Item { } public static ItemStack createReturnBook(LinkTarget returnTarget) { + return createReturnBook(returnTarget, null); + } + + public static ItemStack createReturnBook(LinkTarget returnTarget, String destinationName) { ItemStack returnBook = new ItemStack(ModItems.LINKING_BOOK.get()); returnBook.set(ModDataComponents.LINK_TARGET.get(), returnTarget); + if (destinationName != null && !destinationName.isBlank()) { + returnBook.set(ModDataComponents.LINK_DESTINATION_NAME.get(), destinationName); + } returnBook.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return")); return returnBook; } diff --git a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java index 2662b60..856f4c7 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.item; import java.util.List; import net.dnorberg.rivencraft.book.LinkTarget; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.LinkingService; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModSounds; @@ -10,6 +11,7 @@ import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ServerLevel; import net.minecraft.sounds.SoundSource; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResultHolder; @@ -93,11 +95,49 @@ public final class LinkingBookItem extends Item { BlockPos position = target.location().pos(); tooltipComponents.add(Component.translatable( "item.rivencraft.linking_book.destination", - dimension.toString(), + destinationName(stack, target).getString(), position.getX(), position.getY(), position.getZ() ) .withStyle(ChatFormatting.GRAY)); } + + public static String destinationName(ServerLevel level) { + return AgeRegistrySavedData.get(level) + .findByDimension(level.dimension().location()) + .map(definition -> definition.name()) + .orElseGet(() -> vanillaDimensionName(level.dimension().location())); + } + + public static Component destinationName(ItemStack stack, LinkTarget target) { + String savedName = stack.get(ModDataComponents.LINK_DESTINATION_NAME.get()); + if (savedName != null && !savedName.isBlank()) { + return Component.literal(savedName); + } + ResourceLocation dimension = target.location().dimension().location(); + if (dimension.equals(Level.OVERWORLD.location())) { + return Component.translatable("dimension.minecraft.overworld"); + } + if (dimension.equals(Level.NETHER.location())) { + return Component.translatable("dimension.minecraft.the_nether"); + } + if (dimension.equals(Level.END.location())) { + return Component.translatable("dimension.minecraft.the_end"); + } + return Component.literal(dimension.toString()); + } + + private static String vanillaDimensionName(ResourceLocation dimension) { + if (dimension.equals(Level.OVERWORLD.location())) { + return "Overworld"; + } + if (dimension.equals(Level.NETHER.location())) { + return "The Nether"; + } + if (dimension.equals(Level.END.location())) { + return "The End"; + } + return dimension.toString(); + } } diff --git a/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java b/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java index a850c9d..0cb5ef4 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java +++ b/src/main/java/net/dnorberg/rivencraft/network/LinkPreviewPayload.java @@ -8,10 +8,14 @@ import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; -public record LinkPreviewPayload(int skyColor, List blocks) implements CustomPacketPayload { +public record LinkPreviewPayload(ResourceKey dimension, BlockPos origin, float yaw, float pitch, + int skyColor, List blocks) implements CustomPacketPayload { /** Each axis of the destination volume captured for the book preview. */ public static final int SNAPSHOT_SIZE = 32; /** A square footprint keeps every side visible during an orbit. */ @@ -21,6 +25,10 @@ public record LinkPreviewPayload(int skyColor, List blocks) imple ); public static final StreamCodec STREAM_CODEC = StreamCodec.of( (buffer, payload) -> { + buffer.writeResourceKey(payload.dimension); + buffer.writeBlockPos(payload.origin); + buffer.writeFloat(payload.yaw); + buffer.writeFloat(payload.pitch); buffer.writeInt(payload.skyColor); buffer.writeVarInt(payload.blocks.size()); for (SnapshotBlock block : payload.blocks) { @@ -29,6 +37,10 @@ public record LinkPreviewPayload(int skyColor, List blocks) imple } }, buffer -> { + ResourceKey dimension = buffer.readResourceKey(Registries.DIMENSION); + BlockPos origin = buffer.readBlockPos(); + float yaw = buffer.readFloat(); + float pitch = buffer.readFloat(); int skyColor = buffer.readInt(); int size = buffer.readVarInt(); if (size < 0 || size > MAX_BLOCKS) { @@ -38,7 +50,7 @@ public record LinkPreviewPayload(int skyColor, List blocks) imple for (int i = 0; i < size; i++) { blocks.add(new SnapshotBlock(buffer.readBlockPos(), Block.stateById(buffer.readVarInt()))); } - return new LinkPreviewPayload(skyColor, List.copyOf(blocks)); + return new LinkPreviewPayload(dimension, origin, yaw, pitch, skyColor, List.copyOf(blocks)); } ); diff --git a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java index 3afc0ce..d9dad1c 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java +++ b/src/main/java/net/dnorberg/rivencraft/network/ModNetworking.java @@ -2,15 +2,18 @@ package net.dnorberg.rivencraft.network; import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.AgeReference; +import net.dnorberg.rivencraft.book.AgeRegistrySavedData; import net.dnorberg.rivencraft.book.AgeDimensionService; import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; import net.dnorberg.rivencraft.client.ClientLinkPreviewHandler; +import net.dnorberg.rivencraft.client.ClientBookInteractionHandler; import net.dnorberg.rivencraft.item.DescriptiveBookItem; import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.core.component.DataComponents; import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerLevel; @@ -24,18 +27,29 @@ import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.handling.IPayloadContext; import net.neoforged.neoforge.network.registration.PayloadRegistrar; import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.fml.ModList; +import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; import java.util.List; import java.util.UUID; public final class ModNetworking { public static final int MAX_BOOK_TITLE_LENGTH = 32; + // Only accessed on the server thread; identity keeps identical blank books independent. + private static final Set WRITING_BOOKS = Collections.newSetFromMap(new IdentityHashMap<>()); private ModNetworking() { } public static void register(RegisterPayloadHandlersEvent event) { - PayloadRegistrar registrar = event.registrar("6"); + PayloadRegistrar registrar = event.registrar("8"); + registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC, + ModNetworking::requestDescriptiveBookScreen); + registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC, + (payload, context) -> ClientBookInteractionHandler.openDescriptiveBook(payload, context)); registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, ModNetworking::bindBook); registrar.playToServer(BindDescriptiveBookPayload.TYPE, BindDescriptiveBookPayload.STREAM_CODEC, ModNetworking::bindDescriptiveBook); @@ -45,17 +59,43 @@ public final class ModNetworking { registrar.playToServer(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC, ModNetworking::useBookStand); registrar.playToServer(RequestLinkPreviewPayload.TYPE, RequestLinkPreviewPayload.STREAM_CODEC, ModNetworking::requestLinkPreview); + registrar.playToServer(ReleaseLinkPreviewPayload.TYPE, ReleaseLinkPreviewPayload.STREAM_CODEC, + (payload, context) -> releaseLinkPreview(context)); registrar.playToClient(LinkPreviewPayload.TYPE, LinkPreviewPayload.STREAM_CODEC, ClientLinkPreviewHandler::handle); } + private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) { + if (!(context.player() instanceof ServerPlayer player)) { + return; + } + BookAccess access = descriptiveBookAccess(player, payload.fromStand(), payload.offhand(), payload.standPos()); + if (access == null || WRITING_BOOKS.contains(access.stack())) { + return; + } + AgeReference reference = access.stack().get(ModDataComponents.AGE_REFERENCE.get()); + if (reference != null && !access.stack().has(ModDataComponents.AGE_GENERATION_PROFILE.get())) { + AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition -> { + access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile()); + access.sync().run(); + }); + } + // Requests run on the same server thread as Age creation. A request queued + // during generation can only open the finished book, never a stale client copy. + player.inventoryMenu.broadcastChanges(); + PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload( + payload.fromStand(), payload.offhand(), + GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy())); + } + private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) { if (!(context.player() instanceof ServerPlayer player)) { return; } BookAccess access = descriptiveBookAccess(player, payload.fromStand(), payload.offhand(), payload.standPos()); - if (access == null || access.stack().has(ModDataComponents.AGE_REFERENCE.get())) { + if (access == null || WRITING_BOOKS.contains(access.stack()) + || access.stack().has(ModDataComponents.AGE_REFERENCE.get())) { return; } String title = normalizeTitle(payload.title()); @@ -63,17 +103,24 @@ public final class ModNetworking { return; } - AgeReference reference = new AgeReference(UUID.randomUUID(), title); - if (AgeDimensionService.resolve(player, reference).isEmpty()) { - player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); - return; - } + WRITING_BOOKS.add(access.stack()); + try { + AgeReference reference = new AgeReference(UUID.randomUUID(), title); + if (AgeDimensionService.resolve(player, reference).isEmpty()) { + player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true); + return; + } - access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference); - access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title)); - access.sync().run(); - player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F); - player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.created", title), true); + access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference); + AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition -> + access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile())); + access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title)); + access.sync().run(); + player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F); + player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.created", title), true); + } finally { + WRITING_BOOKS.remove(access.stack()); + } } private static void bindBook(BindLinkingBookPayload payload, IPayloadContext context) { @@ -92,6 +139,7 @@ public final class ModNetworking { } stack.set(ModDataComponents.LINK_TARGET.get(), LinkTarget.from(player)); + stack.set(ModDataComponents.LINK_DESTINATION_NAME.get(), LinkingBookItem.destinationName(player.serverLevel())); stack.set(DataComponents.CUSTOM_NAME, Component.literal(title)); player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F); player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.bound", title), true); @@ -192,7 +240,17 @@ public final class ModNetworking { } } int skyColor = previewSkyColor(level, origin); - PacketDistributor.sendToPlayer(player, new LinkPreviewPayload(skyColor, List.copyOf(blocks))); + if (payload.immersivePortal() && ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.open(player, level, origin); + } + PacketDistributor.sendToPlayer(player, new LinkPreviewPayload( + target.location().dimension(), origin, target.yaw(), target.pitch(), skyColor, List.copyOf(blocks))); + } + + private static void releaseLinkPreview(IPayloadContext context) { + if (context.player() instanceof ServerPlayer player && ModList.get().isLoaded("immersive_portals_core")) { + ImmersivePortalsChunkLoaders.close(player); + } } /** Vanilla Nether and End renderers do not use their biomes' blue Overworld sky tint. */ diff --git a/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java b/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java new file mode 100644 index 0000000..19d73d9 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/OpenDescriptiveBookScreenPayload.java @@ -0,0 +1,30 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.GlobalPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +/** Server-approved book contents, sent only when the book is available to open. */ +public record OpenDescriptiveBookScreenPayload(boolean fromStand, boolean offhand, GlobalPos source, ItemStack book) + implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "open_descriptive_book_screen") + ); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, OpenDescriptiveBookScreenPayload::fromStand, + ByteBufCodecs.BOOL, OpenDescriptiveBookScreenPayload::offhand, + GlobalPos.STREAM_CODEC, OpenDescriptiveBookScreenPayload::source, + ItemStack.STREAM_CODEC, OpenDescriptiveBookScreenPayload::book, + OpenDescriptiveBookScreenPayload::new + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/ReleaseLinkPreviewPayload.java b/src/main/java/net/dnorberg/rivencraft/network/ReleaseLinkPreviewPayload.java new file mode 100644 index 0000000..e90758d --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/ReleaseLinkPreviewPayload.java @@ -0,0 +1,19 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record ReleaseLinkPreviewPayload() implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "release_link_preview")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.unit(new ReleaseLinkPreviewPayload()); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/RequestDescriptiveBookScreenPayload.java b/src/main/java/net/dnorberg/rivencraft/network/RequestDescriptiveBookScreenPayload.java new file mode 100644 index 0000000..e16dffb --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/network/RequestDescriptiveBookScreenPayload.java @@ -0,0 +1,28 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +/** Requests the current Descriptive Book screen after server-side writing has finished. */ +public record RequestDescriptiveBookScreenPayload(boolean fromStand, boolean offhand, BlockPos standPos) + implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_descriptive_book_screen") + ); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, RequestDescriptiveBookScreenPayload::fromStand, + ByteBufCodecs.BOOL, RequestDescriptiveBookScreenPayload::offhand, + BlockPos.STREAM_CODEC, RequestDescriptiveBookScreenPayload::standPos, + RequestDescriptiveBookScreenPayload::new + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java b/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java index 402163e..c82ce74 100644 --- a/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java +++ b/src/main/java/net/dnorberg/rivencraft/network/RequestLinkPreviewPayload.java @@ -8,7 +8,8 @@ import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.ResourceLocation; -public record RequestLinkPreviewPayload(boolean descriptiveBook, boolean fromStand, boolean offhand, BlockPos standPos) +public record RequestLinkPreviewPayload(boolean descriptiveBook, boolean fromStand, boolean offhand, + BlockPos standPos, boolean immersivePortal) implements CustomPacketPayload { public static final Type TYPE = new Type<>( ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_link_preview") @@ -19,6 +20,7 @@ public record RequestLinkPreviewPayload(boolean descriptiveBook, boolean fromSta ByteBufCodecs.BOOL, RequestLinkPreviewPayload::fromStand, ByteBufCodecs.BOOL, RequestLinkPreviewPayload::offhand, BlockPos.STREAM_CODEC, RequestLinkPreviewPayload::standPos, + ByteBufCodecs.BOOL, RequestLinkPreviewPayload::immersivePortal, RequestLinkPreviewPayload::new ); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java index 0c2f3d0..6415a23 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModDataComponents.java @@ -5,6 +5,8 @@ import net.dnorberg.rivencraft.book.LinkTarget; import net.dnorberg.rivencraft.book.AgeReference; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.network.codec.ByteBufCodecs; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredRegister; @@ -25,6 +27,18 @@ public final class ModDataComponents { .networkSynchronized(AgeReference.STREAM_CODEC) .cacheEncoding() .build()); + public static final DeferredHolder, DataComponentType> AGE_GENERATION_PROFILE = + DATA_COMPONENTS.register("age_generation_profile", () -> DataComponentType.builder() + .persistent(ResourceLocation.CODEC) + .networkSynchronized(ResourceLocation.STREAM_CODEC) + .cacheEncoding() + .build()); + public static final DeferredHolder, DataComponentType> LINK_DESTINATION_NAME = + DATA_COMPONENTS.register("link_destination_name", () -> DataComponentType.builder() + .persistent(com.mojang.serialization.Codec.STRING) + .networkSynchronized(ByteBufCodecs.STRING_UTF8) + .cacheEncoding() + .build()); private ModDataComponents() { } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 3ce73fa..748df21 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -28,7 +28,17 @@ "screen.rivencraft.age.title_hint": "Name this Age", "screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book", "screen.rivencraft.age.create": "Create", + "screen.rivencraft.age.generator": "Terrain: %s", + "generator.rivencraft.standard": "Standard", + "generator.rivencraft.amplified": "Amplified", + "generator.rivencraft.island": "Island", + "generator.rivencraft.cave": "Cave", + "generator.rivencraft.flat": "Flat", + "generator.rivencraft.void": "Void", + "generator.rivencraft.random_vanilla": "Standard", "screen.rivencraft.book.preview_pending": "Loading destination...", "screen.rivencraft.book.click_to_link": "Click the panel to link", + "screen.rivencraft.book.explore": "Explore", + "screen.rivencraft.book.exploring": "Exploring", "itemGroup.rivencraft": "Rivencraft" } diff --git a/src/main/resources/assets/rivencraft/models/item/descriptive_book.json b/src/main/resources/assets/rivencraft/models/item/descriptive_book.json index af82ac6..e48af49 100644 --- a/src/main/resources/assets/rivencraft/models/item/descriptive_book.json +++ b/src/main/resources/assets/rivencraft/models/item/descriptive_book.json @@ -1,6 +1,6 @@ { "parent": "minecraft:item/generated", "textures": { - "layer0": "minecraft:item/book" + "layer0": "rivencraft:item/descriptive_book" } } diff --git a/src/main/resources/assets/rivencraft/models/item/linking_book.json b/src/main/resources/assets/rivencraft/models/item/linking_book.json index 01589d3..b61a80c 100644 --- a/src/main/resources/assets/rivencraft/models/item/linking_book.json +++ b/src/main/resources/assets/rivencraft/models/item/linking_book.json @@ -1,6 +1,6 @@ { "parent": "minecraft:item/generated", "textures": { - "layer0": "minecraft:item/enchanted_book" + "layer0": "rivencraft:item/linking_book" } } diff --git a/src/main/resources/assets/rivencraft/textures/entity/descriptive_book.png b/src/main/resources/assets/rivencraft/textures/entity/descriptive_book.png new file mode 100644 index 0000000..59a599c Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/entity/descriptive_book.png differ diff --git a/src/main/resources/assets/rivencraft/textures/entity/linking_book.png b/src/main/resources/assets/rivencraft/textures/entity/linking_book.png new file mode 100644 index 0000000..23bd0e2 Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/entity/linking_book.png differ diff --git a/src/main/resources/assets/rivencraft/textures/item/descriptive_book.png b/src/main/resources/assets/rivencraft/textures/item/descriptive_book.png new file mode 100644 index 0000000..c08219d Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/item/descriptive_book.png differ diff --git a/src/main/resources/assets/rivencraft/textures/item/linking_book.png b/src/main/resources/assets/rivencraft/textures/item/linking_book.png new file mode 100644 index 0000000..92adb1e Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/item/linking_book.png differ diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index 6a3189f..26a4b21 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -87,12 +87,19 @@ A spiritual successor and modern port of Mystcraft for Minecraft 1.21.1 on NeoFo side="BOTH" [[dependencies.${mod_id}]] - modId="ponder" - type="required" - versionRange="[1.0.82,2.0.0)" + modId="create" + type="optional" + versionRange="[0,)" ordering="AFTER" side="CLIENT" +[[dependencies.${mod_id}]] + modId="immersive_portals_core" + type="optional" + versionRange="[6.0.7,7.0.0)" + ordering="AFTER" + side="BOTH" + [[dependencies.${mod_id}]] modId="dynamicdimensions" type="required"