diff --git a/scripts/collision_from_tiled.py b/scripts/collision_from_tiled.py new file mode 100644 index 0000000..6832d91 --- /dev/null +++ b/scripts/collision_from_tiled.py @@ -0,0 +1,167 @@ +"""Convert a finite Tiled JSON map to the docs/13 collision contract. + +Usage: python scripts/collision_from_tiled.py export.tmj +Set string map properties region (rusty-creek), chunk (rc-0-0) and state +(clean/anomaly). Use an orthogonal 2160x720 map. Reference image layers are +ignored; object layer names and shapes follow docs/13. Box layers require +Tiled rectangles, not open polylines or polygons. Object properties supply +kind/id/damage; an object's name can supply id. Additional custom properties +(such as pickup item) are retained unchanged. No image data is read. +""" +from __future__ import annotations + +import argparse +import json +import math +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SIZE = (2160, 720) +LAYERS = { + 'walk': 'walk', 'slide': 'slide', 'one-way': 'oneWay', + 'climb': 'climb', 'solid': 'solid', 'no-go': 'noGo', + 'hazard': 'hazard', 'interact': 'interact', 'spawn': 'spawn', + 'ledge': 'ledge', 'glide-lift': 'glideLift', +} +LINES = {'walk', 'slide', 'one-way'} +POINTS = {'spawn', 'ledge'} + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def integer(value, context): + require(type(value) in (int, float) and math.isfinite(value) + and value == int(value), f'{context}: expected integer stage pixels') + return int(value) + + +def slug(value, context): + require(isinstance(value, str) and re.fullmatch(r'[a-z][a-z0-9-]*', value), + f'{context}: expected a lower-case name with dashes') + return value + + +def properties(data): + result = {} + for prop in data.get('properties', []): + require(prop['name'] not in result, f'duplicate property {prop["name"]}') + result[prop['name']] = property_value(prop) + return result + + +def property_value(prop): + # Tiled 1.12 list entries carry type/value wrappers, unlike plain JSON arrays. + if prop.get('type') == 'list': + return [property_value(entry) for entry in prop['value']] + return prop['value'] + + +def point(x, y, context): + x, y = integer(x, context), integer(y, context) + require(0 <= x <= SIZE[0] and 0 <= y <= SIZE[1], + f'{context}: point ({x}, {y}) outside 2160x720 chunk') + return [x, y] + + +def convert(data): + """Return (region, collision); reject unsupported authoring, never infer it.""" + require(data.get('type') == 'map' and data.get('orientation') == 'orthogonal' + and not data.get('infinite', False), 'expected a finite orthogonal Tiled map') + for axis, tile, expected in [('width', 'tilewidth', 2160), ('height', 'tileheight', 720)]: + count, pixels = integer(data[axis], axis), integer(data[tile], tile) + require(count > 0 and pixels > 0 and count * pixels == expected, + 'map must cover exactly 2160x720 stage pixels') + meta = properties(data) + region = slug(meta.get('region'), 'map region') + chunk = slug(meta.get('chunk'), 'map chunk') + state = meta.get('state') + require(state in ('clean', 'anomaly'), 'map state must be clean or anomaly') + result = {'chunk': chunk, 'state': state, 'size': list(SIZE), + **{name: [] for name in LAYERS.values()}} + identifiers = set() + for layer in data['layers']: + if layer.get('type') == 'imagelayer': + continue # The composed chunk is an authoring backdrop, never collision. + name = layer.get('name') + require(name in LAYERS, f'unknown collision layer {name!r}') + require(layer.get('type') == 'objectgroup', f'{name}: expected an object layer') + offset_x = integer(layer.get('offsetx', 0), name) + offset_y = integer(layer.get('offsety', 0), name) + require(layer.get('x', 0) == 0 and layer.get('y', 0) == 0, + f'{name}: use pixel offsets, not tile offsets') + for obj in layer['objects']: + context = f'{name} object {obj.get("id", "?")}' + require(obj.get('rotation', 0) == 0, f'{context}: rotation is unsupported') + require(not any(key in obj for key in ('gid', 'template', 'text')) + and not obj.get('ellipse') and not obj.get('capsule'), + f'{context}: unsupported object shape') + x, y = point(integer(obj['x'], context) + offset_x, + integer(obj['y'], context) + offset_y, context) + if name in LINES: + require('polyline' in obj and not obj.get('point') and 'polygon' not in obj, + f'{context}: expected a polyline') + vertices = [point(x + p['x'], y + p['y'], context) for p in obj['polyline']] + require(len(vertices) >= 2, f'{context}: polyline needs at least two points') + require(all(a[0] < b[0] for a, b in zip(vertices, vertices[1:])), + f'{context}: polyline must run strictly left to right') + result[LAYERS[name]].append(vertices) + continue + require('polyline' not in obj and 'polygon' not in obj, + f'{context}: expected a closed rectangle or point, not an open box') + item = {'x': x, 'y': y} + if name in POINTS: + require(obj.get('point') is True and obj.get('width', 0) == 0 + and obj.get('height', 0) == 0, f'{context}: expected a Tiled point') + else: + w, h = integer(obj['width'], context), integer(obj['height'], context) + require(not obj.get('point') and w > 0 and h > 0, + f'{context}: rectangle must have positive width and height') + point(x + w, y + h, context) + item.update(w=w, h=h) + props = properties(obj) + require(not {'x', 'y', 'w', 'h'} & props.keys(), + f'{context}: custom properties cannot override geometry') + # Encounter metadata belongs to the level author, not this converter. + item.update({key: value for key, value in props.items() if key not in ('kind', 'id')}) + for field in ('kind', 'id'): + value = props.get(field, obj.get('name') if field == 'id' else None) + if value: + item[field] = slug(value, f'{context} {field}') + required = {'climb': ('kind',), 'interact': ('kind', 'id'), 'spawn': ('kind', 'id')} + for field in required.get(name, ()): + require(field in item, f'{context}: missing {field} property') + if 'id' in item: + require(item['id'] not in identifiers, f'{context}: duplicate id {item["id"]}') + identifiers.add(item['id']) + if name == 'hazard': + damage = integer(props.get('damage'), f'{context} damage') + require(damage > 0, f'{context}: damage must be positive') + item['damage'] = damage + result[LAYERS[name]].append(item) + return region, result + + +def write_collision(source, root=ROOT): + region, collision = convert(json.loads(Path(source).read_text(encoding='utf-8-sig'))) + output = Path(root) / 'art' / 'stages' / region / f'{collision["chunk"]}.{collision["state"]}.collision.json' + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(collision, indent=2) + '\n', encoding='utf-8') + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path, help='Tiled JSON map (.tmj or .json)') + args = parser.parse_args() + try: + print(write_collision(args.source)) + except (ValueError, KeyError, TypeError, OSError) as error: + parser.exit(1, f'Collision export failed: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/test_collision_from_tiled.py b/scripts/test_collision_from_tiled.py new file mode 100644 index 0000000..a82e0fc --- /dev/null +++ b/scripts/test_collision_from_tiled.py @@ -0,0 +1,177 @@ +"""Small hand-authored Tiled export covering every docs/13 collision layer.""" +import copy +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from collision_from_tiled import LAYERS, convert, write_collision + + +def props(**values): + def typed(value): + if isinstance(value, list): + return {'type': 'list', 'value': [typed(item) for item in value]} + return {'type': {int: 'int', bool: 'bool', str: 'string'}[type(value)], 'value': value} + return [{'name': key, **typed(value)} for key, value in values.items()] + + +def fixture(): + layers = [] + for index, name in enumerate(LAYERS): + obj = {'id': index + 1, 'x': 100, 'y': 200, 'width': 30, 'height': 40, 'rotation': 0} + if name in ('walk', 'slide', 'one-way'): + obj.update(width=0, height=0, polyline=[{'x': 0, 'y': 0}, {'x': 80, 'y': -20}]) + elif name in ('spawn', 'ledge'): + obj.update(width=0, height=0, point=True) + if name in ('climb', 'interact', 'spawn'): + kind = {'climb': 'ladder', 'interact': 'coffee-machine', 'spawn': 'player'}[name] + obj['properties'] = props(kind=kind, id=f'{name}-one') + if name == 'hazard': + obj['properties'] = props(damage=1) + layers.append({'type': 'objectgroup', 'name': name, 'objects': [obj]}) + return {'type': 'map', 'orientation': 'orthogonal', 'infinite': False, + 'width': 135, 'height': 45, 'tilewidth': 16, 'tileheight': 16, + 'properties': props(region='rusty-creek', chunk='rc-0-0', state='anomaly'), + 'layers': layers} + + +class CollisionFromTiledTests(unittest.TestCase): + def setUp(self): + self.data = fixture() + + def object(self, name): + return next(layer['objects'][0] for layer in self.data['layers'] if layer['name'] == name) + + def test_exports_all_contract_shapes_and_destination(self): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / 'example.tmj' + source.write_text(json.dumps(self.data), encoding='utf-8') + output = write_collision(source, folder) + self.assertEqual(output.relative_to(folder).as_posix(), + 'art/stages/rusty-creek/rc-0-0.anomaly.collision.json') + actual = json.loads(output.read_text()) + self.assertEqual(actual['size'], [2160, 720]) + self.assertEqual(actual['walk'], [[[100, 200], [180, 180]]]) + self.assertEqual(actual['oneWay'], actual['walk']) + self.assertEqual(actual['climb'], [dict(x=100, y=200, w=30, h=40, kind='ladder', id='climb-one')]) + self.assertEqual(actual['spawn'], [dict(x=100, y=200, kind='player', id='spawn-one')]) + self.assertEqual(actual['hazard'], [dict(x=100, y=200, w=30, h=40, damage=1)]) + self.assertTrue(all(len(actual[key]) == 1 for key in LAYERS.values())) + + def test_layer_offset_and_image_backdrop(self): + self.data['layers'][0].update(offsetx=10, offsety=-20) + self.data['layers'].append({'type': 'imagelayer', 'name': 'composed chunk', 'image': 'chunk.png'}) + self.assertEqual(convert(self.data)[1]['walk'], [[[110, 180], [190, 160]]]) + + def test_unknown_layer_and_wrong_layer_type(self): + for changes in ({'name': 'typo'}, {'type': 'tilelayer'}): + with self.subTest(changes=changes): + data = copy.deepcopy(self.data) + data['layers'][0].update(changes) + with self.assertRaises(ValueError): + convert(data) + + def test_open_and_degenerate_boxes_fail(self): + for changes in ({'polyline': [{'x': 0, 'y': 0}, {'x': 30, 'y': 40}]}, + {'polygon': []}, {'width': 0}, {'height': -1}, {'rotation': 45}): + with self.subTest(changes=changes): + data = copy.deepcopy(self.data) + data['layers'][3]['objects'][0].update(changes) + with self.assertRaises(ValueError): + convert(data) + + def test_out_of_bounds_points_lines_and_boxes_fail(self): + for name, changes in [('spawn', {'x': 2161}), ('ledge', {'y': -1}), + ('solid', {'x': 2150}), ('walk', {'x': 2150})]: + with self.subTest(name=name): + data = copy.deepcopy(self.data) + obj = next(layer['objects'][0] for layer in data['layers'] if layer['name'] == name) + obj.update(changes) + with self.assertRaisesRegex(ValueError, 'outside'): + convert(data) + + def test_fractional_nonfinite_and_bool_coordinates_fail(self): + for value in (0.5, float('nan'), float('inf'), True): + with self.subTest(value=value): + self.object('spawn')['x'] = value + with self.assertRaises(ValueError): + convert(self.data) + + def test_leftward_or_vertical_ground_fails(self): + for x in (-1, 0): + self.object('walk')['polyline'][1]['x'] = x + with self.assertRaisesRegex(ValueError, 'left to right'): + convert(self.data) + + def test_missing_metadata_or_traversal_never_writes(self): + for metadata in (props(), props(region='../escape', chunk='rc-0-0', state='clean'), + props(region='rusty-creek', chunk='rc-0-0', state='wrong')): + self.data['properties'] = metadata + with self.assertRaises(ValueError): + convert(self.data) + + def test_required_object_properties_and_unique_ids(self): + self.object('spawn')['properties'] = props(kind='player') + with self.assertRaisesRegex(ValueError, 'missing id'): + convert(self.data) + self.object('spawn')['properties'] = props(kind='player', id='climb-one') + with self.assertRaisesRegex(ValueError, 'duplicate id'): + convert(self.data) + + def test_encounter_properties_survive_export_without_losing_types(self): + self.object('interact')['properties'] = props(kind='pickup', id='chair', item='chair', + note='First weapon', enabled=True) + self.object('spawn')['properties'] = props(kind='rover', id='rover-1', patrol=[640, 880]) + actual = convert(self.data)[1] + self.assertEqual(actual['interact'][0], dict(x=100, y=200, w=30, h=40, + kind='pickup', id='chair', item='chair', note='First weapon', enabled=True)) + self.assertEqual(actual['spawn'][0]['patrol'], [640, 880]) + + def test_tiled_nested_list_entries_are_decoded(self): + self.object('spawn')['properties'] = props(kind='rover', id='rover-1', route=[[100, 200], [300, 400]]) + self.assertEqual(convert(self.data)[1]['spawn'][0]['route'], [[100, 200], [300, 400]]) + + def test_custom_properties_cannot_replace_geometry(self): + for key in ('x', 'y', 'w', 'h'): + with self.subTest(key=key): + self.object('interact')['properties'] = props(kind='pickup', id='chair', **{key: -1}) + with self.assertRaisesRegex(ValueError, 'override geometry'): + convert(self.data) + + def test_failed_conversion_does_not_overwrite_existing_output(self): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / 'example.tmj' + source.write_text(json.dumps(self.data)) + output = write_collision(source, folder) + original = output.read_bytes() + self.object('solid')['width'] = 0 + source.write_text(json.dumps(self.data)) + with self.assertRaises(ValueError): + write_collision(source, folder) + self.assertEqual(output.read_bytes(), original) + + def test_cli_writes_contract_and_exits_loudly_on_bad_input(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / 'scripts').mkdir() + script = root / 'scripts' / 'collision_from_tiled.py' + shutil.copy(Path(__file__).with_name('collision_from_tiled.py'), script) + source = root / 'export.tmj' + source.write_text(json.dumps(self.data)) + command = [sys.executable, str(script), str(source)] + good = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(good.returncode, 0, good.stderr) + self.assertTrue((root / 'art/stages/rusty-creek/rc-0-0.anomaly.collision.json').exists()) + self.data['layers'][0]['name'] = 'typo' + source.write_text(json.dumps(self.data)) + bad = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(bad.returncode, 1) + self.assertIn('unknown collision layer', bad.stderr) + + +if __name__ == '__main__': + unittest.main()