From 7a2b7c5eae53f79605379d3738598e1d18afb152 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:29:33 +0200 Subject: [PATCH 1/2] feat: convert tiled maps to the collision contract Give collision authors a deterministic export for all docs/13 layer shapes, using stage pixels and explicit region, chunk and state metadata. Reject malformed geometry before writing output and cover the conversion, CLI, bounds and existing-output preservation with tests. --- scripts/collision_from_tiled.py | 155 +++++++++++++++++++++++++++ scripts/test_collision_from_tiled.py | 154 ++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 scripts/collision_from_tiled.py create mode 100644 scripts/test_collision_from_tiled.py diff --git a/scripts/collision_from_tiled.py b/scripts/collision_from_tiled.py new file mode 100644 index 0000000..c3437f5 --- /dev/null +++ b/scripts/collision_from_tiled.py @@ -0,0 +1,155 @@ +"""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. 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']] = prop['value'] + return result + + +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) + 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..e24bee7 --- /dev/null +++ b/scripts/test_collision_from_tiled.py @@ -0,0 +1,154 @@ +"""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): + return [{'name': key, 'type': 'int' if type(value) is int else 'string', 'value': 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_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() From 7a6a5d9bccb54c03462b023772625616a3b9939e Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:20 +0200 Subject: [PATCH 2/2] fix: retain encounter properties in tiled collision exports Rusty Creek annotations now carry pickup item names, patrol bounds and trigger notes. Preserve these custom values instead of silently discarding them during conversion. Decode Tiled 1.12 typed lists to plain arrays and reject custom properties that would override authored geometry. Add regression coverage for both behaviours. --- scripts/collision_from_tiled.py | 16 ++++++++++++++-- scripts/test_collision_from_tiled.py | 27 +++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/scripts/collision_from_tiled.py b/scripts/collision_from_tiled.py index c3437f5..6832d91 100644 --- a/scripts/collision_from_tiled.py +++ b/scripts/collision_from_tiled.py @@ -5,7 +5,8 @@ (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. No image data is read. +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 @@ -48,10 +49,17 @@ def properties(data): result = {} for prop in data.get('properties', []): require(prop['name'] not in result, f'duplicate property {prop["name"]}') - result[prop['name']] = prop['value'] + 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], @@ -115,6 +123,10 @@ def convert(data): 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: diff --git a/scripts/test_collision_from_tiled.py b/scripts/test_collision_from_tiled.py index e24bee7..a82e0fc 100644 --- a/scripts/test_collision_from_tiled.py +++ b/scripts/test_collision_from_tiled.py @@ -12,8 +12,11 @@ def props(**values): - return [{'name': key, 'type': 'int' if type(value) is int else 'string', 'value': value} - for key, value in values.items()] + 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(): @@ -119,6 +122,26 @@ def test_required_object_properties_and_unique_ids(self): 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'