tilemap-parser 4.2.1__tar.gz → 4.2.2__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 (48) hide show
  1. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/PKG-INFO +1 -1
  2. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/pyproject.toml +1 -1
  3. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/map_object.py +37 -6
  4. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  5. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_map_object.py +595 -0
  6. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/LICENSE +0 -0
  7. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/README.md +0 -0
  8. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/setup.cfg +0 -0
  9. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/__init__.py +0 -0
  10. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/__init__.py +0 -0
  11. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/animation.py +0 -0
  12. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/collision.py +0 -0
  13. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/collision_loader.py +0 -0
  14. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/map_parse.py +0 -0
  15. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/node_parse.py +0 -0
  16. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/particle.py +0 -0
  17. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  18. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/__init__.py +0 -0
  19. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/animation_player.py +0 -0
  20. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/area_node.py +0 -0
  21. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/camera.py +0 -0
  22. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  23. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/map_loader.py +0 -0
  24. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/object_collision.py +0 -0
  25. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/particles.py +0 -0
  26. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/renderer.py +0 -0
  27. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  28. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/utils/__init__.py +0 -0
  29. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser/utils/geometry.py +0 -0
  30. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  31. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  32. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser.egg-info/requires.txt +0 -0
  33. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  34. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_camera.py +0 -0
  35. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_collision.py +0 -0
  36. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_geometry.py +0 -0
  37. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_gid_collision.py +0 -0
  38. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_integration_animation.py +0 -0
  39. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_integration_collision.py +0 -0
  40. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_integration_map_loader.py +0 -0
  41. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_map_loader.py +0 -0
  42. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_move_grounded.py +0 -0
  43. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_object_collision.py +0 -0
  44. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_object_surfaces.py +0 -0
  45. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_particle_emitter.py +0 -0
  46. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_render_scale.py +0 -0
  47. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_tile_collision.py +0 -0
  48. {tilemap_parser-4.2.1 → tilemap_parser-4.2.2}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.1
3
+ Version: 4.2.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "4.2.1"
7
+ version = "4.2.2"
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"
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  from pathlib import Path
19
19
  from typing import Dict, List, Optional, Union
20
20
 
21
+ import pygame
21
22
  from pygame import Surface
22
23
 
23
24
  from ..parser.collision import CollisionPolygon, ObjectCollisionData
@@ -31,6 +32,12 @@ class MapObject:
31
32
  A game object loaded from a tilemap, carrying a surface for
32
33
  rendering and collision data for physics.
33
34
 
35
+ All spatial data (``x``, ``y``, ``surface`` size, and collision
36
+ polygon vertices) is pre-scaled by the map's ``render_scale``.
37
+ Units are in *effective* pixels (``render_scale × tile_size``).
38
+ The object is ready for direct use in a game loop that runs in
39
+ effective-pixel space — no additional scaling needed.
40
+
34
41
  Satisfies the :class:`ICollidableObject` protocol so it can be
35
42
  added directly to an :class:`ObjectCollisionManager`.
36
43
 
@@ -89,9 +96,26 @@ def load_map_objects(
89
96
 
90
97
  Iterates every object layer in *tilemap_data*, resolves the
91
98
  corresponding ``.object_collision.json`` from *collision_dir*, and
92
- builds :class:`MapObject` instances with surfaces, owner-local
93
- collision shapes (region offset only; consumers add x/y), and
94
- layer/mask values from the collision regions.
99
+ builds :class:`MapObject` instances with pre-scaled surfaces,
100
+ positions, and collision shapes.
101
+
102
+ **render_scale transparency**
103
+
104
+ All spatial data is automatically scaled by the map's
105
+ ``render_scale`` (from ``tilemap_data.render_scale``):
106
+
107
+ * ``MapObject.x`` / ``MapObject.y`` — raw map coords × ``rs``
108
+ (stored as ``float``; fractional pixel positions are preserved).
109
+ * ``MapObject.surface`` — surface is scaled by ``rs`` via
110
+ :func:`pygame.transform.scale` (no-op when ``rs == 1.0``).
111
+ Raster dimensions are truncated to integers via ``int()``
112
+ to satisfy :func:`pygame.transform.scale` requirements.
113
+ * Collision polygon vertices and ``region_rect`` offsets are
114
+ multiplied by ``rs`` via :meth:`CollisionPolygon.transform`.
115
+
116
+ The returned objects are ready for a game loop that runs in
117
+ effective-pixel space (``render_scale × tile_size``). No
118
+ additional scaling is required by the caller.
95
119
 
96
120
  Collision data is cached per tileset index so the same file is
97
121
  never loaded twice.
@@ -123,6 +147,13 @@ def load_map_objects(
123
147
  continue
124
148
 
125
149
  surf, x, y = surf_x_y
150
+ rs = tilemap_data.render_scale
151
+ x = x * rs
152
+ y = y * rs
153
+
154
+ if rs != 1.0 and surf is not None:
155
+ w, h = surf.get_size()
156
+ surf = pygame.transform.scale(surf, (int(w * rs), int(h * rs)))
126
157
 
127
158
  ttype = obj.ttype
128
159
  if ttype not in loaded_collision:
@@ -142,9 +173,9 @@ def load_map_objects(
142
173
  if not world_shapes:
143
174
  region_layer = region.collision_layer
144
175
  region_mask = region.collision_mask
145
- ox = region.region_rect[0]
146
- oy = region.region_rect[1]
147
- world_shapes.extend(shape.transform(ox, oy) for shape in region.shapes)
176
+ ox = region.region_rect[0] * rs
177
+ oy = region.region_rect[1] * rs
178
+ world_shapes.extend(shape.transform(ox, oy, rs) for shape in region.shapes)
148
179
  if not world_shapes:
149
180
  continue
150
181
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.1
3
+ Version: 4.2.2
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
@@ -652,6 +652,601 @@ class TestLoadMapObjects:
652
652
  hit = check_collision(loaded, manual)
653
653
  assert hit is not None, "loaded and manual object at same world pos should collide"
654
654
 
655
+ def test_default_scale_no_op(self, basic_map):
656
+ """render_scale=1.0 (default) behaves identically to before."""
657
+ td, collision_dir, _ = basic_map
658
+ assert td.render_scale == 1.0
659
+ objects = load_map_objects(td, collision_dir)
660
+ assert len(objects) == 1
661
+ obj = objects[0]
662
+ # Positions are raw (no scaling)
663
+ assert obj.x == 100
664
+ assert obj.y == 200
665
+ # Vertices are raw
666
+ verts = obj.collision_shape.vertices
667
+ assert verts[0] == (0, 0)
668
+ assert verts[1] == (16, 0)
669
+ assert verts[2] == (16, 16)
670
+ assert verts[3] == (0, 16)
671
+
672
+ def test_render_scale_3x(self):
673
+ """render_scale=3.0 scales positions and polygon vertices."""
674
+ from tilemap_parser.runtime.map_object import load_map_objects
675
+ from tilemap_parser.runtime.map_loader import TilemapData
676
+
677
+ with tempfile.TemporaryDirectory() as tmp:
678
+ tmp = Path(tmp)
679
+ assets_dir = tmp / "assets"
680
+ data_dir = tmp / "data"
681
+ collision_dir = tmp / "data" / "collision"
682
+ assets_dir.mkdir(parents=True)
683
+ data_dir.mkdir(parents=True)
684
+ collision_dir.mkdir(parents=True)
685
+
686
+ png = assets_dir / "tileset.png"
687
+ _make_minimal_png(png, (32, 16))
688
+
689
+ collision_payload = {
690
+ "tileset_name": "tileset",
691
+ "regions": {
692
+ "region_r": {
693
+ "region_id": "region_r",
694
+ "region_rect": [5, 10, 16, 16],
695
+ "name": "R",
696
+ "shapes": [{"type": "polygon", "vertices": [[0, 0], [16, 0], [16, 16], [0, 16]], "one_way": False}],
697
+ "properties": {},
698
+ },
699
+ },
700
+ }
701
+ coll_path = collision_dir / "tileset.object_collision.json"
702
+ with open(coll_path, "w") as f:
703
+ json.dump(collision_payload, f, indent=2)
704
+
705
+ # Map with render_scale=3.0
706
+ payload = {
707
+ "meta": {
708
+ "tile_size": "16;16",
709
+ "map_size": "10;10",
710
+ "version": "1.1",
711
+ "render_scale": 3.0,
712
+ },
713
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "object"}]},
714
+ "project_state": {"rules": [], "groups": []},
715
+ "data": {
716
+ "ongrid": {},
717
+ "layers": [
718
+ {
719
+ "name": "Object Layer",
720
+ "type": "object",
721
+ "visible": True,
722
+ "locked": False,
723
+ "opacity": 1.0,
724
+ "z_index": 0,
725
+ "properties": {},
726
+ "tiles": {},
727
+ "objects": {
728
+ "1": {
729
+ "area": {"x": 100, "y": 200, "w": 16, "h": 16},
730
+ "ttype": 0,
731
+ "tileset_type": "object",
732
+ "variant": 0,
733
+ "properties": {},
734
+ },
735
+ },
736
+ "next_object_id": 2,
737
+ },
738
+ ],
739
+ },
740
+ }
741
+ map_path = data_dir / "test_map.json"
742
+ with open(map_path, "w") as f:
743
+ json.dump(payload, f, indent=2)
744
+
745
+ td = TilemapData.load(map_path)
746
+ assert td.render_scale == 3.0
747
+ objects = load_map_objects(td, collision_dir)
748
+ assert len(objects) == 1
749
+ obj = objects[0]
750
+
751
+ # Position scaled by 3x
752
+ assert obj.x == 300 # 100 * 3
753
+ assert obj.y == 600 # 200 * 3
754
+
755
+ # region_rect [5, 10] scaled by 3x → ox=15, oy=30
756
+ # Vertices scaled by 3x: (0,0)→(0,0), (16,0)→(48,0), (16,16)→(48,48), (0,16)→(0,48)
757
+ # World = ox + vx*rs: (15+0, 30+0) = (15, 30)
758
+ # (15+48, 30+0) = (63, 30)
759
+ # (15+48, 30+48) = (63, 78)
760
+ # (15+0, 30+48) = (15, 78)
761
+ verts = obj.collision_shape.vertices
762
+ assert len(verts) == 4
763
+ assert verts[0] == pytest.approx((15.0, 30.0))
764
+ assert verts[1] == pytest.approx((63.0, 30.0))
765
+ assert verts[2] == pytest.approx((63.0, 78.0))
766
+ assert verts[3] == pytest.approx((15.0, 78.0))
767
+
768
+ # Surface scaled by 3x: tileset is 32×16, variant 0 is 16×16
769
+ assert obj.surface.get_size() == (48, 48) # 16*3, 16*3
770
+
771
+ def test_render_scale_surface_scaled(self):
772
+ """Surface is scaled by render_scale in load_map_objects."""
773
+ with tempfile.TemporaryDirectory() as tmp:
774
+ tmp = Path(tmp)
775
+ assets_dir = tmp / "assets"
776
+ data_dir = tmp / "data"
777
+ collision_dir = tmp / "data" / "collision"
778
+ assets_dir.mkdir(parents=True)
779
+ data_dir.mkdir(parents=True)
780
+ collision_dir.mkdir(parents=True)
781
+
782
+ png = assets_dir / "tileset.png"
783
+ # Create a 64×32 tileset with 2 variants (16×16 each)
784
+ surf = pygame.Surface((64, 32))
785
+ surf.fill((255, 0, 255))
786
+ pygame.image.save(surf, str(png))
787
+
788
+ collision_payload = {
789
+ "tileset_name": "tileset",
790
+ "regions": {
791
+ "region_r": {
792
+ "region_id": "region_r",
793
+ "region_rect": [0, 0, 16, 16],
794
+ "name": "R",
795
+ "shapes": [{"type": "polygon", "vertices": [[0, 0], [16, 0], [16, 16], [0, 16]], "one_way": False}],
796
+ "properties": {},
797
+ },
798
+ },
799
+ }
800
+ coll_path = collision_dir / "tileset.object_collision.json"
801
+ with open(coll_path, "w") as f:
802
+ json.dump(collision_payload, f, indent=2)
803
+
804
+ for rs_val, expected_w, expected_h in [(1.0, 16, 16), (2.0, 32, 32), (3.0, 48, 48), (1.5, 24, 24)]:
805
+ payload = {
806
+ "meta": {
807
+ "tile_size": "16;16",
808
+ "map_size": "10;10",
809
+ "version": "1.1",
810
+ "render_scale": rs_val,
811
+ },
812
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "object"}]},
813
+ "project_state": {"rules": [], "groups": []},
814
+ "data": {
815
+ "ongrid": {},
816
+ "layers": [
817
+ {
818
+ "name": "Object Layer",
819
+ "type": "object",
820
+ "visible": True,
821
+ "locked": False,
822
+ "opacity": 1.0,
823
+ "z_index": 0,
824
+ "properties": {},
825
+ "tiles": {},
826
+ "objects": {
827
+ "1": {
828
+ "area": {"x": 10, "y": 20, "w": 16, "h": 16},
829
+ "ttype": 0,
830
+ "tileset_type": "object",
831
+ "variant": 0,
832
+ "properties": {},
833
+ },
834
+ },
835
+ "next_object_id": 2,
836
+ },
837
+ ],
838
+ },
839
+ }
840
+ map_path = data_dir / "test_map.json"
841
+ with open(map_path, "w") as f:
842
+ json.dump(payload, f, indent=2)
843
+
844
+ td = TilemapData.load(map_path)
845
+ objects = load_map_objects(td, collision_dir)
846
+ assert len(objects) == 1
847
+ obj = objects[0]
848
+ assert obj.surface.get_size() == (expected_w, expected_h), \
849
+ f"rs={rs_val}: expected ({expected_w},{expected_h}) got {obj.surface.get_size()}"
850
+ assert obj.x == 10 * rs_val, f"rs={rs_val}: x={obj.x} != {10 * rs_val}"
851
+ assert obj.y == 20 * rs_val, f"rs={rs_val}: y={obj.y} != {20 * rs_val}"
852
+
853
+ # Fractional rs with odd dimensions to exercise surface truncation
854
+ odd_png = assets_dir / "odd_tileset.png"
855
+ odd_surf = pygame.Surface((17, 35))
856
+ odd_surf.fill((0, 255, 0))
857
+ pygame.image.save(odd_surf, str(odd_png))
858
+
859
+ odd_collision = {
860
+ "tileset_name": "odd_tileset",
861
+ "regions": {
862
+ "region_r": {
863
+ "region_id": "region_r",
864
+ "region_rect": [0, 0, 17, 35],
865
+ "name": "R",
866
+ "shapes": [{"type": "polygon", "vertices": [[0, 0], [17, 0], [17, 35], [0, 35]], "one_way": False}],
867
+ "properties": {},
868
+ },
869
+ },
870
+ }
871
+ odd_coll_path = collision_dir / "odd_tileset.object_collision.json"
872
+ with open(odd_coll_path, "w") as f:
873
+ json.dump(odd_collision, f, indent=2)
874
+
875
+ payload_odd = {
876
+ "meta": {
877
+ "tile_size": "16;16",
878
+ "map_size": "10;10",
879
+ "version": "1.1",
880
+ "render_scale": 1.5,
881
+ },
882
+ "resources": {"tilesets": [{"path": "../assets/odd_tileset.png", "type": "object"}]},
883
+ "project_state": {"rules": [], "groups": []},
884
+ "data": {
885
+ "ongrid": {},
886
+ "layers": [
887
+ {
888
+ "name": "Object Layer",
889
+ "type": "object",
890
+ "visible": True,
891
+ "locked": False,
892
+ "opacity": 1.0,
893
+ "z_index": 0,
894
+ "properties": {},
895
+ "tiles": {},
896
+ "objects": {
897
+ "1": {
898
+ "area": {"x": 11, "y": 21, "w": 17, "h": 35},
899
+ "ttype": 0,
900
+ "tileset_type": "object",
901
+ "variant": 0,
902
+ "properties": {},
903
+ },
904
+ },
905
+ "next_object_id": 2,
906
+ },
907
+ ],
908
+ },
909
+ }
910
+ odd_map_path = data_dir / "odd_test_map.json"
911
+ with open(odd_map_path, "w") as f:
912
+ json.dump(payload_odd, f, indent=2)
913
+
914
+ td_odd = TilemapData.load(odd_map_path)
915
+ objects_odd = load_map_objects(td_odd, collision_dir)
916
+ assert len(objects_odd) == 1
917
+ obj = objects_odd[0]
918
+ assert obj.x == 11 * 1.5, f"fractional x: {obj.x} != {11 * 1.5}"
919
+ assert obj.y == 21 * 1.5, f"fractional y: {obj.y} != {21 * 1.5}"
920
+ assert obj.surface.get_size() == (25, 52), \
921
+ f"truncated surface: {obj.surface.get_size()} != (25, 52) [int(17*1.5)=25, int(35*1.5)=52]"
922
+
923
+ def test_render_scale_collision_consistency(self):
924
+ """At render_scale=3.0, a loaded object collides with a manually-placed
925
+ probe object if and only if they overlap in effective (scaled) space."""
926
+ with tempfile.TemporaryDirectory() as tmp:
927
+ tmp = Path(tmp)
928
+ assets_dir = tmp / "assets"
929
+ data_dir = tmp / "data"
930
+ collision_dir = tmp / "data" / "collision"
931
+ assets_dir.mkdir(parents=True)
932
+ data_dir.mkdir(parents=True)
933
+ collision_dir.mkdir(parents=True)
934
+
935
+ png = assets_dir / "tileset.png"
936
+ _make_minimal_png(png, (32, 16))
937
+
938
+ # Collision region at (0,0) with a 16x16 square polygon
939
+ collision_payload = {
940
+ "tileset_name": "tileset",
941
+ "regions": {
942
+ "region_r": {
943
+ "region_id": "region_r",
944
+ "region_rect": [0, 0, 16, 16],
945
+ "name": "R",
946
+ "shapes": [{"type": "polygon", "vertices": [[0, 0], [16, 0], [16, 16], [0, 16]], "one_way": False}],
947
+ "properties": {},
948
+ },
949
+ },
950
+ }
951
+ coll_path = collision_dir / "tileset.object_collision.json"
952
+ with open(coll_path, "w") as f:
953
+ json.dump(collision_payload, f, indent=2)
954
+
955
+ payload = {
956
+ "meta": {
957
+ "tile_size": "16;16",
958
+ "map_size": "10;10",
959
+ "version": "1.1",
960
+ "render_scale": 3.0,
961
+ },
962
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "object"}]},
963
+ "project_state": {"rules": [], "groups": []},
964
+ "data": {
965
+ "ongrid": {},
966
+ "layers": [
967
+ {
968
+ "name": "Object Layer",
969
+ "type": "object",
970
+ "visible": True,
971
+ "locked": False,
972
+ "opacity": 1.0,
973
+ "z_index": 0,
974
+ "properties": {},
975
+ "tiles": {},
976
+ "objects": {
977
+ "1": {
978
+ "area": {"x": 100, "y": 200, "w": 16, "h": 16},
979
+ "ttype": 0,
980
+ "tileset_type": "object",
981
+ "variant": 0,
982
+ "properties": {},
983
+ },
984
+ },
985
+ "next_object_id": 2,
986
+ },
987
+ ],
988
+ },
989
+ }
990
+ map_path = data_dir / "test_map.json"
991
+ with open(map_path, "w") as f:
992
+ json.dump(payload, f, indent=2)
993
+
994
+ td = TilemapData.load(map_path)
995
+ objects = load_map_objects(td, collision_dir)
996
+ assert len(objects) == 1
997
+ loaded = objects[0]
998
+
999
+ # Loaded object position = (300, 600) in effective space
1000
+ # Collision polygon covers (300, 600) to (300+48, 600+48) = (348, 648)
1001
+ assert loaded.x == 300
1002
+ assert loaded.y == 600
1003
+
1004
+ # Probe at (310, 610) with a 16x16 rect in effective space must collide
1005
+ probe_surf = pygame.Surface((16, 16))
1006
+ probe_shape = CollisionPolygon(vertices=[(0, 0), (16, 0), (16, 16), (0, 16)])
1007
+ probe = MapObject(x=310, y=610, surface=probe_surf, collision_shape=probe_shape)
1008
+
1009
+ from tilemap_parser.runtime.object_collision import check_collision
1010
+
1011
+ hit = check_collision(loaded, probe)
1012
+ assert hit is not None, "expected collision in effective space"
1013
+
1014
+ # Probe far away must NOT collide
1015
+ probe_far = MapObject(x=1000, y=1000, surface=probe_surf, collision_shape=probe_shape)
1016
+ hit_far = check_collision(loaded, probe_far)
1017
+ assert hit_far is None, "no collision expected for far probe"
1018
+
1019
+ def test_render_scale_region_offset_scaled(self):
1020
+ """Non-zero region_rect offset is scaled by render_scale."""
1021
+ with tempfile.TemporaryDirectory() as tmp:
1022
+ tmp = Path(tmp)
1023
+ assets_dir = tmp / "assets"
1024
+ data_dir = tmp / "data"
1025
+ collision_dir = tmp / "data" / "collision"
1026
+ assets_dir.mkdir(parents=True)
1027
+ data_dir.mkdir(parents=True)
1028
+ collision_dir.mkdir(parents=True)
1029
+
1030
+ png = assets_dir / "tileset.png"
1031
+ _make_minimal_png(png, (32, 16))
1032
+
1033
+ # region_rect offset of (5,10), polygon at origin within that region
1034
+ collision_payload = {
1035
+ "tileset_name": "tileset",
1036
+ "regions": {
1037
+ "region_r": {
1038
+ "region_id": "region_r",
1039
+ "region_rect": [5, 10, 16, 16],
1040
+ "name": "R",
1041
+ "shapes": [{"type": "polygon", "vertices": [[0, 0], [8, 0], [8, 8], [0, 8]], "one_way": False}],
1042
+ "properties": {},
1043
+ },
1044
+ },
1045
+ }
1046
+ coll_path = collision_dir / "tileset.object_collision.json"
1047
+ with open(coll_path, "w") as f:
1048
+ json.dump(collision_payload, f, indent=2)
1049
+
1050
+ payload = {
1051
+ "meta": {
1052
+ "tile_size": "16;16",
1053
+ "map_size": "10;10",
1054
+ "version": "1.1",
1055
+ "render_scale": 3.0,
1056
+ },
1057
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "object"}]},
1058
+ "project_state": {"rules": [], "groups": []},
1059
+ "data": {
1060
+ "ongrid": {},
1061
+ "layers": [
1062
+ {
1063
+ "name": "Object Layer",
1064
+ "type": "object",
1065
+ "visible": True,
1066
+ "locked": False,
1067
+ "opacity": 1.0,
1068
+ "z_index": 0,
1069
+ "properties": {},
1070
+ "tiles": {},
1071
+ "objects": {
1072
+ "1": {
1073
+ "area": {"x": 100, "y": 200, "w": 16, "h": 16},
1074
+ "ttype": 0,
1075
+ "tileset_type": "object",
1076
+ "variant": 0,
1077
+ "properties": {},
1078
+ },
1079
+ },
1080
+ "next_object_id": 2,
1081
+ },
1082
+ ],
1083
+ },
1084
+ }
1085
+ map_path = data_dir / "test_map.json"
1086
+ with open(map_path, "w") as f:
1087
+ json.dump(payload, f, indent=2)
1088
+
1089
+ td = TilemapData.load(map_path)
1090
+ objects = load_map_objects(td, collision_dir)
1091
+ assert len(objects) == 1
1092
+ obj = objects[0]
1093
+
1094
+ # Position scaled: (300, 600)
1095
+ # region_rect offset scaled: ox=15, oy=30
1096
+ # Vertices scaled: (0,0)→(0,0), (8,0)→(24,0), (8,8)→(24,24), (0,8)→(0,24)
1097
+ # World: (15, 30), (39, 30), (39, 54), (15, 54)
1098
+ verts = obj.collision_shape.vertices
1099
+ assert verts[0] == pytest.approx((15.0, 30.0))
1100
+ assert verts[1] == pytest.approx((39.0, 30.0))
1101
+ assert verts[2] == pytest.approx((39.0, 54.0))
1102
+ assert verts[3] == pytest.approx((15.0, 54.0))
1103
+
1104
+ # Probe at object center must collide
1105
+ probe_surf = pygame.Surface((16, 16))
1106
+ probe_shape = CollisionPolygon(vertices=[(0, 0), (16, 0), (16, 16), (0, 16)])
1107
+ # Loaded world AABB: (300+15, 600+30) to (300+39, 600+54) = (315, 630) to (339, 654)
1108
+ probe = MapObject(x=315, y=630, surface=probe_surf, collision_shape=probe_shape)
1109
+
1110
+ from tilemap_parser.runtime.object_collision import check_collision
1111
+
1112
+ hit = check_collision(obj, probe)
1113
+ assert hit is not None, "expected collision with region offset"
1114
+
1115
+ def test_render_scale_visual_collision_alignment(self):
1116
+ """At render_scale=N, collision AABB is fully inside visual rect
1117
+ and vertex positions are pixel-accurate for known values."""
1118
+ from tilemap_parser.runtime.map_object import load_map_objects
1119
+ from tilemap_parser.runtime.map_loader import TilemapData
1120
+
1121
+ with tempfile.TemporaryDirectory() as tmp:
1122
+ tmp = Path(tmp)
1123
+ assets_dir = tmp / "assets"
1124
+ data_dir = tmp / "data"
1125
+ collision_dir = tmp / "data" / "collision"
1126
+ assets_dir.mkdir(parents=True)
1127
+ data_dir.mkdir(parents=True)
1128
+ collision_dir.mkdir(parents=True)
1129
+
1130
+ # Tileset: 64x96 with a known shape
1131
+ tileset_surf = pygame.Surface((64, 96))
1132
+ tileset_surf.fill((0, 255, 0))
1133
+ png = assets_dir / "tree.png"
1134
+ pygame.image.save(tileset_surf, str(png))
1135
+
1136
+ # Collision: region_rect=[0,3,64,93] with polygon
1137
+ collision_payload = {
1138
+ "tileset_name": "tree",
1139
+ "regions": {
1140
+ "region_r": {
1141
+ "region_id": "region_r",
1142
+ "region_rect": [0, 3, 64, 93],
1143
+ "name": "R",
1144
+ "shapes": [
1145
+ {
1146
+ "type": "polygon",
1147
+ "vertices": [[32.0, 2.75], [8.0, 30.0], [10.5, 58.75],
1148
+ [22.5, 65.25], [20.0, 88.25], [33.5, 81.75],
1149
+ [47.5, 87.75], [41.5, 65.75], [58.5, 48.75],
1150
+ [52.0, 25.0]],
1151
+ "one_way": False,
1152
+ }
1153
+ ],
1154
+ "properties": {"collision_layer": 1, "collision_mask": 65535},
1155
+ },
1156
+ },
1157
+ }
1158
+ coll_path = collision_dir / "tree.object_collision.json"
1159
+ with open(coll_path, "w") as f:
1160
+ json.dump(collision_payload, f, indent=2)
1161
+
1162
+ for rs_val in [1.0, 2.0, 3.0, 4.0]:
1163
+ payload = {
1164
+ "meta": {
1165
+ "tile_size": "16;16",
1166
+ "map_size": "10;10",
1167
+ "version": "1.1",
1168
+ "render_scale": rs_val,
1169
+ },
1170
+ "resources": {"tilesets": [{"path": "../assets/tree.png", "type": "object"}]},
1171
+ "project_state": {"rules": [], "groups": []},
1172
+ "data": {
1173
+ "ongrid": {},
1174
+ "layers": [
1175
+ {
1176
+ "name": "Objects",
1177
+ "type": "object",
1178
+ "visible": True,
1179
+ "locked": False,
1180
+ "opacity": 1.0,
1181
+ "z_index": 0,
1182
+ "properties": {},
1183
+ "tiles": {},
1184
+ "objects": {
1185
+ "1": {
1186
+ "area": {"x": 27, "y": 82, "w": 64, "h": 96},
1187
+ "ttype": 0,
1188
+ "tileset_type": "object",
1189
+ "variant": 0,
1190
+ "properties": {},
1191
+ },
1192
+ },
1193
+ "next_object_id": 2,
1194
+ },
1195
+ ],
1196
+ },
1197
+ }
1198
+ map_path = data_dir / "test_map.json"
1199
+ with open(map_path, "w") as f:
1200
+ json.dump(payload, f, indent=2)
1201
+
1202
+ td = TilemapData.load(map_path)
1203
+ objects = load_map_objects(td, collision_dir)
1204
+ assert len(objects) == 1, f"rs={rs_val}: expected 1 object"
1205
+ obj = objects[0]
1206
+
1207
+ # Verify surface size is pixel-accurate at integer render_scale
1208
+ expected_w = int(64 * rs_val)
1209
+ expected_h = int(96 * rs_val)
1210
+ assert obj.surface.get_size() == (expected_w, expected_h), \
1211
+ f"rs={rs_val}: surface {obj.surface.get_size()} != ({expected_w},{expected_h})"
1212
+
1213
+ # Verify position is pixel-accurate (float)
1214
+ assert obj.x == 27 * rs_val, f"rs={rs_val}: x={obj.x} != {27 * rs_val}"
1215
+ assert obj.y == 82 * rs_val, f"rs={rs_val}: y={obj.y} != {82 * rs_val}"
1216
+
1217
+ # Verify every collision vertex is pixel-accurate for known values
1218
+ shape = obj.collision_shapes[0]
1219
+ # First vertex: (32.0, 2.75) + region_rect (0, 3) = world (32*rs, (3+2.75)*rs)
1220
+ expected_v0 = (32.0 * rs_val, (3.0 + 2.75) * rs_val)
1221
+ actual_v0 = shape.vertices[0]
1222
+ assert abs(actual_v0[0] - expected_v0[0]) < 0.001, \
1223
+ f"rs={rs_val}: vertex[0].x {actual_v0[0]} != {expected_v0[0]}"
1224
+ assert abs(actual_v0[1] - expected_v0[1]) < 0.001, \
1225
+ f"rs={rs_val}: vertex[0].y {actual_v0[1]} != {expected_v0[1]}"
1226
+
1227
+ # Verify collision AABB is inside visual rect
1228
+ xs = [v[0] for v in shape.vertices]
1229
+ ys = [v[1] for v in shape.vertices]
1230
+ coll_min_x = obj.x + min(xs)
1231
+ coll_max_x = obj.x + max(xs)
1232
+ coll_min_y = obj.y + min(ys)
1233
+ coll_max_y = obj.y + max(ys)
1234
+
1235
+ vis_left = obj.x
1236
+ vis_top = obj.y
1237
+ vis_right = obj.x + expected_w
1238
+ vis_bottom = obj.y + expected_h
1239
+
1240
+ margin = -0.001 # allow 0px floating point tolerance
1241
+ assert coll_min_x >= vis_left + margin, \
1242
+ f"rs={rs_val}: collision left {coll_min_x} < visual left {vis_left}"
1243
+ assert coll_min_y >= vis_top + margin, \
1244
+ f"rs={rs_val}: collision top {coll_min_y} < visual top {vis_top}"
1245
+ assert coll_max_x <= vis_right - margin, \
1246
+ f"rs={rs_val}: collision right {coll_max_x} > visual right {vis_right}"
1247
+ assert coll_max_y <= vis_bottom - margin, \
1248
+ f"rs={rs_val}: collision bottom {coll_max_y} > visual bottom {vis_bottom}"
1249
+
655
1250
  def test_invalid_tileset_index_skipped(self):
656
1251
  """Object with ttype out of range is skipped."""
657
1252
  with tempfile.TemporaryDirectory() as tmp:
File without changes
File without changes
File without changes