tilemap-parser 4.0.0__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/PKG-INFO +31 -3
  2. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/README.md +30 -2
  3. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/pyproject.toml +1 -1
  4. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/__init__.py +4 -0
  5. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/map_parse.py +21 -3
  6. tilemap_parser-5.0.0/src/tilemap_parser/parser/tmx_converter.py +447 -0
  7. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser.egg-info/PKG-INFO +31 -3
  8. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser.egg-info/SOURCES.txt +3 -1
  9. tilemap_parser-5.0.0/tests/test_tmx_converter.py +442 -0
  10. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/LICENSE +0 -0
  11. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/setup.cfg +0 -0
  12. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/__init__.py +0 -0
  13. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/animation.py +0 -0
  14. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/collision.py +0 -0
  15. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/collision_loader.py +0 -0
  16. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/node_parse.py +0 -0
  17. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/parser/particle.py +0 -0
  18. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/__init__.py +0 -0
  19. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/animation_player.py +0 -0
  20. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/area_node.py +0 -0
  21. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/camera.py +0 -0
  22. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  23. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/map_loader.py +0 -0
  24. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/object_collision.py +0 -0
  25. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/particles.py +0 -0
  26. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/renderer.py +0 -0
  27. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  28. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/utils/__init__.py +0 -0
  29. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser/utils/geometry.py +0 -0
  30. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  31. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser.egg-info/requires.txt +0 -0
  32. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  33. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_camera.py +0 -0
  34. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_collision.py +0 -0
  35. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_geometry.py +0 -0
  36. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_integration_animation.py +0 -0
  37. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_integration_collision.py +0 -0
  38. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_integration_map_loader.py +0 -0
  39. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_map_loader.py +0 -0
  40. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_move_grounded.py +0 -0
  41. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_object_collision.py +0 -0
  42. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_object_surfaces.py +0 -0
  43. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_particle_emitter.py +0 -0
  44. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_render_scale.py +0 -0
  45. {tilemap_parser-4.0.0 → tilemap_parser-5.0.0}/tests/test_tile_collision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.0.0
3
+ Version: 5.0.0
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -704,7 +704,10 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
704
704
  ## Features
705
705
 
706
706
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
707
+ - **Chunked tile rendering**: Efficient visibility culling and chunk-based rendering with `TileLayerRenderer`
708
+ - **Camera system**: Centered/deadzone follow, lerp smoothing, screen-shake, and bounds clamping with `Camera`
707
709
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
710
+ - **Particle system**: Configurable particle emitters with shapes, color transitions, alpha fades, gravity, and batch rendering with `ParticleSystem` + `SpriteBatchRenderer`
708
711
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
709
712
  - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
710
713
  - **Capsule support**: Full capsule collision against all shape types
@@ -713,16 +716,22 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
713
716
  ## Quick Start
714
717
 
715
718
  ```python
716
- from tilemap_parser import load_map, TileLayerRenderer
719
+ from tilemap_parser import load_map, TileLayerRenderer, Camera
717
720
 
721
+ # Load map and set up renderer
718
722
  game_data = load_map("path/to/map.json")
719
723
  renderer = TileLayerRenderer(game_data)
724
+
725
+ # Set up camera
726
+ camera = Camera(800, 600, mode="centered")
727
+ camera.follow(player)
720
728
  ```
721
729
 
722
730
  ```python
723
731
  from tilemap_parser import (
724
732
  CollisionRunner, CollisionCache,
725
733
  ObjectCollisionManager, CircleShape, RectangleShape,
734
+ ParticleSystem, ParticleSystemConfig
726
735
  )
727
736
 
728
737
  # Tile-based collision
@@ -735,10 +744,29 @@ manager = ObjectCollisionManager()
735
744
  manager.add_object(player)
736
745
  for hit in manager.check_all_collisions():
737
746
  hit.resolve() # separate both objects
747
+
748
+ # Particle system
749
+ particle_config = ParticleSystemConfig(
750
+ particle_shape="circle",
751
+ spawn_rate=10.0,
752
+ max_particles=100,
753
+ lifetime_min=0.5,
754
+ lifetime_max=2.0,
755
+ speed_min=50,
756
+ speed_max=100,
757
+ direction=270,
758
+ spread=45,
759
+ start_color_r=255, start_color_g=200, start_color_b=100, start_color_a=255,
760
+ end_color_r=255, end_color_g=100, end_color_b=50, end_color_a=0,
761
+ alpha_fade="fade_out",
762
+ gravity_x=0,
763
+ gravity_y=50,
764
+ )
765
+ particles = ParticleSystem(particle_config)
738
766
  ```
739
767
 
740
768
  ## Links
741
769
 
742
- - **Docs**: https://tilemap-parser.vercel.app/
770
+ - **Docs**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
743
771
  - **Editor**: https://pypi.org/project/tilemap-editor/
744
772
  - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -5,7 +5,10 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
5
5
  ## Features
6
6
 
7
7
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
8
+ - **Chunked tile rendering**: Efficient visibility culling and chunk-based rendering with `TileLayerRenderer`
9
+ - **Camera system**: Centered/deadzone follow, lerp smoothing, screen-shake, and bounds clamping with `Camera`
8
10
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
11
+ - **Particle system**: Configurable particle emitters with shapes, color transitions, alpha fades, gravity, and batch rendering with `ParticleSystem` + `SpriteBatchRenderer`
9
12
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
10
13
  - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
11
14
  - **Capsule support**: Full capsule collision against all shape types
@@ -14,16 +17,22 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
14
17
  ## Quick Start
15
18
 
16
19
  ```python
17
- from tilemap_parser import load_map, TileLayerRenderer
20
+ from tilemap_parser import load_map, TileLayerRenderer, Camera
18
21
 
22
+ # Load map and set up renderer
19
23
  game_data = load_map("path/to/map.json")
20
24
  renderer = TileLayerRenderer(game_data)
25
+
26
+ # Set up camera
27
+ camera = Camera(800, 600, mode="centered")
28
+ camera.follow(player)
21
29
  ```
22
30
 
23
31
  ```python
24
32
  from tilemap_parser import (
25
33
  CollisionRunner, CollisionCache,
26
34
  ObjectCollisionManager, CircleShape, RectangleShape,
35
+ ParticleSystem, ParticleSystemConfig
27
36
  )
28
37
 
29
38
  # Tile-based collision
@@ -36,10 +45,29 @@ manager = ObjectCollisionManager()
36
45
  manager.add_object(player)
37
46
  for hit in manager.check_all_collisions():
38
47
  hit.resolve() # separate both objects
48
+
49
+ # Particle system
50
+ particle_config = ParticleSystemConfig(
51
+ particle_shape="circle",
52
+ spawn_rate=10.0,
53
+ max_particles=100,
54
+ lifetime_min=0.5,
55
+ lifetime_max=2.0,
56
+ speed_min=50,
57
+ speed_max=100,
58
+ direction=270,
59
+ spread=45,
60
+ start_color_r=255, start_color_g=200, start_color_b=100, start_color_a=255,
61
+ end_color_r=255, end_color_g=100, end_color_b=50, end_color_a=0,
62
+ alpha_fade="fade_out",
63
+ gravity_x=0,
64
+ gravity_y=50,
65
+ )
66
+ particles = ParticleSystem(particle_config)
39
67
  ```
40
68
 
41
69
  ## Links
42
70
 
43
- - **Docs**: https://tilemap-parser.vercel.app/
71
+ - **Docs**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
44
72
  - **Editor**: https://pypi.org/project/tilemap-editor/
45
73
  - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "4.0.0"
7
+ version = "5.0.0"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -46,6 +46,7 @@ from .map_parse import (
46
46
  parse_map_json,
47
47
  )
48
48
  from .node_parse import ParsedNode, parse_nodes_dict, parse_nodes_file
49
+ from .tmx_converter import TmxParseError, parse_tmx_file, parse_tsx_file
49
50
  from .particle import (
50
51
  ALPHA_FADE_MODES,
51
52
  EMISSION_SHAPES,
@@ -84,6 +85,7 @@ __all__ = [
84
85
  "TilesetAnimation",
85
86
  "TileCollisionData",
86
87
  "TilesetCollision",
88
+ "TmxParseError",
87
89
  "parse_animation_dict",
88
90
  "parse_animation_file",
89
91
  "parse_animation_json",
@@ -96,6 +98,8 @@ __all__ = [
96
98
  "parse_map_json",
97
99
  "parse_nodes_dict",
98
100
  "parse_nodes_file",
101
+ "parse_tmx_file",
102
+ "parse_tsx_file",
99
103
  "parse_object_collision",
100
104
  "parse_tileset_collision",
101
105
  "ALPHA_FADE_MODES",
@@ -106,6 +106,10 @@ class ParsedTile:
106
106
  ttype: TilesetRef
107
107
  variant: int
108
108
  properties: Optional[JsonDict] = None
109
+ flip_h: bool = False
110
+ flip_v: bool = False
111
+ flip_d: bool = False
112
+ rotated_hex120: bool = False
109
113
 
110
114
 
111
115
  @dataclass
@@ -213,7 +217,16 @@ def _parse_tile(tile_data: JsonDict, ctx: str) -> ParsedTile:
213
217
  else:
214
218
  ttype = _coerce_int(ttype_raw, f"{ctx}.ttype")
215
219
  props = _optional_dict(tile_data.get("properties"), f"{ctx}.properties")
216
- return ParsedTile(pos=pos, ttype=ttype, variant=variant, properties=props)
220
+ return ParsedTile(
221
+ pos=pos,
222
+ ttype=ttype,
223
+ variant=variant,
224
+ properties=props,
225
+ flip_h=tile_data.get("flip_h", False),
226
+ flip_v=tile_data.get("flip_v", False),
227
+ flip_d=tile_data.get("flip_d", False),
228
+ rotated_hex120=tile_data.get("rotated_hex120", False),
229
+ )
217
230
 
218
231
 
219
232
  def _parse_tiles(tiles_obj: JsonDict, ctx: str) -> Dict[Point, ParsedTile]:
@@ -484,8 +497,13 @@ def parse_map_file(path: Union[str, Path]) -> ParsedMap:
484
497
  p = Path(path)
485
498
  if not p.is_file():
486
499
  raise MapParseError(f"Not a file: {p}")
487
- if p.suffix.lower() != ".json":
488
- raise MapParseError(f"Expected .json map file, got {p.suffix!r}")
500
+ suffix = p.suffix.lower()
501
+ if suffix == ".tmx":
502
+ from .tmx_converter import parse_tmx_file
503
+
504
+ return parse_tmx_file(p)
505
+ if suffix != ".json":
506
+ raise MapParseError(f"Expected .json or .tmx map file, got {suffix!r}")
489
507
  try:
490
508
  text = p.read_text(encoding="utf-8")
491
509
  except OSError as e:
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import gzip
5
+ import logging
6
+ import struct
7
+ import zlib
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple, Union
10
+ from xml.etree import ElementTree
11
+
12
+ from .map_parse import (
13
+ MapParseError,
14
+ ParsedLayer,
15
+ ParsedMap,
16
+ ParsedMeta,
17
+ ParsedProjectState,
18
+ ParsedTile,
19
+ ParsedTileset,
20
+ )
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ TILE_FLIP_H = 0x80000000
25
+ TILE_FLIP_V = 0x40000000
26
+ TILE_FLIP_D = 0x20000000
27
+ TILE_FLIP_HEX = 0x10000000
28
+ TILE_FLIP_MASK = 0x0FFFFFFF
29
+
30
+ FLIP_BIT_NAMES = {
31
+ TILE_FLIP_H: "H",
32
+ TILE_FLIP_V: "V",
33
+ TILE_FLIP_D: "D",
34
+ TILE_FLIP_HEX: "HEX120",
35
+ }
36
+ FLIP_ALL = TILE_FLIP_H | TILE_FLIP_V | TILE_FLIP_D | TILE_FLIP_HEX
37
+
38
+
39
+ class TmxParseError(MapParseError):
40
+ pass
41
+
42
+
43
+ def _coerce_int(value: Any, ctx: str) -> int:
44
+ try:
45
+ return int(value)
46
+ except (ValueError, TypeError) as e:
47
+ raise TmxParseError(f"{ctx}: expected int, got {value!r}") from e
48
+
49
+
50
+ def _coerce_float(value: Any, ctx: str) -> float:
51
+ try:
52
+ return float(value)
53
+ except (ValueError, TypeError) as e:
54
+ raise TmxParseError(f"{ctx}: expected float, got {value!r}") from e
55
+
56
+
57
+ def _require_attr(elem: ElementTree.Element, name: str, ctx: str) -> str:
58
+ value = elem.get(name)
59
+ if value is None:
60
+ raise TmxParseError(f"{ctx}: missing required attribute {name!r}")
61
+ return value
62
+
63
+
64
+ def _decode_flip_flags(
65
+ gid: int,
66
+ ) -> Tuple[int, bool, bool, bool, bool]:
67
+ flipped = gid & FLIP_ALL
68
+ stripped = gid & TILE_FLIP_MASK
69
+ if flipped:
70
+ bits = "+".join(name for mask, name in FLIP_BIT_NAMES.items() if flipped & mask)
71
+ log.debug("Tile GID 0x%x has flip flag(s) %s — decoded", gid, bits)
72
+ return (
73
+ stripped,
74
+ bool(gid & TILE_FLIP_H),
75
+ bool(gid & TILE_FLIP_V),
76
+ bool(gid & TILE_FLIP_D),
77
+ bool(gid & TILE_FLIP_HEX),
78
+ )
79
+
80
+
81
+ def _parse_tsx_root(root: ElementTree.Element, source_path: Path) -> dict:
82
+ if root.tag == "tileset":
83
+ tileset = root
84
+ else:
85
+ tileset = root.find("tileset")
86
+ if tileset is None:
87
+ raise TmxParseError(f"TSX {source_path}: missing <tileset> root element")
88
+ return _build_tsx_data(tileset, source_path)
89
+
90
+
91
+ def _build_tsx_data(ts_elem: ElementTree.Element, source_path: Path) -> dict:
92
+ name = ts_elem.get("name", "")
93
+ tilewidth = _coerce_int(_require_attr(ts_elem, "tilewidth", name), f"{name}.tilewidth")
94
+ tileheight = _coerce_int(_require_attr(ts_elem, "tileheight", name), f"{name}.tileheight")
95
+ tilecount = _coerce_int(ts_elem.get("tilecount", 0), f"{name}.tilecount")
96
+ columns = _coerce_int(ts_elem.get("columns", 0), f"{name}.columns")
97
+
98
+ image_elem = ts_elem.find("image")
99
+ image_source = ""
100
+ image_width = 0
101
+ image_height = 0
102
+ if image_elem is not None:
103
+ image_source = image_elem.get("source", "")
104
+ image_width = _coerce_int(image_elem.get("width", 0), f"{name}.image.width")
105
+ image_height = _coerce_int(image_elem.get("height", 0), f"{name}.image.height")
106
+
107
+ data: dict = {
108
+ "name": name,
109
+ "tilewidth": tilewidth,
110
+ "tileheight": tileheight,
111
+ "tilecount": tilecount,
112
+ "columns": columns,
113
+ "image": image_source,
114
+ "imagewidth": image_width,
115
+ "imageheight": image_height,
116
+ }
117
+
118
+ props = _parse_properties(ts_elem)
119
+ if props:
120
+ data["properties"] = props
121
+
122
+ tile_properties: Dict[str, Dict[str, Any]] = {}
123
+ for tile_elem in ts_elem.findall("tile"):
124
+ tid = _coerce_int(_require_attr(tile_elem, "id", f"{name}.tile"), f"{name}.tile.id")
125
+ tile_props = _parse_properties(tile_elem)
126
+ if tile_props:
127
+ tile_properties[str(tid)] = tile_props
128
+ if tile_properties:
129
+ data["tile_properties"] = tile_properties
130
+
131
+ return data
132
+
133
+
134
+ def _coerce_property_value(value: str, prop_type: str) -> Any:
135
+ if prop_type == "int":
136
+ return int(value)
137
+ elif prop_type == "float":
138
+ return float(value)
139
+ elif prop_type == "bool":
140
+ return value.lower() in ("true", "1")
141
+ elif prop_type == "color":
142
+ return value
143
+ return value
144
+
145
+
146
+ def _parse_properties(elem: ElementTree.Element) -> Dict[str, Any]:
147
+ props: Dict[str, Any] = {}
148
+ props_elem = elem.find("properties")
149
+ if props_elem is None:
150
+ return props
151
+ for prop in props_elem.findall("property"):
152
+ prop_name = prop.get("name", "")
153
+ prop_type = prop.get("type", "string")
154
+ raw_value = prop.get("value")
155
+ if raw_value is None:
156
+ raw_value = (prop.text or "").strip()
157
+ props[prop_name] = _coerce_property_value(raw_value, prop_type)
158
+ return props
159
+
160
+
161
+ def _build_embedded_tileset_properties(data: dict) -> Dict[str, Any]:
162
+ props: Dict[str, Any] = {}
163
+ if "properties" in data:
164
+ props["properties"] = data["properties"]
165
+ return props
166
+
167
+
168
+ def _build_embedded_tile_properties(data: dict) -> Dict[str, Dict[str, Any]]:
169
+ return data.get("tile_properties", {})
170
+
171
+
172
+ def _tileset_data_to_parsed(data: dict) -> ParsedTileset:
173
+ image_path = data.get("image", "")
174
+ ts_type = "tile"
175
+ props = _build_embedded_tileset_properties(data)
176
+ tile_props = _build_embedded_tile_properties(data)
177
+ return ParsedTileset(
178
+ path=image_path,
179
+ type=ts_type,
180
+ properties=props,
181
+ tile_properties=tile_props,
182
+ animation=None,
183
+ )
184
+
185
+
186
+ def parse_tsx_file(path: Union[str, Path]) -> dict:
187
+ p = Path(path)
188
+ if not p.is_file():
189
+ raise TmxParseError(f"TSX file not found: {p}")
190
+ try:
191
+ tree = ElementTree.parse(p)
192
+ except ElementTree.ParseError as e:
193
+ raise TmxParseError(f"Invalid TSX XML in {p}: {e}") from e
194
+ return _parse_tsx_root(tree.getroot(), p)
195
+
196
+
197
+ def _resolve_image_path(
198
+ raw_path: str, map_dir: Path, tsx_dir: Optional[Path] = None
199
+ ) -> str:
200
+ if not raw_path:
201
+ return ""
202
+ p = Path(raw_path)
203
+ if p.is_absolute():
204
+ return raw_path
205
+ if tsx_dir:
206
+ candidate = tsx_dir / p
207
+ if candidate.exists():
208
+ return str(candidate)
209
+ candidate = map_dir / p
210
+ if candidate.exists():
211
+ return str(candidate)
212
+ return raw_path
213
+
214
+
215
+ def _decode_csv(text: str) -> List[int]:
216
+ gids: List[int] = []
217
+ for line in text.strip().splitlines():
218
+ line = line.strip()
219
+ if not line:
220
+ continue
221
+ for token in line.split(","):
222
+ token = token.strip()
223
+ if not token:
224
+ continue
225
+ base = 16 if token.lower().startswith("0x") else 10
226
+ gids.append(int(token, base))
227
+ return gids
228
+
229
+
230
+ def _decode_base64(text: str, compression: Optional[str]) -> List[int]:
231
+ try:
232
+ raw = base64.b64decode(text.strip())
233
+ if compression == "zlib":
234
+ raw = zlib.decompress(raw)
235
+ elif compression == "gzip":
236
+ raw = gzip.decompress(raw)
237
+ elif compression:
238
+ raise TmxParseError(
239
+ f"Unsupported tile data compression: {compression!r}"
240
+ )
241
+ except (ValueError, OSError, zlib.error) as e:
242
+ raise TmxParseError(f"Failed to decode tile data: {e}") from e
243
+
244
+ if len(raw) % 4 != 0:
245
+ raise TmxParseError(
246
+ f"Tile data payload has {len(raw)} bytes, expected multiple of 4"
247
+ )
248
+ count = len(raw) // 4
249
+ return list(struct.unpack(f"<{count}I", raw))
250
+
251
+
252
+ def _parse_tile_layer(
253
+ layer_elem: ElementTree.Element,
254
+ tilesets: List[Tuple[int, ParsedTileset]],
255
+ ctx: str,
256
+ ) -> ParsedLayer:
257
+ name = layer_elem.get("name", "Unnamed")
258
+ width = _coerce_int(_require_attr(layer_elem, "width", ctx), f"{ctx}.width")
259
+ height = _coerce_int(_require_attr(layer_elem, "height", ctx), f"{ctx}.height")
260
+ visible = layer_elem.get("visible", "1") != "0"
261
+ opacity = _coerce_float(layer_elem.get("opacity", 1.0), f"{ctx}.opacity")
262
+
263
+ layer = ParsedLayer(
264
+ id=0,
265
+ name=name,
266
+ layer_type="tile",
267
+ visible=visible,
268
+ locked=False,
269
+ opacity=opacity,
270
+ z_index=0,
271
+ )
272
+
273
+ layer.properties = _parse_properties(layer_elem)
274
+
275
+ data_elem = layer_elem.find("data")
276
+ if data_elem is None:
277
+ return layer
278
+
279
+ encoding = data_elem.get("encoding", "xml")
280
+ compression = data_elem.get("compression")
281
+ text = data_elem.text or ""
282
+
283
+ if encoding == "csv":
284
+ gids = _decode_csv(text)
285
+ elif encoding == "base64":
286
+ gids = _decode_base64(text, compression)
287
+ else:
288
+ if encoding != "xml":
289
+ log.warning("%s: unsupported encoding %r, treating as xml", ctx, encoding)
290
+ gids = _decode_xml_tiles(data_elem, ctx)
291
+
292
+ expected = width * height
293
+ if len(gids) != expected:
294
+ raise TmxParseError(
295
+ f"{ctx}: expected {expected} tile GIDs, got {len(gids)}"
296
+ )
297
+
298
+ for idx, raw_gid in enumerate(gids):
299
+ if raw_gid == 0:
300
+ continue
301
+ tx = idx % width
302
+ ty = idx // width
303
+ stripped_gid, flip_h, flip_v, flip_d, flip_hex = _decode_flip_flags(raw_gid)
304
+ ttype, variant = _map_gid(stripped_gid, tilesets, f"{ctx}.tile[{ty},{tx}]")
305
+ if ttype is not None:
306
+ pos = (tx, ty)
307
+ layer.tiles[pos] = ParsedTile(
308
+ pos=pos,
309
+ ttype=ttype,
310
+ variant=variant,
311
+ flip_h=flip_h,
312
+ flip_v=flip_v,
313
+ flip_d=flip_d,
314
+ rotated_hex120=flip_hex,
315
+ )
316
+
317
+ return layer
318
+
319
+
320
+ def _decode_xml_tiles(data_elem: ElementTree.Element, ctx: str) -> List[int]:
321
+ gids: List[int] = []
322
+ for tile_elem in data_elem.findall("tile"):
323
+ gid_str = tile_elem.get("gid", "0")
324
+ gids.append(int(gid_str))
325
+ return gids
326
+
327
+
328
+ def _map_gid(
329
+ gid: int,
330
+ tilesets: List[Tuple[int, ParsedTileset]],
331
+ ctx: str,
332
+ ) -> Tuple[Optional[Union[int, str]], int]:
333
+ for i, (firstgid, ts) in enumerate(tilesets):
334
+ tilecount = int(getattr(ts, "_tilecount", 0))
335
+ if ts.animation is not None:
336
+ tilecount = max(tilecount, ts.animation.frame_count)
337
+ if tilecount > 0:
338
+ if firstgid <= gid < firstgid + tilecount:
339
+ return i, gid - firstgid
340
+ else:
341
+ next_firstgid = tilesets[i + 1][0] if i + 1 < len(tilesets) else float("inf")
342
+ if firstgid <= gid < next_firstgid:
343
+ return i, gid - firstgid
344
+ log.debug("%s: GID %d does not fall into any known tileset range", ctx, gid)
345
+ return None, 0
346
+
347
+
348
+ def _parse_tmx_root(root: ElementTree.Element, map_dir: Path) -> ParsedMap:
349
+ map_elem = root
350
+
351
+ tilewidth = _coerce_int(
352
+ _require_attr(map_elem, "tilewidth", "<map>"), "<map>.tilewidth"
353
+ )
354
+ tileheight = _coerce_int(
355
+ _require_attr(map_elem, "tileheight", "<map>"), "<map>.tileheight"
356
+ )
357
+ map_width = _coerce_int(
358
+ _require_attr(map_elem, "width", "<map>"), "<map>.width"
359
+ )
360
+ map_height = _coerce_int(
361
+ _require_attr(map_elem, "height", "<map>"), "<map>.height"
362
+ )
363
+
364
+ meta = ParsedMeta(
365
+ tile_size=(tilewidth, tileheight),
366
+ map_size=(map_width, map_height),
367
+ initial_map_size=(map_width, map_height),
368
+ zoom_level=1.0,
369
+ scroll=(0, 0),
370
+ version="1.1",
371
+ render_scale=1.0,
372
+ )
373
+
374
+ parsed_tilesets: List[ParsedTileset] = []
375
+ tileset_ranges: List[Tuple[int, ParsedTileset]] = []
376
+
377
+ for ts_elem in map_elem.findall("tileset"):
378
+ firstgid = _coerce_int(
379
+ _require_attr(ts_elem, "firstgid", "<tileset>"), "<tileset>.firstgid"
380
+ )
381
+ source = ts_elem.get("source", "")
382
+ if source:
383
+ tsx_path = (map_dir / source).resolve()
384
+ if not tsx_path.is_file():
385
+ log.warning("Referenced TSX not found: %s", tsx_path)
386
+ ts = ParsedTileset(path="", type="tile")
387
+ ts_data: dict = {"tilecount": 0}
388
+ else:
389
+ ts_data = parse_tsx_file(tsx_path)
390
+ ts_data["image"] = _resolve_image_path(
391
+ ts_data.get("image", ""), map_dir, tsx_path.parent
392
+ )
393
+ ts = _tileset_data_to_parsed(ts_data)
394
+ else:
395
+ ts_data = _build_tsx_data(ts_elem, map_dir)
396
+ ts_data["image"] = _resolve_image_path(
397
+ ts_data.get("image", ""), map_dir, None
398
+ )
399
+ ts = _tileset_data_to_parsed(ts_data)
400
+
401
+ tilecount = ts_data.get("tilecount", 0)
402
+ if tilecount == 0 and ts_data.get("imagewidth") and ts_data.get("tilewidth"):
403
+ cols = ts_data["imagewidth"] // ts_data["tilewidth"]
404
+ rows = ts_data["imageheight"] // ts_data["tileheight"]
405
+ tilecount = cols * rows
406
+ if tilecount:
407
+ ts._tilecount = tilecount
408
+ parsed_tilesets.append(ts)
409
+ tileset_ranges.append((firstgid, parsed_tilesets[-1]))
410
+ if tilecount == 0:
411
+ log.warning(
412
+ "Tileset %r (firstgid=%d) has tilecount=0, GID lookup may be imprecise",
413
+ ts_data.get("name", ""),
414
+ firstgid,
415
+ )
416
+
417
+ layers: List[ParsedLayer] = []
418
+ for i, layer_elem in enumerate(map_elem.findall("layer")):
419
+ layer = _parse_tile_layer(
420
+ layer_elem, tileset_ranges, f"layer[{i}]"
421
+ )
422
+ layer.id = i
423
+ layer.z_index = i
424
+ layers.append(layer)
425
+
426
+ project_state = ParsedProjectState(rules=[], groups=[], automap_rules=None)
427
+
428
+ return ParsedMap(
429
+ meta=meta,
430
+ layers=layers,
431
+ tilesets=parsed_tilesets,
432
+ project_state=project_state,
433
+ raw={},
434
+ )
435
+
436
+
437
+ def parse_tmx_file(path: Union[str, Path]) -> ParsedMap:
438
+ p = Path(path)
439
+ if not p.is_file():
440
+ raise TmxParseError(f"TMX file not found: {p}")
441
+ if p.suffix.lower() not in (".tmx", ".xml"):
442
+ raise TmxParseError(f"Expected .tmx file, got {p.suffix!r}")
443
+ try:
444
+ tree = ElementTree.parse(p)
445
+ except ElementTree.ParseError as e:
446
+ raise TmxParseError(f"Invalid TMX XML in {p}: {e}") from e
447
+ return _parse_tmx_root(tree.getroot(), p.parent)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.0.0
3
+ Version: 5.0.0
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -704,7 +704,10 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
704
704
  ## Features
705
705
 
706
706
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
707
+ - **Chunked tile rendering**: Efficient visibility culling and chunk-based rendering with `TileLayerRenderer`
708
+ - **Camera system**: Centered/deadzone follow, lerp smoothing, screen-shake, and bounds clamping with `Camera`
707
709
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
710
+ - **Particle system**: Configurable particle emitters with shapes, color transitions, alpha fades, gravity, and batch rendering with `ParticleSystem` + `SpriteBatchRenderer`
708
711
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
709
712
  - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
710
713
  - **Capsule support**: Full capsule collision against all shape types
@@ -713,16 +716,22 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
713
716
  ## Quick Start
714
717
 
715
718
  ```python
716
- from tilemap_parser import load_map, TileLayerRenderer
719
+ from tilemap_parser import load_map, TileLayerRenderer, Camera
717
720
 
721
+ # Load map and set up renderer
718
722
  game_data = load_map("path/to/map.json")
719
723
  renderer = TileLayerRenderer(game_data)
724
+
725
+ # Set up camera
726
+ camera = Camera(800, 600, mode="centered")
727
+ camera.follow(player)
720
728
  ```
721
729
 
722
730
  ```python
723
731
  from tilemap_parser import (
724
732
  CollisionRunner, CollisionCache,
725
733
  ObjectCollisionManager, CircleShape, RectangleShape,
734
+ ParticleSystem, ParticleSystemConfig
726
735
  )
727
736
 
728
737
  # Tile-based collision
@@ -735,10 +744,29 @@ manager = ObjectCollisionManager()
735
744
  manager.add_object(player)
736
745
  for hit in manager.check_all_collisions():
737
746
  hit.resolve() # separate both objects
747
+
748
+ # Particle system
749
+ particle_config = ParticleSystemConfig(
750
+ particle_shape="circle",
751
+ spawn_rate=10.0,
752
+ max_particles=100,
753
+ lifetime_min=0.5,
754
+ lifetime_max=2.0,
755
+ speed_min=50,
756
+ speed_max=100,
757
+ direction=270,
758
+ spread=45,
759
+ start_color_r=255, start_color_g=200, start_color_b=100, start_color_a=255,
760
+ end_color_r=255, end_color_g=100, end_color_b=50, end_color_a=0,
761
+ alpha_fade="fade_out",
762
+ gravity_x=0,
763
+ gravity_y=50,
764
+ )
765
+ particles = ParticleSystem(particle_config)
738
766
  ```
739
767
 
740
768
  ## Links
741
769
 
742
- - **Docs**: https://tilemap-parser.vercel.app/
770
+ - **Docs**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
743
771
  - **Editor**: https://pypi.org/project/tilemap-editor/
744
772
  - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -14,6 +14,7 @@ src/tilemap_parser/parser/collision_loader.py
14
14
  src/tilemap_parser/parser/map_parse.py
15
15
  src/tilemap_parser/parser/node_parse.py
16
16
  src/tilemap_parser/parser/particle.py
17
+ src/tilemap_parser/parser/tmx_converter.py
17
18
  src/tilemap_parser/runtime/__init__.py
18
19
  src/tilemap_parser/runtime/animation_player.py
19
20
  src/tilemap_parser/runtime/area_node.py
@@ -38,4 +39,5 @@ tests/test_object_collision.py
38
39
  tests/test_object_surfaces.py
39
40
  tests/test_particle_emitter.py
40
41
  tests/test_render_scale.py
41
- tests/test_tile_collision.py
42
+ tests/test_tile_collision.py
43
+ tests/test_tmx_converter.py
@@ -0,0 +1,442 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ FIXTURES = Path(__file__).parent / "fixtures"
6
+
7
+
8
+ def _resolve(name: str) -> Path:
9
+ p = FIXTURES / name
10
+ assert p.is_file(), f"Fixture not found: {p}"
11
+ return p
12
+
13
+
14
+ class TestParseTsx:
15
+ def test_basic_tsx(self):
16
+ from tilemap_parser.parser.tmx_converter import parse_tsx_file
17
+
18
+ data = parse_tsx_file(_resolve("test_tileset.tsx"))
19
+ assert data["name"] == "test_tileset"
20
+ assert data["tilewidth"] == 32
21
+ assert data["tileheight"] == 32
22
+ assert data["tilecount"] == 4
23
+ assert data["columns"] == 2
24
+ assert data["image"] == "tileset.png"
25
+ assert "properties" not in data
26
+ assert "tile_properties" in data
27
+ tile0 = data["tile_properties"]["0"]
28
+ assert tile0["grass"] is True
29
+ tile1 = data["tile_properties"]["1"]
30
+ assert tile1["hp"] == 10
31
+
32
+ def test_tsx_file_not_found(self):
33
+ from tilemap_parser.parser.tmx_converter import TmxParseError, parse_tsx_file
34
+
35
+ with pytest.raises(TmxParseError, match="not found"):
36
+ parse_tsx_file("/nonexistent/nope.tsx")
37
+
38
+ def test_tsx_invalid_xml(self, tmp_path):
39
+ from tilemap_parser.parser.tmx_converter import TmxParseError, parse_tsx_file
40
+
41
+ bad = tmp_path / "bad.tsx"
42
+ bad.write_text("not xml")
43
+ with pytest.raises(TmxParseError, match="Invalid TSX"):
44
+ parse_tsx_file(bad)
45
+
46
+
47
+ class TestParseTmxCsv:
48
+ def test_load_via_parse_map_file(self):
49
+ from tilemap_parser.parser.map_parse import parse_map_file
50
+
51
+ parsed = parse_map_file(_resolve("test_map_csv.tmx"))
52
+
53
+ assert parsed.meta.tile_size == (32, 32)
54
+ assert parsed.meta.map_size == (3, 3)
55
+
56
+ assert len(parsed.tilesets) == 1
57
+ assert parsed.tilesets[0].path.endswith("tileset.png")
58
+
59
+ assert len(parsed.layers) == 1
60
+ layer = parsed.layers[0]
61
+ assert layer.name == "Tile Layer 1"
62
+ assert layer.layer_type == "tile"
63
+ assert layer.visible is True
64
+ assert layer.opacity == 1.0
65
+
66
+ assert len(layer.tiles) == 4 # only GIDs 1-4 valid (tilecount=4)
67
+
68
+ assert (0, 0) in layer.tiles
69
+ assert layer.tiles[(0, 0)].ttype == 0
70
+ assert layer.tiles[(0, 0)].variant == 0
71
+ assert layer.tiles[(1, 0)].variant == 1
72
+ assert layer.tiles[(2, 0)].variant == 2
73
+ assert layer.tiles[(0, 1)].variant == 3
74
+
75
+ assert (2, 2) not in layer.tiles # GID 0 → empty
76
+
77
+ def test_embedded_tileset(self):
78
+ from tilemap_parser.parser.map_parse import parse_map_file
79
+
80
+ parsed = parse_map_file(_resolve("test_map_embedded_tileset.tmx"))
81
+
82
+ assert len(parsed.tilesets) == 1
83
+ ts = parsed.tilesets[0]
84
+ assert ts.path.endswith("builtin.png")
85
+ assert ts.type == "tile"
86
+ assert ts._tilecount == 4
87
+
88
+ layer = parsed.layers[0]
89
+ assert len(layer.tiles) == 4
90
+ assert layer.tiles[(0, 0)].ttype == 0
91
+ assert layer.tiles[(0, 0)].variant == 0
92
+ assert layer.tiles[(1, 1)].variant == 3
93
+
94
+ def test_empty_map(self):
95
+ from tilemap_parser.parser.map_parse import parse_map_file
96
+
97
+ parsed = parse_map_file(_resolve("test_map_empty.tmx"))
98
+
99
+ assert parsed.meta.map_size == (10, 10)
100
+ assert len(parsed.tilesets) == 0
101
+ assert len(parsed.layers) == 1
102
+ assert len(parsed.layers[0].tiles) == 0
103
+
104
+
105
+ class TestParseTmxBase64:
106
+ def test_base64_zlib(self):
107
+ from tilemap_parser.parser.map_parse import parse_map_file
108
+
109
+ parsed = parse_map_file(_resolve("test_map_base64.tmx"))
110
+
111
+ assert len(parsed.layers) == 1
112
+ layer = parsed.layers[0]
113
+ assert len(layer.tiles) == 4
114
+
115
+ assert layer.tiles[(0, 0)].ttype == 0
116
+ assert layer.tiles[(0, 0)].variant == 0
117
+ assert layer.tiles[(1, 0)].variant == 1
118
+ assert (2, 2) not in layer.tiles # GID 0 → empty
119
+
120
+ def test_base64_raw_uncompressed(self):
121
+ from tilemap_parser.parser.map_parse import parse_map_file
122
+
123
+ parsed = parse_map_file(_resolve("test_map_base64_raw.tmx"))
124
+
125
+ assert len(parsed.layers) == 1
126
+ layer = parsed.layers[0]
127
+ assert len(layer.tiles) == 4
128
+ assert layer.tiles[(0, 0)].variant == 0
129
+ assert layer.tiles[(2, 0)].variant == 2
130
+ assert (1, 1) not in layer.tiles # GID 5 is out of range
131
+
132
+
133
+ class TestFlipFlags:
134
+ def test_flip_flags_preserved_on_parsed_tile(self):
135
+ from tilemap_parser.parser.map_parse import parse_map_file
136
+
137
+ parsed = parse_map_file(_resolve("test_map_flip.tmx"))
138
+
139
+ layer = parsed.layers[0]
140
+ assert len(layer.tiles) == 2
141
+
142
+ tile_with_flag = layer.tiles[(0, 0)]
143
+ assert tile_with_flag.variant == 0
144
+ assert tile_with_flag.flip_h is True
145
+ assert tile_with_flag.flip_v is False
146
+ assert tile_with_flag.flip_d is False
147
+ assert tile_with_flag.rotated_hex120 is False
148
+
149
+ tile_no_flag = layer.tiles[(1, 0)]
150
+ assert tile_no_flag.variant == 0
151
+ assert tile_no_flag.flip_h is False
152
+ assert tile_no_flag.flip_v is False
153
+ assert tile_no_flag.flip_d is False
154
+ assert tile_no_flag.rotated_hex120 is False
155
+
156
+ def test_strip_flip_bits(self):
157
+ from tilemap_parser.parser.tmx_converter import (
158
+ TILE_FLIP_H,
159
+ TILE_FLIP_V,
160
+ TILE_FLIP_D,
161
+ TILE_FLIP_HEX,
162
+ TILE_FLIP_MASK,
163
+ _decode_flip_flags,
164
+ )
165
+
166
+ assert _decode_flip_flags(1) == (1, False, False, False, False)
167
+ assert _decode_flip_flags(TILE_FLIP_H | 5) == (5, True, False, False, False)
168
+ assert _decode_flip_flags(TILE_FLIP_V | 3) == (3, False, True, False, False)
169
+ assert _decode_flip_flags(TILE_FLIP_D | 7) == (7, False, False, True, False)
170
+ assert _decode_flip_flags(TILE_FLIP_HEX | 11) == (11, False, False, False, True)
171
+ assert _decode_flip_flags(TILE_FLIP_H | TILE_FLIP_V | 42) == (42, True, True, False, False)
172
+ assert _decode_flip_flags(TILE_FLIP_H | TILE_FLIP_V | TILE_FLIP_D | 99) == (99, True, True, True, False)
173
+ assert _decode_flip_flags(0) == (0, False, False, False, False)
174
+
175
+
176
+ class TestDecodeHelpers:
177
+ def test_decode_csv(self):
178
+ from tilemap_parser.parser.tmx_converter import _decode_csv
179
+
180
+ result = _decode_csv("1,2,3\n4,5,6\n")
181
+ assert result == [1, 2, 3, 4, 5, 6]
182
+
183
+ def test_decode_csv_empty(self):
184
+ from tilemap_parser.parser.tmx_converter import _decode_csv
185
+
186
+ assert _decode_csv("") == []
187
+
188
+ def test_decode_base64_zlib(self):
189
+ import base64
190
+ import struct
191
+ import zlib
192
+
193
+ from tilemap_parser.parser.tmx_converter import _decode_base64
194
+
195
+ data = struct.pack("<4I", 10, 20, 30, 40)
196
+ b64 = base64.b64encode(zlib.compress(data)).decode()
197
+ result = _decode_base64(b64, "zlib")
198
+ assert result == [10, 20, 30, 40]
199
+
200
+ def test_decode_base64_uncompressed(self):
201
+ import base64
202
+ import struct
203
+
204
+ from tilemap_parser.parser.tmx_converter import _decode_base64
205
+
206
+ data = struct.pack("<3I", 7, 8, 9)
207
+ b64 = base64.b64encode(data).decode()
208
+ result = _decode_base64(b64, None)
209
+ assert result == [7, 8, 9]
210
+
211
+ def test_decode_base64_unsupported_compression(self):
212
+ from tilemap_parser.parser.tmx_converter import TmxParseError, _decode_base64
213
+
214
+ with pytest.raises(TmxParseError, match="Unsupported"):
215
+ _decode_base64("AAAA", "lzma")
216
+
217
+
218
+ class TestProjectState:
219
+ def test_project_state_empty(self):
220
+ from tilemap_parser.parser.map_parse import parse_map_file
221
+
222
+ parsed = parse_map_file(_resolve("test_map_csv.tmx"))
223
+
224
+ assert len(parsed.project_state.rules) == 0
225
+ assert len(parsed.project_state.groups) == 0
226
+ assert parsed.project_state.automap_rules is None
227
+
228
+
229
+ class TestXmlEncoding:
230
+ def test_xml_tile_encoding(self, tmp_path):
231
+ from tilemap_parser.parser.tmx_converter import parse_tmx_file
232
+
233
+ tmx = tmp_path / "xml_encoded.tmx"
234
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
235
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="16" tileheight="16" infinite="0">
236
+ <tileset firstgid="1" name="a" tilewidth="16" tileheight="16" tilecount="4" columns="2">
237
+ <image source="a.png" width="32" height="32"/>
238
+ </tileset>
239
+ <layer id="1" name="Tile Layer 1" width="2" height="2">
240
+ <data>
241
+ <tile gid="1"/>
242
+ <tile gid="2"/>
243
+ <tile gid="3"/>
244
+ <tile gid="4"/>
245
+ </data>
246
+ </layer>
247
+ </map>""")
248
+ parsed = parse_tmx_file(tmx)
249
+ assert len(parsed.layers[0].tiles) == 4
250
+ assert parsed.layers[0].tiles[(0, 0)].variant == 0
251
+ assert parsed.layers[0].tiles[(1, 1)].variant == 3
252
+
253
+ def test_xml_with_empty_tiles(self, tmp_path):
254
+ from tilemap_parser.parser.tmx_converter import parse_tmx_file
255
+
256
+ tmx = tmp_path / "xml_empty.tmx"
257
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
258
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="16" tileheight="16" infinite="0">
259
+ <tileset firstgid="1" name="a" tilewidth="16" tileheight="16" tilecount="4" columns="2">
260
+ <image source="a.png" width="32" height="32"/>
261
+ </tileset>
262
+ <layer id="1" name="Empty" width="2" height="2">
263
+ <data>
264
+ <tile gid="0"/>
265
+ <tile gid="0"/>
266
+ <tile gid="0"/>
267
+ <tile gid="1"/>
268
+ </data>
269
+ </layer>
270
+ </map>""")
271
+ parsed = parse_tmx_file(tmx)
272
+ assert len(parsed.layers[0].tiles) == 1
273
+ assert (1, 1) in parsed.layers[0].tiles
274
+
275
+
276
+ class TestLayerProperties:
277
+ def test_layer_visibility_and_opacity(self, tmp_path):
278
+ from tilemap_parser.parser.tmx_converter import parse_tmx_file
279
+
280
+ tmx = tmp_path / "props.tmx"
281
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
282
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="1" height="1" tilewidth="16" tileheight="16" infinite="0">
283
+ <layer id="1" name="Hidden" width="1" height="1" visible="0" opacity="0.5">
284
+ <data encoding="csv">0</data>
285
+ </layer>
286
+ </map>""")
287
+ parsed = parse_tmx_file(tmx)
288
+ layer = parsed.layers[0]
289
+ assert layer.visible is False
290
+ assert layer.opacity == 0.5
291
+ assert layer.name == "Hidden"
292
+
293
+
294
+ class TestMultipleLayers:
295
+ def test_two_layers(self, tmp_path):
296
+ from tilemap_parser.parser.tmx_converter import parse_tmx_file
297
+
298
+ tmx = tmp_path / "two_layers.tmx"
299
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
300
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="16" tileheight="16" infinite="0">
301
+ <tileset firstgid="1" name="a" tilewidth="16" tileheight="16" tilecount="4" columns="2">
302
+ <image source="a.png" width="32" height="32"/>
303
+ </tileset>
304
+ <layer id="1" name="Ground" width="2" height="2">
305
+ <data encoding="csv">1,1,1,1</data>
306
+ </layer>
307
+ <layer id="2" name="Overlay" width="2" height="2">
308
+ <data encoding="csv">0,2,0,0</data>
309
+ </layer>
310
+ </map>""")
311
+ parsed = parse_tmx_file(tmx)
312
+ assert len(parsed.layers) == 2
313
+ assert parsed.layers[0].name == "Ground"
314
+ assert parsed.layers[0].z_index == 0
315
+ assert len(parsed.layers[0].tiles) == 4
316
+ assert parsed.layers[1].name == "Overlay"
317
+ assert parsed.layers[1].z_index == 1
318
+ assert len(parsed.layers[1].tiles) == 1
319
+
320
+
321
+ class TestRuntimeIntegration:
322
+ def test_tmx_loads_via_tilemap_data(self, tmp_path):
323
+ import pygame
324
+
325
+ from tilemap_parser.runtime.map_loader import TilemapData
326
+
327
+ data_dir = tmp_path / "data"
328
+ data_dir.mkdir()
329
+ assets_dir = tmp_path / "assets"
330
+ assets_dir.mkdir()
331
+
332
+ png = assets_dir / "tileset.png"
333
+ surf = pygame.Surface((64, 64))
334
+ surf.fill((255, 0, 255))
335
+ pygame.image.save(surf, str(png))
336
+
337
+ tmx = data_dir / "test.tmx"
338
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
339
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="32" tileheight="32" infinite="0">
340
+ <tileset firstgid="1" name="test" tilewidth="32" tileheight="32" tilecount="4" columns="2">
341
+ <image source="../assets/tileset.png" width="64" height="64"/>
342
+ </tileset>
343
+ <layer id="1" name="Ground" width="2" height="2">
344
+ <data encoding="csv">1,2,3,4</data>
345
+ </layer>
346
+ </map>""")
347
+
348
+ td = TilemapData.load(tmx, skip_missing_images=False)
349
+ assert len(td.warnings) == 0
350
+ assert len(td.surfaces) == 1
351
+ assert td.surfaces[0] is not None
352
+ assert len(td.parsed.layers) == 1
353
+ layer_data = td.get_layer("Ground")
354
+ assert layer_data is not None
355
+ assert len(layer_data.tiles) == 4
356
+
357
+ def test_tmx_missing_tileset_warns(self, tmp_path):
358
+ import pygame
359
+
360
+ from tilemap_parser.runtime.map_loader import TilemapData
361
+
362
+ tmx = tmp_path / "missing.tmx"
363
+ tmx.write_text("""<?xml version="1.0" encoding="UTF-8"?>
364
+ <map version="1.10" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="32" tileheight="32" infinite="0">
365
+ <tileset firstgid="1" source="nonexistent.tsx"/>
366
+ <layer id="1" name="Ground" width="2" height="2">
367
+ <data encoding="csv">0,0,0,0</data>
368
+ </layer>
369
+ </map>""")
370
+
371
+ td = TilemapData.load(tmx, extra_search_base=tmp_path)
372
+ assert len(td.warnings) >= 1
373
+
374
+ def test_real_sewers_map_from_tiled_examples(self):
375
+ import pygame
376
+
377
+ from tilemap_parser.runtime.map_loader import TilemapData
378
+ from tilemap_parser.parser.map_parse import parse_map_file
379
+
380
+ tmx_path = _resolve("sewers.tmx")
381
+ png_path = _resolve("sewer_tileset.png")
382
+ assert png_path.is_file()
383
+
384
+ parsed = parse_map_file(tmx_path)
385
+
386
+ assert parsed.meta.tile_size == (24, 24)
387
+ assert parsed.meta.map_size == (50, 50)
388
+
389
+ assert len(parsed.tilesets) == 1
390
+ ts = parsed.tilesets[0]
391
+ assert ts.path.endswith("sewer_tileset.png")
392
+ assert ts.type == "tile"
393
+
394
+ assert len(parsed.layers) == 2
395
+ bottom = parsed.layers[0]
396
+ top = parsed.layers[1]
397
+
398
+ assert bottom.name == "Bottom"
399
+ assert bottom.layer_type == "tile"
400
+ assert bottom.opacity == 1.0
401
+ assert len(bottom.tiles) > 0
402
+
403
+ assert top.name == "Top"
404
+ assert top.layer_type == "tile"
405
+ assert top.opacity == 0.49
406
+ assert len(top.tiles) > 0
407
+
408
+ all_ttypes = {t.ttype for t in bottom.tiles.values()}
409
+ assert all_ttypes == {0}
410
+
411
+ all_variants = {t.variant for t in bottom.tiles.values()}
412
+ max_variant = max(all_variants)
413
+ min_variant = min(all_variants)
414
+ assert min_variant >= 0
415
+ assert max_variant < 72 # 8 cols * 9 rows
416
+
417
+ td = TilemapData.load(tmx_path, skip_missing_images=False)
418
+ assert len(td.warnings) == 0
419
+ assert len(td.surfaces) == 1
420
+ assert td.surfaces[0] is not None
421
+ assert td.surfaces[0].get_size() == (192, 217)
422
+
423
+ assert len(td.parsed.layers) == 2
424
+
425
+ assert td.get_layer("Bottom") is not None
426
+ assert td.get_layer("Top") is not None
427
+
428
+
429
+ class TestErrorCases:
430
+ def test_nonexistent_file(self):
431
+ from tilemap_parser.parser.map_parse import parse_map_file
432
+
433
+ with pytest.raises(Exception, match="Not a file"):
434
+ parse_map_file("/nonexistent/map.tmx")
435
+
436
+ def test_wrong_extension_raises_error(self, tmp_path):
437
+ from tilemap_parser.parser.map_parse import parse_map_file
438
+
439
+ f = tmp_path / "test.py"
440
+ f.write_text("not a map")
441
+ with pytest.raises(Exception, match=r"\.json.*\.tmx"):
442
+ parse_map_file(f)
File without changes
File without changes