Files
snowy/codegen/blockbuilder.py
2026-08-12 07:09:10 -05:00

153 lines
6.5 KiB
Python

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/<face> 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)