Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions scripts/collision_from_tiled.py
Original file line number Diff line number Diff line change
@@ -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()
177 changes: 177 additions & 0 deletions scripts/test_collision_from_tiled.py
Original file line number Diff line number Diff line change
@@ -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()
Loading