diff --git a/.gitignore b/.gitignore index f3ca93a..b784848 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ run-data repo *.blockymodel *.png +*.blockyanim diff --git a/README.md b/README.md index 2fe327d..6702e41 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ - Hytale Model Loader ======= -A mod for Minecraft Neoforge 1.21.11 that allows the player to import and use Hytale models in game. It converts Hytale's BlockyModel file format into a renderable version inside of Minecraft. Currently, these can be rendered as item or block models with entity models coming in a future version. This is usable with both resource packs and mods. +A mod for Minecraft Neoforge 1.21.11 that allows the player to import and use Hytale models in game. It converts +Hytale's BlockyModel and BLockyAnim file formats into renderable versions inside of Minecraft. Currently, these can be +rendered as item or block models with entity models coming in a future version. This is usable with both resource packs +and mods. ### Creating a Model -Models are defined using `.blockymodel` files (custom binary/text format) and referenced in `.json` model files just like standard Minecraft models. To create the model simply download this mod and when creating a model file make sure to add the loader and model location as shown below. +Refer to the wiki to learn how to create and animate your models. #### Example: `pot.json` @@ -23,15 +25,16 @@ Models are defined using `.blockymodel` files (custom binary/text format) and re ``` **Key fields:** + - `loader` – References the BlockyModelLoader **This is most important** - `model` – Path to your `.blockymodel` file (This file is best in the models folder but put it wherever) - `render_type` – Standard Minecraft render type this needs to be changed depending on model transparency - `textures` – Texture references used by your model - ## TODO ### v1.0.0 + - [x] Add model parser `.blockymodel` - [x] Implement custom item/block loader - [x] Add block rotation support @@ -39,24 +42,28 @@ Models are defined using `.blockymodel` files (custom binary/text format) and re - [x] Add UV rotation and mirroring support ### v1.1.0 -- [x] Check item and block scaling/translating using model json -- [ ] Make bounding boxes fit models -- [ ] Fix and clean up code -### v1.2.0 -- [ ] Add parser for animation support `.blockyanim` -- [ ] Load animations in for blocks and items -- [ ] Create animation system to actually play and time these animations +- [x] Check item and block scaling/translating using model json +- [x] Implement custom BlockEntities for animation support since baked models cannot +- [x] Add parser for animation support `.blockyanim` +- [x] Load animations in for blocks +- [x] Create animation system to actually play and time these animations +- [x] Add wiki to show how to use different parts of the mod +- [x] Fix and clean up code ### v2.0.0 + - [ ] Implement entity model loading -- [ ] Create in-game model preview/editing tool - [ ] Support for custom render layers and transparency blending - [ ] Clean code and docs for v2 release ### v2.1.0 + - [ ] Add animation support for entities +### v2.2.0 + +- [ ] Add player model swapping ## Contributing diff --git a/gradle.properties b/gradle.properties index b36419c..4ef24b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,6 @@ org.gradle.daemon=true org.gradle.parallel=true org.gradle.caching=true org.gradle.configuration-cache=true - #read more on this at https://github.com/neoforged/ModDevGradle?tab=readme-ov-file#better-minecraft-parameter-names--javadoc-parchment # you can also find the latest versions at: https://parchmentmc.org/docs/getting-started parchment_minecraft_version=1.21.11 @@ -19,18 +18,16 @@ minecraft_version=1.21.11 minecraft_version_range=[1.21.11] # The Neo version must agree with the Minecraft version to get a valid artifact neo_version=21.11.37-beta - ## Mod Properties - # The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63} # Must match the String constant located in the main mod class annotated with @Mod. mod_id=hytalemodelloader # The human-readable display name for the mod. mod_name=HytaleModelLoader # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. -mod_license=All Rights Reserved +mod_license=MIT # The mod version. See https://semver.org/ -mod_version=1.0.0 +mod_version=1.1.0 # 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/com/litehed/hytalemodels/Config.java b/src/main/java/com/litehed/hytalemodels/Config.java deleted file mode 100644 index 3385b69..0000000 --- a/src/main/java/com/litehed/hytalemodels/Config.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.litehed.hytalemodels; - -import net.neoforged.neoforge.common.ModConfigSpec; - -public class Config { - private static final ModConfigSpec.Builder BUILDER = new ModConfigSpec.Builder(); - static final ModConfigSpec SPEC = BUILDER.build(); -} diff --git a/src/main/java/com/litehed/hytalemodels/HytaleModelLoader.java b/src/main/java/com/litehed/hytalemodels/HytaleModelLoader.java index 5ece8ee..be8c8e2 100644 --- a/src/main/java/com/litehed/hytalemodels/HytaleModelLoader.java +++ b/src/main/java/com/litehed/hytalemodels/HytaleModelLoader.java @@ -1,12 +1,12 @@ package com.litehed.hytalemodels; +import com.litehed.hytalemodels.init.BlockEntityInit; import com.litehed.hytalemodels.init.BlockInit; import com.litehed.hytalemodels.init.ItemInit; import com.mojang.logging.LogUtils; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; -import net.neoforged.fml.config.ModConfig; import org.slf4j.Logger; // The value here should match an entry in the META-INF/neoforge.mods.toml file @@ -18,9 +18,7 @@ public class HytaleModelLoader { public HytaleModelLoader(IEventBus modEventBus, ModContainer modContainer) { // Remember to comment these out for version releases BlockInit.BLOCKS.register(modEventBus); + BlockEntityInit.BLOCK_ENTITIES.register(modEventBus); ItemInit.ITEMS.register(modEventBus); - - // Register our mod's ModConfigSpec so that FML can create and load the config file for us - modContainer.registerConfig(ModConfig.Type.COMMON, Config.SPEC); } } diff --git a/src/main/java/com/litehed/hytalemodels/HytaleModelLoaderClient.java b/src/main/java/com/litehed/hytalemodels/HytaleModelLoaderClient.java index 6648d2f..e8fa175 100644 --- a/src/main/java/com/litehed/hytalemodels/HytaleModelLoaderClient.java +++ b/src/main/java/com/litehed/hytalemodels/HytaleModelLoaderClient.java @@ -1,12 +1,16 @@ package com.litehed.hytalemodels; -import com.litehed.hytalemodels.modelstuff.BlockyModelLoader; +import com.litehed.hytalemodels.blocks.entity.AnimatedChestRenderer; +import com.litehed.hytalemodels.blocks.entity.CoffinRenderer; +import com.litehed.hytalemodels.blockymodel.BlockyModelLoader; +import com.litehed.hytalemodels.init.BlockEntityInit; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.EntityRenderersEvent; import net.neoforged.neoforge.client.event.ModelEvent; import net.neoforged.neoforge.client.gui.ConfigurationScreen; import net.neoforged.neoforge.client.gui.IConfigScreenFactory; @@ -32,4 +36,10 @@ public static void onRegisterReloadListeners(AddClientReloadListenersEvent event event.addListener(BlockyModelLoader.ID, BlockyModelLoader.INSTANCE); event.addDependency(BlockyModelLoader.ID, VanillaClientListeners.MODELS); } + + @SubscribeEvent + public static void registerEntityRenderers(EntityRenderersEvent.RegisterRenderers event) { + event.registerBlockEntityRenderer(BlockEntityInit.CHEST_TEST_ENT.get(), AnimatedChestRenderer::new); + event.registerBlockEntityRenderer(BlockEntityInit.COFFIN_ENT.get(), CoffinRenderer::new); + } } diff --git a/src/main/java/com/litehed/hytalemodels/api/NodeTransform.java b/src/main/java/com/litehed/hytalemodels/api/NodeTransform.java new file mode 100644 index 0000000..4dbe5ce --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/api/NodeTransform.java @@ -0,0 +1,78 @@ +package com.litehed.hytalemodels.api; + +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import static com.mojang.math.Constants.EPSILON; + +public record NodeTransform(Vector3f position, Quaternionf rotation, Vector3f scale, boolean visible) { + + public NodeTransform(Vector3f position, Quaternionf rotation, Vector3f scale, boolean visible) { + this.position = new Vector3f(position); + this.rotation = new Quaternionf(rotation); + this.scale = new Vector3f(scale); + this.visible = visible; + } + + public static NodeTransform translation(Vector3f position) { + return new NodeTransform(position, new Quaternionf(), new Vector3f(1, 1, 1), true); + } + + public static NodeTransform rotation(Quaternionf rotation) { + return new NodeTransform(new Vector3f(), rotation, new Vector3f(1, 1, 1), true); + } + + public static NodeTransform scale(Vector3f scale) { + return new NodeTransform(new Vector3f(), new Quaternionf(), scale, true); + } + + public static NodeTransform visibility(boolean visible) { + return new NodeTransform(new Vector3f(), new Quaternionf(), new Vector3f(1, 1, 1), visible); + } + + public static NodeTransform identity() { + return new NodeTransform(new Vector3f(), new Quaternionf(), new Vector3f(1, 1, 1), true); + } + + @Override + public Vector3f position() { + return new Vector3f(position); + } + + @Override + public Quaternionf rotation() { + return new Quaternionf(rotation); + } + + @Override + public Vector3f scale() { + return new Vector3f(scale); + } + + /** + * Merges this NodeTransform with another NodeTransform + * + * @param other The other NodeTransform to merge with + * @return The combined node transform + */ + public NodeTransform merge(NodeTransform other) { + Vector3f mergedPos = new Vector3f(this.position).add(other.position); + Quaternionf mergedRot = new Quaternionf(this.rotation).mul(other.rotation); + Vector3f mergedScale = new Vector3f(this.scale).mul(other.scale); + boolean mergedVis = this.visible && other.visible; + return new NodeTransform(mergedPos, mergedRot, mergedScale, mergedVis); + } + + /** + * Checks if this NodeTransform is the identity transform + * @return true if this NodeTransform is the identity transform, false otherwise + */ + public boolean isIdentity() { + return position.lengthSquared() < EPSILON && + rotation.equals(new Quaternionf(), EPSILON) && + Math.abs(scale.x - 1.0f) < EPSILON && + Math.abs(scale.y - 1.0f) < EPSILON && + Math.abs(scale.z - 1.0f) < EPSILON && + visible; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blocks/HytaleTestBlock.java b/src/main/java/com/litehed/hytalemodels/api/block/HytaleBlockBase.java similarity index 89% rename from src/main/java/com/litehed/hytalemodels/blocks/HytaleTestBlock.java rename to src/main/java/com/litehed/hytalemodels/api/block/HytaleBlockBase.java index 280ad7c..7972d47 100644 --- a/src/main/java/com/litehed/hytalemodels/blocks/HytaleTestBlock.java +++ b/src/main/java/com/litehed/hytalemodels/api/block/HytaleBlockBase.java @@ -1,4 +1,4 @@ -package com.litehed.hytalemodels.blocks; +package com.litehed.hytalemodels.api.block; import net.minecraft.core.Direction; import net.minecraft.world.item.context.BlockPlaceContext; @@ -10,12 +10,12 @@ import net.minecraft.world.level.block.state.StateDefinition; import net.minecraft.world.level.block.state.properties.EnumProperty; -public class HytaleTestBlock extends Block { +public class HytaleBlockBase extends Block { public static final EnumProperty FACING = HorizontalDirectionalBlock.FACING; - public HytaleTestBlock(Properties properties) { + public HytaleBlockBase(Properties properties) { super(properties.noOcclusion()); this.registerDefaultState(this.stateDefinition.any().setValue(FACING, Direction.NORTH)); } @@ -31,10 +31,12 @@ public BlockState getStateForPlacement(BlockPlaceContext context) { return super.getStateForPlacement(context).setValue(FACING, context.getHorizontalDirection().getOpposite()); } + @Override public BlockState rotate(BlockState state, Rotation rot) { return state.setValue(FACING, rot.rotate(state.getValue(FACING))); } + @Override public BlockState mirror(BlockState state, Mirror mirrorIn) { return state.rotate(mirrorIn.getRotation(state.getValue(FACING))); } diff --git a/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntity.java b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntity.java new file mode 100644 index 0000000..adbb65f --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntity.java @@ -0,0 +1,87 @@ +package com.litehed.hytalemodels.api.block.entity; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +public abstract class HytaleBlockEntity extends BlockEntity { + + private final String modelName; + + + public HytaleBlockEntity(BlockEntityType type, BlockPos pos, BlockState state, String modelName) { + super(type, pos, state); + this.modelName = modelName; + + } + + /** + * Returns the name of the Blocky model used to render this block entity. + * This is used to locate the {@code .blockymodel} file and the associated texture. + * + * @return the model name; never null or blank + */ + public String getModelName() { + return modelName; + } + + /** + * Returns the current animation tick for this block entity. + * + *

This value is used alongside the partial tick to compute smooth animation playback. + * Internally, it is multiplied by 4 before being passed to the animation system to match + * Blockyanims expected animation speed. Increment this each game tick in your static + * {@code tick()} method for continuous animations. + * + * @return the current animation tick; must be a non-negative integer + */ + public abstract int getAnimationTick(); + + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + loadAnimationData(input); + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + saveAnimationData(output); + } + + + /** + * Override this method to load custom animation or state data from NBT/storage on world load. + * Called automatically during {@link #loadAdditional}. + * + *

Default implementation does nothing. + * + * @param input the value input to read from + */ + protected void loadAnimationData(ValueInput input) { + // Default implementation does nothing + } + + /** + * Override this method to save custom animation or state data to NBT/storage for persistence. + * Called automatically during {@link #saveAdditional}. + * + *

Default implementation does nothing. + * + * @param output the value output to write to + */ + protected void saveAnimationData(ValueOutput output) { + // Default implementation does nothing + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{" + + "modelName='" + modelName + '\'' + + ", pos=" + worldPosition + + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntityRenderer.java b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntityRenderer.java new file mode 100644 index 0000000..0193955 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleBlockEntityRenderer.java @@ -0,0 +1,486 @@ +package com.litehed.hytalemodels.api.block.entity; + +import com.litehed.hytalemodels.HytaleModelLoader; +import com.litehed.hytalemodels.api.NodeTransform; +import com.litehed.hytalemodels.api.block.HytaleBlockBase; +import com.litehed.hytalemodels.blockymodel.BlockyModelGeometry; +import com.litehed.hytalemodels.blockymodel.BlockyModelLoader; +import com.litehed.hytalemodels.blockymodel.QuadBuilder; +import com.litehed.hytalemodels.blockymodel.TransformCalculator; +import com.litehed.hytalemodels.blockymodel.animations.BlockyAnimationDefinition; +import com.litehed.hytalemodels.blockymodel.animations.BlockyAnimationLoader; +import com.litehed.hytalemodels.blockymodel.animations.BlockyAnimationPlayer; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.Material; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix3f; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract class HytaleBlockEntityRenderer + implements BlockEntityRenderer { + + private final Map geometryCache = new HashMap<>(); + private final Map playerCache = new HashMap<>(); + + public HytaleBlockEntityRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public void extractRenderState(T blockEntity, S renderState, float partialTick, + Vec3 cameraPosition, ModelFeatureRenderer.CrumblingOverlay breakProgress) { + BlockEntityRenderer.super.extractRenderState(blockEntity, renderState, partialTick, cameraPosition, breakProgress); + + renderState.modelName = blockEntity.getModelName(); + renderState.animationTick = blockEntity.getAnimationTick(); + renderState.partialTick = partialTick; + renderState.ageInTicks = (blockEntity.getAnimationTick() + partialTick) * 4; + + if (blockEntity.getLevel() != null) { + BlockState blockState = blockEntity.getLevel().getBlockState(blockEntity.getBlockPos()); + if (blockState.hasProperty(HytaleBlockBase.FACING)) { + renderState.facing = blockState.getValue(HytaleBlockBase.FACING); + } + } + + extractAdditionalRenderState(blockEntity, renderState, partialTick); + } + + + /** + * Allows subclasses to extract additional data from the block entity and put it into the render state + * + * @param blockEntity the block entity being rendered + * @param renderState the render state being built for this block entity + * @param partialTick the current partial tick + */ + protected void extractAdditionalRenderState(T blockEntity, S renderState, float partialTick) { + } + + @Override + public void submit(S renderState, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, + CameraRenderState cameraRenderState) { + if (renderState.modelName == null) { + return; + } + + Identifier modelLocation = getModelLocation(renderState.modelName); + BlockyModelGeometry geometry = getOrLoadGeometry(modelLocation); + if (geometry == null) { + HytaleModelLoader.LOGGER.warn("Failed to load geometry for model: {}", modelLocation); + return; + } + + Material textureMaterial = getTextureMaterial(renderState.modelName); + TextureAtlasSprite sprite = Minecraft.getInstance() + .getAtlasManager().get(textureMaterial); + + poseStack.pushPose(); + + poseStack.translate(0.5, 0.5, 0.5); + + float yRotDegrees = getFacingYRotation(renderState.facing); + if (yRotDegrees != 0f) { + poseStack.mulPose(new Quaternionf().rotationY((float) Math.toRadians(yRotDegrees))); + } + + Map nodeTransforms = calculateAnimationTransforms(renderState, geometry); + + List nodes = geometry.getNodes(); + for (BlockyModelGeometry.BlockyNode node : nodes) { + if (node.hasShape()) { + renderNode(poseStack, submitNodeCollector, node, sprite, nodeTransforms, renderState); + } + } + + poseStack.popPose(); + } + + /** + * Returns the Y-axis rotation in degrees that should be applied to the model based on the + * block's facing direction. + * + *

Override to customize facing rotation behaviour. Default mapping: + *

+ * + * @param facing the direction the block is facing; may be {@code null} if the block has no + * facing property, in which case 0° is returned + * @return the rotation in degrees around the Y axis + */ + protected float getFacingYRotation(Direction facing) { + return switch (facing) { + case NORTH -> 180f; + case EAST -> 90f; + case WEST -> 270f; + default -> 0f; + }; + } + + + /** + * Calculates the transforms for all nodes in the model + * + * @param renderState the current render state containing animation tick and other relevant data + * @param geometry the geometry of the model being rendered + * @return a map of node IDs/names to their corresponding transforms for the current frame + */ + protected abstract Map calculateAnimationTransforms(S renderState, BlockyModelGeometry geometry); + + + /** + * Gets the model location for a given model name + * + * @param modelName the name of the model + * @return the identifier pointing to the model file for this model name + */ + protected Identifier getModelLocation(String modelName) { + return Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, "models/" + modelName + ".blockymodel"); + } + + /** + * Gets the texture material for a given model name + * + * @param modelName the name of the model + * @return the material pointing to the texture for this model name + */ + protected Material getTextureMaterial(String modelName) { + return new Material(TextureAtlas.LOCATION_BLOCKS, + Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, "block/" + modelName + "_texture")); + } + + /** + * Renders a single node of the model, including applying any relevant transforms and rendering the quads for its shape + * + * @param poseStack the current pose stack for rendering + * @param collector the submit node collector to submit geometry to + * @param node the node being rendered + * @param sprite the texture sprite to use for rendering this node + * @param nodeTransforms the map of node transforms calculated for the current frame for animations + * @param renderState the current render state + */ + private void renderNode(PoseStack poseStack, SubmitNodeCollector collector, + BlockyModelGeometry.BlockyNode node, TextureAtlasSprite sprite, + Map nodeTransforms, S renderState) { + + NodeTransform transform = nodeTransforms.get(node.getId()); + if (transform == null) transform = nodeTransforms.get(node.getName()); + if (transform != null && !transform.visible()) return; + + poseStack.pushPose(); + + applyNodeTransform(poseStack, node, nodeTransforms); + + BlockyModelGeometry.BlockyShape shape = node.getShape(); + + Vector3f halfSizes = TransformCalculator.calculateHalfSizes(shape.getSize()); + Vector3f min = new Vector3f(-halfSizes.x, -halfSizes.y, -halfSizes.z); + Vector3f max = new Vector3f(halfSizes.x, halfSizes.y, halfSizes.z); + + RenderType renderType = getRenderType(); + + for (Direction direction : Direction.values()) { + if (!shape.hasTextureLayout(direction)) { + continue; + } + + BlockyModelGeometry.FaceTextureLayout texLayout = shape.getTextureLayout(direction); + boolean shouldReverse = shape.needsWindingReversal(); + + collector.submitCustomGeometry(poseStack, renderType, (pose, buffer) -> + renderQuad(buffer, pose, direction, min, max, sprite, texLayout, + shape.getOriginalSize(), renderState, shouldReverse)); + + // Render backface if double-sided + if (shape.isDoubleSided()) { + collector.submitCustomGeometry(poseStack, renderType, (pose, buffer) -> + renderQuad(buffer, pose, direction, min, max, sprite, texLayout, + shape.getOriginalSize(), renderState, !shouldReverse)); + } + } + + poseStack.popPose(); + } + + protected RenderType getRenderType() { + return RenderTypes.cutoutMovingBlock(); + } + + /** + * Applies the necessary transformations to the pose stack for rendering a given node + * + * @param poseStack the current pose stack for rendering + * @param node the node being rendered + * @param effectiveTransforms the map of effective transforms for all nodes + */ + private void applyNodeTransform(PoseStack poseStack, BlockyModelGeometry.BlockyNode node, + Map effectiveTransforms) { + Vector3f worldPos = TransformCalculator.calculateWorldPosition(node); + Quaternionf worldRot = TransformCalculator.calculateWorldOrientation(node); + + Vector3f shapeOffset = node.getShape().getOffset(); + Vector3f rotatedOffset = new Vector3f(shapeOffset); + worldRot.transform(rotatedOffset); + + float centerX = (worldPos.x + rotatedOffset.x) / 32.0f; + float centerY = ((worldPos.y + rotatedOffset.y) - 16.0f) / 32.0f; + float centerZ = (worldPos.z + rotatedOffset.z) / 32.0f; + + NodeTransform effectiveTransform = effectiveTransforms.get(node.getId()); + if (effectiveTransform == null) { + effectiveTransform = effectiveTransforms.get(node.getName()); + } + + NodeTransform parentAnimTransform = getParentAnimationTransform(node, effectiveTransforms); + + boolean hasOwnAnimation = effectiveTransform != null && !effectiveTransform.equals(NodeTransform.identity()); + boolean parentHasAnimation = parentAnimTransform != null && !parentAnimTransform.equals(NodeTransform.identity()); + + if (hasOwnAnimation || parentHasAnimation) { + Vector3f parentPivot = null; + BlockyModelGeometry.BlockyNode parent = node.getParent(); + if (parent != null && (effectiveTransforms.containsKey(parent.getId()) || + effectiveTransforms.containsKey(parent.getName()))) { + Vector3f parentWorldPos = TransformCalculator.calculateWorldPosition(parent); + parentPivot = new Vector3f( + parentWorldPos.x / 32.0f, + (parentWorldPos.y - 16.0f) / 32.0f, + parentWorldPos.z / 32.0f + ); + } + + float childPivotX = worldPos.x / 32.0f; + float childPivotY = (worldPos.y - 16.0f) / 32.0f; + float childPivotZ = worldPos.z / 32.0f; + + if (parentHasAnimation && parentPivot != null) { + poseStack.translate(parentPivot.x, parentPivot.y, parentPivot.z); + + poseStack.mulPose(parentAnimTransform.rotation()); + Vector3f parentPos = parentAnimTransform.position(); + poseStack.translate(parentPos.x, parentPos.y, parentPos.z); + + poseStack.translate(-parentPivot.x, -parentPivot.y, -parentPivot.z); + } + + if (hasOwnAnimation) { + poseStack.translate(childPivotX, childPivotY, childPivotZ); + + poseStack.mulPose(effectiveTransform.rotation()); + Vector3f childAnimPos = effectiveTransform.position(); + poseStack.translate(childAnimPos.x, childAnimPos.y, childAnimPos.z); + + poseStack.translate(-childPivotX, -childPivotY, -childPivotZ); + } + + poseStack.translate(centerX, centerY, centerZ); + poseStack.mulPose(worldRot); + + Vector3f animScale = effectiveTransform != null ? effectiveTransform.scale() : new Vector3f(1, 1, 1); + poseStack.scale(animScale.x, animScale.y, animScale.z); + } else { + // No animation + poseStack.translate(centerX, centerY, centerZ); + poseStack.mulPose(worldRot); + } + } + + /** + * Recursively checks parent nodes for animation transforms to apply to the current node (Child nodes inherit animations) + * + * @param node the node being rendered + * @param effectiveTransforms the map of effective transforms for all nodes + * @return the first parent animation transform found, else null + */ + private NodeTransform getParentAnimationTransform(BlockyModelGeometry.BlockyNode node, + Map effectiveTransforms) { + BlockyModelGeometry.BlockyNode current = node.getParent(); + + while (current != null) { + NodeTransform transform = effectiveTransforms.get(current.getId()); + if (transform == null) { + transform = effectiveTransforms.get(current.getName()); + } + if (transform != null && !transform.equals(NodeTransform.identity())) { + return transform; + } + current = current.getParent(); + } + + return null; + } + + /** + * Renders a single quad for a given face of the shape, applying the appropriate texture coordinates and normal based on the face direction + * + * @param buffer the vertex consumer buffer to submit vertices to + * @param pose the current pose for rendering, containing the transformation matrix and normal matrix + * @param direction the face direction this quad is being rendered for + * @param min the minimum corner of the quad in model space + * @param max the maximum corner of the quad in model space + * @param sprite the texture sprite to use for calculating UV coordinates + * @param texLayout the texture layout information for this face, containing UV offsets and sizes + * @param originalSize the original size of the shape, used for calculating UV coordinates when set to stretch + * @param renderState the current render state + * @param reversed whether to reverse the vertex order for this quad + */ + private void renderQuad(VertexConsumer buffer, PoseStack.Pose pose, Direction direction, + Vector3f min, Vector3f max, TextureAtlasSprite sprite, + BlockyModelGeometry.FaceTextureLayout texLayout, Vector3f originalSize, + S renderState, boolean reversed) { + + Matrix4f poseMatrix = pose.pose(); + Matrix3f normalMatrix = pose.normal(); + + int normalMult = reversed ? -1 : 1; + Vector3f normal = new Vector3f( + direction.getStepX() * normalMult, + direction.getStepY() * normalMult, + direction.getStepZ() * normalMult + ); + normalMatrix.transform(normal); + + float[][] uvCoords = QuadBuilder.calculateUVCoordinates(direction, texLayout, originalSize, sprite); + + Vector3f[] vertices = QuadBuilder.getFaceVertices(direction, min, max); + + if (reversed) { + for (int i = 3; i >= 0; i--) { + addVertex(buffer, poseMatrix, normal, vertices[i], uvCoords[i], renderState.lightCoords); + } + } else { + for (int i = 0; i < 4; i++) { + addVertex(buffer, poseMatrix, normal, vertices[i], uvCoords[i], renderState.lightCoords); + } + } + } + + /** + * Adds a single vertex to the buffer with the given position, normal, UV coordinates, and light information + * + * @param buffer the vertex consumer buffer to submit the vertex to + * @param pose the current pose transformation matrix to apply to the vertex position + * @param normal the normal vector for this vertex, used for lighting calculations + * @param vertex the position of the vertex in model space + * @param uv the UV coordinates for this vertex, calculated based on the texture layout and sprite + * @param lightCoords the combined block and sky light coordinates for this vertex, used for lighting calculations + */ + private void addVertex(VertexConsumer buffer, Matrix4f pose, Vector3f normal, + Vector3f vertex, float[] uv, int lightCoords) { + int blockLight = lightCoords & 0xFFFF; + int skyLight = (lightCoords >> 16) & 0xFFFF; + + buffer.addVertex(pose, vertex.x, vertex.y, vertex.z) + .setColor(255, 255, 255, 255) + .setUv(uv[0], uv[1]) + .setUv2(blockLight, skyLight) + .setNormal(normal.x, normal.y, normal.z); + } + + /** + * Finds a node in the model geometry by its name, searching recursively through all nodes and their children + * + * @param geometry the model geometry to search through + * @param name the name of the node to find + * @return the node with the given name, else null + */ + protected BlockyModelGeometry.BlockyNode findNodeByName(BlockyModelGeometry geometry, String name) { + return findNodeByNameRecursive(geometry.getNodes(), name); + } + + /** + * Helper method to recursively search for a node by name through a list of nodes and their children + * + * @param nodes the list of nodes to search through + * @param name the name of the node to find + * @return the node with the given name, else null + */ + private BlockyModelGeometry.BlockyNode findNodeByNameRecursive( + List nodes, String name) { + for (BlockyModelGeometry.BlockyNode node : nodes) { + if (node.getName().equals(name)) return node; + BlockyModelGeometry.BlockyNode found = findNodeByNameRecursive(node.getChildren(), name); + if (found != null) return found; + } + return null; + } + + /** + * Recursively applies a given rotation transform to a node and all of its descendants, storing the results in the provided transforms map + * + * @param node the node to apply the transform to, along with all of its children + * @param rotation the rotation to apply to this node and its descendants + * @param transforms the map to store the resulting transforms for each node, keyed by node ID or name + */ + protected void applyTransformToDescendants(BlockyModelGeometry.BlockyNode node, + Quaternionf rotation, + Map transforms) { + for (BlockyModelGeometry.BlockyNode child : node.getChildren()) { + transforms.put(child.getId(), NodeTransform.rotation(rotation)); + applyTransformToDescendants(child, rotation, transforms); + } + } + + /** + * Gets the geometry for a given model location, either from the cache or by loading it from the model file if not already cached + * + * @param modelLocation the identifier pointing to the model file for this model + * @return the geometry for this model, else null + */ + private BlockyModelGeometry getOrLoadGeometry(Identifier modelLocation) { + String key = modelLocation.toString(); + + if (geometryCache.containsKey(key)) { + return geometryCache.get(key); + } + + try { + BlockyModelGeometry geometry = BlockyModelLoader.INSTANCE.loadGeometry( + new BlockyModelGeometry.Settings(modelLocation) + ); + geometryCache.put(key, geometry); + return geometry; + } catch (Exception e) { + HytaleModelLoader.LOGGER.error("Failed to load geometry for {}: {}", modelLocation, e.getMessage()); + return null; + } + } + + /** + * Returns a cached BlockyAnimationPlayer for the given animation ID, creating and caching one + * on first access. Returns null if the animation definition cannot be loaded. + * + * @param animId the identifier pointing to the animation file + * @return the cached player for this animation, else null + */ + protected BlockyAnimationPlayer getOrCreatePlayer(Identifier animId) { + return playerCache.computeIfAbsent(animId, id -> { + BlockyAnimationDefinition definition = BlockyAnimationLoader.INSTANCE.loadAnimation(id); + return definition != null ? new BlockyAnimationPlayer(definition) : null; + }); + } + +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleRenderState.java b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleRenderState.java new file mode 100644 index 0000000..c9eb2a2 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/api/block/entity/HytaleRenderState.java @@ -0,0 +1,17 @@ +package com.litehed.hytalemodels.api.block.entity; + +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.core.Direction; + +public class HytaleRenderState extends BlockEntityRenderState { + public String modelName; + public int animationTick; // Animation tick + public float partialTick; // Partial tick for smooth animation + /** + * The smoothed animation time in ticks, pre-multiplied by 4 to match blockyanim internal + * animation speed expectations. Computed as {@code (animationTick + partialTick) * 4}. + * Pass this value directly to {@code BlockyAnimationPlayer} when sampling keyframes. + */ + public float ageInTicks; + public Direction facing; // Block facing direction for rotation +} diff --git a/src/main/java/com/litehed/hytalemodels/blocks/HytaleChest.java b/src/main/java/com/litehed/hytalemodels/blocks/HytaleChest.java new file mode 100644 index 0000000..16d4b42 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/HytaleChest.java @@ -0,0 +1,57 @@ +package com.litehed.hytalemodels.blocks; + +import com.litehed.hytalemodels.api.block.HytaleBlockBase; +import com.litehed.hytalemodels.blocks.entity.AnimatedChestBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import org.jspecify.annotations.Nullable; + +public class HytaleChest extends HytaleBlockBase implements EntityBlock { + public HytaleChest(Properties properties) { + super(properties); + } + + @Override + public @Nullable BlockEntity newBlockEntity(BlockPos blockPos, BlockState blockState) { + return new AnimatedChestBlockEntity(blockPos, blockState); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hitResult) { + if (level.isClientSide()) { + BlockEntity entity = level.getBlockEntity(pos); + if (entity instanceof AnimatedChestBlockEntity chest) { + if (chest.isOpen()) { + chest.closeChest(); + } else { + chest.openChest(); + } + } + } + return InteractionResult.SUCCESS; + } + + @Override + public @Nullable BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType blockEntityType) { + if (!level.isClientSide()) return null; + return (lvl, pos, blockState, blockEntity) -> { + if (blockEntity instanceof AnimatedChestBlockEntity chest) { + AnimatedChestBlockEntity.clientTick(lvl, pos, blockState, chest); + } + }; + } + + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } +} diff --git a/src/main/java/com/litehed/hytalemodels/blocks/HytaleCoffin.java b/src/main/java/com/litehed/hytalemodels/blocks/HytaleCoffin.java new file mode 100644 index 0000000..2bad1b9 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/HytaleCoffin.java @@ -0,0 +1,61 @@ +package com.litehed.hytalemodels.blocks; + +import com.litehed.hytalemodels.api.block.HytaleBlockBase; +import com.litehed.hytalemodels.blocks.entity.CoffinBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; + +import javax.annotation.Nullable; + +public class HytaleCoffin extends HytaleBlockBase implements EntityBlock { + + public HytaleCoffin(Properties properties) { + super(properties); + } + + @Override + public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new CoffinBlockEntity(pos, state); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hitResult) { + if (level.isClientSide()) { + BlockEntity entity = level.getBlockEntity(pos); + if (entity instanceof CoffinBlockEntity coffin) { + if (coffin.isOpen()) { + coffin.closeCoffin(); + } else { + coffin.openCoffin(); + } + } + } + return InteractionResult.SUCCESS; + } + + @Override + public @Nullable BlockEntityTicker getTicker(Level level, BlockState state, + BlockEntityType type) { + if (!level.isClientSide()) return null; + return (lvl, pos, blockState, blockEntity) -> { + if (blockEntity instanceof CoffinBlockEntity coffin) { + CoffinBlockEntity.clientTick(lvl, pos, blockState, coffin); + } + }; + } + + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestBlockEntity.java b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestBlockEntity.java new file mode 100644 index 0000000..b834408 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestBlockEntity.java @@ -0,0 +1,106 @@ +package com.litehed.hytalemodels.blocks.entity; + +import com.litehed.hytalemodels.api.block.entity.HytaleBlockEntity; +import com.litehed.hytalemodels.init.BlockEntityInit; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +public class AnimatedChestBlockEntity extends HytaleBlockEntity { + + private static final String NBT_KEY = "ChestData"; + private static final String NBT_IS_OPEN = "IsOpen"; + + private boolean isOpen = false; + private int animationTick = 0; + private boolean lastKnownOpen = false; + + public AnimatedChestBlockEntity(BlockPos pos, BlockState state) { + super(BlockEntityInit.CHEST_TEST_ENT.get(), pos, state, "chest_small"); + } + + /** + * Opens the chest and starts the opening animation. + */ + public void openChest() { + if (!isOpen) { + animationTick = 0; + isOpen = true; + setChanged(); + syncToClients(); + } + } + + /** + * Closes the chest and stops the opening animation. + */ + public void closeChest() { + if (isOpen) { + animationTick = 0; + isOpen = false; + setChanged(); + syncToClients(); + } + } + + /** + * Toggles the chest open/closed state. + */ + + public boolean isOpen() { + return isOpen; + } + + + @Override + public int getAnimationTick() { + return animationTick; + } + + /** + * Server/client tick for animation updates. + */ + public static void clientTick(Level level, BlockPos pos, BlockState state, + AnimatedChestBlockEntity be) { + be.animationTick++; + } + + @Override + protected void loadAnimationData(ValueInput input) { + input.read(NBT_KEY, CompoundTag.CODEC).ifPresent(tag -> { + boolean newOpen = tag.getBoolean(NBT_IS_OPEN).orElse(false); + // If the state changed (server pushed an update), reset the animation tick + if (newOpen != lastKnownOpen) { + animationTick = 0; + lastKnownOpen = newOpen; + } + isOpen = newOpen; + }); + } + + @Override + protected void saveAnimationData(ValueOutput output) { + CompoundTag chestTag = new CompoundTag(); + chestTag.putBoolean(NBT_IS_OPEN, isOpen); + output.store(NBT_KEY, CompoundTag.CODEC, chestTag); + } + + /** + * Sync the block entity state to clients. + */ + private void syncToClients() { + if (level != null && !level.isClientSide()) { + level.blockEntityChanged(getBlockPos()); + } + } + + @Override + public String toString() { + return "AnimatedChestBlockEntity{" + + "pos=" + worldPosition + + ", isOpen=" + isOpen + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderState.java b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderState.java new file mode 100644 index 0000000..48b4a86 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderState.java @@ -0,0 +1,7 @@ +package com.litehed.hytalemodels.blocks.entity; + +import com.litehed.hytalemodels.api.block.entity.HytaleRenderState; + +public class AnimatedChestRenderState extends HytaleRenderState { + public boolean isOpen; +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderer.java b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderer.java new file mode 100644 index 0000000..3b4025e --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/entity/AnimatedChestRenderer.java @@ -0,0 +1,64 @@ +package com.litehed.hytalemodels.blocks.entity; + +import com.litehed.hytalemodels.HytaleModelLoader; +import com.litehed.hytalemodels.api.block.entity.HytaleBlockEntityRenderer; +import com.litehed.hytalemodels.api.NodeTransform; +import com.litehed.hytalemodels.blockymodel.BlockyModelGeometry; +import com.litehed.hytalemodels.blockymodel.animations.BlockyAnimationPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.resources.Identifier; + +import java.util.HashMap; +import java.util.Map; + +public class AnimatedChestRenderer extends HytaleBlockEntityRenderer { + + private static final float ANIMATION_SPEED = 0.01f; + private static final float MAX_LID_OFFSET = 3.0f; + + private static final float MAX_LID_ANGLE = 45; + + private static final Identifier ANIM_OPEN = + Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, + "animations/chest_small/chest_open.blockyanim"); + + private static final Identifier ANIM_CLOSE = + Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, + "animations/chest_small/chest_close.blockyanim"); + + public AnimatedChestRenderer(BlockEntityRendererProvider.Context context) { + super(context); + } + + @Override + public AnimatedChestRenderState createRenderState() { + return new AnimatedChestRenderState(); + } + + @Override + protected void extractAdditionalRenderState(AnimatedChestBlockEntity blockEntity, + AnimatedChestRenderState renderState, + float partialTick) { + renderState.isOpen = blockEntity.isOpen(); + } + + @Override + protected Map calculateAnimationTransforms(AnimatedChestRenderState renderState, + BlockyModelGeometry geometry) { + Map transforms = new HashMap<>(); + + Identifier animId = renderState.isOpen ? ANIM_OPEN : ANIM_CLOSE; + BlockyAnimationPlayer player = getOrCreatePlayer(animId); + if (player != null) { + transforms.putAll(player.calculateTransforms(renderState.ageInTicks)); + } + + + // Procedural System +// float angle = (float) Math.sin(renderState.ageInTicks * ANIMATION_SPEED) * MAX_LID_ANGLE; +// Quaternionf rotation = new Quaternionf().rotateX((float) Math.toRadians(-Math.abs(angle))); +// transforms.put("Lid", NodeTransform.rotation(rotation)); + + return transforms; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinBlockEntity.java b/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinBlockEntity.java new file mode 100644 index 0000000..118ed6b --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinBlockEntity.java @@ -0,0 +1,55 @@ +package com.litehed.hytalemodels.blocks.entity; + +import com.litehed.hytalemodels.api.block.entity.HytaleBlockEntity; +import com.litehed.hytalemodels.init.BlockEntityInit; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; + +public class CoffinBlockEntity extends HytaleBlockEntity { + private boolean isOpen = false; + private int animationTick = 0; + + public CoffinBlockEntity(BlockPos pos, BlockState state) { + super(BlockEntityInit.COFFIN_ENT.get(), pos, state, "coffin"); + } + + public void openCoffin() { + if (!isOpen) { + animationTick = 0; + isOpen = true; + setChanged(); + syncToClients(); + } + } + + public void closeCoffin() { + if (isOpen) { + animationTick = 0; + isOpen = false; + setChanged(); + syncToClients(); + } + } + + public boolean isOpen() { + return isOpen; + } + + @Override + public int getAnimationTick() { + return animationTick; + } + + public static void clientTick(Level level, BlockPos pos, BlockState state, + CoffinBlockEntity be) { + be.animationTick++; + } + + private void syncToClients() { + if (level != null && !level.isClientSide()) { + level.blockEntityChanged(getBlockPos()); + } + } + +} diff --git a/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinRenderer.java b/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinRenderer.java new file mode 100644 index 0000000..8709255 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blocks/entity/CoffinRenderer.java @@ -0,0 +1,54 @@ +package com.litehed.hytalemodels.blocks.entity; + +import com.litehed.hytalemodels.HytaleModelLoader; +import com.litehed.hytalemodels.api.block.entity.HytaleBlockEntityRenderer; +import com.litehed.hytalemodels.api.NodeTransform; +import com.litehed.hytalemodels.blockymodel.BlockyModelGeometry; +import com.litehed.hytalemodels.blockymodel.animations.BlockyAnimationPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.resources.Identifier; + +import java.util.HashMap; +import java.util.Map; + +public class CoffinRenderer extends HytaleBlockEntityRenderer { + + private static final Identifier ANIM_OPEN = + Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, + "animations/coffin/coffin_open.blockyanim"); + + private static final Identifier ANIM_CLOSE = + Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, + "animations/coffin/coffin_close.blockyanim"); + + public CoffinRenderer(BlockEntityRendererProvider.Context context) { + super(context); + } + + @Override + public AnimatedChestRenderState createRenderState() { + return new AnimatedChestRenderState(); + } + + @Override + protected void extractAdditionalRenderState(CoffinBlockEntity blockEntity, + AnimatedChestRenderState renderState, + float partialTick) { + renderState.isOpen = blockEntity.isOpen(); + } + + @Override + protected Map calculateAnimationTransforms(AnimatedChestRenderState renderState, + BlockyModelGeometry geometry) { + Map transforms = new HashMap<>(); + + Identifier animId = renderState.isOpen ? ANIM_OPEN : ANIM_CLOSE; + + BlockyAnimationPlayer player = getOrCreatePlayer(animId); + if (player != null) { + transforms.putAll(player.calculateTransforms(renderState.ageInTicks)); + } + + return transforms; + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModel.java b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModel.java similarity index 91% rename from src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModel.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModel.java index 024b868..72d9c06 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModel.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModel.java @@ -1,27 +1,27 @@ -package com.litehed.hytalemodels.modelstuff; - -import net.minecraft.client.resources.model.UnbakedGeometry; -import net.neoforged.neoforge.client.model.AbstractUnbakedModel; -import net.neoforged.neoforge.client.model.StandardModelParameters; -import org.jspecify.annotations.Nullable; - -public class BlockyModel extends AbstractUnbakedModel { - - private final BlockyModelGeometry geometry; - - /** - * Constructor for BlockyModel - * - * @param parameters the standard model parameters - * @param geometry the blocky model geometry - */ - protected BlockyModel(StandardModelParameters parameters, BlockyModelGeometry geometry) { - super(parameters); - this.geometry = geometry; - } - - @Override - public @Nullable UnbakedGeometry geometry() { - return geometry; - } -} +package com.litehed.hytalemodels.blockymodel; + +import net.minecraft.client.resources.model.UnbakedGeometry; +import net.neoforged.neoforge.client.model.AbstractUnbakedModel; +import net.neoforged.neoforge.client.model.StandardModelParameters; +import org.jspecify.annotations.Nullable; + +public class BlockyModel extends AbstractUnbakedModel { + + private final BlockyModelGeometry geometry; + + /** + * Constructor for BlockyModel + * + * @param parameters the standard model parameters + * @param geometry the blocky model geometry + */ + protected BlockyModel(StandardModelParameters parameters, BlockyModelGeometry geometry) { + super(parameters); + this.geometry = geometry; + } + + @Override + public @Nullable UnbakedGeometry geometry() { + return geometry; + } +} diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelGeometry.java b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelGeometry.java similarity index 83% rename from src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelGeometry.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelGeometry.java index d12095e..63d2bb1 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelGeometry.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelGeometry.java @@ -1,319 +1,357 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.google.common.collect.Lists; -import com.mojang.math.Transformation; -import net.minecraft.client.renderer.block.model.BakedQuad; -import net.minecraft.client.renderer.block.model.TextureSlots; -import net.minecraft.client.renderer.texture.TextureAtlasSprite; -import net.minecraft.client.resources.model.ModelBaker; -import net.minecraft.client.resources.model.ModelDebugName; -import net.minecraft.client.resources.model.ModelState; -import net.minecraft.client.resources.model.QuadCollection; -import net.minecraft.core.Direction; -import net.minecraft.resources.Identifier; -import net.minecraft.util.context.ContextMap; -import net.neoforged.neoforge.client.model.ExtendedUnbakedGeometry; -import net.neoforged.neoforge.client.model.NeoForgeModelProperties; -import org.apache.commons.lang3.tuple.Pair; -import org.joml.Quaternionf; -import org.joml.Vector3f; - -import java.util.Collections; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; - -import static com.litehed.hytalemodels.modelstuff.QuadBuilder.DEBUG_BORDERS; - -public class BlockyModelGeometry implements ExtendedUnbakedGeometry { - - private final List nodes; - private final Identifier modelLocation; - - public BlockyModelGeometry(Settings settings) { - this.nodes = Lists.newArrayList(); - this.modelLocation = settings.modelLocation(); - } - - public BlockyModelGeometry(List nodes, Identifier modelLocation) { - this.nodes = nodes; - this.modelLocation = modelLocation; - } - - /** - * Parses a BlockyModelGeometry from the given tokenizer and settings - * - * @param tokenizer the tokenizer to use for parsing - * @param settings the settings to use for parsing - * @return a new BlockyModelGeometry instance - */ - public static BlockyModelGeometry parse(BlockyModelTokenizer tokenizer, Settings settings) { - List nodes = BlockyModelParser.parseNodes(tokenizer.getRoot()); - return new BlockyModelGeometry(nodes, settings.modelLocation()); - } - - - /** - * Bakes the model into a QuadCollection for rendering - * - * @param textureSlots the texture slots for this model - * @param modelBaker the model baker instance - * @param modelState the model state (transformations) - * @param modelDebugName the debug name for this model - * @param contextMap the context map for additional properties - * @return the baked QuadCollection - */ - @Override - public QuadCollection bake(TextureSlots textureSlots, ModelBaker modelBaker, ModelState modelState, ModelDebugName modelDebugName, ContextMap contextMap) { - QuadCollection.Builder builder = new QuadCollection.Builder(); - Transformation rootTransform = contextMap.getOrDefault( - NeoForgeModelProperties.TRANSFORM, - Transformation.identity() - ); - - Transformation finalTransform = rootTransform.isIdentity() - ? modelState.transformation() - : modelState.transformation().compose(rootTransform); - - for (BlockyNode node : nodes) { - if (node.hasShape()) { - bakeNode(builder, node, textureSlots, modelBaker, finalTransform, modelDebugName); - } - } - - return builder.build(); - } - - /** - * Bakes a single BlockyNode into the QuadCollection builder - * - * @param builder the QuadCollection.Builder to add quads to - * @param node the BlockyNode to bake - * @param textureSlots the texture slots for this model - * @param modelBaker the model baker instance - * @param modelTransform the combined transformation for this model - * @param modelDebugName the debug name for this model - */ - private void bakeNode(QuadCollection.Builder builder, BlockyNode node, - TextureSlots textureSlots, ModelBaker modelBaker, - Transformation modelTransform, ModelDebugName modelDebugName) { - - BlockyShape shape = node.getShape(); - TextureAtlasSprite sprite = modelBaker.sprites() - .resolveSlot(textureSlots, "texture", modelDebugName); - - Vector3f worldPos = TransformCalculator.calculateWorldPosition(node); - Quaternionf worldRot = TransformCalculator.calculateWorldOrientation(node); - Transformation nodeTransform = TransformCalculator.createNodeTransform( - worldPos, shape.getOffset(), worldRot - ); - - // Translate after rotation - Transformation centerTranslate = new Transformation( - new Vector3f(0.5f, 0.5f, 0.5f), null, null, null - ); - - Transformation finalTransform; - if (modelTransform.isIdentity()) { - finalTransform = centerTranslate.compose(nodeTransform); - } else { - finalTransform = centerTranslate.compose(modelTransform).compose(nodeTransform); - } - - // Bounds - Vector3f halfSizes = TransformCalculator.calculateHalfSizes(shape.getSize()); - Vector3f min = new Vector3f(-halfSizes.x, -halfSizes.y, -halfSizes.z); - Vector3f max = new Vector3f(halfSizes.x, halfSizes.y, halfSizes.z); - - // Generate quads for each face - for (Direction direction : Direction.values()) { - if (!shape.hasTextureLayout(direction)) { - continue; - } - - FaceTextureLayout texLayout = shape.getTextureLayout(direction); - Pair quad = QuadBuilder.createQuad( - direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform - ); - - addQuadToBuilder(builder, quad); - - // Debug quads - if (DEBUG_BORDERS) { - List borderQuads = QuadBuilder.createBorderQuads( - direction, min, max, sprite, finalTransform - ); - for (BakedQuad borderQuad : borderQuads) { - builder.addUnculledFace(borderQuad); - } - } - - // Backface if double-sided - if (shape.isDoubleSided()) { - Pair backQuad = QuadBuilder.createReversedQuad( - direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform); - builder.addUnculledFace(backQuad.getLeft()); - } - } - } - - /** - * Adds a quad to the QuadCollection builder, handling culling - * - * @param builder the QuadCollection.Builder to add to - * @param quad the quad and its culling direction - */ - private void addQuadToBuilder(QuadCollection.Builder builder, Pair quad) { - if (quad.getRight() == null) { - builder.addUnculledFace(quad.getLeft()); - } else { - builder.addCulledFace(quad.getRight(), quad.getLeft()); - } - } - - public record Settings(Identifier modelLocation) { - public Identifier modelLocation() { - return this.modelLocation; - } - } - - public static final class BlockyNode { - private final String id; - private final String name; - private final Vector3f position; - private final Quaternionf orientation; - private final BlockyShape shape; - private final BlockyNode parent; - - public BlockyNode(String id, String name, Vector3f position, Quaternionf orientation, - BlockyShape shape, BlockyNode parent) { - this.id = id; - this.name = name; - // Defensive copies for mutable objects - this.position = new Vector3f(position); - this.orientation = new Quaternionf(orientation); - this.shape = shape; - this.parent = parent; - } - - // Getters only - no setters (immutable) - public String getId() { - return id; - } - - public String getName() { - return name; - } - - public Vector3f getPosition() { - return new Vector3f(position); - } - - public Quaternionf getOrientation() { - return new Quaternionf(orientation); - } - - public BlockyShape getShape() { - return shape; - } - - public BlockyNode getParent() { - return parent; - } - - public boolean hasShape() { - return shape != null && shape.isVisible(); - } - - @Override - public String toString() { - return "BlockyNode{id='" + id + "', name='" + name + "'}"; - } - } - - public static final class BlockyShape { - private final boolean visible; - private final boolean doubleSided; - private final Vector3f offset; - private final Vector3f stretch; - private final Vector3f originalSize; - private final Vector3f size; - private final Map textureLayout; - - public BlockyShape(boolean visible, boolean doubleSided, Vector3f offset, - Vector3f stretch, Vector3f size, - Map textureLayout) { - this.visible = visible; - this.doubleSided = doubleSided; - this.offset = new Vector3f(offset); - - this.stretch = new Vector3f(stretch); - this.originalSize = new Vector3f(size); - this.size = new Vector3f( - size.x * Math.abs(stretch.x), - size.y * Math.abs(stretch.y), - size.z * Math.abs(stretch.z) - ); - - // Immutable map - this.textureLayout = Collections.unmodifiableMap( - new EnumMap<>(textureLayout) - ); - } - - public static BlockyShape invisible() { - return new BlockyShape( - false, false, - new Vector3f(0, 0, 0), - new Vector3f(1, 1, 1), - new Vector3f(16, 16, 16), - new EnumMap<>(Direction.class) - ); - } - - // Getters - public boolean isVisible() { - return visible; - } - - public boolean isDoubleSided() { - return doubleSided; - } - - public Vector3f getOffset() { - return new Vector3f(offset); - } - - public Vector3f getStretch() { - return stretch; - } - - public Vector3f getOriginalSize() { - return originalSize; - } - - public Vector3f getSize() { - return new Vector3f(size); - } - - public FaceTextureLayout getTextureLayout(Direction face) { - return textureLayout.get(face); - } - - public boolean hasTextureLayout(Direction face) { - return textureLayout.containsKey(face); - } - } - - public record FaceTextureLayout(int offsetX, int offsetY, boolean mirrorX, boolean mirrorY, int angle) { - public FaceTextureLayout { - if (angle != 0 && angle != 90 && angle != 180 && angle != 270) { - throw new IllegalArgumentException("Angle must be 0, 90, 180, or 270, got: " + angle); - } - } - - public static FaceTextureLayout defaultLayout() { - return new FaceTextureLayout(0, 0, false, false, 0); - } - } -} +package com.litehed.hytalemodels.blockymodel; + +import com.google.common.collect.Lists; +import com.mojang.math.Transformation; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.TextureSlots; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.ModelBaker; +import net.minecraft.client.resources.model.ModelDebugName; +import net.minecraft.client.resources.model.ModelState; +import net.minecraft.client.resources.model.QuadCollection; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import net.minecraft.util.context.ContextMap; +import net.neoforged.neoforge.client.model.ExtendedUnbakedGeometry; +import net.neoforged.neoforge.client.model.NeoForgeModelProperties; +import org.apache.commons.lang3.tuple.Pair; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.*; + +import static com.litehed.hytalemodels.blockymodel.QuadBuilder.DEBUG_BORDERS; + +public class BlockyModelGeometry implements ExtendedUnbakedGeometry { + + private final List nodes; + private final Identifier modelLocation; + + public BlockyModelGeometry(Settings settings) { + this.nodes = Lists.newArrayList(); + this.modelLocation = settings.modelLocation(); + } + + public BlockyModelGeometry(List nodes, Identifier modelLocation) { + this.nodes = nodes; + this.modelLocation = modelLocation; + } + + /** + * Parses a BlockyModelGeometry from the given tokenizer and settings + * + * @param tokenizer the tokenizer to use for parsing + * @param settings the settings to use for parsing + * @return a new BlockyModelGeometry instance + */ + public static BlockyModelGeometry parse(BlockyTokenizer tokenizer, Settings settings) { + List nodes = BlockyModelParser.parseNodes(tokenizer.getRoot()); + return new BlockyModelGeometry(nodes, settings.modelLocation()); + } + + + /** + * Bakes the model into a QuadCollection for rendering + * + * @param textureSlots the texture slots for this model + * @param modelBaker the model baker instance + * @param modelState the model state (transformations) + * @param modelDebugName the debug name for this model + * @param contextMap the context map for additional properties + * @return the baked QuadCollection + */ + @Override + public QuadCollection bake(TextureSlots textureSlots, ModelBaker modelBaker, ModelState modelState, ModelDebugName modelDebugName, ContextMap contextMap) { + QuadCollection.Builder builder = new QuadCollection.Builder(); + Transformation rootTransform = contextMap.getOrDefault( + NeoForgeModelProperties.TRANSFORM, + Transformation.identity() + ); + + Transformation finalTransform = rootTransform.isIdentity() + ? modelState.transformation() + : modelState.transformation().compose(rootTransform); + + for (BlockyNode node : nodes) { + if (node.hasShape()) { + bakeNode(builder, node, textureSlots, modelBaker, finalTransform, modelDebugName); + } + } + + return builder.build(); + } + + /** + * Bakes a single BlockyNode into the QuadCollection builder + * + * @param builder the QuadCollection.Builder to add quads to + * @param node the BlockyNode to bake + * @param textureSlots the texture slots for this model + * @param modelBaker the model baker instance + * @param modelTransform the combined transformation for this model + * @param modelDebugName the debug name for this model + */ + private void bakeNode(QuadCollection.Builder builder, BlockyNode node, + TextureSlots textureSlots, ModelBaker modelBaker, + Transformation modelTransform, ModelDebugName modelDebugName) { + + BlockyShape shape = node.getShape(); + TextureAtlasSprite sprite = modelBaker.sprites() + .resolveSlot(textureSlots, "texture", modelDebugName); + + Vector3f worldPos = TransformCalculator.calculateWorldPosition(node); + Quaternionf worldRot = TransformCalculator.calculateWorldOrientation(node); + Transformation nodeTransform = TransformCalculator.createNodeTransform( + worldPos, shape.getOffset(), worldRot + ); + + // Translate after rotation + Transformation centerTranslate = new Transformation( + new Vector3f(0.5f, 0.5f, 0.5f), null, null, null + ); + + Transformation finalTransform; + if (modelTransform.isIdentity()) { + finalTransform = centerTranslate.compose(nodeTransform); + } else { + finalTransform = centerTranslate.compose(modelTransform).compose(nodeTransform); + } + + // Bounds + Vector3f halfSizes = TransformCalculator.calculateHalfSizes(shape.getSize()); + Vector3f min = new Vector3f(-halfSizes.x, -halfSizes.y, -halfSizes.z); + Vector3f max = new Vector3f(halfSizes.x, halfSizes.y, halfSizes.z); + + // Generate quads for each face + for (Direction direction : Direction.values()) { + if (!shape.hasTextureLayout(direction)) { + continue; + } + + FaceTextureLayout texLayout = shape.getTextureLayout(direction); + + boolean shouldReverse = shape.needsWindingReversal(); + + Pair quad; + if (shouldReverse) { + quad = QuadBuilder.createReversedQuad( + direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform); + } else { + quad = QuadBuilder.createQuad( + direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform); + } + + addQuadToBuilder(builder, quad); + + // Debug quads + if (DEBUG_BORDERS) { + List borderQuads = QuadBuilder.createBorderQuads( + direction, min, max, sprite, finalTransform + ); + for (BakedQuad borderQuad : borderQuads) { + builder.addUnculledFace(borderQuad); + } + } + + // Backface if double-sided + if (shape.isDoubleSided()) { + Pair backQuad; + if (shouldReverse) { + // If winding is reversed, backface should be normal + backQuad = QuadBuilder.createQuad( + direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform); + } else { + backQuad = QuadBuilder.createReversedQuad( + direction, min, max, sprite, texLayout, shape.getOriginalSize(), finalTransform); + } + builder.addUnculledFace(backQuad.getLeft()); + } + } + } + + /** + * Adds a quad to the QuadCollection builder, handling culling + * + * @param builder the QuadCollection.Builder to add to + * @param quad the quad and its culling direction + */ + private void addQuadToBuilder(QuadCollection.Builder builder, Pair quad) { + if (quad.getRight() == null) { + builder.addUnculledFace(quad.getLeft()); + } else { + builder.addCulledFace(quad.getRight(), quad.getLeft()); + } + } + + public List getNodes() { + return nodes; + } + + public record Settings(Identifier modelLocation) { + public Identifier modelLocation() { + return this.modelLocation; + } + } + + public static final class BlockyNode { + private final String id; + private final String name; + private final Vector3f position; + private final Quaternionf orientation; + private final BlockyShape shape; + private final BlockyNode parent; + private final List children; + + public BlockyNode(String id, String name, Vector3f position, Quaternionf orientation, + BlockyShape shape, BlockyNode parent) { + this.id = id; + this.name = name; + // Defensive copies for mutable objects + this.position = new Vector3f(position); + this.orientation = new Quaternionf(orientation); + this.shape = shape; + this.parent = parent; + this.children = new ArrayList<>(); + } + + // Getters only - no setters (immutable) + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Vector3f getPosition() { + return new Vector3f(position); + } + + public Quaternionf getOrientation() { + return new Quaternionf(orientation); + } + + public BlockyShape getShape() { + return shape; + } + + public BlockyNode getParent() { + return parent; + } + + public List getChildren() { + return Collections.unmodifiableList(children); + } + + void addChild(BlockyNode child) { + this.children.add(child); + } + + + public boolean hasShape() { + return shape != null && shape.isVisible(); + } + + @Override + public String toString() { + return "BlockyNode{id='" + id + "', name='" + name + "'}"; + } + } + + public static final class BlockyShape { + private final boolean visible; + private final boolean doubleSided; + private final Vector3f offset; + private final Vector3f stretch; + private final boolean needsWindingReversal; + private final Vector3f originalSize; + private final Vector3f size; + private final Map textureLayout; + + public BlockyShape(boolean visible, boolean doubleSided, Vector3f offset, + Vector3f stretch, Vector3f size, + Map textureLayout) { + this.visible = visible; + this.doubleSided = doubleSided; + this.offset = new Vector3f(offset); + + this.stretch = new Vector3f(stretch); + this.originalSize = new Vector3f(size); + this.size = new Vector3f( + size.x * stretch.x, + size.y * stretch.y, + size.z * stretch.z + ); + + int negativeCount = 0; + if (stretch.x < 0) negativeCount++; + if (stretch.y < 0) negativeCount++; + if (stretch.z < 0) negativeCount++; + this.needsWindingReversal = (negativeCount % 2) == 1; + + // Immutable map + this.textureLayout = Collections.unmodifiableMap( + new EnumMap<>(textureLayout) + ); + } + + public static BlockyShape invisible() { + return new BlockyShape( + false, false, + new Vector3f(0, 0, 0), + new Vector3f(1, 1, 1), + new Vector3f(16, 16, 16), + new EnumMap<>(Direction.class) + ); + } + + // Getters + public boolean isVisible() { + return visible; + } + + public boolean isDoubleSided() { + return doubleSided; + } + + public Vector3f getOffset() { + return new Vector3f(offset); + } + + public Vector3f getStretch() { + return stretch; + } + + public boolean needsWindingReversal() { + return needsWindingReversal; + } + + public Vector3f getOriginalSize() { + return originalSize; + } + + public Vector3f getSize() { + return new Vector3f(size); + } + + public FaceTextureLayout getTextureLayout(Direction face) { + return textureLayout.get(face); + } + + public boolean hasTextureLayout(Direction face) { + return textureLayout.containsKey(face); + } + } + + public record FaceTextureLayout(int offsetX, int offsetY, boolean mirrorX, boolean mirrorY, int angle) { + public FaceTextureLayout { + if (angle != 0 && angle != 90 && angle != 180 && angle != 270) { + throw new IllegalArgumentException("Angle must be 0, 90, 180, or 270, got: " + angle); + } + } + + public static FaceTextureLayout defaultLayout() { + return new FaceTextureLayout(0, 0, false, false, 0); + } + } +} diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelLoader.java b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelLoader.java similarity index 93% rename from src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelLoader.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelLoader.java index 564e7fe..331febd 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelLoader.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelLoader.java @@ -1,73 +1,73 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.google.common.collect.Maps; -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.litehed.hytalemodels.HytaleModelLoader; -import net.minecraft.client.Minecraft; -import net.minecraft.resources.Identifier; -import net.minecraft.server.packs.resources.Resource; -import net.minecraft.server.packs.resources.ResourceManager; -import net.minecraft.server.packs.resources.ResourceManagerReloadListener; -import net.neoforged.neoforge.client.model.StandardModelParameters; -import net.neoforged.neoforge.client.model.UnbakedModelLoader; - -import java.io.FileNotFoundException; -import java.util.Map; - -public class BlockyModelLoader implements UnbakedModelLoader, ResourceManagerReloadListener { - - public static final BlockyModelLoader INSTANCE = new BlockyModelLoader(); - public static final Identifier ID = Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, "blockymodel_loader"); - private final Map geometryCache = Maps.newConcurrentMap(); - - // Important for cleaning up during resource reloads - @Override - public void onResourceManagerReload(ResourceManager resourceManager) { - geometryCache.clear(); - } - - /** - * Reads a BlockyModel file and returns a BlockyModel instance - * - * @param jsonObject the JsonObject representing the BlockyModel - * @param jsonDeserializationContext the context for deserializing JSON - * @return a BlockyModel instance - * @throws JsonParseException if the model is malformed or missing required fields - */ - @Override - public BlockyModel read(JsonObject jsonObject, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException { - HytaleModelLoader.LOGGER.debug("[BlockyModelLoader] Reading model from file: {}", jsonObject); - - if (!jsonObject.has("model")) { - throw new JsonParseException("BlockyModel Loader requires a 'model' key that points to a valid BlockyModel file."); - } - - String modelLocation = jsonObject.get("model").getAsString(); - StandardModelParameters parameters = StandardModelParameters.parse(jsonObject, jsonDeserializationContext); - - var geometry = loadGeometry(new BlockyModelGeometry.Settings(Identifier.parse(modelLocation))); - return new BlockyModel(parameters, geometry); - } - - /** - * Loads and parses a BlockyModel file from the given location - * - * @param settings the settings containing the model location - * @return the parsed BlockyModelGeometry - */ - public BlockyModelGeometry loadGeometry(BlockyModelGeometry.Settings settings) { - return geometryCache.computeIfAbsent(settings, (data) -> { - ResourceManager manager = Minecraft.getInstance().getResourceManager(); - Resource resource = manager.getResource(settings.modelLocation()).orElseThrow(); - try (BlockyModelTokenizer tokenizer = new BlockyModelTokenizer(resource.open())) { - return BlockyModelGeometry.parse(tokenizer, data); - } catch (FileNotFoundException e) { - throw new RuntimeException("Could not find BlockyModel file", e); - } catch (Exception e) { - throw new RuntimeException("Could not read BlockyModel file", e); - } - }); - } -} +package com.litehed.hytalemodels.blockymodel; + +import com.google.common.collect.Maps; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.litehed.hytalemodels.HytaleModelLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import net.neoforged.neoforge.client.model.StandardModelParameters; +import net.neoforged.neoforge.client.model.UnbakedModelLoader; + +import java.io.FileNotFoundException; +import java.util.Map; + +public class BlockyModelLoader implements UnbakedModelLoader, ResourceManagerReloadListener { + + public static final BlockyModelLoader INSTANCE = new BlockyModelLoader(); + public static final Identifier ID = Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID, "blockymodel_loader"); + private final Map geometryCache = Maps.newConcurrentMap(); + + // Important for cleaning up during resource reloads + @Override + public void onResourceManagerReload(ResourceManager resourceManager) { + geometryCache.clear(); + } + + /** + * Reads a BlockyModel file and returns a BlockyModel instance + * + * @param jsonObject the JsonObject representing the BlockyModel + * @param jsonDeserializationContext the context for deserializing JSON + * @return a BlockyModel instance + * @throws JsonParseException if the model is malformed or missing required fields + */ + @Override + public BlockyModel read(JsonObject jsonObject, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException { + HytaleModelLoader.LOGGER.debug("[BlockyModelLoader] Reading model from file: {}", jsonObject); + + if (!jsonObject.has("model")) { + throw new JsonParseException("BlockyModel Loader requires a 'model' key that points to a valid BlockyModel file."); + } + + String modelLocation = jsonObject.get("model").getAsString(); + StandardModelParameters parameters = StandardModelParameters.parse(jsonObject, jsonDeserializationContext); + + var geometry = loadGeometry(new BlockyModelGeometry.Settings(Identifier.parse(modelLocation))); + return new BlockyModel(parameters, geometry); + } + + /** + * Loads and parses a BlockyModel file from the given location + * + * @param settings the settings containing the model location + * @return the parsed BlockyModelGeometry + */ + public BlockyModelGeometry loadGeometry(BlockyModelGeometry.Settings settings) { + return geometryCache.computeIfAbsent(settings, (data) -> { + ResourceManager manager = Minecraft.getInstance().getResourceManager(); + Resource resource = manager.getResource(settings.modelLocation()).orElseThrow(); + try (BlockyTokenizer tokenizer = new BlockyTokenizer(resource.open())) { + return BlockyModelGeometry.parse(tokenizer, data); + } catch (FileNotFoundException e) { + throw new RuntimeException("Could not find BlockyModel file", e); + } catch (Exception e) { + throw new RuntimeException("Could not read BlockyModel file", e); + } + }); + } +} diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelParser.java b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelParser.java similarity index 91% rename from src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelParser.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelParser.java index 5c293b3..869cc66 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelParser.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyModelParser.java @@ -1,334 +1,326 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.litehed.hytalemodels.HytaleModelLoader; -import net.minecraft.core.Direction; -import org.joml.Quaternionf; -import org.joml.Vector3f; - -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; - -public class BlockyModelParser { - - private static final float DEFAULT_SIZE = 16.0f; - private static final float DEFAULT_STRETCH = 1.0f; - private static final Vector3f DEFAULT_POSITION = new Vector3f(0, 0, 0); - private static final Quaternionf DEFAULT_ORIENTATION = new Quaternionf(); - - /** - * Parse the nodes from the root JsonObject - * - * @param root the root JsonObject of the BlockyModel - * @return a list of parsed BlockyNodes - * @throws JsonParseException if required fields are missing or invalid - */ - public static List parseNodes(JsonObject root) throws JsonParseException { - if (!root.has("nodes")) { - throw new JsonParseException("BlockyModel file must contain a 'nodes' array"); - } - - List nodes = new ArrayList<>(); - JsonArray nodesArray = root.getAsJsonArray("nodes"); - - for (JsonElement nodeElement : nodesArray) { - parseNode(nodeElement.getAsJsonObject(), null, nodes); - } - - return nodes; - } - - /** - * Parse a single node and its children recursively - * - * @param nodeObj the JsonObject representing the node - * @param parent the parent BlockyNode, or null if root - * @param allNodes the list to add parsed nodes to - * @throws JsonParseException if required fields are missing or invalid - */ - private static void parseNode(JsonObject nodeObj, BlockyModelGeometry.BlockyNode parent, List allNodes) { - validateRequiredFields(nodeObj); - - BlockyModelGeometry.BlockyNode node = new BlockyModelGeometry.BlockyNode( - parseString(nodeObj, "id"), - parseString(nodeObj, "name"), - parsePosition(nodeObj), - parseOrientation(nodeObj), - parseShape(nodeObj), - parent - ); - - allNodes.add(node); - - // Parse children recursively - if (nodeObj.has("children")) { - JsonArray children = nodeObj.getAsJsonArray("children"); - for (JsonElement childElement : children) { - parseNode(childElement.getAsJsonObject(), node, allNodes); - } - } - } - - /** - * Validate that required fields are present in the node JsonObject - * - * @param nodeObj the JsonObject representing the node - * @throws JsonParseException if required fields are missing - */ - private static void validateRequiredFields(JsonObject nodeObj) throws JsonParseException { - if (!nodeObj.has("name")) { - throw new JsonParseException("Node missing required field: 'name'"); - } - if (!nodeObj.has("id")) { - throw new JsonParseException("Node missing required field: 'id'"); - } - } - - /** - * Parse a string field from a JsonObject - * - * @param obj the JsonObject - * @param key the key of the string field - * @return the string value - */ - private static String parseString(JsonObject obj, String key) { - return obj.get(key).getAsString(); - } - - /** - * Parse the position vector from a node JsonObject - * - * @param nodeObj the JsonObject representing the node - * @return the parsed position vector - */ - private static Vector3f parsePosition(JsonObject nodeObj) { - if (!nodeObj.has("position")) { - return new Vector3f(DEFAULT_POSITION); - } - - JsonObject pos = nodeObj.getAsJsonObject("position"); - return new Vector3f( - getFloatOrDefault(pos, "x", 0), - getFloatOrDefault(pos, "y", 0), - getFloatOrDefault(pos, "z", 0) - ); - } - - /** - * Parse the orientation quaternion from a node JsonObject - * - * @param nodeObj the JsonObject representing the node - * @return the parsed orientation quaternion - */ - private static Quaternionf parseOrientation(JsonObject nodeObj) { - if (!nodeObj.has("orientation")) { - return new Quaternionf(DEFAULT_ORIENTATION); - } - - JsonObject orient = nodeObj.getAsJsonObject("orientation"); - return new Quaternionf( - getFloatOrDefault(orient, "x", 0), - getFloatOrDefault(orient, "y", 0), - getFloatOrDefault(orient, "z", 0), - getFloatOrDefault(orient, "w", 1) - ); - } - - /** - * Parse the shape from a node - * - * @param nodeObj the JsonObject representing the node - * @return the parsed BlockyShape, or null if none - */ - private static BlockyModelGeometry.BlockyShape parseShape(JsonObject nodeObj) { - if (!nodeObj.has("shape")) { - return null; - } - - JsonObject shapeObj = nodeObj.getAsJsonObject("shape"); - - boolean visible = getBooleanOrDefault(shapeObj, "visible", true); - if (!visible) { - return BlockyModelGeometry.BlockyShape.invisible(); - } - - return new BlockyModelGeometry.BlockyShape( - visible, - getBooleanOrDefault(shapeObj, "doubleSided", false), - parseOffset(shapeObj), - parseStretch(shapeObj), - parseSize(shapeObj), - parseTextureLayout(shapeObj) - ); - } - - /** - * Parse the offset vector from a shape JsonObject - * - * @param shapeObj the JsonObject representing the shape - * @return the parsed offset vector - */ - private static Vector3f parseOffset(JsonObject shapeObj) { - if (!shapeObj.has("offset")) { - return new Vector3f(0, 0, 0); - } - - JsonObject offset = shapeObj.getAsJsonObject("offset"); - return new Vector3f( - getFloatOrDefault(offset, "x", 0), - getFloatOrDefault(offset, "y", 0), - getFloatOrDefault(offset, "z", 0) - ); - } - - /** - * Parse the stretch vector from a shape JsonObject - * - * @param shapeObj the JsonObject representing the shape - * @return the parsed stretch vector - */ - private static Vector3f parseStretch(JsonObject shapeObj) { - if (!shapeObj.has("stretch")) { - return new Vector3f(1, 1, 1); - } - - JsonObject stretch = shapeObj.getAsJsonObject("stretch"); - return new Vector3f( - getFloatOrDefault(stretch, "x", DEFAULT_STRETCH), - getFloatOrDefault(stretch, "y", DEFAULT_STRETCH), - getFloatOrDefault(stretch, "z", DEFAULT_STRETCH) - ); - } - - /** - * Parse the size vector from a shape JsonObject - * - * @param shapeObj the JsonObject representing the shape - * @return the parsed size vector - */ - private static Vector3f parseSize(JsonObject shapeObj) { - if (!shapeObj.has("settings")) { - return new Vector3f(DEFAULT_SIZE, DEFAULT_SIZE, DEFAULT_SIZE); - } - - JsonObject settings = shapeObj.getAsJsonObject("settings"); - if (!settings.has("size")) { - return new Vector3f(DEFAULT_SIZE, DEFAULT_SIZE, DEFAULT_SIZE); - } - - JsonObject size = settings.getAsJsonObject("size"); - return new Vector3f( - getFloatOrDefault(size, "x", DEFAULT_SIZE), - getFloatOrDefault(size, "y", DEFAULT_SIZE), - getFloatOrDefault(size, "z", DEFAULT_SIZE) - ); - } - - /** - * Parse the texture layout from a shape JsonObject - * - * @param shapeObj the JsonObject representing the shape - * @return the parsed texture layout map - */ - private static Map parseTextureLayout(JsonObject shapeObj) { - Map layoutMap = new EnumMap<>(Direction.class); - - if (!shapeObj.has("textureLayout")) { - return layoutMap; - } - - JsonObject texLayout = shapeObj.getAsJsonObject("textureLayout"); - for (String dirName : texLayout.keySet()) { - Direction dir = parseDirectionName(dirName); - if (dir != null) { - layoutMap.put(dir, parseFaceLayout(texLayout.getAsJsonObject(dirName))); - } - } - - return layoutMap; - } - - /** - * Parse a single face texture layout from a JsonObject - * - * @param faceLayout the JsonObject representing the face layout - * @return the parsed FaceTextureLayout - */ - private static BlockyModelGeometry.FaceTextureLayout parseFaceLayout(JsonObject faceLayout) { - int offsetX = 0, offsetY = 0; - boolean mirrorX = false, mirrorY = false; - int angle = 0; - - if (faceLayout.has("offset")) { - JsonObject offset = faceLayout.getAsJsonObject("offset"); - offsetX = getIntOrDefault(offset, "x", 0); - offsetY = getIntOrDefault(offset, "y", 0); - } - - if (faceLayout.has("mirror")) { - JsonObject mirror = faceLayout.getAsJsonObject("mirror"); - mirrorX = getBooleanOrDefault(mirror, "x", false); - mirrorY = getBooleanOrDefault(mirror, "y", false); - } - - if (faceLayout.has("angle")) { - angle = faceLayout.get("angle").getAsInt(); - validateAngle(angle); - } - - return new BlockyModelGeometry.FaceTextureLayout(offsetX, offsetY, mirrorX, mirrorY, angle); - } - - /** - * Parse a direction name string to a Direction enum - * - * @param name the direction name string - * @return the corresponding Direction enum, or null if unknown - */ - private static Direction parseDirectionName(String name) { - return switch (name.toLowerCase()) { - case "front" -> Direction.SOUTH; - case "back" -> Direction.NORTH; - case "left" -> Direction.WEST; - case "right" -> Direction.EAST; - case "top" -> Direction.UP; - case "bottom" -> Direction.DOWN; - case "north", "south", "west", "east", "up", "down" -> Direction.valueOf(name.toUpperCase()); - default -> { - HytaleModelLoader.LOGGER.warn("Unknown direction name: {}, skipping", name); - yield null; - } - }; - } - - /** - * Validate that the angle is one of the allowed values - * - * @param angle the angle to validate - * @throws JsonParseException if the angle is invalid - */ - private static void validateAngle(int angle) { - if (angle != 0 && angle != 90 && angle != 180 && angle != 270) { - throw new JsonParseException("Invalid angle: " + angle + ". Must be 0, 90, 180, or 270"); - } - } - - // Utility methods to get values with defaults - - private static float getFloatOrDefault(JsonObject obj, String key, float defaultValue) { - return obj.has(key) ? obj.get(key).getAsFloat() : defaultValue; - } - - private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) { - return obj.has(key) ? obj.get(key).getAsInt() : defaultValue; - } - - private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { - return obj.has(key) ? obj.get(key).getAsBoolean() : defaultValue; - } +package com.litehed.hytalemodels.blockymodel; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.litehed.hytalemodels.HytaleModelLoader; +import net.minecraft.core.Direction; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import static com.litehed.hytalemodels.blockymodel.ParserUtil.*; + +public class BlockyModelParser { + + private static final float DEFAULT_SIZE = 16.0f; + private static final float DEFAULT_STRETCH = 1.0f; + private static final Vector3f DEFAULT_POSITION = new Vector3f(0, 0, 0); + private static final Quaternionf DEFAULT_ORIENTATION = new Quaternionf(); + + /** + * Parse the nodes from the root JsonObject + * + * @param root the root JsonObject of the BlockyModel + * @return a list of parsed BlockyNodes + * @throws JsonParseException if required fields are missing or invalid + */ + public static List parseNodes(JsonObject root) throws JsonParseException { + if (!root.has("nodes")) { + throw new JsonParseException("BlockyModel file must contain a 'nodes' array"); + } + + List nodes = new ArrayList<>(); + JsonArray nodesArray = root.getAsJsonArray("nodes"); + + for (JsonElement nodeElement : nodesArray) { + parseNode(nodeElement.getAsJsonObject(), null, nodes); + } + + return nodes; + } + + /** + * Parse a single node and its children recursively + * + * @param nodeObj the JsonObject representing the node + * @param parent the parent BlockyNode, or null if root + * @param allNodes the list to add parsed nodes to + * @throws JsonParseException if required fields are missing or invalid + */ + private static void parseNode(JsonObject nodeObj, BlockyModelGeometry.BlockyNode parent, List allNodes) { + validateRequiredFields(nodeObj); + + BlockyModelGeometry.BlockyNode node = new BlockyModelGeometry.BlockyNode( + parseString(nodeObj, "id"), + parseString(nodeObj, "name"), + parsePosition(nodeObj), + parseOrientation(nodeObj), + parseShape(nodeObj), + parent + ); + + allNodes.add(node); + + if (parent != null) { + parent.addChild(node); + } + + // Parse children recursively + if (nodeObj.has("children")) { + JsonArray children = nodeObj.getAsJsonArray("children"); + for (JsonElement childElement : children) { + parseNode(childElement.getAsJsonObject(), node, allNodes); + } + } + } + + /** + * Validate that required fields are present in the node JsonObject + * + * @param nodeObj the JsonObject representing the node + * @throws JsonParseException if required fields are missing + */ + private static void validateRequiredFields(JsonObject nodeObj) throws JsonParseException { + if (!nodeObj.has("name")) { + throw new JsonParseException("Node missing required field: 'name'"); + } + if (!nodeObj.has("id")) { + throw new JsonParseException("Node missing required field: 'id'"); + } + } + + /** + * Parse a string field from a JsonObject + * + * @param obj the JsonObject + * @param key the key of the string field + * @return the string value + */ + private static String parseString(JsonObject obj, String key) { + return obj.get(key).getAsString(); + } + + /** + * Parse the position vector from a node JsonObject + * + * @param nodeObj the JsonObject representing the node + * @return the parsed position vector + */ + private static Vector3f parsePosition(JsonObject nodeObj) { + if (!nodeObj.has("position")) { + return new Vector3f(DEFAULT_POSITION); + } + + JsonObject pos = nodeObj.getAsJsonObject("position"); + return new Vector3f( + getFloatOrDefault(pos, "x", 0), + getFloatOrDefault(pos, "y", 0), + getFloatOrDefault(pos, "z", 0) + ); + } + + /** + * Parse the orientation quaternion from a node JsonObject + * + * @param nodeObj the JsonObject representing the node + * @return the parsed orientation quaternion + */ + private static Quaternionf parseOrientation(JsonObject nodeObj) { + if (!nodeObj.has("orientation")) { + return new Quaternionf(DEFAULT_ORIENTATION); + } + + JsonObject orient = nodeObj.getAsJsonObject("orientation"); + return new Quaternionf( + getFloatOrDefault(orient, "x", 0), + getFloatOrDefault(orient, "y", 0), + getFloatOrDefault(orient, "z", 0), + getFloatOrDefault(orient, "w", 1) + ); + } + + /** + * Parse the shape from a node + * + * @param nodeObj the JsonObject representing the node + * @return the parsed BlockyShape, or null if none + */ + private static BlockyModelGeometry.BlockyShape parseShape(JsonObject nodeObj) { + if (!nodeObj.has("shape")) { + return null; + } + + JsonObject shapeObj = nodeObj.getAsJsonObject("shape"); + + boolean visible = getBooleanOrDefault(shapeObj, "visible", true); + if (!visible) { + return BlockyModelGeometry.BlockyShape.invisible(); + } + + return new BlockyModelGeometry.BlockyShape( + visible, + getBooleanOrDefault(shapeObj, "doubleSided", false), + parseOffset(shapeObj), + parseStretch(shapeObj), + parseSize(shapeObj), + parseTextureLayout(shapeObj) + ); + } + + /** + * Parse the offset vector from a shape JsonObject + * + * @param shapeObj the JsonObject representing the shape + * @return the parsed offset vector + */ + private static Vector3f parseOffset(JsonObject shapeObj) { + if (!shapeObj.has("offset")) { + return new Vector3f(0, 0, 0); + } + + JsonObject offset = shapeObj.getAsJsonObject("offset"); + return new Vector3f( + getFloatOrDefault(offset, "x", 0), + getFloatOrDefault(offset, "y", 0), + getFloatOrDefault(offset, "z", 0) + ); + } + + /** + * Parse the stretch vector from a shape JsonObject + * + * @param shapeObj the JsonObject representing the shape + * @return the parsed stretch vector + */ + private static Vector3f parseStretch(JsonObject shapeObj) { + if (!shapeObj.has("stretch")) { + return new Vector3f(1, 1, 1); + } + + JsonObject stretch = shapeObj.getAsJsonObject("stretch"); + return new Vector3f( + getFloatOrDefault(stretch, "x", DEFAULT_STRETCH), + getFloatOrDefault(stretch, "y", DEFAULT_STRETCH), + getFloatOrDefault(stretch, "z", DEFAULT_STRETCH) + ); + } + + /** + * Parse the size vector from a shape JsonObject + * + * @param shapeObj the JsonObject representing the shape + * @return the parsed size vector + */ + private static Vector3f parseSize(JsonObject shapeObj) { + if (!shapeObj.has("settings")) { + return new Vector3f(DEFAULT_SIZE, DEFAULT_SIZE, DEFAULT_SIZE); + } + + JsonObject settings = shapeObj.getAsJsonObject("settings"); + if (!settings.has("size")) { + return new Vector3f(DEFAULT_SIZE, DEFAULT_SIZE, DEFAULT_SIZE); + } + + JsonObject size = settings.getAsJsonObject("size"); + return new Vector3f( + getFloatOrDefault(size, "x", DEFAULT_SIZE), + getFloatOrDefault(size, "y", DEFAULT_SIZE), + getFloatOrDefault(size, "z", 0) + ); + } + + /** + * Parse the texture layout from a shape JsonObject + * + * @param shapeObj the JsonObject representing the shape + * @return the parsed texture layout map + */ + private static Map parseTextureLayout(JsonObject shapeObj) { + Map layoutMap = new EnumMap<>(Direction.class); + + if (!shapeObj.has("textureLayout")) { + return layoutMap; + } + + JsonObject texLayout = shapeObj.getAsJsonObject("textureLayout"); + for (String dirName : texLayout.keySet()) { + Direction dir = parseDirectionName(dirName); + if (dir != null) { + layoutMap.put(dir, parseFaceLayout(texLayout.getAsJsonObject(dirName))); + } + } + + return layoutMap; + } + + /** + * Parse a single face texture layout from a JsonObject + * + * @param faceLayout the JsonObject representing the face layout + * @return the parsed FaceTextureLayout + */ + private static BlockyModelGeometry.FaceTextureLayout parseFaceLayout(JsonObject faceLayout) { + int offsetX = 0, offsetY = 0; + boolean mirrorX = false, mirrorY = false; + int angle = 0; + + if (faceLayout.has("offset")) { + JsonObject offset = faceLayout.getAsJsonObject("offset"); + offsetX = getIntOrDefault(offset, "x", 0); + offsetY = getIntOrDefault(offset, "y", 0); + } + + if (faceLayout.has("mirror")) { + JsonObject mirror = faceLayout.getAsJsonObject("mirror"); + mirrorX = getBooleanOrDefault(mirror, "x", false); + mirrorY = getBooleanOrDefault(mirror, "y", false); + } + + if (faceLayout.has("angle")) { + angle = faceLayout.get("angle").getAsInt(); + validateAngle(angle); + } + + return new BlockyModelGeometry.FaceTextureLayout(offsetX, offsetY, mirrorX, mirrorY, angle); + } + + /** + * Parse a direction name string to a Direction enum + * + * @param name the direction name string + * @return the corresponding Direction enum, or null if unknown + */ + private static Direction parseDirectionName(String name) { + return switch (name.toLowerCase()) { + case "front" -> Direction.SOUTH; + case "back" -> Direction.NORTH; + case "left" -> Direction.WEST; + case "right" -> Direction.EAST; + case "top" -> Direction.UP; + case "bottom" -> Direction.DOWN; + case "north", "south", "west", "east", "up", "down" -> Direction.valueOf(name.toUpperCase()); + default -> { + HytaleModelLoader.LOGGER.warn("Unknown direction name: {}, skipping", name); + yield null; + } + }; + } + + /** + * Validate that the angle is one of the allowed values + * + * @param angle the angle to validate + * @throws JsonParseException if the angle is invalid + */ + private static void validateAngle(int angle) { + if (angle != 0 && angle != 90 && angle != 180 && angle != 270) { + throw new JsonParseException("Invalid angle: " + angle + ". Must be 0, 90, 180, or 270"); + } + } } \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelTokenizer.java b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyTokenizer.java similarity index 66% rename from src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelTokenizer.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/BlockyTokenizer.java index cd26482..7b98870 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/BlockyModelTokenizer.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/BlockyTokenizer.java @@ -1,33 +1,34 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.google.common.base.Charsets; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; - -public class BlockyModelTokenizer implements AutoCloseable { - private final BufferedReader lineReader; - private final JsonObject root; - - /** - * Creates a new BlockyModelTokenizer that reads from the given InputStream - * - * @param inputStream The InputStream to read from - */ - public BlockyModelTokenizer(InputStream inputStream) { - this.lineReader = new BufferedReader(new InputStreamReader(inputStream, Charsets.UTF_8)); - this.root = JsonParser.parseReader(lineReader).getAsJsonObject(); - } - - public JsonObject getRoot() { - return root; - } - - @Override - public void close() throws Exception { - this.lineReader.close(); - } -} +package com.litehed.hytalemodels.blockymodel; + +import com.google.common.base.Charsets; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; + +// Tokenizer for reading BlockyModel JSON files. It reads the entire file into memory and parses it as a JsonObject. +public class BlockyTokenizer implements AutoCloseable { + private final BufferedReader lineReader; + private final JsonObject root; + + /** + * Creates a new BlockyTokenizer that reads from the given InputStream + * + * @param inputStream The InputStream to read from + */ + public BlockyTokenizer(InputStream inputStream) { + this.lineReader = new BufferedReader(new InputStreamReader(inputStream, Charsets.UTF_8)); + this.root = JsonParser.parseReader(lineReader).getAsJsonObject(); + } + + public JsonObject getRoot() { + return root; + } + + @Override + public void close() throws Exception { + this.lineReader.close(); + } +} diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/ParserUtil.java b/src/main/java/com/litehed/hytalemodels/blockymodel/ParserUtil.java new file mode 100644 index 0000000..27a7387 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/ParserUtil.java @@ -0,0 +1,18 @@ +package com.litehed.hytalemodels.blockymodel; + +import com.google.gson.JsonObject; + +// Utility class for parsing JSON objects with default values +public class ParserUtil { + public static float getFloatOrDefault(JsonObject obj, String key, float defaultValue) { + return obj.has(key) ? obj.get(key).getAsFloat() : defaultValue; + } + + public static int getIntOrDefault(JsonObject obj, String key, int defaultValue) { + return obj.has(key) ? obj.get(key).getAsInt() : defaultValue; + } + + public static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) { + return obj.has(key) ? obj.get(key).getAsBoolean() : defaultValue; + } +} diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/QuadBuilder.java b/src/main/java/com/litehed/hytalemodels/blockymodel/QuadBuilder.java similarity index 96% rename from src/main/java/com/litehed/hytalemodels/modelstuff/QuadBuilder.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/QuadBuilder.java index 4d66837..8eb120d 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/QuadBuilder.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/QuadBuilder.java @@ -1,536 +1,536 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.mojang.math.Transformation; -import net.minecraft.client.renderer.block.model.BakedQuad; -import net.minecraft.client.renderer.texture.TextureAtlasSprite; -import net.minecraft.core.Direction; -import net.neoforged.neoforge.client.model.pipeline.QuadBakingVertexConsumer; -import org.apache.commons.lang3.tuple.Pair; -import org.joml.Vector3f; -import org.joml.Vector4f; - -import java.util.ArrayList; -import java.util.List; - -public class QuadBuilder { - - private static final int TINT_INDEX_NONE = -1; // No tinting -1 - private static final float[] COLOR_WHITE = {1.0f, 1.0f, 1.0f, 1.0f}; - - public static final boolean DEBUG_BORDERS = false; - private static final float BORDER_THICKNESS = 0.002f; - - /** - * Create a single quad for a given face - * - * @param face the Direction of the face - * @param min the minimum coordinates of the face - * @param max the maximum coordinates of the face - * @param sprite the TextureAtlasSprite to use for the quad - * @param texLayout the texture layout for this face - * @param size the size of the block in each axis (x, y, z) - * @param transform the transformation to apply to the quad - * @return a Pair containing the BakedQuad and its cull face Direction - */ - public static Pair createQuad( - Direction face, - Vector3f min, - Vector3f max, - TextureAtlasSprite sprite, - BlockyModelGeometry.FaceTextureLayout texLayout, - Vector3f size, - Transformation transform) { - - QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); - - float[][] uvCoords = calculateUVCoordinates(face, texLayout, size, sprite); - Vector3f[] vertices = getFaceVertices(face, min, max); - - bakeVertices(baker, vertices, uvCoords, face, transform); - - Direction cullFace = calculateCullFace(face, min, max); - return Pair.of(baker.bakeQuad(), cullFace); - } - - - /** - * Create a reversed quad for backfaces - * - * @param face the Direction of the face - * @param min the minimum coordinates of the face - * @param max the maximum coordinates of the face - * @param sprite the TextureAtlasSprite to use for the quad - * @param texLayout the texture layout for this face - * @param size the size of the block in each axis (x, y, z) - * @param transform the transformation to apply to the quad - * @return a Pair containing the BakedQuad and null cull face - */ - public static Pair createReversedQuad( - Direction face, - Vector3f min, - Vector3f max, - TextureAtlasSprite sprite, - BlockyModelGeometry.FaceTextureLayout texLayout, - Vector3f size, - Transformation transform) { - - QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); - - float[][] uvCoords = calculateUVCoordinates(face, texLayout, size, sprite); - Vector3f[] vertices = getFaceVertices(face, min, max); - - // Bake in reverse order with flipped normals - bakeVerticesReversed(baker, vertices, uvCoords, face, transform); - // No cull, it breaks the backface - return Pair.of(baker.bakeQuad(), null); - } - - /** - * Set up the quad baker with common parameters - * - * @param face the Direction of the face - * @param sprite the TextureAtlasSprite to use for the quad - * @return a QuadBakingVertexConsumer instance - */ - private static QuadBakingVertexConsumer setupQuadBaker(Direction face, TextureAtlasSprite sprite) { - QuadBakingVertexConsumer baker = new QuadBakingVertexConsumer(); - baker.setSprite(sprite); - baker.setDirection(face); - baker.setTintIndex(TINT_INDEX_NONE); - baker.setShade(true); - return baker; - } - - /** - * Create border quads for debugging (4 thin strips around the edge) - * - * @param face the Direction of the face - * @param min the minimum coordinates of the face - * @param max the maximum coordinates of the face - * @param sprite the TextureAtlasSprite to use for the border quads - * @param transform the transformation to apply to each border quad - * @return a list of BakedQuad instances representing the border strips - */ - public static List createBorderQuads( - Direction face, - Vector3f min, - Vector3f max, - TextureAtlasSprite sprite, - Transformation transform) { - - List borderQuads = new ArrayList<>(); - Vector3f[] vertices = getFaceVertices(face, min, max); - - float[] color = getDebugColor(face); - float[][] dummyUVs = {{0, 0}, {1, 0}, {1, 1}, {0, 1}}; - float borderWidth = 0.05f; - - for (int i = 0; i < 4; i++) { - QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); - Vector3f[] borderVerts = createBorderStrip(vertices, i, borderWidth); - bakeBorderVertices(baker, borderVerts, dummyUVs, face, transform, color); - borderQuads.add(baker.bakeQuad()); - } - - return borderQuads; - } - - /** - * Create a border strip along one edge of the quad - * - * @param vertices Original quad vertices - * @param edge Edge index (0=bottom, 1=right, 2=top, 3=left) - * @param width Width of the border strip - * @return Vertices of the border strip quad - */ - private static Vector3f[] createBorderStrip(Vector3f[] vertices, int edge, float width) { - Vector3f[] strip = new Vector3f[4]; - - int v1 = (edge + 1) % 4; - - Vector3f start = new Vector3f(vertices[edge]); - Vector3f end = new Vector3f(vertices[v1]); - - // Calculate inward direction - Vector3f center = new Vector3f(vertices[0]).add(vertices[1]).add(vertices[2]).add(vertices[3]).mul(0.25f); - Vector3f toCenter = new Vector3f(start).add(end).mul(0.5f); - toCenter.sub(center); - toCenter.normalize().mul(width); - - strip[0] = new Vector3f(start); - strip[1] = new Vector3f(end); - strip[2] = new Vector3f(end).add(toCenter); - strip[3] = new Vector3f(start).add(toCenter); - - return strip; - } - - - /** - * Calculate UV coordinates for a face - * - * @param face the Direction of the face - * @param layout the texture layout for the face - * @param size the size of the face - * @param sprite the TextureAtlasSprite to use for UV calculation - * @return an array of UV coordinates for each vertex of the face - */ - private static float[][] calculateUVCoordinates(Direction face, BlockyModelGeometry.FaceTextureLayout layout, - Vector3f size, TextureAtlasSprite sprite) { - UVSize uvSize = getUVSizeForFace(face, size); - UVBounds bounds = calculateUVBounds(layout, uvSize, sprite); - return transformUVCoordinates(bounds, layout, uvSize, sprite); - } - - /** - * Get UV size based on face orientation - * - * @param face the Direction of the face - * @param size the size of the face - * @return the UVSize for the given face and rotation - */ - private static UVSize getUVSizeForFace(Direction face, Vector3f size) { - return switch (face) { - case UP, DOWN -> new UVSize(size.x, size.z); - case WEST, EAST -> new UVSize(size.z, size.y); - default -> new UVSize(size.x, size.y); - }; - } - - /** - * Calculate UV bounds using layout and sprite - * - * @param layout the texture layout for the face - * @param uvSize the UV size for the face - * @param sprite the TextureAtlasSprite to use for UV calculation - * @return the UV bounds for the given layout and sprite - */ - private static UVBounds calculateUVBounds(BlockyModelGeometry.FaceTextureLayout layout, UVSize uvSize, - TextureAtlasSprite sprite) { - int textureWidth = sprite.contents().width(); - int textureHeight = sprite.contents().height(); - - float uMin = layout.offsetX() / (float) textureWidth; - float vMin = layout.offsetY() / (float) textureHeight; - float uMax = (layout.offsetX() + (layout.mirrorX() ? -uvSize.u : uvSize.u)) / (float) textureWidth; - float vMax = (layout.offsetY() + (layout.mirrorY() ? -uvSize.v : uvSize.v)) / (float) textureHeight; - - if (layout.mirrorX()) { - float temp = uMin; - uMin = uMax; - uMax = temp; - } - if (layout.mirrorY()) { - float temp = vMin; - vMin = vMax; - vMax = temp; - } - - // Map to sprite atlas - uMin = sprite.getU0() + (sprite.getU1() - sprite.getU0()) * uMin; - vMin = sprite.getV0() + (sprite.getV1() - sprite.getV0()) * vMin; - uMax = sprite.getU0() + (sprite.getU1() - sprite.getU0()) * uMax; - vMax = sprite.getV0() + (sprite.getV1() - sprite.getV0()) * vMax; - return new UVBounds(uMin, vMin, uMax, vMax); - } - - /** - * Transform UV coordinates with rotation and mirroring - * - * @param bounds the UV bounds - * @param layout the texture layout containing rotation and mirror flags - * @param uvSize the size of the UV region - * @param sprite the TextureAtlasSprite for coordinate conversion - * @return transformed UV coordinates for all 4 vertices - */ - private static float[][] transformUVCoordinates( - UVBounds bounds, - BlockyModelGeometry.FaceTextureLayout layout, - UVSize uvSize, - TextureAtlasSprite sprite) { - - int textureWidth = sprite.contents().width(); - int textureHeight = sprite.contents().height(); - int angle = layout.angle(); - boolean mirrorX = layout.mirrorX(); - boolean mirrorY = layout.mirrorY(); - - float[][] uvs = new float[4][2]; - - if (angle != 0) { - float pivotUPx = layout.offsetX(); - float pivotVPx = layout.offsetY(); - - float uSize = mirrorX ? -uvSize.u : uvSize.u; - float vSize = mirrorY ? -uvSize.v : uvSize.v; - - float[][] cornersPx = new float[4][2]; - cornersPx[0] = new float[]{pivotUPx, pivotVPx}; // Top-left (pivot) - cornersPx[1] = new float[]{pivotUPx + uSize, pivotVPx}; // Top-right - cornersPx[2] = new float[]{pivotUPx + uSize, pivotVPx + vSize}; // Bottom-right - cornersPx[3] = new float[]{pivotUPx, pivotVPx + vSize}; // Bottom-left - - for (int i = 0; i < 4; i++) { - float[] rotated = rotatePointClockwise( - cornersPx[i][0], cornersPx[i][1], - pivotUPx, pivotVPx, - angle - ); - - uvs[i][0] = normalizeU(rotated[0], sprite, textureWidth); - uvs[i][1] = normalizeV(rotated[1], sprite, textureHeight); - } - } else { - uvs[0] = new float[]{bounds.uMin, bounds.vMax}; - uvs[1] = new float[]{bounds.uMax, bounds.vMax}; - uvs[2] = new float[]{bounds.uMax, bounds.vMin}; - uvs[3] = new float[]{bounds.uMin, bounds.vMin}; - - if (mirrorX) { - float[] temp = uvs[0]; - uvs[0] = uvs[1]; - uvs[1] = temp; - - temp = uvs[3]; - uvs[3] = uvs[2]; - uvs[2] = temp; - } - if (mirrorY) { - float[] temp = uvs[0]; - uvs[0] = uvs[3]; - uvs[3] = temp; - - temp = uvs[1]; - uvs[1] = uvs[2]; - uvs[2] = temp; - } - } - - return uvs; - } - - - /** - * Rotate a point (x, y) around a pivot (pivotX, pivotY) by a given angle in degrees - * - * @param x the x-coordinate of the point to rotate - * @param y the y-coordinate of the point to rotate - * @param pivotX the x-coordinate of the pivot point - * @param pivotY the y-coordinate of the pivot point - * @param angle the rotation angle in degrees (must be one of 0, 90, 180, 270) - * @return the new coordinates of the point after rotation as a float array [newX, newY] - */ - private static float[] rotatePointClockwise(float x, float y, float pivotX, float pivotY, int angle) { - float dx = x - pivotX; - float dy = y - pivotY; - - float newDx, newDy; - newDy = switch (angle) { - case 90 -> { - newDx = -dy; - yield dx; - } - case 180 -> { - newDx = -dx; - yield -dy; - } - case 270 -> { - newDx = dy; - yield -dx; - } - default -> { - newDx = dx; - yield dy; - } - }; - - return new float[]{pivotX + newDx, pivotY + newDy}; - } - - - // Helper funcs to get u and v normalized to proper coords - private static float normalizeU(float pixelU, TextureAtlasSprite sprite, int textureWidth) { - float relativeU = pixelU / textureWidth; - return sprite.getU0() + (sprite.getU1() - sprite.getU0()) * relativeU; - } - - private static float normalizeV(float pixelV, TextureAtlasSprite sprite, int textureHeight) { - float relativeV = pixelV / textureHeight; - return sprite.getV0() + (sprite.getV1() - sprite.getV0()) * relativeV; - } - - /** - * Get the 4 vertices for a given face - * - * @param face the Direction of the face - * @param min the minimum coordinates of the face - * @param max the maximum coordinates of the face - * @return an array of 4 Vector3f vertices - */ - private static Vector3f[] getFaceVertices(Direction face, Vector3f min, Vector3f max) { - float x0 = min.x, y0 = min.y, z0 = min.z; - float x1 = max.x, y1 = max.y, z1 = max.z; - - return switch (face) { - case DOWN -> new Vector3f[]{ - new Vector3f(x0, y0, z0), new Vector3f(x1, y0, z0), - new Vector3f(x1, y0, z1), new Vector3f(x0, y0, z1) - }; - case UP -> new Vector3f[]{ - new Vector3f(x0, y1, z1), new Vector3f(x1, y1, z1), - new Vector3f(x1, y1, z0), new Vector3f(x0, y1, z0) - }; - case NORTH -> new Vector3f[]{ - new Vector3f(x1, y0, z0), new Vector3f(x0, y0, z0), - new Vector3f(x0, y1, z0), new Vector3f(x1, y1, z0) - }; - case SOUTH -> new Vector3f[]{ - new Vector3f(x0, y0, z1), new Vector3f(x1, y0, z1), - new Vector3f(x1, y1, z1), new Vector3f(x0, y1, z1) - }; - case WEST -> new Vector3f[]{ - new Vector3f(x0, y0, z0), new Vector3f(x0, y0, z1), - new Vector3f(x0, y1, z1), new Vector3f(x0, y1, z0) - }; - case EAST -> new Vector3f[]{ - new Vector3f(x1, y0, z1), new Vector3f(x1, y0, z0), - new Vector3f(x1, y1, z0), new Vector3f(x1, y1, z1) - }; - }; - } - - /** - * Bake vertices for the quad - * - * @param baker the QuadBakingVertexConsumer - * @param vertices the vertices of the quad - * @param uvCoords the UV coordinates for the quad - * @param face the Direction of the face - * @param transform the transformation to apply - */ - private static void bakeVertices(QuadBakingVertexConsumer baker, Vector3f[] vertices, - float[][] uvCoords, Direction face, Transformation transform) { - Vector3f normal = new Vector3f(face.getStepX(), face.getStepY(), face.getStepZ()); - boolean hasTransform = !transform.isIdentity(); - - for (int i = 0; i < 4; i++) { - Vector4f pos = new Vector4f(vertices[i].x, vertices[i].y, vertices[i].z, 1.0f); - Vector3f norm = new Vector3f(normal); - - if (hasTransform) { - transform.transformPosition(pos); - transform.transformNormal(norm); - } - - baker.addVertex(pos.x(), pos.y(), pos.z()); - baker.setColor(COLOR_WHITE[0], COLOR_WHITE[1], COLOR_WHITE[2], COLOR_WHITE[3]); - baker.setUv(uvCoords[i][0], uvCoords[i][1]); - baker.setNormal(norm.x(), norm.y(), norm.z()); - } - } - - /** - * Bake vertices in reverse order for backfaces - * - * @param baker the QuadBakingVertexConsumer - * @param vertices the vertices of the quad - * @param uvCoords the UV coordinates for the quad - * @param face the Direction of the face - * @param transform the transformation to apply - */ - private static void bakeVerticesReversed(QuadBakingVertexConsumer baker, Vector3f[] vertices, - float[][] uvCoords, Direction face, Transformation transform) { - Vector3f normal = new Vector3f(-face.getStepX(), -face.getStepY(), -face.getStepZ()); - boolean hasTransform = !transform.isIdentity(); - - // Reverse order - for (int i = 3; i >= 0; i--) { - Vector4f pos = new Vector4f(vertices[i].x, vertices[i].y, vertices[i].z, 1.0f); - Vector3f norm = new Vector3f(normal); - - if (hasTransform) { - transform.transformPosition(pos); - transform.transformNormal(norm); - } - - baker.addVertex(pos.x(), pos.y(), pos.z()); - baker.setColor(COLOR_WHITE[0], COLOR_WHITE[1], COLOR_WHITE[2], COLOR_WHITE[3]); - baker.setUv(uvCoords[i][0], uvCoords[i][1]); - baker.setNormal(norm.x(), norm.y(), norm.z()); - } - } - - /** - * Bake border vertices for debugging - * - * @param baker the QuadBakingVertexConsumer - * @param vertices the vertices of the border strip - * @param uvCoords the UV coordinates for the border strip - * @param face the Direction of the face - * @param transform the transformation to apply - * @param color the color to use for debugging - */ - private static void bakeBorderVertices(QuadBakingVertexConsumer baker, Vector3f[] vertices, - float[][] uvCoords, Direction face, Transformation transform, float[] color) { - Vector3f normal = new Vector3f(face.getStepX(), face.getStepY(), face.getStepZ()); - boolean hasTransform = !transform.isIdentity(); - - // Offset vertices slightly outward to prevent z-fighting - Vector3f offset = new Vector3f(normal).mul(BORDER_THICKNESS); - - for (int i = 0; i < 4; i++) { - Vector3f offsetVert = new Vector3f(vertices[i]).add(offset); - Vector4f pos = new Vector4f(offsetVert.x, offsetVert.y, offsetVert.z, 1.0f); - Vector3f norm = new Vector3f(normal); - - if (hasTransform) { - transform.transformPosition(pos); - transform.transformNormal(norm); - } - - baker.addVertex(pos.x(), pos.y(), pos.z()); - baker.setColor(color[0], color[1], color[2], color[3]); - baker.setUv(uvCoords[i][0], uvCoords[i][1]); - baker.setNormal(norm.x(), norm.y(), norm.z()); - } - } - - /** - * Get debug color for vertex based on index and face - * Creates a colored border effect to visualize quad boundaries - * - * @param face the Direction of the face - * @return the RGBA color array - */ - private static float[] getDebugColor(Direction face) { - // Assign different colors to different faces - return switch (face) { - case UP -> new float[]{1.0f, 0.0f, 0.0f, 1.0f}; // Red - case DOWN -> new float[]{0.0f, 1.0f, 0.0f, 1.0f}; // Green - case NORTH -> new float[]{0.0f, 0.0f, 1.0f, 1.0f}; // Blue - case SOUTH -> new float[]{1.0f, 1.0f, 0.0f, 1.0f}; // Yellow - case WEST -> new float[]{1.0f, 0.0f, 1.0f, 1.0f}; // Magenta - case EAST -> new float[]{0.0f, 1.0f, 1.0f, 1.0f}; // Cyan - }; - } - - /** - * Calculate cull face based on quad orientation - * - * @param face the Direction of the face - * @param min the minimum coordinates of the face - * @param max the maximum coordinates of the face - * @return the cull face Direction, or null if none - */ - private static Direction calculateCullFace(Direction face, Vector3f min, Vector3f max) { - // Currently disabled - return null; - } - - // Helper records for clean data passing - private record UVSize(float u, float v) { - } - - private record UVBounds(float uMin, float vMin, float uMax, float vMax) { - } +package com.litehed.hytalemodels.blockymodel; + +import com.mojang.math.Transformation; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.Direction; +import net.neoforged.neoforge.client.model.pipeline.QuadBakingVertexConsumer; +import org.apache.commons.lang3.tuple.Pair; +import org.joml.Vector3f; +import org.joml.Vector4f; + +import java.util.ArrayList; +import java.util.List; + +public class QuadBuilder { + + private static final int TINT_INDEX_NONE = -1; // No tinting -1 + private static final float[] COLOR_WHITE = {1.0f, 1.0f, 1.0f, 1.0f}; + + public static final boolean DEBUG_BORDERS = false; + private static final float BORDER_THICKNESS = 0.002f; + + /** + * Create a single quad for a given face + * + * @param face the Direction of the face + * @param min the minimum coordinates of the face + * @param max the maximum coordinates of the face + * @param sprite the TextureAtlasSprite to use for the quad + * @param texLayout the texture layout for this face + * @param size the size of the block in each axis (x, y, z) + * @param transform the transformation to apply to the quad + * @return a Pair containing the BakedQuad and its cull face Direction + */ + public static Pair createQuad( + Direction face, + Vector3f min, + Vector3f max, + TextureAtlasSprite sprite, + BlockyModelGeometry.FaceTextureLayout texLayout, + Vector3f size, + Transformation transform) { + + QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); + + float[][] uvCoords = calculateUVCoordinates(face, texLayout, size, sprite); + Vector3f[] vertices = getFaceVertices(face, min, max); + + bakeVertices(baker, vertices, uvCoords, face, transform); + + Direction cullFace = calculateCullFace(face, min, max); + return Pair.of(baker.bakeQuad(), cullFace); + } + + + /** + * Create a reversed quad for backfaces + * + * @param face the Direction of the face + * @param min the minimum coordinates of the face + * @param max the maximum coordinates of the face + * @param sprite the TextureAtlasSprite to use for the quad + * @param texLayout the texture layout for this face + * @param size the size of the block in each axis (x, y, z) + * @param transform the transformation to apply to the quad + * @return a Pair containing the BakedQuad and null cull face + */ + public static Pair createReversedQuad( + Direction face, + Vector3f min, + Vector3f max, + TextureAtlasSprite sprite, + BlockyModelGeometry.FaceTextureLayout texLayout, + Vector3f size, + Transformation transform) { + + QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); + + float[][] uvCoords = calculateUVCoordinates(face, texLayout, size, sprite); + Vector3f[] vertices = getFaceVertices(face, min, max); + + // Bake in reverse order with flipped normals + bakeVerticesReversed(baker, vertices, uvCoords, face, transform); + // No cull, it breaks the backface + return Pair.of(baker.bakeQuad(), null); + } + + /** + * Set up the quad baker with common parameters + * + * @param face the Direction of the face + * @param sprite the TextureAtlasSprite to use for the quad + * @return a QuadBakingVertexConsumer instance + */ + private static QuadBakingVertexConsumer setupQuadBaker(Direction face, TextureAtlasSprite sprite) { + QuadBakingVertexConsumer baker = new QuadBakingVertexConsumer(); + baker.setSprite(sprite); + baker.setDirection(face); + baker.setTintIndex(TINT_INDEX_NONE); + baker.setShade(true); + return baker; + } + + /** + * Create border quads for debugging (4 thin strips around the edge) + * + * @param face the Direction of the face + * @param min the minimum coordinates of the face + * @param max the maximum coordinates of the face + * @param sprite the TextureAtlasSprite to use for the border quads + * @param transform the transformation to apply to each border quad + * @return a list of BakedQuad instances representing the border strips + */ + public static List createBorderQuads( + Direction face, + Vector3f min, + Vector3f max, + TextureAtlasSprite sprite, + Transformation transform) { + + List borderQuads = new ArrayList<>(); + Vector3f[] vertices = getFaceVertices(face, min, max); + + float[] color = getDebugColor(face); + float[][] dummyUVs = {{0, 0}, {1, 0}, {1, 1}, {0, 1}}; + float borderWidth = 0.05f; + + for (int i = 0; i < 4; i++) { + QuadBakingVertexConsumer baker = setupQuadBaker(face, sprite); + Vector3f[] borderVerts = createBorderStrip(vertices, i, borderWidth); + bakeBorderVertices(baker, borderVerts, dummyUVs, face, transform, color); + borderQuads.add(baker.bakeQuad()); + } + + return borderQuads; + } + + /** + * Create a border strip along one edge of the quad + * + * @param vertices Original quad vertices + * @param edge Edge index (0=bottom, 1=right, 2=top, 3=left) + * @param width Width of the border strip + * @return Vertices of the border strip quad + */ + private static Vector3f[] createBorderStrip(Vector3f[] vertices, int edge, float width) { + Vector3f[] strip = new Vector3f[4]; + + int v1 = (edge + 1) % 4; + + Vector3f start = new Vector3f(vertices[edge]); + Vector3f end = new Vector3f(vertices[v1]); + + // Calculate inward direction + Vector3f center = new Vector3f(vertices[0]).add(vertices[1]).add(vertices[2]).add(vertices[3]).mul(0.25f); + Vector3f toCenter = new Vector3f(start).add(end).mul(0.5f); + toCenter.sub(center); + toCenter.normalize().mul(width); + + strip[0] = new Vector3f(start); + strip[1] = new Vector3f(end); + strip[2] = new Vector3f(end).add(toCenter); + strip[3] = new Vector3f(start).add(toCenter); + + return strip; + } + + + /** + * Calculate UV coordinates for a face + * + * @param face the Direction of the face + * @param layout the texture layout for the face + * @param size the size of the face + * @param sprite the TextureAtlasSprite to use for UV calculation + * @return an array of UV coordinates for each vertex of the face + */ + public static float[][] calculateUVCoordinates(Direction face, BlockyModelGeometry.FaceTextureLayout layout, + Vector3f size, TextureAtlasSprite sprite) { + UVSize uvSize = getUVSizeForFace(face, size); + UVBounds bounds = calculateUVBounds(layout, uvSize, sprite); + return transformUVCoordinates(bounds, layout, uvSize, sprite); + } + + /** + * Get UV size based on face orientation + * + * @param face the Direction of the face + * @param size the size of the face + * @return the UVSize for the given face and rotation + */ + private static UVSize getUVSizeForFace(Direction face, Vector3f size) { + return switch (face) { + case UP, DOWN -> new UVSize(size.x, size.z); + case WEST, EAST -> new UVSize(size.z, size.y); + default -> new UVSize(size.x, size.y); + }; + } + + /** + * Calculate UV bounds using layout and sprite + * + * @param layout the texture layout for the face + * @param uvSize the UV size for the face + * @param sprite the TextureAtlasSprite to use for UV calculation + * @return the UV bounds for the given layout and sprite + */ + private static UVBounds calculateUVBounds(BlockyModelGeometry.FaceTextureLayout layout, UVSize uvSize, + TextureAtlasSprite sprite) { + int textureWidth = sprite.contents().width(); + int textureHeight = sprite.contents().height(); + + float uMin = layout.offsetX() / (float) textureWidth; + float vMin = layout.offsetY() / (float) textureHeight; + float uMax = (layout.offsetX() + (layout.mirrorX() ? -uvSize.u : uvSize.u)) / (float) textureWidth; + float vMax = (layout.offsetY() + (layout.mirrorY() ? -uvSize.v : uvSize.v)) / (float) textureHeight; + + if (layout.mirrorX()) { + float temp = uMin; + uMin = uMax; + uMax = temp; + } + if (layout.mirrorY()) { + float temp = vMin; + vMin = vMax; + vMax = temp; + } + + // Map to sprite atlas + uMin = sprite.getU0() + (sprite.getU1() - sprite.getU0()) * uMin; + vMin = sprite.getV0() + (sprite.getV1() - sprite.getV0()) * vMin; + uMax = sprite.getU0() + (sprite.getU1() - sprite.getU0()) * uMax; + vMax = sprite.getV0() + (sprite.getV1() - sprite.getV0()) * vMax; + return new UVBounds(uMin, vMin, uMax, vMax); + } + + /** + * Transform UV coordinates with rotation and mirroring + * + * @param bounds the UV bounds + * @param layout the texture layout containing rotation and mirror flags + * @param uvSize the size of the UV region + * @param sprite the TextureAtlasSprite for coordinate conversion + * @return transformed UV coordinates for all 4 vertices + */ + private static float[][] transformUVCoordinates( + UVBounds bounds, + BlockyModelGeometry.FaceTextureLayout layout, + UVSize uvSize, + TextureAtlasSprite sprite) { + + int textureWidth = sprite.contents().width(); + int textureHeight = sprite.contents().height(); + int angle = layout.angle(); + boolean mirrorX = layout.mirrorX(); + boolean mirrorY = layout.mirrorY(); + + float[][] uvs = new float[4][2]; + + if (angle != 0) { + float pivotUPx = layout.offsetX(); + float pivotVPx = layout.offsetY(); + + float uSize = mirrorX ? -uvSize.u : uvSize.u; + float vSize = mirrorY ? -uvSize.v : uvSize.v; + + float[][] cornersPx = new float[4][2]; + cornersPx[0] = new float[]{pivotUPx, pivotVPx}; // Top-left (pivot) + cornersPx[1] = new float[]{pivotUPx + uSize, pivotVPx}; // Top-right + cornersPx[2] = new float[]{pivotUPx + uSize, pivotVPx + vSize}; // Bottom-right + cornersPx[3] = new float[]{pivotUPx, pivotVPx + vSize}; // Bottom-left + + for (int i = 0; i < 4; i++) { + float[] rotated = rotatePointClockwise( + cornersPx[i][0], cornersPx[i][1], + pivotUPx, pivotVPx, + angle + ); + + uvs[i][0] = normalizeU(rotated[0], sprite, textureWidth); + uvs[i][1] = normalizeV(rotated[1], sprite, textureHeight); + } + } else { + uvs[0] = new float[]{bounds.uMin, bounds.vMax}; + uvs[1] = new float[]{bounds.uMax, bounds.vMax}; + uvs[2] = new float[]{bounds.uMax, bounds.vMin}; + uvs[3] = new float[]{bounds.uMin, bounds.vMin}; + + if (mirrorX) { + float[] temp = uvs[0]; + uvs[0] = uvs[1]; + uvs[1] = temp; + + temp = uvs[3]; + uvs[3] = uvs[2]; + uvs[2] = temp; + } + if (mirrorY) { + float[] temp = uvs[0]; + uvs[0] = uvs[3]; + uvs[3] = temp; + + temp = uvs[1]; + uvs[1] = uvs[2]; + uvs[2] = temp; + } + } + + return uvs; + } + + + /** + * Rotate a point (x, y) around a pivot (pivotX, pivotY) by a given angle in degrees + * + * @param x the x-coordinate of the point to rotate + * @param y the y-coordinate of the point to rotate + * @param pivotX the x-coordinate of the pivot point + * @param pivotY the y-coordinate of the pivot point + * @param angle the rotation angle in degrees (must be one of 0, 90, 180, 270) + * @return the new coordinates of the point after rotation as a float array [newX, newY] + */ + private static float[] rotatePointClockwise(float x, float y, float pivotX, float pivotY, int angle) { + float dx = x - pivotX; + float dy = y - pivotY; + + float newDx, newDy; + newDy = switch (angle) { + case 90 -> { + newDx = -dy; + yield dx; + } + case 180 -> { + newDx = -dx; + yield -dy; + } + case 270 -> { + newDx = dy; + yield -dx; + } + default -> { + newDx = dx; + yield dy; + } + }; + + return new float[]{pivotX + newDx, pivotY + newDy}; + } + + + // Helper funcs to get u and v normalized to proper coords + private static float normalizeU(float pixelU, TextureAtlasSprite sprite, int textureWidth) { + float relativeU = pixelU / textureWidth; + return sprite.getU0() + (sprite.getU1() - sprite.getU0()) * relativeU; + } + + private static float normalizeV(float pixelV, TextureAtlasSprite sprite, int textureHeight) { + float relativeV = pixelV / textureHeight; + return sprite.getV0() + (sprite.getV1() - sprite.getV0()) * relativeV; + } + + /** + * Get the 4 vertices for a given face + * + * @param face the Direction of the face + * @param min the minimum coordinates of the face + * @param max the maximum coordinates of the face + * @return an array of 4 Vector3f vertices + */ + public static Vector3f[] getFaceVertices(Direction face, Vector3f min, Vector3f max) { + float x0 = min.x, y0 = min.y, z0 = min.z; + float x1 = max.x, y1 = max.y, z1 = max.z; + + return switch (face) { + case DOWN -> new Vector3f[]{ + new Vector3f(x0, y0, z0), new Vector3f(x1, y0, z0), + new Vector3f(x1, y0, z1), new Vector3f(x0, y0, z1) + }; + case UP -> new Vector3f[]{ + new Vector3f(x0, y1, z1), new Vector3f(x1, y1, z1), + new Vector3f(x1, y1, z0), new Vector3f(x0, y1, z0) + }; + case NORTH -> new Vector3f[]{ + new Vector3f(x1, y0, z0), new Vector3f(x0, y0, z0), + new Vector3f(x0, y1, z0), new Vector3f(x1, y1, z0) + }; + case SOUTH -> new Vector3f[]{ + new Vector3f(x0, y0, z1), new Vector3f(x1, y0, z1), + new Vector3f(x1, y1, z1), new Vector3f(x0, y1, z1) + }; + case WEST -> new Vector3f[]{ + new Vector3f(x0, y0, z0), new Vector3f(x0, y0, z1), + new Vector3f(x0, y1, z1), new Vector3f(x0, y1, z0) + }; + case EAST -> new Vector3f[]{ + new Vector3f(x1, y0, z1), new Vector3f(x1, y0, z0), + new Vector3f(x1, y1, z0), new Vector3f(x1, y1, z1) + }; + }; + } + + /** + * Bake vertices for the quad + * + * @param baker the QuadBakingVertexConsumer + * @param vertices the vertices of the quad + * @param uvCoords the UV coordinates for the quad + * @param face the Direction of the face + * @param transform the transformation to apply + */ + private static void bakeVertices(QuadBakingVertexConsumer baker, Vector3f[] vertices, + float[][] uvCoords, Direction face, Transformation transform) { + Vector3f normal = new Vector3f(face.getStepX(), face.getStepY(), face.getStepZ()); + boolean hasTransform = !transform.isIdentity(); + + for (int i = 0; i < 4; i++) { + Vector4f pos = new Vector4f(vertices[i].x, vertices[i].y, vertices[i].z, 1.0f); + Vector3f norm = new Vector3f(normal); + + if (hasTransform) { + transform.transformPosition(pos); + transform.transformNormal(norm); + } + + baker.addVertex(pos.x(), pos.y(), pos.z()); + baker.setColor(COLOR_WHITE[0], COLOR_WHITE[1], COLOR_WHITE[2], COLOR_WHITE[3]); + baker.setUv(uvCoords[i][0], uvCoords[i][1]); + baker.setNormal(norm.x(), norm.y(), norm.z()); + } + } + + /** + * Bake vertices in reverse order for backfaces + * + * @param baker the QuadBakingVertexConsumer + * @param vertices the vertices of the quad + * @param uvCoords the UV coordinates for the quad + * @param face the Direction of the face + * @param transform the transformation to apply + */ + private static void bakeVerticesReversed(QuadBakingVertexConsumer baker, Vector3f[] vertices, + float[][] uvCoords, Direction face, Transformation transform) { + Vector3f normal = new Vector3f(-face.getStepX(), -face.getStepY(), -face.getStepZ()); + boolean hasTransform = !transform.isIdentity(); + + // Reverse order + for (int i = 3; i >= 0; i--) { + Vector4f pos = new Vector4f(vertices[i].x, vertices[i].y, vertices[i].z, 1.0f); + Vector3f norm = new Vector3f(normal); + + if (hasTransform) { + transform.transformPosition(pos); + transform.transformNormal(norm); + } + + baker.addVertex(pos.x(), pos.y(), pos.z()); + baker.setColor(COLOR_WHITE[0], COLOR_WHITE[1], COLOR_WHITE[2], COLOR_WHITE[3]); + baker.setUv(uvCoords[i][0], uvCoords[i][1]); + baker.setNormal(norm.x(), norm.y(), norm.z()); + } + } + + /** + * Bake border vertices for debugging + * + * @param baker the QuadBakingVertexConsumer + * @param vertices the vertices of the border strip + * @param uvCoords the UV coordinates for the border strip + * @param face the Direction of the face + * @param transform the transformation to apply + * @param color the color to use for debugging + */ + private static void bakeBorderVertices(QuadBakingVertexConsumer baker, Vector3f[] vertices, + float[][] uvCoords, Direction face, Transformation transform, float[] color) { + Vector3f normal = new Vector3f(face.getStepX(), face.getStepY(), face.getStepZ()); + boolean hasTransform = !transform.isIdentity(); + + // Offset vertices slightly outward to prevent z-fighting + Vector3f offset = new Vector3f(normal).mul(BORDER_THICKNESS); + + for (int i = 0; i < 4; i++) { + Vector3f offsetVert = new Vector3f(vertices[i]).add(offset); + Vector4f pos = new Vector4f(offsetVert.x, offsetVert.y, offsetVert.z, 1.0f); + Vector3f norm = new Vector3f(normal); + + if (hasTransform) { + transform.transformPosition(pos); + transform.transformNormal(norm); + } + + baker.addVertex(pos.x(), pos.y(), pos.z()); + baker.setColor(color[0], color[1], color[2], color[3]); + baker.setUv(uvCoords[i][0], uvCoords[i][1]); + baker.setNormal(norm.x(), norm.y(), norm.z()); + } + } + + /** + * Get debug color for vertex based on index and face + * Creates a colored border effect to visualize quad boundaries + * + * @param face the Direction of the face + * @return the RGBA color array + */ + private static float[] getDebugColor(Direction face) { + // Assign different colors to different faces + return switch (face) { + case UP -> new float[]{1.0f, 0.0f, 0.0f, 1.0f}; // Red + case DOWN -> new float[]{0.0f, 1.0f, 0.0f, 1.0f}; // Green + case NORTH -> new float[]{0.0f, 0.0f, 1.0f, 1.0f}; // Blue + case SOUTH -> new float[]{1.0f, 1.0f, 0.0f, 1.0f}; // Yellow + case WEST -> new float[]{1.0f, 0.0f, 1.0f, 1.0f}; // Magenta + case EAST -> new float[]{0.0f, 1.0f, 1.0f, 1.0f}; // Cyan + }; + } + + /** + * Calculate cull face based on quad orientation + * + * @param face the Direction of the face + * @param min the minimum coordinates of the face + * @param max the maximum coordinates of the face + * @return the cull face Direction, or null if none + */ + private static Direction calculateCullFace(Direction face, Vector3f min, Vector3f max) { + // Currently disabled + return null; + } + + // Helper records for clean data passing + private record UVSize(float u, float v) { + } + + private record UVBounds(float uMin, float vMin, float uMax, float vMax) { + } } \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/modelstuff/TransformCalculator.java b/src/main/java/com/litehed/hytalemodels/blockymodel/TransformCalculator.java similarity index 96% rename from src/main/java/com/litehed/hytalemodels/modelstuff/TransformCalculator.java rename to src/main/java/com/litehed/hytalemodels/blockymodel/TransformCalculator.java index 7bbf2c3..3a57667 100644 --- a/src/main/java/com/litehed/hytalemodels/modelstuff/TransformCalculator.java +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/TransformCalculator.java @@ -1,101 +1,101 @@ -package com.litehed.hytalemodels.modelstuff; - -import com.mojang.math.Transformation; -import org.joml.Quaternionf; -import org.joml.Vector3f; - -public class TransformCalculator { - - private static final float POSITION_SCALE = 32.0f; // Convert from model units to block units - private static final float POSITION_OFFSET_Y = 16.0f; // Y-axis offset - - /** - * Calculate the world-space position of a node - * Recursively adds parent positions and applies parent orientations - * - * @param node the node to calculate the world position for - * @return the world-space position of the node - */ - public static Vector3f calculateWorldPosition(BlockyModelGeometry.BlockyNode node) { - Vector3f worldPos = new Vector3f(node.getPosition()); - - BlockyModelGeometry.BlockyNode current = node.getParent(); - while (current != null) { - Quaternionf parentOrientation = new Quaternionf(current.getOrientation()); - parentOrientation.transform(worldPos); - - worldPos.add(current.getPosition()); - if (current.hasShape()) { - Vector3f parentOffset = new Vector3f(current.getShape().getOffset()); - parentOrientation.transform(parentOffset); - worldPos.add(parentOffset); - } - - current = current.getParent(); - } - - return worldPos; - } - - /** - * Calculate the world-space orientation of a node - * Recursively combines parent orientations - * - * @param node the node to calculate the world orientation for - * @return the world-space orientation of the node - */ - public static Quaternionf calculateWorldOrientation(BlockyModelGeometry.BlockyNode node) { - Quaternionf worldRot = new Quaternionf(node.getOrientation()); - - BlockyModelGeometry.BlockyNode current = node.getParent(); - while (current != null) { - // Compose rotations: parent * child - worldRot = new Quaternionf(current.getOrientation()).mul(worldRot); - current = current.getParent(); - } - - return worldRot; - } - - /** - * Create a Transformation for a node given its world position, shape offset, and world orientation - * - * @param worldPosition the world-space position of the node - * @param shapeOffset the offset of the shape relative to the node's position - * @param worldOrientation the world-space orientation of the node - * @return a Transformation object representing the node's transform in world space - */ - public static Transformation createNodeTransform(Vector3f worldPosition, Vector3f shapeOffset, - Quaternionf worldOrientation) { - - Vector3f rotatedOffset = new Vector3f(shapeOffset); - worldOrientation.transform(rotatedOffset); - - // Calculate center position in block coordinates - float centerX = (worldPosition.x + rotatedOffset.x) / POSITION_SCALE; - float centerY = (worldPosition.y + rotatedOffset.y - POSITION_OFFSET_Y) / POSITION_SCALE; - float centerZ = (worldPosition.z + rotatedOffset.z) / POSITION_SCALE; - - return new Transformation( - new Vector3f(centerX, centerY, centerZ), // Translation - worldOrientation, // Rotation - new Vector3f(1), // Scale (no scaling) - null // No additional rotation - ); - } - - /** - * Calculate half-sizes for a shape given its full size - * - * @param size the full size of the shape - * @return the half-sizes of the shape - */ - public static Vector3f calculateHalfSizes(Vector3f size) { - return new Vector3f( - (size.x / 2) / POSITION_SCALE, - (size.y / 2) / POSITION_SCALE, - (size.z / 2) / POSITION_SCALE - ); - } - +package com.litehed.hytalemodels.blockymodel; + +import com.mojang.math.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +public class TransformCalculator { + + private static final float POSITION_SCALE = 32.0f; // Convert from model units to block units + private static final float POSITION_OFFSET_Y = 16.0f; // Y-axis offset + + /** + * Calculate the world-space position of a node + * Recursively adds parent positions and applies parent orientations + * + * @param node the node to calculate the world position for + * @return the world-space position of the node + */ + public static Vector3f calculateWorldPosition(BlockyModelGeometry.BlockyNode node) { + Vector3f worldPos = new Vector3f(node.getPosition()); + + BlockyModelGeometry.BlockyNode current = node.getParent(); + while (current != null) { + Quaternionf parentOrientation = new Quaternionf(current.getOrientation()); + parentOrientation.transform(worldPos); + + worldPos.add(current.getPosition()); + if (current.hasShape()) { + Vector3f parentOffset = new Vector3f(current.getShape().getOffset()); + parentOrientation.transform(parentOffset); + worldPos.add(parentOffset); + } + + current = current.getParent(); + } + + return worldPos; + } + + /** + * Calculate the world-space orientation of a node + * Recursively combines parent orientations + * + * @param node the node to calculate the world orientation for + * @return the world-space orientation of the node + */ + public static Quaternionf calculateWorldOrientation(BlockyModelGeometry.BlockyNode node) { + Quaternionf worldRot = new Quaternionf(node.getOrientation()); + + BlockyModelGeometry.BlockyNode current = node.getParent(); + while (current != null) { + // Compose rotations: parent * child + worldRot = new Quaternionf(current.getOrientation()).mul(worldRot); + current = current.getParent(); + } + + return worldRot; + } + + /** + * Create a Transformation for a node given its world position, shape offset, and world orientation + * + * @param worldPosition the world-space position of the node + * @param shapeOffset the offset of the shape relative to the node's position + * @param worldOrientation the world-space orientation of the node + * @return a Transformation object representing the node's transform in world space + */ + public static Transformation createNodeTransform(Vector3f worldPosition, Vector3f shapeOffset, + Quaternionf worldOrientation) { + + Vector3f rotatedOffset = new Vector3f(shapeOffset); + worldOrientation.transform(rotatedOffset); + + // Calculate center position in block coordinates + float centerX = (worldPosition.x + rotatedOffset.x) / POSITION_SCALE; + float centerY = (worldPosition.y + rotatedOffset.y - POSITION_OFFSET_Y) / POSITION_SCALE; + float centerZ = (worldPosition.z + rotatedOffset.z) / POSITION_SCALE; + + return new Transformation( + new Vector3f(centerX, centerY, centerZ), // Translation + worldOrientation, // Rotation + new Vector3f(1), // Scale (no scaling) + null // No additional rotation + ); + } + + /** + * Calculate half-sizes for a shape given its full size + * + * @param size the full size of the shape + * @return the half-sizes of the shape + */ + public static Vector3f calculateHalfSizes(Vector3f size) { + return new Vector3f( + (size.x / 2) / POSITION_SCALE, + (size.y / 2) / POSITION_SCALE, + (size.z / 2) / POSITION_SCALE + ); + } + } \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimParser.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimParser.java new file mode 100644 index 0000000..05dc7d1 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimParser.java @@ -0,0 +1,168 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.litehed.hytalemodels.HytaleModelLoader; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.litehed.hytalemodels.blockymodel.ParserUtil.getBooleanOrDefault; +import static com.litehed.hytalemodels.blockymodel.ParserUtil.getFloatOrDefault; + +public final class BlockyAnimParser { + + public static BlockyAnimationDefinition parse(JsonObject root) throws JsonParseException { + validateRequiredFields(root); + +// int formatVersion = getIntOrDefault(root, "formatVersion", 1); idk if this will have a use, commented for now + float duration = root.get("duration").getAsFloat(); + boolean holdLastKeyframe = getBooleanOrDefault(root, "holdLastKeyframe", false); + + return new BlockyAnimationDefinition.Builder() + .duration(duration) + .holdLastKeyframe(holdLastKeyframe) + .addNodeAnimations(parseNodeAnimations(root)) + .build(); + } + + private static void validateRequiredFields(JsonObject root) throws JsonParseException { + if (!root.has("duration")) { + throw new JsonParseException("Animation file missing required field: 'duration'"); + } + if (!root.has("nodeAnimations")) { + throw new JsonParseException("Animation file missing required field: 'nodeAnimations'"); + } + } + + private static Map> parseNodeAnimations(JsonObject root) { + Map> result = new HashMap<>(); + + JsonObject nodeAnimationsObj = root.getAsJsonObject("nodeAnimations"); + for (String nodeId : nodeAnimationsObj.keySet()) { + List tracks = parseNodeAnimationTracks(nodeId, nodeAnimationsObj.getAsJsonObject(nodeId)); + if (!tracks.isEmpty()) { + result.put(nodeId, tracks); + } + } + + return result; + } + + private static List parseNodeAnimationTracks(String nodeId, JsonObject nodeObj) { + List tracks = new ArrayList<>(); + + for (String trackKey : nodeObj.keySet()) { + NodeAnimationTrack.AnimationTrackType trackType = NodeAnimationTrack.AnimationTrackType.fromJsonKey(trackKey); + if (trackType == null) { + HytaleModelLoader.LOGGER.warn("Unknown animation track type: {}, skipping", trackKey); + continue; + } + + JsonArray keyframesArray = nodeObj.getAsJsonArray(trackKey); + if (keyframesArray.isEmpty()) continue; // Skip empty tracks + + List keyframes = parseKeyframes(trackType, keyframesArray); + if (!keyframes.isEmpty()) { + tracks.add(new NodeAnimationTrack(nodeId, trackType, keyframes)); + } + } + + return tracks; + } + + private static List parseKeyframes( + NodeAnimationTrack.AnimationTrackType trackType, + JsonArray keyframesArray) { + + List keyframes = new ArrayList<>(); + + for (JsonElement element : keyframesArray) { + JsonObject keyframeObj = element.getAsJsonObject(); + float time = keyframeObj.get("time").getAsFloat(); + BlockyKeyframe.InterpolationType interpolationType = parseInterpolationType(keyframeObj); + + BlockyKeyframe keyframe = switch (trackType) { + case POSITION -> parsePositionKeyframe(time, keyframeObj, interpolationType); + case ORIENTATION -> parseOrientationKeyframe(time, keyframeObj, interpolationType); + case SHAPE_STRETCH -> parseScaleKeyframe(time, keyframeObj, interpolationType); + case SHAPE_VISIBLE -> parseVisibilityKeyframe(time, keyframeObj, interpolationType); + case SHAPE_UV_OFFSET -> null; + }; + + if (keyframe != null) { + keyframes.add(keyframe); + } + } + + return keyframes; + } + + private static BlockyKeyframe.PositionKeyframe parsePositionKeyframe( + float time, JsonObject kfObj, BlockyKeyframe.InterpolationType interp) { + + Vector3f delta = parseVector3f(kfObj.getAsJsonObject("delta")); + return new BlockyKeyframe.PositionKeyframe(time, delta, interp); + } + + private static BlockyKeyframe.OrientationKeyframe parseOrientationKeyframe( + float time, + JsonObject keyframeObj, + BlockyKeyframe.InterpolationType interpolationType) { + + JsonObject deltaObj = keyframeObj.getAsJsonObject("delta"); + Quaternionf delta = new Quaternionf( + getFloatOrDefault(deltaObj, "x", 0), + getFloatOrDefault(deltaObj, "y", 0), + getFloatOrDefault(deltaObj, "z", 0), + getFloatOrDefault(deltaObj, "w", 1) + ); + + return new BlockyKeyframe.OrientationKeyframe(time, delta, interpolationType); + } + + private static BlockyKeyframe.ScaleKeyframe parseScaleKeyframe( + float time, JsonObject kfObj, BlockyKeyframe.InterpolationType interp) { + + Vector3f delta = parseVector3f(kfObj.getAsJsonObject("delta")); + return new BlockyKeyframe.ScaleKeyframe(time, delta, interp); + } + + private static BlockyKeyframe.VisibilityKeyframe parseVisibilityKeyframe( + float time, JsonObject kfObj, BlockyKeyframe.InterpolationType interp) { + + boolean visible = kfObj.get("delta").getAsBoolean(); + return new BlockyKeyframe.VisibilityKeyframe(time, visible, interp); + } + + private static Vector3f parseVector3f(JsonObject obj) { + return new Vector3f( + getFloatOrDefault(obj, "x", 0f), + getFloatOrDefault(obj, "y", 0f), + getFloatOrDefault(obj, "z", 0f) + ); + } + + + private static BlockyKeyframe.InterpolationType parseInterpolationType(JsonObject keyframeObj) { + if (!keyframeObj.has("interpolationType")) { + return BlockyKeyframe.InterpolationType.SMOOTH; + } + + String typeStr = keyframeObj.get("interpolationType").getAsString().toLowerCase(); + return switch (typeStr) { + case "linear" -> BlockyKeyframe.InterpolationType.LINEAR; + case "smooth" -> BlockyKeyframe.InterpolationType.SMOOTH; + default -> { + HytaleModelLoader.LOGGER.warn("Unknown interpolation type: '{}', defaulting to SMOOTH", typeStr); + yield BlockyKeyframe.InterpolationType.SMOOTH; + } + }; + } +} diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationDefinition.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationDefinition.java new file mode 100644 index 0000000..bcedd48 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationDefinition.java @@ -0,0 +1,74 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BlockyAnimationDefinition { + + private final float duration; // in ticks/frames + private final boolean holdLastKeyframe; + private final Map> nodeAnimations; + + public BlockyAnimationDefinition( + float duration, + boolean holdLastKeyframe, + Map> nodeAnimations) { + this.duration = duration; + this.holdLastKeyframe = holdLastKeyframe; + this.nodeAnimations = Map.copyOf(nodeAnimations); + } + + public float getDuration() { + return duration; + } + + public boolean isHoldLastKeyframe() { + return holdLastKeyframe; + } + + public Map> getNodeAnimations() { + return nodeAnimations; + } + + public List getNodeTracks(String nodeId) { + return nodeAnimations.getOrDefault(nodeId, List.of()); + } + + public NodeAnimationTrack getNodeTrack(String nodeId, NodeAnimationTrack.AnimationTrackType trackType) { + return getNodeTracks(nodeId).stream() + .filter(track -> track.getTrackType() == trackType) + .findFirst() + .orElse(null); + } + + public static final class Builder { + private float duration = 0; + private boolean holdLastKeyframe = false; + private final Map> nodeAnimations = new HashMap<>(); + + public Builder duration(float duration) { + this.duration = duration; + return this; + } + + public Builder holdLastKeyframe(boolean hold) { + this.holdLastKeyframe = hold; + return this; + } + + public Builder addNodeAnimations(String nodeId, List tracks) { + this.nodeAnimations.put(nodeId, List.copyOf(tracks)); + return this; + } + + public Builder addNodeAnimations(Map> animations) { + this.nodeAnimations.putAll(animations); + return this; + } + + public BlockyAnimationDefinition build() { + return new BlockyAnimationDefinition(duration, holdLastKeyframe, nodeAnimations); + } + } +} diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationLoader.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationLoader.java new file mode 100644 index 0000000..db312ad --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationLoader.java @@ -0,0 +1,54 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import com.google.common.collect.Maps; +import com.litehed.hytalemodels.HytaleModelLoader; +import com.litehed.hytalemodels.blockymodel.BlockyTokenizer; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; + +import java.util.Map; +import java.util.Optional; + +public final class BlockyAnimationLoader implements ResourceManagerReloadListener { + + public static final BlockyAnimationLoader INSTANCE = new BlockyAnimationLoader(); + + private final Map animationCache = Maps.newConcurrentMap(); + + public BlockyAnimationDefinition loadAnimation(Identifier animationId) { + return animationCache.computeIfAbsent(animationId, this::parseAnimation); + } + + public BlockyAnimationDefinition getAnimation(Identifier animationId) { + return animationCache.get(animationId); + } + + public void clearAnimation(Identifier animationId) { + animationCache.remove(animationId); + } + + private BlockyAnimationDefinition parseAnimation(Identifier id) { + ResourceManager manager = Minecraft.getInstance().getResourceManager(); + Optional resource = manager.getResource(id); + + if (resource.isEmpty()) { + HytaleModelLoader.LOGGER.warn("Animation resource not found: {}", id); + return null; + } + + try (BlockyTokenizer tokenizer = new BlockyTokenizer(resource.get().open())) { + return BlockyAnimParser.parse(tokenizer.getRoot()); + } catch (Exception e) { + HytaleModelLoader.LOGGER.error("Failed to parse animation '{}': {}", id, e.getMessage(), e); + return null; + } + } + + @Override + public void onResourceManagerReload(ResourceManager resourceManager) { + animationCache.clear(); + } +} diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationPlayer.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationPlayer.java new file mode 100644 index 0000000..61fbe3b --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyAnimationPlayer.java @@ -0,0 +1,318 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import com.litehed.hytalemodels.api.NodeTransform; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class BlockyAnimationPlayer { + + private static final float POSITION_SCALE = 1f / 32f; + + private final BlockyAnimationDefinition definition; + private final Map positionInterpolators = new HashMap<>(); + private final Map orientationInterpolators = new HashMap<>(); + private final Map scaleInterpolators = new HashMap<>(); + private final Map visibilityInterpolators = new HashMap<>(); + + public BlockyAnimationPlayer(BlockyAnimationDefinition definition) { + this.definition = definition; + initializeInterpolators(); + } + + public float getAnimationDuration() { + return definition.getDuration(); + } + + public boolean isHoldLastKeyframe() { + return definition.isHoldLastKeyframe(); + } + + /** + * Resolves the input time in ticks to a valid time within the animation's duration + * @param timeInTicks The input time in ticks + * @return A time value that is either clamped to the animation's duration (if holdLastKeyframe is true) else wrapped + */ + private float resolveTime(float timeInTicks) { + float duration = definition.getDuration(); + return definition.isHoldLastKeyframe() + ? Math.min(timeInTicks, duration) + : timeInTicks % duration; + } + + /** + * Merges a new NodeTransform into the map for a given nodeId + * @param map The map to merge into + * @param nodeId The ID of the node whose transform is being merged + * @param incoming The new NodeTransform to merge + */ + private static void mergeIntoMap(Map map, + String nodeId, + NodeTransform incoming) { + map.merge(nodeId, incoming, NodeTransform::merge); + } + + /** + * Calculates the current transforms for all nodes based on the input time in ticks + * @param timeInTicks The current time in ticks for which to calculate the transforms + * @return A map of node IDs to their corresponding NodeTransforms at the given time + */ + public Map calculateTransforms(float timeInTicks) { + float t = resolveTime(timeInTicks); + Map transforms = new HashMap<>(); + + positionInterpolators.forEach((nodeId, interp) -> { + Vector3f pos = interp.interpolate(t); + if (pos != null) { + Vector3f scaled = pos.mul(POSITION_SCALE, new Vector3f()); + mergeIntoMap(transforms, nodeId, NodeTransform.translation(scaled)); + } + }); + + orientationInterpolators.forEach((nodeId, interp) -> { + Quaternionf rot = interp.interpolate(t); + if (rot != null) { + mergeIntoMap(transforms, nodeId, NodeTransform.rotation(rot)); + } + }); + + scaleInterpolators.forEach((nodeId, interp) -> { + Vector3f scale = interp.interpolate(t); + if (scale != null) { + mergeIntoMap(transforms, nodeId, NodeTransform.scale(scale)); + } + }); + + visibilityInterpolators.forEach((nodeId, interp) -> { + boolean visible = interp.isVisible(t); + mergeIntoMap(transforms, nodeId, NodeTransform.visibility(visible)); + }); + + return transforms; + } + + /** + * Initializes the interpolators for each node and track type based on the animation definition + */ + private void initializeInterpolators() { + for (Map.Entry> entry : definition.getNodeAnimations().entrySet()) { + String nodeId = entry.getKey(); + for (NodeAnimationTrack track : entry.getValue()) { + switch (track.getTrackType()) { + case POSITION -> { + List kfs = castKeyframes( + track.getKeyframes(), BlockyKeyframe.PositionKeyframe.class); + if (!kfs.isEmpty()) positionInterpolators.put(nodeId, new PositionInterpolator(kfs)); + } + case ORIENTATION -> { + List kfs = castKeyframes( + track.getKeyframes(), BlockyKeyframe.OrientationKeyframe.class); + if (!kfs.isEmpty()) orientationInterpolators.put(nodeId, new OrientationInterpolator(kfs)); + } + case SHAPE_STRETCH -> { + List kfs = castKeyframes( + track.getKeyframes(), BlockyKeyframe.ScaleKeyframe.class); + if (!kfs.isEmpty()) scaleInterpolators.put(nodeId, new ScaleInterpolator(kfs)); + } + case SHAPE_VISIBLE -> { + List kfs = castKeyframes( + track.getKeyframes(), BlockyKeyframe.VisibilityKeyframe.class); + if (!kfs.isEmpty()) visibilityInterpolators.put(nodeId, new VisibilityInterpolator(kfs)); + } + default -> { + } // Other track types ignored for now + } + } + } + } + + /** + * Utility method to filter and cast a list of BlockyKeyframes to a specific subtype, based on the provided class type + * @param The specific subtype of BlockyKeyframe to filter and cast to + * @param raw The original list of BlockyKeyframes to filter and cast + * @param type The Class object representing the specific subtype of BlockyKeyframe to filter and cast to + * @return A new list containing only the keyframes from the original list that are instances of the specified type, cast to that type + */ + private static List castKeyframes( + List raw, Class type) { + List result = new ArrayList<>(raw.size()); + for (BlockyKeyframe kf : raw) { + if (type.isInstance(kf)) result.add((K) kf); + } + return result; + } + + private static final class PositionInterpolator { + private final List keyframes; + + PositionInterpolator(List keyframes) { + this.keyframes = new ArrayList<>(keyframes); + } + + Vector3f interpolate(float t) { + if (keyframes.isEmpty()) return null; + if (keyframes.size() == 1) return keyframes.getFirst().getDelta(); + + int prev = prevIndex(keyframes, t); + int next = Math.min(prev + 1, keyframes.size() - 1); + + if (prev == next) return new Vector3f(keyframes.get(prev).getDelta()); + + BlockyKeyframe.PositionKeyframe a = keyframes.get(prev); + BlockyKeyframe.PositionKeyframe b = keyframes.get(next); + float alpha = clampAlpha(t, a.getTime(), b.getTime()); + + return interpolateVec(a.getDelta(), b.getDelta(), alpha, a.getInterpolationType()); + } + } + + + private static final class OrientationInterpolator { + private final List keyframes; + + OrientationInterpolator(List keyframes) { + this.keyframes = new ArrayList<>(keyframes); + } + + Quaternionf interpolate(float t) { + if (keyframes.isEmpty()) return null; + if (keyframes.size() == 1) return keyframes.getFirst().getDelta(); + + int prev = prevIndex(keyframes, t); + int next = Math.min(prev + 1, keyframes.size() - 1); + + if (prev == next) return new Quaternionf(keyframes.get(prev).getDelta()); + + BlockyKeyframe.OrientationKeyframe a = keyframes.get(prev); + BlockyKeyframe.OrientationKeyframe b = keyframes.get(next); + float alpha = clampAlpha(t, a.getTime(), b.getTime()); + + float slerpAlpha = (a.getInterpolationType() == BlockyKeyframe.InterpolationType.LINEAR) + ? alpha + : smoothstep(alpha); + + return new Quaternionf(a.getDelta()).slerp(b.getDelta(), slerpAlpha); + } + } + + private static final class ScaleInterpolator { + private final List keyframes; + + ScaleInterpolator(List keyframes) { + this.keyframes = new ArrayList<>(keyframes); + } + + Vector3f interpolate(float t) { + if (keyframes.isEmpty()) return null; + if (keyframes.size() == 1) return keyframes.getFirst().getDelta(); + + int prev = prevIndex(keyframes, t); + int next = Math.min(prev + 1, keyframes.size() - 1); + + if (prev == next) return new Vector3f(keyframes.get(prev).getDelta()); + + BlockyKeyframe.ScaleKeyframe a = keyframes.get(prev); + BlockyKeyframe.ScaleKeyframe b = keyframes.get(next); + float alpha = clampAlpha(t, a.getTime(), b.getTime()); + + return interpolateVec(a.getDelta(), b.getDelta(), alpha, a.getInterpolationType()); + } + } + + + private static final class VisibilityInterpolator { + private final List keyframes; + + VisibilityInterpolator(List keyframes) { + this.keyframes = new ArrayList<>(keyframes); + } + + boolean isVisible(float t) { + if (keyframes.isEmpty()) return true; + return keyframes.get(prevIndex(keyframes, t)).isVisible(); + } + } + + + /** + * Utility method to clamp the interpolation alpha value between 0 and 1 based on the input time and the keyframe times + * @param t The current time for which to calculate the alpha + * @param tStart The time of the previous keyframe + * @param tEnd The time of the next keyframe + * @return The clamped alpha value between 0 and 1 + */ + private static float clampAlpha(float t, float tStart, float tEnd) { + float span = tEnd - tStart; + if (span <= 0f) return 0f; + return Math.clamp((t - tStart) / span, 0f, 1f); + } + + /** + * Utility method to apply a smoothstep function to the input alpha value for smoother interpolation + * @param t The input alpha value between 0 and 1 + * @return The output alpha value after applying the smoothstep function, also between 0 and 1 + */ + private static float smoothstep(float t) { + return t * t * (3 - 2 * t); + } + + /** + * Utility method to linearly interpolate between two Vector3f values based on the input alpha + * @param from The starting Vector3f value + * @param to The ending Vector3f value + * @param t The interpolation alpha value between 0 and 1 + * @return A new Vector3f that is the result of linearly interpolating between 'from' and 'to' based on 't' + */ + private static Vector3f lerpVec(Vector3f from, Vector3f to, float t) { + return new Vector3f(from).lerp(to, t); + } + + /** + * Utility method to interpolate between two Vector3f values using either linear or smooth interpolation based on the specified type + * @param from The starting Vector3f value + * @param to The ending Vector3f value + * @param t The interpolation alpha value between 0 and 1 + * @return A new Vector3f that is the result of interpolating between 'from' and 'to' based on 't' + */ + private static Vector3f smoothstepVec(Vector3f from, Vector3f to, float t) { + return lerpVec(from, to, smoothstep(t)); + } + + /** + * Utility method to interpolate between two Vector3f values based on the input alpha and interpolation type (linear or smooth) + * @param from The starting Vector3f value + * @param to The ending Vector3f value + * @param alpha The interpolation alpha value between 0 and 1 + * @param type The interpolation type (LINEAR or SMOOTH) + * @return A new Vector3f that is the result of interpolating between 'from' and 'to' based on 'alpha' + */ + private static Vector3f interpolateVec(Vector3f from, Vector3f to, float alpha, + BlockyKeyframe.InterpolationType type) { + return switch (type) { + case LINEAR -> lerpVec(from, to, alpha); + case SMOOTH -> smoothstepVec(from, to, alpha); + }; + } + + /** + * Utility method to find the index of the previous keyframe in a sorted list of keyframes based on the input time + * @param The specific subtype of BlockyKeyframe contained in the list + * @param keyframes A list of keyframes sorted by their time value in ascending order + * @param time The input time for which to find the previous keyframe index + * @return The index of the keyframe in the list that is the greatest keyframe time less than or equal to the input time + */ + private static int prevIndex(List keyframes, float time) { + int lo = 0, hi = keyframes.size() - 1; + while (lo < hi) { + int mid = (lo + hi + 1) >>> 1; + if (keyframes.get(mid).getTime() <= time) lo = mid; + else hi = mid - 1; + } + return lo; + } + +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyKeyframe.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyKeyframe.java new file mode 100644 index 0000000..11c1a5c --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/BlockyKeyframe.java @@ -0,0 +1,83 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import org.joml.Quaternionf; +import org.joml.Vector3f; + +public class BlockyKeyframe { + + protected final float time; + protected final InterpolationType interpolationType; + + protected BlockyKeyframe(float time, InterpolationType interpolationType) { + this.time = time; + this.interpolationType = interpolationType; + } + + public float getTime() { + return time; + } + + public InterpolationType getInterpolationType() { + return interpolationType; + } + + public static final class PositionKeyframe extends BlockyKeyframe { + private final Vector3f delta; + + public PositionKeyframe(float time, Vector3f delta, InterpolationType interpolationType) { + super(time, interpolationType); + this.delta = new Vector3f(delta); + } + + public Vector3f getDelta() { + return new Vector3f(delta); + } + } + + public static final class OrientationKeyframe extends BlockyKeyframe { + private final Quaternionf delta; + + public OrientationKeyframe(float time, Quaternionf delta, InterpolationType interpolationType) { + super(time, interpolationType); + this.delta = new Quaternionf(delta); + } + + public Quaternionf getDelta() { + return new Quaternionf(delta); + } + } + + public static final class ScaleKeyframe extends BlockyKeyframe { + private final Vector3f delta; + + public ScaleKeyframe(float time, Vector3f delta, InterpolationType interpolationType) { + super(time, interpolationType); + this.delta = new Vector3f(delta); + } + + public Vector3f getDelta() { + return new Vector3f(delta); + } + } + + public static final class VisibilityKeyframe extends BlockyKeyframe { + private final boolean visible; + + public VisibilityKeyframe(float time, boolean visible, InterpolationType interpolationType) { + super(time, interpolationType); + this.visible = visible; + } + + public boolean isVisible() { + return visible; + } + } + + /** + * Interpolation type enum for animation curves + */ + public enum InterpolationType { + LINEAR, + SMOOTH + } +} \ No newline at end of file diff --git a/src/main/java/com/litehed/hytalemodels/blockymodel/animations/NodeAnimationTrack.java b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/NodeAnimationTrack.java new file mode 100644 index 0000000..88d8f18 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/blockymodel/animations/NodeAnimationTrack.java @@ -0,0 +1,66 @@ +package com.litehed.hytalemodels.blockymodel.animations; + +import java.util.List; + +public final class NodeAnimationTrack { + + private final String nodeId; + private final AnimationTrackType trackType; + private final List keyframes; + + public NodeAnimationTrack(String nodeId, AnimationTrackType trackType, List keyframes) { + this.nodeId = nodeId; + this.trackType = trackType; + this.keyframes = List.copyOf(keyframes); // Immutable copy + } + + public String getNodeId() { + return nodeId; + } + + public AnimationTrackType getTrackType() { + return trackType; + } + + public List getKeyframes() { + return keyframes; + } + + /** + * Enum representing different types of animation tracks + */ + public enum AnimationTrackType { + POSITION("position"), + ORIENTATION("orientation"), + SHAPE_STRETCH("shapeStretch"), + SHAPE_VISIBLE("shapeVisible"), + SHAPE_UV_OFFSET("shapeUvOffset"); + + private final String jsonKey; + + AnimationTrackType(String jsonKey) { + this.jsonKey = jsonKey; + } + + public String getJsonKey() { + return jsonKey; + } + + public static AnimationTrackType fromJsonKey(String key) { + return switch (key) { + case "position" -> POSITION; + case "orientation" -> ORIENTATION; + case "shapeStretch" -> SHAPE_STRETCH; + case "shapeVisible" -> SHAPE_VISIBLE; + case "shapeUvOffset" -> SHAPE_UV_OFFSET; + default -> null; + }; + } + } +} + + + + + + diff --git a/src/main/java/com/litehed/hytalemodels/init/BlockEntityInit.java b/src/main/java/com/litehed/hytalemodels/init/BlockEntityInit.java new file mode 100644 index 0000000..004faa6 --- /dev/null +++ b/src/main/java/com/litehed/hytalemodels/init/BlockEntityInit.java @@ -0,0 +1,32 @@ +package com.litehed.hytalemodels.init; + +import com.litehed.hytalemodels.HytaleModelLoader; +import com.litehed.hytalemodels.blocks.entity.AnimatedChestBlockEntity; +import com.litehed.hytalemodels.blocks.entity.CoffinBlockEntity; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.neoforged.neoforge.registries.DeferredRegister; + +import java.util.function.Supplier; + +public class BlockEntityInit { + + public static final DeferredRegister> BLOCK_ENTITIES = + DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, HytaleModelLoader.MODID); + + public static final Supplier> CHEST_TEST_ENT = BLOCK_ENTITIES.register( + "hytale_chest", () -> new BlockEntityType<>( + AnimatedChestBlockEntity::new, + false, + BlockInit.SMALL_CHEST.get() + ) + ); + + public static final Supplier> COFFIN_ENT = BLOCK_ENTITIES.register( + "coffin", () -> new BlockEntityType<>( + CoffinBlockEntity::new, + false, + BlockInit.COFFIN.get() + ) + ); +} diff --git a/src/main/java/com/litehed/hytalemodels/init/BlockInit.java b/src/main/java/com/litehed/hytalemodels/init/BlockInit.java index dab1ccf..672bf54 100644 --- a/src/main/java/com/litehed/hytalemodels/init/BlockInit.java +++ b/src/main/java/com/litehed/hytalemodels/init/BlockInit.java @@ -1,7 +1,9 @@ package com.litehed.hytalemodels.init; import com.litehed.hytalemodels.HytaleModelLoader; -import com.litehed.hytalemodels.blocks.HytaleTestBlock; +import com.litehed.hytalemodels.api.block.HytaleBlockBase; +import com.litehed.hytalemodels.blocks.HytaleChest; +import com.litehed.hytalemodels.blocks.HytaleCoffin; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockBehaviour; import net.neoforged.neoforge.registries.DeferredBlock; @@ -14,8 +16,12 @@ public class BlockInit { public static final DeferredBlock POT = BLOCKS.registerSimpleBlock("pot", () -> BlockBehaviour.Properties.of().noOcclusion()); public static final DeferredBlock CRYSTAL_BIG = BLOCKS.registerSimpleBlock("crystal_big", () -> BlockBehaviour.Properties.of().noOcclusion()); public static final DeferredBlock BED = BLOCKS.registerSimpleBlock("bed", () -> BlockBehaviour.Properties.of().noOcclusion()); - public static final DeferredBlock COFFIN = BLOCKS.registerSimpleBlock("coffin", () -> BlockBehaviour.Properties.of().noOcclusion()); - public static final DeferredBlock SLOPE = BLOCKS.registerBlock("slope", HytaleTestBlock::new); + public static final DeferredBlock COFFIN = BLOCKS.registerBlock("coffin", HytaleCoffin::new); + public static final DeferredBlock SLOPE = BLOCKS.registerBlock("slope", HytaleBlockBase::new); + public static final DeferredBlock SMALL_CHEST = BLOCKS.registerBlock("chest_small", HytaleChest::new); public static final DeferredBlock CHAIR = BLOCKS.registerSimpleBlock("chair", () -> BlockBehaviour.Properties.of().noOcclusion()); public static final DeferredBlock TABLE = BLOCKS.registerSimpleBlock("table", () -> BlockBehaviour.Properties.of().noOcclusion()); + public static final DeferredBlock ASH_LEAVES = BLOCKS.registerBlock("ash_leaves", HytaleBlockBase::new); + public static final DeferredBlock SLOPE_HOLLOW = BLOCKS.registerBlock("slope_hay_hollow", HytaleBlockBase::new); + } diff --git a/src/main/java/com/litehed/hytalemodels/init/ItemInit.java b/src/main/java/com/litehed/hytalemodels/init/ItemInit.java index e9044e0..a72cbe6 100644 --- a/src/main/java/com/litehed/hytalemodels/init/ItemInit.java +++ b/src/main/java/com/litehed/hytalemodels/init/ItemInit.java @@ -14,8 +14,12 @@ public class ItemInit { public static final DeferredItem BED = ITEMS.registerSimpleBlockItem("bed", BlockInit.BED); public static final DeferredItem COFFIN = ITEMS.registerSimpleBlockItem("coffin", BlockInit.COFFIN); public static final DeferredItem SLOPE = ITEMS.registerSimpleBlockItem("slope", BlockInit.SLOPE); + public static final DeferredItem SMALL_CHEST = ITEMS.registerSimpleBlockItem("chest_small", BlockInit.SMALL_CHEST); public static final DeferredItem CHAIR = ITEMS.registerSimpleBlockItem("chair", BlockInit.CHAIR); public static final DeferredItem TABLE = ITEMS.registerSimpleBlockItem("table", BlockInit.TABLE); + public static final DeferredItem ASH_LEAVES = ITEMS.registerSimpleBlockItem("ash_leaves", BlockInit.ASH_LEAVES); + public static final DeferredItem SLOPE_HOLLOW = ITEMS.registerSimpleBlockItem("slope_hay_hollow", BlockInit.SLOPE_HOLLOW); public static final DeferredItem ADAMANTITE_PICK = ITEMS.registerSimpleItem("adamantite"); + public static final DeferredItem ONYXIUM = ITEMS.registerSimpleItem("onyxium"); } diff --git a/src/main/resources/assets/hytalemodelloader/blockstates/ash_leaves.json b/src/main/resources/assets/hytalemodelloader/blockstates/ash_leaves.json new file mode 100644 index 0000000..8f4e31e --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/blockstates/ash_leaves.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "hytalemodelloader:block/ash_leaves", + "y": 180 + }, + "facing=east": { + "model": "hytalemodelloader:block/ash_leaves", + "y": 270 + }, + "facing=south": { + "model": "hytalemodelloader:block/ash_leaves" + }, + "facing=west": { + "model": "hytalemodelloader:block/ash_leaves", + "y": 90 + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/blockstates/chest_small.json b/src/main/resources/assets/hytalemodelloader/blockstates/chest_small.json new file mode 100644 index 0000000..8d1524d --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/blockstates/chest_small.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "hytalemodelloader:block/chest_small", + "y": 180 + }, + "facing=east": { + "model": "hytalemodelloader:block/chest_small", + "y": 270 + }, + "facing=south": { + "model": "hytalemodelloader:block/chest_small" + }, + "facing=west": { + "model": "hytalemodelloader:block/chest_small", + "y": 90 + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/blockstates/slope_hay_hollow.json b/src/main/resources/assets/hytalemodelloader/blockstates/slope_hay_hollow.json new file mode 100644 index 0000000..3e7f6c2 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/blockstates/slope_hay_hollow.json @@ -0,0 +1,19 @@ +{ + "variants": { + "facing=north": { + "model": "hytalemodelloader:block/slope_hay_hollow", + "y": 180 + }, + "facing=east": { + "model": "hytalemodelloader:block/slope_hay_hollow", + "y": 270 + }, + "facing=south": { + "model": "hytalemodelloader:block/slope_hay_hollow" + }, + "facing=west": { + "model": "hytalemodelloader:block/slope_hay_hollow", + "y": 90 + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/items/ash_leaves.json b/src/main/resources/assets/hytalemodelloader/items/ash_leaves.json new file mode 100644 index 0000000..0557af9 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/items/ash_leaves.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "hytalemodelloader:item/ash_leaves" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/items/chest_small.json b/src/main/resources/assets/hytalemodelloader/items/chest_small.json new file mode 100644 index 0000000..7a83a3d --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/items/chest_small.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "hytalemodelloader:item/chest_small" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/items/onyxium.json b/src/main/resources/assets/hytalemodelloader/items/onyxium.json new file mode 100644 index 0000000..8e7cf7b --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/items/onyxium.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "hytalemodelloader:item/onyxium" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/items/slope_hay_hollow.json b/src/main/resources/assets/hytalemodelloader/items/slope_hay_hollow.json new file mode 100644 index 0000000..fa40f43 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/items/slope_hay_hollow.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "hytalemodelloader:item/slope_hay_hollow" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/block/ash_leaves.json b/src/main/resources/assets/hytalemodelloader/models/block/ash_leaves.json new file mode 100644 index 0000000..8300e8c --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/block/ash_leaves.json @@ -0,0 +1,9 @@ +{ + "loader": "hytalemodelloader:blockymodel_loader", + "model": "hytalemodelloader:models/ash_leaves.blockymodel", + "render_type": "minecraft:cutout", + "textures": { + "texture": "hytalemodelloader:block/ash_leaves", + "particle": "hytalemodelloader:block/ash_leaves" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/block/chest_small.json b/src/main/resources/assets/hytalemodelloader/models/block/chest_small.json new file mode 100644 index 0000000..bfb62af --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/block/chest_small.json @@ -0,0 +1,9 @@ +{ + "loader": "hytalemodelloader:blockymodel_loader", + "model": "hytalemodelloader:models/chest_small.blockymodel", + "render_type": "minecraft:cutout", + "textures": { + "texture": "hytalemodelloader:block/chest_small_texture", + "particle": "hytalemodelloader:block/chest_small_texture" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/block/slope_hay_hollow.json b/src/main/resources/assets/hytalemodelloader/models/block/slope_hay_hollow.json new file mode 100644 index 0000000..1289ee3 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/block/slope_hay_hollow.json @@ -0,0 +1,9 @@ +{ + "loader": "hytalemodelloader:blockymodel_loader", + "model": "hytalemodelloader:models/slope_hay_hollow.blockymodel", + "render_type": "minecraft:cutout", + "textures": { + "texture": "hytalemodelloader:block/slope_hay_hollow", + "particle": "hytalemodelloader:block/slope_hay_hollow" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/gen_model.py b/src/main/resources/assets/hytalemodelloader/models/gen_model.py new file mode 100644 index 0000000..09230d7 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/gen_model.py @@ -0,0 +1,101 @@ +import os +import json + +def ensure_folder(path): + if not os.path.exists(path): + os.makedirs(path) + +def write_json(path, data): + with open(path, "w") as f: + json.dump(data, f, indent=2) + +def generate_from_blockymodel(filepath, model_type): + filename = os.path.basename(filepath) + name = filename.replace(".blockymodel", "") + + # Folder structure + block_folder = "./block" + item_folder = "./item" + items_folder = "../items" + blockstates_folder = "../blockstates" + + ensure_folder(block_folder) + ensure_folder(item_folder) + ensure_folder(items_folder) + + if model_type == "block": + ensure_folder(blockstates_folder) + + # BLOCK MODEL + if model_type == "block": + block_json = { + "loader": "hytalemodelloader:blockymodel_loader", + "model": f"hytalemodelloader:models/{name}.blockymodel", + "render_type": "minecraft:cutout", + "textures": { + "texture": f"hytalemodelloader:block/{name}", + "particle": f"hytalemodelloader:block/{name}" + } + } + write_json(f"{block_folder}/{name}.json", block_json) + + # Item JSON (block item) + item_json = { + "parent": f"hytalemodelloader:block/{name}" + } + write_json(f"{item_folder}/{name}.json", item_json) + + # Items folder JSON + items_json = { + "model": { + "type": "minecraft:model", + "model": f"hytalemodelloader:item/{name}" + } + } + write_json(f"{items_folder}/{name}.json", items_json) + + # Blockstates JSON + blockstates_json = { + "variants": { + "facing=north": {"model": f"hytalemodelloader:block/{name}", "y": 180}, + "facing=east": {"model": f"hytalemodelloader:block/{name}", "y": 270}, + "facing=south": {"model": f"hytalemodelloader:block/{name}"}, + "facing=west": {"model": f"hytalemodelloader:block/{name}", "y": 90} + } + } + write_json(f"{blockstates_folder}/{name}.json", blockstates_json) + + print(f"Block model generated for {name}") + + # ITEM MODEL + elif model_type == "item": + item_json = { + "parent": "neoforge:item/default", + "loader": "hytalemodelloader:blockymodel_loader", + "model": f"hytalemodelloader:models/{name}.blockymodel", + "textures": { + "texture": f"hytalemodelloader:item/{name}_texture", + "particle": f"hytalemodelloader:item/{name}_texture" + } + } + write_json(f"{item_folder}/{name}.json", item_json) + + items_json = { + "model": { + "type": "minecraft:model", + "model": f"hytalemodelloader:item/{name}" + } + } + write_json(f"{items_folder}/{name}.json", items_json) + + print(f"Item model generated for {name}") + + else: + print("Invalid type. Must be 'block' or 'item'.") + + +if __name__ == "__main__": + filepath = input("Enter the .blockymodel file path: ").strip() + model_type = input("Is this a block or item? ").strip().lower() + + generate_from_blockymodel(filepath, model_type) \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/item/ash_leaves.json b/src/main/resources/assets/hytalemodelloader/models/item/ash_leaves.json new file mode 100644 index 0000000..00ba395 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/item/ash_leaves.json @@ -0,0 +1,3 @@ +{ + "parent": "hytalemodelloader:block/ash_leaves" +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/item/chest_small.json b/src/main/resources/assets/hytalemodelloader/models/item/chest_small.json new file mode 100644 index 0000000..4d5cea5 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/item/chest_small.json @@ -0,0 +1,3 @@ +{ + "parent": "hytalemodelloader:block/chest_small" +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/item/onyxium.json b/src/main/resources/assets/hytalemodelloader/models/item/onyxium.json new file mode 100644 index 0000000..def3f4e --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/item/onyxium.json @@ -0,0 +1,9 @@ +{ + "parent": "neoforge:item/default", + "loader": "hytalemodelloader:blockymodel_loader", + "model": "hytalemodelloader:models/onyxium.blockymodel", + "textures": { + "texture": "hytalemodelloader:item/onyxium_texture", + "particle": "hytalemodelloader:item/onyxium_texture" + } +} \ No newline at end of file diff --git a/src/main/resources/assets/hytalemodelloader/models/item/slope_hay_hollow.json b/src/main/resources/assets/hytalemodelloader/models/item/slope_hay_hollow.json new file mode 100644 index 0000000..7c6b508 --- /dev/null +++ b/src/main/resources/assets/hytalemodelloader/models/item/slope_hay_hollow.json @@ -0,0 +1,3 @@ +{ + "parent": "hytalemodelloader:block/slope_hay_hollow" +} \ No newline at end of file diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index bb98394..c5e4be6 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -27,7 +27,7 @@ displayName = "${mod_name}" #mandatory #updateJSONURL="https://change.me.example.invalid/updates.json" #optional # A URL for the "homepage" for this mod, displayed in the mod UI -#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional +displayURL="https://github.com/litehed/HytaleModelLoader/wiki" #optional # A file name (in the root of the mod JAR) containing a logo for display #logoFile="examplemod.png" #optional @@ -40,7 +40,8 @@ authors = "litehed" # The description text for the mod (multi line!) (#mandatory) description = ''' -Hytale Model Loader enables importing .blockymodel models from Hytale into Minecraft. Simply reference this loader in your model file and point to the blockymodel location to utilize this mod. Animation and entity support will come in the future. +Hytale Model Loader enables importing .blockymodel models and their respective .blockyanim animations from Hytale into Minecraft. Simply reference this loader in your model file and point to the blockymodel location to utilize this mod. Entity support will come in the future. +For more advanced usages check the wiki. ''' # The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.