Files
shacraft-core/scripts/prepare_vanilla_texture_pack.py
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
2026-09-17 02:10:53 +03:00

263 lines
12 KiB
Python

#!/usr/bin/env python3
"""Prepare a local test atlas from an existing Java client JAR (no downloads).
Keeps original pixel colors; smaller sprites use integer nearest-neighbor
scaling. Animated sprites use their first declared frame. Face materials are
projected onto Shacraft's existing geometry, not a replacement model renderer.
Requires Pillow. Generated assets must not be redistributed without permission.
"""
import argparse
from functools import lru_cache
import hashlib
from io import BytesIO
import json
import math
from pathlib import Path
import shutil
import zipfile
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
PREFIX = 'assets/minecraft/'
DIRECTIONS = ['east', 'west', 'up', 'down', 'south', 'north']
NORMALS = [(1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)]
def norm(name):
return name.removeprefix('minecraft:')
def projection(direction, p):
x, y, z = p
return [(1-z, 1-y), (z, 1-y), (x, z), (x, 1-z), (x, 1-y), (1-x, 1-y)][direction]
def rotate(p, x, y):
# Java blockstate rotations are clockwise around the block center.
x, y = math.radians(-x), math.radians(-y)
a, b, c = p
b, c = b*math.cos(x)-c*math.sin(x), b*math.sin(x)+c*math.cos(x)
return (a*math.cos(y)+c*math.sin(y), b, -a*math.sin(y)+c*math.cos(y))
def condition(when, props):
if 'OR' in when:
return any(condition(c, props) for c in when['OR'])
if 'AND' in when:
return all(condition(c, props) for c in when['AND'])
return all(props.get(k) in str(v).split('|') for k, v in when.items())
class Compiler:
def __init__(self, jar, images):
self.jar, self.images = jar, images
@lru_cache(None)
def read(self, path):
return json.loads(self.jar.read(PREFIX + path))
@lru_cache(None)
def model(self, name):
data = self.read('models/' + norm(name) + '.json')
parent = data.get('parent')
base = self.model(parent) if parent and 'builtin/' not in parent else {}
return {**base, **data, 'textures': {**base.get('textures', {}), **data.get('textures', {})}}
def resolve(self, value, textures):
seen = set()
while isinstance(value, str) and value.startswith('#') and value not in seen:
seen.add(value)
value = textures.get(value[1:])
if isinstance(value, dict):
value = value.get('sprite')
if isinstance(value, str) and norm(value).startswith('block/'):
name = norm(value).removeprefix('block/')
if name in self.images:
return name
return None
@lru_cache(None)
def model_faces(self, name, rx=0, ry=0, uvlock=False):
model = self.model(name)
textures = model.get('textures', {})
result, areas = [None]*6, [-1]*6
for element in model.get('elements', []):
lo = [v/16 for v in element['from']]
hi = [v/16 for v in element['to']]
for direction, data in element.get('faces', {}).items():
texture = self.resolve(data.get('texture'), textures)
if not texture or texture == 'grass_block_side_overlay':
continue # The opaque grass side already includes its edge.
face = DIRECTIONS.index(direction)
normal = tuple(round(v) for v in rotate(NORMALS[face], rx, ry))
dest = NORMALS.index(normal)
axes = [i for i, v in enumerate(NORMALS[face]) if not v]
area = math.prod(abs(hi[i]-lo[i]) for i in axes)
if area <= areas[dest]:
continue
areas[dest] = area
corners = [projection(face, p) for p in [lo, hi]]
low = [min(c[i] for c in corners) for i in range(2)]
high = [max(c[i] for c in corners) for i in range(2)]
rect = [v/16 for v in data.get('uv', [low[0]*16, low[1]*16, high[0]*16, high[1]*16])]
def uv_at(world):
# Inverse rotation is the transpose of the orthogonal basis.
offset = [v-.5 for v in world]
basis = [rotate(axis, rx, ry) for axis in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]]
local = [.5 + sum(a*b for a, b in zip(offset, axis)) for axis in basis]
uv = projection(face, local)
if uvlock:
return projection(dest, world)
uv = [(uv[i]-low[i])/max(high[i]-low[i], 1e-6) for i in range(2)]
for _ in range(data.get('rotation', 0)//90):
uv = [uv[1], 1-uv[0]]
return [rect[i] + uv[i]*(rect[i+2]-rect[i]) for i in range(2)]
zero = uv_at([0, 0, 0])
unit = [uv_at(p) for p in [[1, 0, 0], [0, 1, 0], [0, 0, 1]]]
transform = [round(v, 6) for i in range(2) for v in [*(p[i]-zero[i] for p in unit), zero[i]]]
result[dest] = {'texture': texture, 'uv': transform, 'tinted': 'tintindex' in data}
particle = self.resolve(textures.get('particle'), textures)
fallback = next((v for v in result if v), None)
for i, face in enumerate(result):
if face is None:
texture = particle or (fallback and fallback['texture'])
result[i] = {'texture': texture, 'tinted': bool(fallback and fallback['tinted'])} if texture else None
return result
def faces(self, block, props):
definition = self.read('blockstates/' + norm(block) + '.json')
models = []
for selector, model in definition.get('variants', {}).items():
when = dict(part.split('=', 1) for part in selector.split(',') if part)
if condition(when, props):
models.append(model[0] if isinstance(model, list) else model)
break
for part in definition.get('multipart', []):
if condition(part.get('when', {}), props):
model = part['apply']
models.append(model[0] if isinstance(model, list) else model)
faces = [None]*6
for model in models:
incoming = self.model_faces(model['model'], model.get('x', 0), model.get('y', 0), model.get('uvlock', False))
faces = [a or b for a, b in zip(faces, incoming)]
result = []
for face in faces:
if not face:
result.append(None)
continue
face = face.copy()
tinted = face.pop('tinted', False)
texture = face['texture']
if tinted:
tint = [0.58, 0.8, 0.34]
if 'leaves' in block or 'vine' in block:
tint = [0.46, 0.7, 0.28]
if 'spruce' in block:
tint = [0.38, 0.60, 0.38]
if block == 'minecraft:redstone_wire':
power = int(props.get('power', '0'))/15
tint = [.3+.7*power, max(0, power*power*.7-.5), 0]
face['tint'] = tint
if block in ('minecraft:water', 'minecraft:bubble_column'):
face['tint'] = [0.25, 0.46, 0.9]
alpha = self.images[texture].getchannel('A').getextrema()
face['cutout'] = alpha[0] == 0 and alpha[1] == 255
result.append(face)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--jar', type=Path, required=True)
parser.add_argument('--states', type=Path, required=True, help='Matching generated/reports/blocks.json')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
output = args.output.resolve()
if output.exists():
parser.error('Output already exists; choose a new directory.')
jar = zipfile.ZipFile(args.jar)
images, animated, native_sizes = {}, [], {}
for path in sorted(jar.namelist()):
if not path.startswith(PREFIX + 'textures/block/') or not path.endswith('.png'):
continue
name = Path(path).stem
image = Image.open(BytesIO(jar.read(path))).convert('RGBA')
metadata = json.loads(jar.read(path + '.mcmeta')).get('animation', {}) if path + '.mcmeta' in jar.namelist() else {}
width = metadata.get('width', min(image.size))
height = metadata.get('height', width)
frames = metadata.get('frames', [0])
first = frames[0] if frames else 0
index = first.get('index', 0) if isinstance(first, dict) else first
x, y = index % (image.width//width)*width, index // (image.width//width)*height
frame = image.crop((x, y, x+width, y+height))
native_sizes[name] = [width, height]
if metadata or image.size != frame.size:
animated.append(name)
images[name] = frame.resize((32, 32), Image.Resampling.NEAREST)
compiler = Compiler(jar, images)
report = json.loads(args.states.read_text())
sets, lookup, states, defaults, uncovered = [], {}, {}, {}, []
for block, definition in report.items():
for state in definition['states']:
props = state.get('properties', {})
try:
faces = compiler.faces(block, props)
except KeyError:
faces = [None]*6
key = json.dumps(faces, separators=(',', ':'), sort_keys=True)
if key not in lookup:
lookup[key] = len(sets)
sets.append(faces)
canonical = block + ('['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']' if props else '')
states[canonical] = lookup[key]
if state.get('default'):
defaults[block] = lookup[key]
if not any(faces):
uncovered.append(block)
output.mkdir(parents=True)
for base in ('base', 'trampoline'):
shutil.copytree(ROOT/'packages'/base, output/base)
package = output/'vanilla'
(package/'client').mkdir(parents=True)
columns = 40
rows = math.ceil(len(images)/columns)
atlas = Image.new('RGBA', (columns*32, rows*32))
for i, image in enumerate(images.values()):
atlas.paste(image, (i % columns*32, i//columns*32))
atlas.save(package/'client/atlas.png', optimize=True)
descriptor = {'schema': 1, 'texture_pack': {
'name': 'Minecraft 26.2 Original — Local Test', 'pixel_size': 32,
'atlas': {'path': 'client/atlas.png', 'columns': columns, 'rows': rows},
'textures': [{'name': name} for name in images],
'grass_overlay': {'base': 'grass_block_side', 'overlay': 'grass_block_side_overlay'},
'block_faces': {'sets': sets, 'states': states, 'defaults': defaults},
}}
(package/'client/style.json').write_text(json.dumps(descriptor, separators=(',', ':'))+'\n')
resources = []
for path, role in [('client/atlas.png', 'texture'), ('client/style.json', 'client-style')]:
data = (package/path).read_bytes()
assert len(data) < 16*1024*1024
resources.append({'path': path, 'scope': 'client', 'role': role, 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()})
base = json.loads((output/'base/manifest.json').read_text())
manifest = {'schema': 1, 'id': 'shacraft.vanilla.local', 'version': '1.0.1',
'license': 'Minecraft assets copyright Mojang/Microsoft. Local testing only; no redistribution rights granted.',
'dependencies': [{'id': base['id'], 'version': base['version']}],
'capabilities': ['client.texture', 'client.style'], 'resources': resources}
(package/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
receipt = {'textures': len(images), 'states': len(states), 'face_sets': len(sets),
'source_sha256': hashlib.sha256(args.jar.read_bytes()).hexdigest(),
'native_sizes': dict((str(size), list(native_sizes.values()).count(size)) for size in native_sizes.values()),
'animated_first_frame': animated, 'untextured_default_blocks': uncovered,
'atlas_bytes': (package/'client/atlas.png').stat().st_size,
'mapping_bytes': (package/'client/style.json').stat().st_size,
'limitations': ['Static first animation frame', 'Existing engine geometry; complex multipart and entity-rendered blocks are approximated', 'Fixed foliage tint rather than biome colors']}
(output/'receipt.json').write_text(json.dumps(receipt, indent=2)+'\n')
print(json.dumps(receipt, indent=2))
if __name__ == '__main__':
main()