commit 37de14c621e4642bed5a744629c515c2c3b1586d Author: Khaumi <51966695+CircuitButKerbin@users.noreply.github.com> Date: Wed Aug 12 07:09:10 2026 -0500 init diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9ebf2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/TODO b/TODO new file mode 100644 index 0000000..2bf153a --- /dev/null +++ b/TODO @@ -0,0 +1,77 @@ +Key: + [X] : Done + [~] : Partially Implemetned + [!] : Blocked + [ ] : Need to do + + +General: + [~] Neoforge Support + [!] Fabric Support + [!] Basic support for 1.7.10, 1.12.1, 1.21.1, 26.1 + + +Core (Neoforge): + [X] Basic skeleton project generation + [X] Defining basic blocks + Nix Related: + [X] Mod meta generation + [~] Block defintion generation + [~] Block state generation -- single-variant blockstates.json is generated (declarative cube textures, or a customModel escape hatch for hand-authored shapes); block STATE PROPERTIES (facing, powered, waterlogged, ...) aren't representable in blocks.nix yet, so multi-variant blockstates aren't possible + [ ] Equivalent functionality to regular neoforge development: + + Registration / content: + [X] Blocks + [X] BlockBehaviour.Properties (hardness/resistance/collision/sounds/etc.) + [X] Block entities + client/server ticking + [X] Custom Block/BlockEntity subclasses (customClass escape hatch) + [ ] Custom collision shapes / VoxelShape overrides + [ ] Waterlogging and other vanilla block-state property helpers + [~] Items + [X] BlockItems (auto-registered alongside a block, or opt out via item.enabled) + [ ] Standalone (non-block) items -- tools, food, armor, etc. + [ ] Item-specific properties (food, durability, stack size, rarity, tool tiers) + [ ] Creative tabs / item groups -- blocks/items aren't added to any tab yet + [ ] Entities + [ ] EntityType registration + [ ] Entity renderers/models (client) + [ ] Entity attributes + [ ] Commands + [ ] Basic fields for quickly registering commands. + [ ] Sounds (SoundEvent registration + sounds.json) + [ ] Particles + [ ] Fluids + [ ] Enchantments + [ ] Mob effects / potions + [ ] Attributes + + Data-driven content: + [ ] Loot tables -- properties.noLootTable/lootFrom exist as per-block hooks, but nothing generates an actual loot table yet + [ ] Recipes + recipe types + [ ] Tags (block/item/entity/fluid, e.g. mineable/pickaxe, needed for tool interactions) + [ ] Advancements + [ ] Data generation (GatherDataEvent providers to emit the above at build time) + [ ] Localization / lang files -- displayName isn't wired to an en_us.json yet, so names show as untranslated keys in-game + + Client: + [X] Block models -- declarative cube (textures.all/top/bottom/side/) or hand-authored via customModel + [X] Item models -- 2D icon (item.texture) or 3D block render (default), or hand-authored via item.customModel + [ ] Blockstate variants for block state properties (depends on block state generation above) + [ ] Block entity renderers (BER) + [ ] Custom item/entity renderers + [ ] Screens / menus (GUIs for block entities, e.g. a container menu) + + Networking & logic: + [ ] Custom packets / PayloadRegistrar + [ ] Capabilities (block/item/entity) + [ ] Config (ModConfigSpec) + [ ] Commands + + World: + [ ] World generation (biomes/features/structures) + [ ] Structure/village integration + + Tooling: + [ ] Mod-author-facing GameTest scaffolding (snowy's own build already runs headless runGameTestServer checks internally, but mods built with snowy have no way to declare their own game tests) + [ ] Mod publishing (CurseForge/Modrinth Gradle plugins, GitHub Actions CI templates) + [ ] Multi-MC-version support beyond the current 1.21.1 pin (versionTable in lib/mkMod.nix) diff --git a/codegen/blockbuilder.py b/codegen/blockbuilder.py new file mode 100644 index 0000000..b44f2dc --- /dev/null +++ b/codegen/blockbuilder.py @@ -0,0 +1,152 @@ +from .lib.utils import pascal_case, split_prefixed +from .lib.java import java_float, java_static_invoke + +def resolve_block(block: dict) -> dict: + block = dict(block) + block["concreteClassName"] = block["customClassName"] or pascal_case(block["path"]) + block["propertyCalls"], block["propertyImports"] = build_property_calls(block["properties"]) + if block["textures"] is not None: + block["resolvedTextures"] = resolve_textures(block["textures"]) + if block["blockEntity"] is not None: + block_entity = dict(block["blockEntity"]) + block_entity["concreteClassName"] = block_entity["customClassName"] or pascal_case(block["path"]) + "Entity" + block["blockEntity"] = block_entity + return block + + +def resolve_textures(textures: dict) -> dict: + """Resolves the all/top/bottom/side/ convenience fields into the six + explicit faces (using the vanilla "cube" parent model's own variable + names: up/down instead of top/bottom) plus particle, applying + face-specific > side > all precedence.""" + all_tex = textures["all"] + side = textures["side"] or all_tex + faces = { + "up": textures["top"] or all_tex, + "down": textures["bottom"] or all_tex, + "north": textures["north"] or side, + "south": textures["south"] or side, + "east": textures["east"] or side, + "west": textures["west"] or side, + } + faces["particle"] = faces["up"] or next(iter(faces.values())) + return faces + + +# Each entry describes how one `properties.*` field becomes a chained +# BlockBehaviour.Properties builder call. Extra fully-qualified imports are +# collected separately (Java method references can use FQNs inline, so only +# constants/enums referenced by simple name need an import). +def build_property_calls(properties: dict) -> tuple[list[str], list[str]]: + p = properties + calls: list[str] = [] + imports: set[str] = set() + + calls.append(f".destroyTime({java_float(p['hardness'])})") + if p["resistance"] is not None: + calls.append(f".explosionResistance({java_float(p['resistance'])})") + if p["requiresTool"]: + calls.append(".requiresCorrectToolForDrops()") + if p["noCollision"]: + calls.append(".noCollission()") + if p["noOcclusion"]: + calls.append(".noOcclusion()") + if p["dynamicShape"]: + calls.append(".dynamicShape()") + if p["forceSolidOn"]: + calls.append(".forceSolidOn()") + if p["pushReaction"] != "NORMAL": + calls.append(f".pushReaction(PushReaction.{p['pushReaction']})") + imports.add("net.minecraft.world.level.material.PushReaction") + if p["offsetType"] != "NONE": + calls.append(f".offsetType(BlockBehaviour.OffsetType.{p['offsetType']})") + if p["replaceable"]: + calls.append(".replaceable()") + if p["friction"] != 0.6: + calls.append(f".friction({java_float(p['friction'])})") + if p["speedFactor"] != 1.0: + calls.append(f".speedFactor({java_float(p['speedFactor'])})") + if p["jumpFactor"] != 1.0: + calls.append(f".jumpFactor({java_float(p['jumpFactor'])})") + + if p["mapColor"] is not None: + kind, value = split_prefixed(p["mapColor"], "properties.mapColor") + if kind == "dye": + calls.append(f".mapColor(DyeColor.{value})") + imports.add("net.minecraft.world.item.DyeColor") + elif kind == "map": + calls.append(f".mapColor(MapColor.{value})") + imports.add("net.minecraft.world.level.material.MapColor") + elif kind == "closure": + calls.append(f".mapColor({value})") + else: + raise ValueError(f"properties.mapColor: unknown prefix '{kind}:', expected 'dye:', 'map:', or 'closure:'") + + if p["lightLevel"] is not None: + light_level = p["lightLevel"] + if isinstance(light_level, int): + calls.append(f".lightLevel(state -> {light_level})") + else: + kind, value = split_prefixed(light_level, "properties.lightLevel") + if kind != "closure": + raise ValueError(f"properties.lightLevel: string values must be prefixed with 'closure:', got: {light_level!r}") + calls.append(f".lightLevel({value})") + + if p["noTerrainParticles"]: + calls.append(".noTerrainParticles()") + + if p["isViewBlocking"] is not None: + _, value = split_prefixed(p["isViewBlocking"], "properties.isViewBlocking") + calls.append(f".isViewBlocking({value})") + + if p["isSuffocating"] is not None: + _, value = split_prefixed(p["isSuffocating"], "properties.isSuffocating") + calls.append(f".isSuffocating({value})") + + calls.append(f".sound(SoundType.{p['sounds']})") + + if p["instruments"] is not None: + calls.append(f".instrument(NoteBlockInstrument.{p['instruments']})") + imports.add("net.minecraft.world.level.block.state.properties.NoteBlockInstrument") + + if p["noLootTable"]: + calls.append(".noLootTable()") + + if p["lootFrom"] is not None: + kind, value = split_prefixed(p["lootFrom"], "properties.lootFrom") + if kind != "closure": + raise ValueError(f"properties.lootFrom: expected 'closure:pkg.Class::method', got: {p['lootFrom']!r}") + calls.append(f".dropsLike({java_static_invoke(value, 'properties.lootFrom')})") + + if p["randomTicks"]: + calls.append(".randomTicks()") + if p["ignitesByLava"]: + calls.append(".ignitedByLava()") + if p["liquid"]: + calls.append(".liquid()") + + if p["isValidSpawn"] is not None: + _, value = split_prefixed(p["isValidSpawn"], "properties.isValidSpawn") + calls.append(f".isValidSpawn({value})") + + if p["air"]: + calls.append(".air()") + + if p["requiredFeatures"]: + parts = [] + for feature in p["requiredFeatures"]: + if feature.startswith("closure:"): + _, value = split_prefixed(feature, "properties.requiredFeatures") + parts.append(value) + else: + namespace, _, name = feature.partition(":") + if namespace != "minecraft": + raise ValueError( + f"properties.requiredFeatures: non-vanilla feature '{feature}' needs 'closure:pkg.Class::FIELD' " + "pointing at a static FeatureFlag field -- snowy doesn't yet know how to look up custom mods' flags by id" + ) + parts.append(f"FeatureFlags.{name.upper()}") + imports.add("net.minecraft.world.flag.FeatureFlags") + calls.append(f".requiredFeatures({', '.join(parts)})") + + return calls, sorted(imports) diff --git a/codegen/lib/java.py b/codegen/lib/java.py new file mode 100644 index 0000000..eb48bdc --- /dev/null +++ b/codegen/lib/java.py @@ -0,0 +1,14 @@ +def java_float(value: float) -> str: + return f"{value}f" + + +def java_static_invoke(closure: str, option_name: str) -> str: + """Turns a "com.example.MyMod::method" closure reference into a zero-arg + static method call expression, for APIs that need an already-computed + value (e.g. Block) rather than a functional-interface reference.""" + if "::" not in closure: + raise ValueError(f"{option_name}: closure must be of the form 'pkg.Class::method', got: {closure!r}") + owner, _, method = closure.partition("::") + return f"{owner}.{method}()" + + diff --git a/codegen/lib/utils.py b/codegen/lib/utils.py new file mode 100644 index 0000000..b22aa2f --- /dev/null +++ b/codegen/lib/utils.py @@ -0,0 +1,9 @@ +def split_prefixed(value: str, option_name: str) -> tuple[str, str]: + if ":" not in value: + raise ValueError(f"{option_name}: expected a 'prefix:value' string (e.g. 'closure:...'), got: {value!r}") + prefix, _, rest = value.partition(":") + return prefix, rest + + +def pascal_case(snake: str) -> str: + return "".join(word.capitalize() for word in snake.split("_")) diff --git a/codegen/main.py b/codegen/main.py new file mode 100644 index 0000000..fea2ee1 --- /dev/null +++ b/codegen/main.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Renders a mod's Gradle/NeoForge project skeleton from a resolved JSON config. + +This script is intentionally "dumb": all version resolution and validation +happens on the Nix side (lib/mkMod.nix). Given a flat JSON context and a +directory of Jinja2 templates, it renders a fixed manifest of files plus one +set of files per declared block into an output directory. + +Generated code always lives under a "generated" subpackage, kept separate +from hand-written code (which lib/mkMod.nix symlinks in directly from the +mod's own src/ directory) so regenerating never clobbers hand-written files. +""" + +import argparse +import json +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +from . import blockbuilder + +# (template file relative to --templates, output path relative to --out) +# Output paths may reference {package_path}, filled in from the config +# before rendering. +STATIC_MANIFEST = [ + ("build.gradle.jinja", "build.gradle"), + ("settings.gradle.jinja", "settings.gradle"), + ("gradle.properties.jinja", "gradle.properties"), + ("gitignore.jinja", ".gitignore"), + ("neoforge.mods.toml.jinja", "src/main/resources/META-INF/neoforge.mods.toml"), +] + + + + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, type=Path, help="Path to the resolved mod config JSON") + parser.add_argument("--templates", required=True, type=Path, help="Directory of .jinja templates for the target MC version") + parser.add_argument("--out", required=True, type=Path, help="Output directory to render the project into") + args = parser.parse_args() + + context = json.loads(args.config.read_text()) + context["blocks"] = [blockbuilder.resolve_block(b) for b in context["blocks"]] + context["blockPropertyImports"] = sorted({ + imp for block in context["blocks"] for imp in block["propertyImports"] + }) + + package_path = context["javaPackage"].replace(".", "/") + + env = Environment( + loader=FileSystemLoader(str(args.templates)), + keep_trailing_newline=True, + ) + + def render(template_name: str, output_relpath: str, **extra) -> None: + template = env.get_template(template_name) + rendered = template.render(**context, **extra) + output_path = args.out / output_relpath + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered) + + for template_name, output_template in STATIC_MANIFEST: + render(template_name, output_template.format(package_path=package_path)) + + # Main mod class: flat + concrete when there's no hand-written override, + # otherwise an unannotated, abstract Base class under generated/. + if context["customClassName"]: + render("MainMod.java.jinja", f"src/main/java/{package_path}/generated/{context['className']}Base.java") + else: + render("MainMod.java.jinja", f"src/main/java/{package_path}/{context['className']}.java") + + # Client-only class: only generated when the mod actually declares one. + if context["customClientClassName"]: + render("ClientMod.java.jinja", f"src/main/java/{package_path}/generated/{context['clientClassName']}Base.java") + + # Block/block-entity registries: always generated (even empty), so the main + # class can unconditionally call their register(modEventBus) helpers. + render("Blocks.java.jinja", f"src/main/java/{package_path}/generated/blocks/{context['blocksClassName']}.java") + render("BlockEntities.java.jinja", f"src/main/java/{package_path}/generated/blocks/{context['blockEntitiesClassName']}.java") + + for block in context["blocks"]: + block_file_name = ("Base" + block["concreteClassName"]) if block["customClassName"] else block["concreteClassName"] + render("Block.java.jinja", f"src/main/java/{package_path}/generated/blocks/{block_file_name}.java", block=block) + + if block["blockEntity"] is not None: + be = block["blockEntity"] + be_file_name = ("Base" + be["concreteClassName"]) if be["customClassName"] else be["concreteClassName"] + render("BlockEntity.java.jinja", f"src/main/java/{package_path}/generated/blocks/{be_file_name}.java", block=block) + + # blockstates.json always references ":block/" regardless of + # whether that model is generated below or hand-written (customModel). + assets_dir = f"src/main/resources/assets/{context['modid']}" + render("blockstates.json.jinja", f"{assets_dir}/blockstates/{block['path']}.json", block=block) + + if not block["customModel"]: + render("BlockModel.json.jinja", f"{assets_dir}/models/block/{block['path']}.json", block=block) + + if block["item"]["enabled"] and not block["item"]["customModel"]: + render("ItemModel.json.jinja", f"{assets_dir}/models/item/{block['path']}.json", block=block) + + +if __name__ == "__main__": + main() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d74e00e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1786247143, + "narHash": "sha256-8S3Kcxs7D4UtxJxSJZz0m14CGhuW0MxfrIwJxeGWGnQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "279b4a8275f032c566576b3f181fa0f27197f588", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c16bbb7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + description = "snowy: a Nix framework for developing Minecraft mods"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); + in + { + lib.mkMod = { pkgs, lib ? pkgs.lib }: import ./lib/mkMod.nix { inherit pkgs lib; }; + + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + mkMod = self.lib.mkMod { inherit pkgs; }; + in + { + basicmod-neoforge-1_21_1 = mkMod { + modPath = ./tests/fixtures/basicmod-neoforge-1.21.1/mod.nix; + }; + silicon-neoforge-1_21_1 = mkMod { + modPath = ./tests/fixtures/silicon-neoforge-1.21.1/mod.nix; + }; + }); + }; +} diff --git a/lib/mkMod.nix b/lib/mkMod.nix new file mode 100644 index 0000000..14f5481 --- /dev/null +++ b/lib/mkMod.nix @@ -0,0 +1,182 @@ +{ pkgs, lib }: + +let + # Static per-Minecraft-version pins. Real "latest-recommended" resolution + # would need network access at eval time, so for now every supported + # targetVersion needs an entry here. + versionTable = { + "1.21.1" = { + neoVersion = "21.1.244"; + minecraftVersionRange = "[1.21.1]"; + loaderVersionRange = "[1,)"; + parchmentMinecraftVersion = "1.21.1"; + parchmentMappingsVersion = "2024.11.17"; + templateDir = "1.21.x"; + }; + }; + trace = value: builtins.trace value value; +in + +{ modPath }: + +let + evaluated = lib.evalModules { + modules = [ ../modules/mod-meta.nix ../modules/mod-blocks.nix modPath ]; + }; + cfg = evaluated.config; + + versionInfo = + versionTable.${cfg.targetVersion} or (throw + "snowy: unsupported targetVersion '${cfg.targetVersion}'. Supported versions: ${lib.concatStringsSep ", " (lib.attrNames versionTable)}"); + + resolvedNeoVersion = + if cfg.frameworkVersion == "latest-recommended" + then versionInfo.neoVersion + else cfg.frameworkVersion; + + # Hand-written files (customClass, blocks.*.customClass, ...) are given as plain + # strings, e.g. "./src/CreateSilicon.java" -- snowy never reads their content or + # location, it only needs the class name (the file's basename) to generate the + # matching Base class and wire up imports/registration on the generated side. The + # actual file is expected to already live at the real Gradle-conventional path + # (src/main/java/{package}/...) in the project directory the shellHook runs from. + classNameFromPath = p: if p == null then null else lib.removeSuffix ".java" (baseNameOf p); + + splitBlockId = id: + let parts = lib.splitString ":" id; + in + assert lib.assertMsg (builtins.length parts == 2) + "snowy: block id '${id}' must be of the form ':'"; + { namespace = builtins.elemAt parts 0; path = builtins.elemAt parts 1; }; + + blocksList = lib.mapAttrsToList + (id: blockCfg: + let + split = splitBlockId id; + hasEnoughTextures = blockCfg.textures != null && ( + blockCfg.textures.all != null + || (blockCfg.textures.top != null && blockCfg.textures.bottom != null + && (blockCfg.textures.side != null + || (blockCfg.textures.north != null && blockCfg.textures.south != null + && blockCfg.textures.east != null && blockCfg.textures.west != null))) + ); + in + assert lib.assertMsg (split.namespace == cfg.modid) + "snowy: block '${id}' namespace must match modid '${cfg.modid}'"; + assert lib.assertMsg (!(blockCfg.textures != null && blockCfg.customModel)) + "snowy: block '${id}': textures and customModel are mutually exclusive"; + assert lib.assertMsg (blockCfg.customModel || hasEnoughTextures) + "snowy: block '${id}': textures must set 'all', or 'top'+'bottom'+('side' or all four side faces), unless customModel is set"; + assert lib.assertMsg (!(blockCfg.item.texture != null && blockCfg.item.customModel)) + "snowy: block '${id}': item.texture and item.customModel are mutually exclusive"; + { + inherit id; + inherit (split) path; + displayName = blockCfg.displayName; + properties = blockCfg.properties; + customClassName = classNameFromPath blockCfg.customClass; + textures = blockCfg.textures; + customModel = blockCfg.customModel; + item = blockCfg.item; + blockEntity = + if blockCfg.blockEntity == null then null + else { + customClassName = classNameFromPath blockCfg.blockEntity.class; + tickerClient = blockCfg.blockEntity.ticker.client; + tickerServer = blockCfg.blockEntity.ticker.server; + }; + }) + cfg.blocks; + + capitalizedModid = lib.toUpper (lib.substring 0 1 cfg.modid) + lib.substring 1 (-1) cfg.modid; + + templateContext = cfg // { + neoVersion = resolvedNeoVersion; + inherit (versionInfo) minecraftVersionRange loaderVersionRange parchmentMinecraftVersion parchmentMappingsVersion; + customClassName = classNameFromPath cfg.customClass; + customClientClassName = classNameFromPath cfg.customClientClass; + blocks = blocksList; + blocksClassName = "${capitalizedModid}Blocks"; + blockEntitiesClassName = "${capitalizedModid}BlockEntities"; + }; + + configJson = pkgs.writeText "snowy-mod-config-${cfg.modid}.json" (builtins.toJSON templateContext); + + packagePath = builtins.replaceStrings [ "." ] [ "/" ] cfg.javaPackage; + + pythonEnv = pkgs.python3.withPackages (ps: [ ps.jinja2 ]); + + generated = pkgs.runCommand "snowy-generated-${cfg.modid}" { + nativeBuildInputs = [ pythonEnv ]; + } '' + mkdir -p $out + python3 ${../codegen/main.py} \ + --config ${configJson} \ + --templates ${../templates}/${versionInfo.templateDir} \ + --out $out + ''; + + # The `gradle wrapper` task validates its distribution URL over the network, + # which the (non-fixed-output) Nix build sandbox blocks. Instead, fetch the + # three wrapper artifacts verbatim (fixed-output derivations may use the + # network) from the upstream NeoForge MDK, which already pins Gradle 9.2.1, + # and write gradle-wrapper.properties ourselves. + gradleWrapperVersion = "9.2.1"; + mdkWrapperBase = "https://raw.githubusercontent.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle/main"; + + gradlew = pkgs.fetchurl { + url = "${mdkWrapperBase}/gradlew"; + sha256 = "0284687nawivy3gz16r4zcssv61a3g1ssvyxmxh5g7vl5v2v91xi"; + }; + gradlewBat = pkgs.fetchurl { + url = "${mdkWrapperBase}/gradlew.bat"; + sha256 = "1kg2qjh904cz6da71l30pa1sfbdb707lr4lp4c1jvclgxc9jf44l"; + }; + gradleWrapperJar = pkgs.fetchurl { + url = "${mdkWrapperBase}/gradle/wrapper/gradle-wrapper.jar"; + sha256 = "0wk157gn9nh2d7v9j7mfy46csb31iv5qxsx4qsdvachwvv24lfkx"; + }; + gradleWrapperProperties = pkgs.writeText "gradle-wrapper.properties" '' + distributionBase=GRADLE_USER_HOME + distributionPath=wrapper/dists + distributionUrl=https\://services.gradle.org/distributions/gradle-${gradleWrapperVersion}-bin.zip + networkTimeout=10000 + validateDistributionUrl=true + zipStoreBase=GRADLE_USER_HOME + zipStorePath=wrapper/dists + ''; + + gradleWrapper = pkgs.runCommand "snowy-gradle-wrapper" { } '' + mkdir -p $out/gradle/wrapper + install -m755 ${gradlew} $out/gradlew + install -m755 ${gradlewBat} $out/gradlew.bat + install -m644 ${gradleWrapperJar} $out/gradle/wrapper/gradle-wrapper.jar + install -m644 ${gradleWrapperProperties} $out/gradle/wrapper/gradle-wrapper.properties + ''; +in +assert lib.assertMsg (cfg.targetFramework == [ "neoforge" ]) + "snowy: only targetFramework = [ \"neoforge\" ] is currently supported, got: [ ${lib.concatStringsSep " " cfg.targetFramework} ]"; +pkgs.mkShell { + packages = [ pkgs.jdk21 pkgs.gradle ]; + + shellHook = '' + echo "snowy: refreshing generated NeoForge project files in $(pwd)" + + # Only ever touch this explicit, framework-owned set of paths -- this is the + # real project root now (not an isolated throwaway directory), so it may also + # contain the mod author's own unrelated files (.git, flake.nix, an IDE config, + # hand-written sources outside src/main/java/**/generated, ...) that must never + # be swept up by this cleanup. + rm -rf build.gradle settings.gradle gradle.properties .gitignore \ + gradlew gradlew.bat gradle \ + src/main/resources/META-INF \ + "src/main/resources/assets/${cfg.modid}/blockstates" \ + "src/main/java/${packagePath}/generated" + + cp -r --no-preserve=mode,ownership ${generated}/. ./ + cp -r --no-preserve=mode,ownership ${gradleWrapper}/. ./ + chmod +x gradlew + + echo "snowy: done. ./gradlew build" + ''; +} diff --git a/modules/mod-blocks.nix b/modules/mod-blocks.nix new file mode 100644 index 0000000..90976f6 --- /dev/null +++ b/modules/mod-blocks.nix @@ -0,0 +1,318 @@ +{ lib, config, ... }: +with lib; +{ + options = { + blocks = mkOption { + type = types.attrsOf ( + types.submodule { + options = { + displayName = mkOption { + type = types.str; + description = "The display name of the block."; + }; + properties = { + ## Mining properties + + # .strength(float destroyTime, ?) + hardness = mkOption { + type = types.float; + default = 1.0; + description = "The block's destroy time (hardness)."; + }; + # .strength(?, float explosionResistance) + resistance = mkOption { + type = types.nullOr types.float; + default = null; + description = "The block's explosion resistance."; + }; + # requiresCorrectToolForDrops() + requiresTool = mkOption { + type = types.bool; + default = false; + description = "Whether the block requires the correct tool to drop."; + }; + + ## Physical properties + + # noCollision() + noCollision = mkOption { + type = types.bool; + default = false; + description = "Whether the block has no collision."; + }; + + # noOcclusion() + noOcclusion = mkOption { + type = types.bool; + default = false; + description = "Whether the block has no occlusion."; + }; + + # dynamicShape() + dynamicShape = mkOption { + type = types.bool; + default = false; + description = "Whether the block has a dynamic shape."; + }; + + # forceSolidOn() + forceSolidOn = mkOption { + type = types.bool; + default = false; + description = "Whether the block is forced to be solid on all sides."; + }; + + # pushReaction(PushReaction) + pushReaction = mkOption { + type = types.enum [ "NORMAL" "DESTROY" "BLOCK" "IGNORE" "PUSH_ONLY" ]; + default = "NORMAL"; + description = "The block's reaction to being pushed by pistons."; + }; + + # offsetType(BlockBehaviour.OffsetType) + offsetType = mkOption { + type = types.enum [ "NONE" "XYZ" "XZ" ]; + default = "NONE"; + description = "The block's offset type."; + }; + + # replaceable() + replaceable = mkOption { + type = types.bool; + default = false; + description = "Whether the block is replaceable; e.g. like tall grass or snow layers."; + }; + + ## Movement + + # friction(float) + friction = mkOption { + type = types.float; + default = 0.6; + description = "The block's friction."; + }; + + # speedFactor(float) + speedFactor = mkOption { + type = types.float; + default = 1.0; + description = "The block's speed factor."; + }; + + #jumpFactor(float) + jumpFactor = mkOption { + type = types.float; + default = 1.0; + description = "The block's jump factor."; + }; + + ## Visual + #mapColor(MapColor) & mapColor(DyeColor) & mapColor(closure) + mapColor = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The block's map color. For dyecolor, use dye:, map:. + To specify a closure, use closure:. For example, to reference the GetMapColor() method of a class, use closure:com.example.MyBlock::getMapColor. + ''; + }; + + #lightLevel(ToIntFunction) + lightLevel = mkOption { + type = types.nullOr (types.oneOf [ types.int types.str ]); + default = null; + description = '' + The block's light level. Can be a constant integer (0-15), or a path method with (BlockState) -> Integer, e.g. closure:com.example.MyBlock::getLightLevel. + ''; + }; + + #noTerrainParticles() + noTerrainParticles = mkOption { + type = types.bool; + default = false; + description = "Whether the block has no terrain particles."; + }; + + #isViewBlocking(BlockBehaviour.StatePredicate) + isViewBlocking = mkOption { + type = types.nullOr types.str; + default = null; + description = "Whether the block is view blocking. Provide a path to a BlockBehavior.StatePredicate method, e.g. closure:com.example.MyBlock::isViewBlocking."; + }; + + #isSuffocating(BlockBehaviour.StatePredicate) + isSuffocating = mkOption { + type = types.nullOr types.str; + default = null; + description = "Whether the block is suffocating. Provide a path to a BlockBehavior.StatePredicate method, e.g. closure:com.example.MyBlock::isSuffocating."; + }; + ## Sound + + sounds = mkOption { + type = types.str; + default = "STONE"; + description = "The name of the vanilla SoundType constant to use."; + }; + + instruments = mkOption { + type = types.nullOr types.str; + default = null; + description = "The name of the vanilla Instrument constant to use."; + }; + + ## Loot + noLootTable = mkOption { + type = types.bool; + default = false; + description = "Whether the block has no loot table."; + }; + lootFrom = mkOption { + type = types.nullOr types.str; + default = null; + description = "A Supplier that provides the loot table for this block. Provide a path to a method that returns a Block, e.g. closure:com.example.MyBlock::getLootTable."; + }; + + ## Miscellaneous + randomTicks = mkOption { + type = types.bool; + default = false; + description = "Whether the block receives random ticks."; + }; + + ignitesByLava = mkOption { + type = types.bool; + default = false; + description = "Whether the block can be ignited by lava."; + }; + + liquid = mkOption { + type = types.bool; + default = false; + description = "Whether the block is a liquid."; + }; + + isValidSpawn = mkOption { + type = types.nullOr types.str; + default = null; + description = "BlockBehaviour.StateArgumentPredicate> that determines whether this block is a valid spawn point for the given entity type. Provide a path to a method that returns a boolean, e.g. closure:com.example.MyBlock::isValidSpawn."; + }; + + air = mkOption { + type = types.bool; + default = false; + description = "Whether the block is air-like"; + }; + + requiredFeatures = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "A list of required features for this block to be available. Written as :."; + }; + }; + customClass = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path (relative to this mod.nix) to a hand-written Java file whose + public class extends the generated "Base''${className}" block class. + When unset, snowy generates one flat, concrete block class instead. + ''; + }; + /* + states = { + "Integer:power" = { + min = 0; + max = 15; + default = 0; + }; + "Boolean:lit" = { + default = true; + } + "Enum:facing" = { + enum = "net.minecraft.world.level.block.state.properties.BlockStateProperties.FACING" + } + + } + */ + states = mkOption { + type = types.attrsOf { + + }; + }; + customModel = mkOption { + type = types.bool; + default = false; + description = '' + When true, snowy still generates this block's blockstates.json + (pointing at ":block/" as usual), but expects a + hand-written models/block/.json to already exist there + instead of generating one from textures. Mutually exclusive with + textures. + ''; + }; + item = { + enabled = mkOption { + type = types.bool; + default = true; + description = "Whether to register a BlockItem for this block."; + }; + texture = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Flat 2D item icon (relative to textures/item/, no extension). + When unset, the item renders the block's 3D model instead. + Mutually exclusive with item.customModel. + ''; + }; + customModel = mkOption { + type = types.bool; + default = false; + description = '' + Like the block-level customModel: expect a hand-written + models/item/.json instead of generating one. + Mutually exclusive with item.texture. + ''; + }; + }; + blockEntity = mkOption { + type = types.nullOr ( + types.submodule { + options = { + class = mkOption { + type = types.str; + description = '' + Path (relative to this mod.nix) to a hand-written Java file + whose public class extends the generated + "Base''${className}Entity" block entity class. + ''; + }; + ticker = { + client = mkOption { + type = types.bool; + default = false; + description = "Whether this block entity ticks on the client."; + }; + server = mkOption { + type = types.bool; + default = false; + description = "Whether this block entity ticks on the server."; + }; + }; + }; + } + ); + default = null; + description = "The block entity attached to this block, if any."; + }; + }; + } + ); + default = { }; + description = '' + The blocks this mod registers, keyed by full registry name + (":"). + ''; + }; + }; +} diff --git a/modules/mod-meta.nix b/modules/mod-meta.nix new file mode 100644 index 0000000..e55dba5 --- /dev/null +++ b/modules/mod-meta.nix @@ -0,0 +1,162 @@ +{ lib, config, ... }: +with lib; +{ + # TODO: abstract this away from what neoforge asks for, and generalize it to all mod loaders + options = { + modid = mkOption { + type = types.str; + description = "The modid of the mod."; + }; + version = mkOption { + type = types.str; + description = "The version of the mod."; + }; + displayName = mkOption { + type = types.str; + description = "The display name of the mod."; + }; + authors = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "The authors of the mod."; + }; + description = mkOption { + type = types.str; + description = "The description of the mod."; + }; + + targetFramework = mkOption { + type = types.listOf (types.enum [ "forge" "neoforge" "fabric" "quilt" ]); + description = "The target framework of the mod."; + }; + + targetVersion = mkOption { + type = types.str; + description = "The target minecraft version of the mod."; + }; + + frameworkVersion = mkOption { + type = types.str; + default = "latest-recommended"; + description = "The target framework version of the mod."; + }; + + javaPackage = mkOption { + type = types.str; + default = "com.example.${config.modid}"; + description = "The Java package the mod's sources live under."; + }; + + + className = mkOption { + type = types.str; + default = + if config.customClass != null + then removeSuffix ".java" (baseNameOf config.customClass) + else toUpper (substring 0 1 config.modid) + substring 1 (-1) config.modid; + description = '' + The name of the generated main mod class. Defaults to customClass's own + basename when set (so the hand-written file's "extends ...Base" clause + lines up automatically), else the capitalized modid. + ''; + }; + + customClass = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path (relative to this mod.nix) to a hand-written Java file whose public + class extends the generated "''${className}Base" class. When unset, snowy + generates one flat, concrete, @Mod-annotated main class instead. + ''; + }; + + clientClassName = mkOption { + type = types.str; + default = + if config.customClientClass != null + then removeSuffix ".java" (baseNameOf config.customClientClass) + else "${config.className}Client"; + description = '' + The name of the generated client-only mod class. Defaults to + customClientClass's own basename when set, else "''${className}Client". + ''; + }; + + customClientClass = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path (relative to this mod.nix) to a hand-written Java file whose public + class extends the generated "''${clientClassName}Base" class. When unset, + snowy generates one flat, concrete, @Mod(dist = CLIENT)-annotated class + instead. + ''; + }; + + dependencies = mkOption { + type = types.listOf (types.submodule { + options = { + modId = mkOption { + type = types.str; + description = "The modid of the dependency."; + }; + type = mkOption { + type = types.enum [ "required" "optional" "incompatible" "discouraged" ]; + description = "The kind of dependency relationship."; + }; + versionRange = mkOption { + type = types.str; + description = "The version range of the dependency."; + }; + ordering = mkOption { + type = types.enum [ "before" "after" "none" ]; + default = "none"; + description = "Load ordering relative to the dependency."; + }; + side = mkOption { + type = types.enum [ "client" "server" "both" ]; + default = "both"; + description = "The side this dependency is required on."; + }; + }; + }); + default = [ ]; + description = "The dependencies of the mod."; + }; + license = mkOption { + type = types.str; + description = "The license of the mod."; + }; + + javaDependencies = mkOption { + type = types.listOf (types.submodule { + options = { + coordinate = mkOption { + type = types.str; + description = "Maven coordinate of the dependency, as group:artifact:version."; + }; + configuration = mkOption { + type = types.enum [ "implementation" "api" "compileOnly" "runtimeOnly" ]; + default = "implementation"; + description = "The Gradle dependency configuration to add the coordinate under."; + }; + }; + }); + default = [ ]; + description = "Plain Maven/Java dependencies (not other mods) to add to the Gradle build."; + }; + + mavenRepositories = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Additional Maven repository URLs to resolve javaDependencies from. mavenCentral() is always included."; + }; + + features = mkOption { + type = types.listOf (types.attrsOf types.str); + default = [ ]; + description = "Features the mod requires in the game"; + }; + }; +} diff --git a/templates/1.21.x/Block.java.jinja b/templates/1.21.x/Block.java.jinja new file mode 100644 index 0000000..734ffef --- /dev/null +++ b/templates/1.21.x/Block.java.jinja @@ -0,0 +1,89 @@ +// This file is generated by snowy. Edits will be overriden! + +{% set class_name = ("Base" if block.customClassName else "") ~ block.concreteClassName %} +{% set concrete_name = block.customClassName if block.customClassName else block.concreteClassName %} +package {{ javaPackage }}.generated.blocks; + +import com.mojang.serialization.MapCodec; + +{% if block.blockEntity %} +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +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.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +{% if block.blockEntity.customClassName %} +import {{ javaPackage }}.blocks.{{ block.blockEntity.customClassName }}; +{% else %} +import {{ javaPackage }}.generated.blocks.{{ block.blockEntity.concreteClassName }}; +{% endif %} +{% else %} +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockBehaviour; +{% endif %} +{% if block.customClassName %} +import {{ javaPackage }}.blocks.{{ block.customClassName }}; +{% endif %} + +public {% if block.customClassName %}abstract {% endif %}class {{ class_name }} extends {% if block.blockEntity %}BaseEntityBlock{% else %}Block{% endif %} { + public {{ class_name }}(BlockBehaviour.Properties properties) { + super(properties); + } + + // Required since Minecraft 1.20.5: every Block subclass needs its own codec + // for data-driven block (de)serialization, even if nothing here actually + // exercises it. Built inline (not cached in a static field) so {{ class_name }}'s + // class-init never eagerly references {{ concrete_name }}, which would otherwise + // be a superclass-to-subclass static reference and a JVM classloading deadlock risk. + @Override + public MapCodec codec() { + return simpleCodec({{ concrete_name }}::new); + } +{% if block.blockEntity %} + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new {{ block.blockEntity.concreteClassName }}(pos, state); + } + + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType type) { +{% if block.blockEntity.tickerClient and block.blockEntity.tickerServer %} + return (lvl, pos, st, be) -> { + if (be instanceof {{ block.blockEntity.concreteClassName }} typed) { + typed.tick(); + } + }; +{% elif block.blockEntity.tickerServer %} + if (level.isClientSide()) { + return null; + } + return (lvl, pos, st, be) -> { + if (be instanceof {{ block.blockEntity.concreteClassName }} typed) { + typed.tick(); + } + }; +{% elif block.blockEntity.tickerClient %} + if (!level.isClientSide()) { + return null; + } + return (lvl, pos, st, be) -> { + if (be instanceof {{ block.blockEntity.concreteClassName }} typed) { + typed.tick(); + } + }; +{% else %} + return null; +{% endif %} + } +{% endif %} +} diff --git a/templates/1.21.x/BlockEntities.java.jinja b/templates/1.21.x/BlockEntities.java.jinja new file mode 100644 index 0000000..5e14ca9 --- /dev/null +++ b/templates/1.21.x/BlockEntities.java.jinja @@ -0,0 +1,33 @@ +// This file is generated by snowy. Edits will be overriden! +package {{ javaPackage }}.generated.blocks; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; +{% for block in blocks %} +{% if block.blockEntity %} +{% if block.blockEntity.customClassName %} +import {{ javaPackage }}.blocks.{{ block.blockEntity.customClassName }}; +{% else %} +import {{ javaPackage }}.generated.blocks.{{ block.blockEntity.concreteClassName }}; +{% endif %} +{% endif %} +{% endfor %} + +public class {{ blockEntitiesClassName }} { + private static final String MODID = "{{ modid }}"; + + public static final DeferredRegister> BLOCK_ENTITY_TYPES = DeferredRegister.create(BuiltInRegistries.BLOCK_ENTITY_TYPE, MODID); +{% for block in blocks %} +{% if block.blockEntity %} + public static final DeferredHolder, BlockEntityType<{{ block.blockEntity.concreteClassName }}>> {{ block.path | upper }}_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("{{ block.path }}", + () -> BlockEntityType.Builder.of((pos, state) -> new {{ block.blockEntity.concreteClassName }}(pos, state), {{ blocksClassName }}.{{ block.path | upper }}.get()).build(null)); +{% endif %} +{% endfor %} + + public static void register(IEventBus modEventBus) { + BLOCK_ENTITY_TYPES.register(modEventBus); + } +} diff --git a/templates/1.21.x/BlockEntity.java.jinja b/templates/1.21.x/BlockEntity.java.jinja new file mode 100644 index 0000000..38a50ac --- /dev/null +++ b/templates/1.21.x/BlockEntity.java.jinja @@ -0,0 +1,17 @@ +// This file is generated by snowy. Edits will be overriden! +package {{ javaPackage }}.generated.blocks; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +public {% if block.blockEntity.customClassName %}abstract {% endif %}class {% if block.blockEntity.customClassName %}Base{% endif %}{{ block.blockEntity.concreteClassName }} extends BlockEntity { + // {{ blockEntitiesClassName }} lives in this same package, so no import needed. + public {% if block.blockEntity.customClassName %}Base{% endif %}{{ block.blockEntity.concreteClassName }}(BlockPos pos, BlockState state) { + super({{ blockEntitiesClassName }}.{{ block.path | upper }}_BLOCK_ENTITY.get(), pos, state); + } + + public void tick() { + // Default no-op; override in a customClass to add behavior. + } +} diff --git a/templates/1.21.x/BlockModel.json.jinja b/templates/1.21.x/BlockModel.json.jinja new file mode 100644 index 0000000..f7bdc9a --- /dev/null +++ b/templates/1.21.x/BlockModel.json.jinja @@ -0,0 +1,12 @@ +{ + "parent": "minecraft:block/cube", + "textures": { + "particle": "{{ modid }}:block/{{ block.resolvedTextures.particle }}", + "north": "{{ modid }}:block/{{ block.resolvedTextures.north }}", + "south": "{{ modid }}:block/{{ block.resolvedTextures.south }}", + "east": "{{ modid }}:block/{{ block.resolvedTextures.east }}", + "west": "{{ modid }}:block/{{ block.resolvedTextures.west }}", + "up": "{{ modid }}:block/{{ block.resolvedTextures.up }}", + "down": "{{ modid }}:block/{{ block.resolvedTextures.down }}" + } +} diff --git a/templates/1.21.x/Blocks.java.jinja b/templates/1.21.x/Blocks.java.jinja new file mode 100644 index 0000000..30556df --- /dev/null +++ b/templates/1.21.x/Blocks.java.jinja @@ -0,0 +1,39 @@ +// This file is generated by snowy. Edits will be overriden! +package {{ javaPackage }}.generated.blocks; + +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredBlock; +import net.neoforged.neoforge.registries.DeferredItem; +import net.neoforged.neoforge.registries.DeferredRegister; +{% for import in blockPropertyImports %} +import {{ import }}; +{% endfor %} +{% for block in blocks %} +{% if block.customClassName %} +import {{ javaPackage }}.blocks.{{ block.customClassName }}; +{% else %} +import {{ javaPackage }}.generated.blocks.{{ block.concreteClassName }}; +{% endif %} +{% endfor %} + +public class {{ blocksClassName }} { + private static final String MODID = "{{ modid }}"; + + public static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MODID); + public static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MODID); +{% for block in blocks %} + public static final DeferredBlock<{{ block.concreteClassName }}> {{ block.path | upper }} = BLOCKS.register("{{ block.path }}", + () -> new {{ block.concreteClassName }}(BlockBehaviour.Properties.of(){% for call in block.propertyCalls %}{{ call }}{% endfor %})); +{% if block.item.enabled %} + public static final DeferredItem {{ block.path | upper }}_ITEM = ITEMS.registerSimpleBlockItem("{{ block.path }}", {{ block.path | upper }}); +{% endif %} +{% endfor %} + + public static void register(IEventBus modEventBus) { + BLOCKS.register(modEventBus); + ITEMS.register(modEventBus); + } +} diff --git a/templates/1.21.x/ClientMod.java.jinja b/templates/1.21.x/ClientMod.java.jinja new file mode 100644 index 0000000..5cdaecc --- /dev/null +++ b/templates/1.21.x/ClientMod.java.jinja @@ -0,0 +1,41 @@ +// This file is generated by snowy. Edits will be overriden! +package {{ javaPackage }}{% if customClientClassName %}.generated{% endif %}; + +import org.slf4j.Logger; + +import com.mojang.logging.LogUtils; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +{% if not customClientClassName %} +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; +{% endif %} + +{% if not customClientClassName %} +// Client-only setup. A separate @Mod class with dist = Dist.CLIENT is NeoForge's +// recommended pattern -- it keeps this class (and anything it references) from ever +// being loaded on a dedicated server, where those classes don't exist in the jar. +@Mod(value = {{ clientClassName }}.MODID, dist = Dist.CLIENT) +@EventBusSubscriber(modid = {{ clientClassName }}.MODID, value = Dist.CLIENT) +{% endif %} +public {% if customClientClassName %}abstract {% endif %}class {{ clientClassName }}{% if customClientClassName %}Base{% endif %} { + public static final String MODID = "{{ modid }}"; + public static final Logger LOGGER = LogUtils.getLogger(); + + public {{ clientClassName }}{% if customClientClassName %}Base{% endif %}(ModContainer modContainer) { + } +{% if not customClientClassName %} + + // @EventBusSubscriber only scans static @SubscribeEvent methods declared + // directly on the annotated class -- a customClientClass subclass needs its + // own @EventBusSubscriber (and its own static handlers) rather than relying + // on this one. + @SubscribeEvent + static void onClientSetup(FMLClientSetupEvent event) { + LOGGER.info("HELLO FROM CLIENT SETUP"); + } +{% endif %} +} diff --git a/templates/1.21.x/ItemModel.json.jinja b/templates/1.21.x/ItemModel.json.jinja new file mode 100644 index 0000000..5f37c99 --- /dev/null +++ b/templates/1.21.x/ItemModel.json.jinja @@ -0,0 +1,12 @@ +{% if block.item.texture %} +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "{{ modid }}:item/{{ block.item.texture }}" + } +} +{% else %} +{ + "parent": "{{ modid }}:block/{{ block.path }}" +} +{% endif %} diff --git a/templates/1.21.x/MainMod.java.jinja b/templates/1.21.x/MainMod.java.jinja new file mode 100644 index 0000000..d379f29 --- /dev/null +++ b/templates/1.21.x/MainMod.java.jinja @@ -0,0 +1,51 @@ +// This file is generated by snowy. Edits will be overriden! +package {{ javaPackage }}{% if customClassName %}.generated{% endif %}; + +import org.slf4j.Logger; + +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.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStartingEvent; + +import {{ javaPackage }}.generated.blocks.{{ blocksClassName }}; +import {{ javaPackage }}.generated.blocks.{{ blockEntitiesClassName }}; + +{% if not customClassName %} +// The value here should match an entry in the META-INF/neoforge.mods.toml file +@Mod({{ className }}.MODID) +{% endif %} +public {% if customClassName %}abstract {% endif %}class {{ className }}{% if customClassName %}Base{% endif %} { + // Define mod id in a common place for everything to reference + public static final String MODID = "{{ modid }}"; + // Directly reference a slf4j logger + public static final Logger LOGGER = LogUtils.getLogger(); + + // The constructor for the mod class is the first code that is run when your mod is loaded. + // FML will recognize some parameter types like IEventBus or ModContainer and pass them in automatically. + public {{ className }}{% if customClassName %}Base{% endif %}(IEventBus modEventBus, ModContainer modContainer) { + // Register the commonSetup method for modloading + modEventBus.addListener(this::commonSetup); + + {{ blocksClassName }}.register(modEventBus); + {{ blockEntitiesClassName }}.register(modEventBus); + + // Registered via addListener (not @SubscribeEvent + EVENT_BUS.register(this)): + // NeoForge's EventBus refuses register(this) whenever the listener's runtime + // class is a subclass of the one declaring the @SubscribeEvent method, which + // is exactly what happens once a customClass extends this Base class. + NeoForge.EVENT_BUS.addListener(this::onServerStarting); + } + + {% if customClassName %}protected{% else %}private{% endif %} void commonSetup(FMLCommonSetupEvent event) { + LOGGER.info("HELLO FROM COMMON SETUP"); + } + + {% if customClassName %}protected{% else %}private{% endif %} void onServerStarting(ServerStartingEvent event) { + LOGGER.info("HELLO from server starting"); + } +} diff --git a/templates/1.21.x/blockstates.json.jinja b/templates/1.21.x/blockstates.json.jinja new file mode 100644 index 0000000..bfe1e2c --- /dev/null +++ b/templates/1.21.x/blockstates.json.jinja @@ -0,0 +1,5 @@ +{ + "variants": { + "": { "model": "{{ modid }}:block/{{ block.path }}" } + } +} diff --git a/templates/1.21.x/build.gradle.jinja b/templates/1.21.x/build.gradle.jinja new file mode 100644 index 0000000..3e4bf3d --- /dev/null +++ b/templates/1.21.x/build.gradle.jinja @@ -0,0 +1,101 @@ +// This file is generated by snowy. Edits will be overriden! +plugins { + id 'java-library' + id 'maven-publish' + id 'net.neoforged.moddev' version '2.0.143' + id 'idea' +} + +tasks.named('wrapper', Wrapper).configure { + distributionType = Wrapper.DistributionType.BIN +} + +version = mod_version +group = mod_group_id + +repositories { + mavenCentral() +{% for repo in mavenRepositories %} + maven { + url "{{ repo }}" + } +{% endfor %} +} + +base { + archivesName = mod_id +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + version = project.neo_version + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + runs { + client { + client() + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + configureEach { + systemProperty 'forge.logging.markers', 'REGISTRIES' + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + "{{ modid }}" { + sourceSet(sourceSets.main) + } + } +} + +configurations { + runtimeClasspath.extendsFrom localRuntime +} + +dependencies { +{% for dep in javaDependencies %} + {{ dep.configuration }} "{{ dep.coordinate }}" +{% endfor %} +} + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + } + } + repositories { + maven { + url "file://${project.projectDir}/repo" + } + } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} diff --git a/templates/1.21.x/gitignore.jinja b/templates/1.21.x/gitignore.jinja new file mode 100644 index 0000000..1a8c6c8 --- /dev/null +++ b/templates/1.21.x/gitignore.jinja @@ -0,0 +1,52 @@ +### snowy ### +# Regenerated by `nix develop` on every shell entry -- never hand-edit these. +/build.gradle +/settings.gradle +/gradle.properties +/gradlew +/gradlew.bat +/gradle/ +/src/main/resources/META-INF/ +/src/main/resources/assets/*/blockstates/ +/src/main/java/**/generated/ + +### Gradle ### +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/**/build/ + +### IntelliJ IDEA ### +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/**/out/ + +.run/ + +### Eclipse ### +.apt_generated +.classpath +.eclipse/ +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/**/bin/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Minecraft Modding ### +run/ +!**/src/**/run/ +**/src/generated/**/.cache/ +repo/ +!**/src/**/repo/ diff --git a/templates/1.21.x/gradle.properties.jinja b/templates/1.21.x/gradle.properties.jinja new file mode 100644 index 0000000..24fec45 --- /dev/null +++ b/templates/1.21.x/gradle.properties.jinja @@ -0,0 +1,21 @@ +# Generated by snowy. Do not edit by hand -- edit mod.nix instead. +org.gradle.jvmargs=-Xmx1G +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +parchment_minecraft_version={{ parchmentMinecraftVersion }} +parchment_mappings_version={{ parchmentMappingsVersion }} + +minecraft_version={{ targetVersion }} +minecraft_version_range={{ minecraftVersionRange }} +neo_version={{ neoVersion }} +loader_version_range={{ loaderVersionRange }} + +## Mod Properties +mod_id={{ modid }} +mod_name={{ displayName }} +mod_license={{ license }} +mod_version={{ version }} +mod_group_id={{ javaPackage }} diff --git a/templates/1.21.x/neoforge.mods.toml.jinja b/templates/1.21.x/neoforge.mods.toml.jinja new file mode 100644 index 0000000..a217177 --- /dev/null +++ b/templates/1.21.x/neoforge.mods.toml.jinja @@ -0,0 +1,51 @@ +# This file is generated by snowy. Edit via mod.nix. + +modLoader="javafml" #mandatory + +loaderVersion="{{ loaderVersionRange }}" #mandatory + +license="{{ license }}" + +[[mods]] #mandatory + +modId="{{ modid }}" #mandatory + +version="{{ version }}" #mandatory + +displayName="{{ displayName }}" #mandatory + +{% if authors -%} +authors="{{ authors | join(', ') }}" +{% endif -%} + +description=''' +{{ description }} +''' + +[[dependencies.{{ modid }}]] #optional + modId="neoforge" #mandatory + type="required" #mandatory + versionRange="[{{ neoVersion }},)" #mandatory + ordering="NONE" + side="BOTH" + +[[dependencies.{{ modid }}]] + modId="minecraft" + type="required" + versionRange="{{ minecraftVersionRange }}" + ordering="NONE" + side="BOTH" +{% for dep in dependencies %} +[[dependencies.{{ modid }}]] + modId="{{ dep.modId }}" + type="{{ dep.type }}" + versionRange="{{ dep.versionRange }}" + ordering="{{ dep.ordering | upper }}" + side="{{ dep.side | upper }}" +{% endfor %} +{% for feature in features %} +[features.{{ modid }}] +{% for key, value in feature.items() %} +{{ key }}="{{ value }}" +{% endfor %} +{% endfor %} diff --git a/templates/1.21.x/settings.gradle.jinja b/templates/1.21.x/settings.gradle.jinja new file mode 100644 index 0000000..1d99c7e --- /dev/null +++ b/templates/1.21.x/settings.gradle.jinja @@ -0,0 +1,12 @@ +// This file is generated by snowy. Edits will be overriden! +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = "{{ modid }}" diff --git a/tests/fixtures/basicmod-neoforge-1.21.1/.gitignore b/tests/fixtures/basicmod-neoforge-1.21.1/.gitignore new file mode 100644 index 0000000..1a8c6c8 --- /dev/null +++ b/tests/fixtures/basicmod-neoforge-1.21.1/.gitignore @@ -0,0 +1,52 @@ +### snowy ### +# Regenerated by `nix develop` on every shell entry -- never hand-edit these. +/build.gradle +/settings.gradle +/gradle.properties +/gradlew +/gradlew.bat +/gradle/ +/src/main/resources/META-INF/ +/src/main/resources/assets/*/blockstates/ +/src/main/java/**/generated/ + +### Gradle ### +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/**/build/ + +### IntelliJ IDEA ### +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/**/out/ + +.run/ + +### Eclipse ### +.apt_generated +.classpath +.eclipse/ +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/**/bin/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Minecraft Modding ### +run/ +!**/src/**/run/ +**/src/generated/**/.cache/ +repo/ +!**/src/**/repo/ diff --git a/tests/fixtures/basicmod-neoforge-1.21.1/mod.nix b/tests/fixtures/basicmod-neoforge-1.21.1/mod.nix new file mode 100644 index 0000000..d616ea8 --- /dev/null +++ b/tests/fixtures/basicmod-neoforge-1.21.1/mod.nix @@ -0,0 +1,10 @@ +{ + modid = "examplemod"; + version = "1.0.0"; + displayName = "Example Mod"; + authors = [ "Khaumi" "You" ]; + description = "An example mod for the snowy framework."; + targetFramework = ["neoforge"]; + targetVersion = "1.21.1"; + license = "MIT"; +} \ No newline at end of file diff --git a/tests/fixtures/basicmod-neoforge-1.21.1/src/main/java/com/example/examplemod/Examplemod.java b/tests/fixtures/basicmod-neoforge-1.21.1/src/main/java/com/example/examplemod/Examplemod.java new file mode 100644 index 0000000..0027027 --- /dev/null +++ b/tests/fixtures/basicmod-neoforge-1.21.1/src/main/java/com/example/examplemod/Examplemod.java @@ -0,0 +1,51 @@ +// This file is generated by snowy. Edits will be overriden! +package com.example.examplemod; + +import org.slf4j.Logger; + +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.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStartingEvent; + +import com.example.examplemod.generated.blocks.ExamplemodBlocks; +import com.example.examplemod.generated.blocks.ExamplemodBlockEntities; + + +// The value here should match an entry in the META-INF/neoforge.mods.toml file +@Mod(Examplemod.MODID) + +public class Examplemod { + // Define mod id in a common place for everything to reference + public static final String MODID = "examplemod"; + // Directly reference a slf4j logger + public static final Logger LOGGER = LogUtils.getLogger(); + + // The constructor for the mod class is the first code that is run when your mod is loaded. + // FML will recognize some parameter types like IEventBus or ModContainer and pass them in automatically. + public Examplemod(IEventBus modEventBus, ModContainer modContainer) { + // Register the commonSetup method for modloading + modEventBus.addListener(this::commonSetup); + + ExamplemodBlocks.register(modEventBus); + ExamplemodBlockEntities.register(modEventBus); + + // Registered via addListener (not @SubscribeEvent + EVENT_BUS.register(this)): + // NeoForge's EventBus refuses register(this) whenever the listener's runtime + // class is a subclass of the one declaring the @SubscribeEvent method, which + // is exactly what happens once a customClass extends this Base class. + NeoForge.EVENT_BUS.addListener(this::onServerStarting); + } + + private void commonSetup(FMLCommonSetupEvent event) { + LOGGER.info("HELLO FROM COMMON SETUP"); + } + + private void onServerStarting(ServerStartingEvent event) { + LOGGER.info("HELLO from server starting"); + } +} diff --git a/tests/fixtures/silicon-neoforge-1.21.1 b/tests/fixtures/silicon-neoforge-1.21.1 new file mode 120000 index 0000000..fa546c8 --- /dev/null +++ b/tests/fixtures/silicon-neoforge-1.21.1 @@ -0,0 +1 @@ +/home/jebkerman/git/silicon/ \ No newline at end of file