tilemap-parser 5.0.2__tar.gz → 5.0.4__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 (74) hide show
  1. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/PKG-INFO +28 -84
  2. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/README.md +5 -2
  3. tilemap_parser-5.0.4/pypi-readme.md +49 -0
  4. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/pyproject.toml +2 -2
  5. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/__init__.py +5 -0
  6. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/__init__.py +8 -0
  7. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/collision.py +53 -3
  8. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/collision_loader.py +4 -1
  9. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/particle.py +66 -11
  10. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/__init__.py +10 -0
  11. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/animation_player.py +49 -8
  12. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision_cache.py +12 -6
  13. tilemap_parser-5.0.4/src/tilemap_parser/runtime/particles.py +1030 -0
  14. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/PKG-INFO +28 -84
  15. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/SOURCES.txt +1 -0
  16. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_collision.py +121 -1
  17. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_integration_animation.py +125 -0
  18. tilemap_parser-5.0.4/tests/test_particle_emitter.py +601 -0
  19. tilemap_parser-5.0.2/src/tilemap_parser/runtime/particles.py +0 -587
  20. tilemap_parser-5.0.2/tests/test_particle_emitter.py +0 -74
  21. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/LICENSE +0 -0
  22. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/setup.cfg +0 -0
  23. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/animation.py +0 -0
  24. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/map_parse.py +0 -0
  25. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/node_parse.py +0 -0
  26. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  27. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/area_node.py +0 -0
  28. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/body.py +0 -0
  29. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/camera.py +0 -0
  30. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/__init__.py +0 -0
  31. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/hit.py +0 -0
  32. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/manager.py +0 -0
  33. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/shapes.py +0 -0
  34. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/map_loader.py +0 -0
  35. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/map_object.py +0 -0
  36. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/__init__.py +0 -0
  37. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/grounded.py +0 -0
  38. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/platformer.py +0 -0
  39. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/queries.py +0 -0
  40. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/rpg.py +0 -0
  41. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/runner.py +0 -0
  42. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/slide.py +0 -0
  43. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/types.py +0 -0
  44. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  45. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/nav_grid.py +0 -0
  46. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/path_follower.py +0 -0
  47. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  48. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/object_collision.py +0 -0
  49. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/polygon_query.py +0 -0
  50. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/protocols.py +0 -0
  51. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/renderer.py +0 -0
  52. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  53. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/world.py +0 -0
  54. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/utils/__init__.py +0 -0
  55. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser/utils/geometry.py +0 -0
  56. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  57. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/requires.txt +0 -0
  58. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  59. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_body.py +0 -0
  60. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_camera.py +0 -0
  61. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_geometry.py +0 -0
  62. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_gid_collision.py +0 -0
  63. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_integration_collision.py +0 -0
  64. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_integration_map_loader.py +0 -0
  65. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_map_loader.py +0 -0
  66. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_map_object.py +0 -0
  67. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_move_grounded.py +0 -0
  68. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_navigation.py +0 -0
  69. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_object_collision.py +0 -0
  70. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_object_surfaces.py +0 -0
  71. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_render_scale.py +0 -0
  72. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_tile_collision.py +0 -0
  73. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  74. {tilemap_parser-5.0.2 → tilemap_parser-5.0.4}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.0.2
3
+ Version: 5.0.4
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
@@ -699,106 +699,50 @@ Dynamic: license-file
699
699
 
700
700
  # tilemap-parser
701
701
 
702
- Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
702
+ Standalone parser/loader for [tilemap-editor](https://pypi.org/project/tilemap-editor/) JSON maps, sprite animations, and a collision detection runtime.
703
703
 
704
- ## Features
704
+ ## Install
705
705
 
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`
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`
711
- - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
712
- - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
713
- - **Capsule support**: Full capsule collision against all shape types
714
- - **Hit helpers**: `CollisionHit.resolve()`, `involves()`, `other()` for ergonomic separation
706
+ ```bash
707
+ pip install tilemap-parser
708
+ ```
715
709
 
716
- ## Quick Start
710
+ ## Quick start
717
711
 
718
712
  ```python
719
- from tilemap_parser import load_map, TileLayerRenderer, Camera
713
+ from tilemap_parser import load_map, TileLayerRenderer, Camera, ParticleField, FOG_PROFILE
720
714
 
721
- # Load map and set up renderer
722
715
  game_data = load_map("path/to/map.json")
723
716
  renderer = TileLayerRenderer(game_data)
724
717
 
725
- # Set up camera
726
718
  camera = Camera(800, 600, mode="centered")
727
719
  camera.follow(player)
728
- ```
729
-
730
- ```python
731
- from tilemap_parser import (
732
- CollisionRunner, CollisionCache,
733
- ObjectCollisionManager, CircleShape, RectangleShape,
734
- ParticleSystem, ParticleSystemConfig
735
- )
736
-
737
- # Tile-based collision
738
- cache = CollisionCache()
739
- tileset = cache.get_tileset_collision("data/collision/tileset.collision.json")
740
- runner = CollisionRunner.from_game_type("topdown", tile_size=(32, 32))
741
-
742
- # Object-to-object collision
743
- manager = ObjectCollisionManager()
744
- manager.add_object(player)
745
- for hit in manager.check_all_collisions():
746
- hit.resolve() # separate both objects
747
720
 
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,
721
+ # Persistent fog: fill once, wrap forever — no particle churn
722
+ mist = ParticleField(
723
+ area=(0, 0, 800, 600),
724
+ profile=FOG_PROFILE, # plain data — copy and tweak for your own moods
725
+ color=(200, 50, 80),
726
+ global_alpha=0.5,
764
727
  )
765
- particles = ParticleSystem(particle_config)
766
728
  ```
767
729
 
768
- ## Physics bodies
769
-
770
- A `PhysicsWorld` is a single space holding the tile layer plus solid `Body` objects (static walls, kinematic crates). Attach a `CollisionRunner` to the world once and every move method resolves tiles and bodies through it — no per-call tile arguments.
771
-
772
- > Full contract — object↔world↔`move_*`↔tiles↔rendering: see [docs/physics-world.md](docs/physics-world.md) and `examples/physics-crate/main.py`.
773
-
774
- ```python
775
- from tilemap_parser import (
776
- Body, PhysicsWorld, CollisionRunner, RectangleShape,
777
- TilemapData, TilesetCollision, CollisionCache,
778
- )
779
-
780
- cache = CollisionCache()
781
- tileset = cache.get_tileset_collision("data/collision/tileset.collision.json")
782
- world = PhysicsWorld.from_map(game_data, tileset) # adopts tile_size / render_scale
783
-
784
- crate = Body(RectangleShape(width=16, height=16), x=320, y=480, mode="kinematic")
785
- world.add_body(crate)
786
-
787
- runner = CollisionRunner.from_world(world) # or runner.attach(world)
788
- ```
730
+ ## Features
789
731
 
790
- Pushable crates are just kinematic bodies moved with an explicit velocity (gravity is skipped when `velocity=` is given):
732
+ - **Map parsing** tilemaps, layers, objects, and autotile data from JSON
733
+ - **Collision** — tile-based (`CollisionRunner`) and object-to-object (`ObjectCollisionManager`) with rect, circle, capsule, and polygon shapes
734
+ - **Physics** — `PhysicsWorld` with static/kinematic `Body` solids and one-way platforms
735
+ - **Particles** — wrapped continuous fields (`ParticleField`, `FOG_PROFILE`), burst emitters, alpha fades, and batch rendering (`SpriteBatchRenderer`)
736
+ - **Pathfinding** — A* `Pathfinder` over eroded `NavGrid`s
737
+ - **Animation** — frame-based `AnimationPlayer`, plus a `Camera` with centered/deadzone follow
738
+ - **Rendering** — chunked `TileLayerRenderer` and `LayerRenderStats`
791
739
 
792
- ```python
793
- # player pressed against the crate -> hand the player's vx over
794
- crate.vx = player.vx
795
- result = runner.move_grounded(crate, None, None, dt, velocity=(crate.vx, 0))
796
- if result.hit_wall_x:
797
- crate.vx = 0 # crate stopped against a tile wall or another body
798
- ```
740
+ ## Requirements
799
741
 
800
- `Body` accepts primitive shapes only (`RectangleShape`, `CircleShape`, `CapsuleShape`); use `MapObject` for polygon solids. Collision pairs honor both sides' `collision_layer` / `collision_mask`, and bodies are landing surfaces for platformer step-up (small crates are climbed, tall ones block like walls).
742
+ - Python 3.10+
743
+ - `pygame-ce>=2.5` (installed automatically)
801
744
 
802
- ## Docs
745
+ ## Links
803
746
 
804
- Full documentation: [https://tilemap-parser.vercel.app/](https://tilemap-parser.vercel.app/)
747
+ - **Documentation**: https://tilemap-parser.vercel.app/
748
+ - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -100,6 +100,9 @@ if result.hit_wall_x:
100
100
 
101
101
  `Body` accepts primitive shapes only (`RectangleShape`, `CircleShape`, `CapsuleShape`); use `MapObject` for polygon solids. Collision pairs honor both sides' `collision_layer` / `collision_mask`, and bodies are landing surfaces for platformer step-up (small crates are climbed, tall ones block like walls).
102
102
 
103
- ## Docs
103
+ ## Links
104
104
 
105
- Full documentation: [https://tilemap-parser.vercel.app/](https://tilemap-parser.vercel.app/)
105
+ - **Docs**: [https://tilemap-parser.vercel.app/](https://tilemap-parser.vercel.app/)
106
+ - **Docs (deepwiki)**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
107
+ - **Editor**: https://pypi.org/project/tilemap-editor/
108
+ - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -0,0 +1,49 @@
1
+ # tilemap-parser
2
+
3
+ Standalone parser/loader for [tilemap-editor](https://pypi.org/project/tilemap-editor/) JSON maps, sprite animations, and a collision detection runtime.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install tilemap-parser
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from tilemap_parser import load_map, TileLayerRenderer, Camera, ParticleField, FOG_PROFILE
15
+
16
+ game_data = load_map("path/to/map.json")
17
+ renderer = TileLayerRenderer(game_data)
18
+
19
+ camera = Camera(800, 600, mode="centered")
20
+ camera.follow(player)
21
+
22
+ # Persistent fog: fill once, wrap forever — no particle churn
23
+ mist = ParticleField(
24
+ area=(0, 0, 800, 600),
25
+ profile=FOG_PROFILE, # plain data — copy and tweak for your own moods
26
+ color=(200, 50, 80),
27
+ global_alpha=0.5,
28
+ )
29
+ ```
30
+
31
+ ## Features
32
+
33
+ - **Map parsing** — tilemaps, layers, objects, and autotile data from JSON
34
+ - **Collision** — tile-based (`CollisionRunner`) and object-to-object (`ObjectCollisionManager`) with rect, circle, capsule, and polygon shapes
35
+ - **Physics** — `PhysicsWorld` with static/kinematic `Body` solids and one-way platforms
36
+ - **Particles** — wrapped continuous fields (`ParticleField`, `FOG_PROFILE`), burst emitters, alpha fades, and batch rendering (`SpriteBatchRenderer`)
37
+ - **Pathfinding** — A* `Pathfinder` over eroded `NavGrid`s
38
+ - **Animation** — frame-based `AnimationPlayer`, plus a `Camera` with centered/deadzone follow
39
+ - **Rendering** — chunked `TileLayerRenderer` and `LayerRenderStats`
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.10+
44
+ - `pygame-ce>=2.5` (installed automatically)
45
+
46
+ ## Links
47
+
48
+ - **Documentation**: https://tilemap-parser.vercel.app/
49
+ - **Repository**: https://github.com/FluffyBrudy/tilemap-parser
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "5.0.2"
7
+ version = "5.0.4"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
- readme = "README.md"
9
+ readme = "pypi-readme.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { file = "LICENSE" }
12
12
  authors = [{ name = "tilemap parser contributors" }]
@@ -46,8 +46,13 @@ __all__ = [
46
46
  "TileLayerRenderer",
47
47
  "TilesetCollision",
48
48
  "TilemapData",
49
+ "FOG_PROFILE",
50
+ "FieldLayerSpec",
51
+ "FieldProfile",
49
52
  "Particle",
50
53
  "ParticleEmitter",
54
+ "ParticleField",
55
+ "ParticleFieldLayer",
51
56
  "ParticleRenderer",
52
57
  "ParticleSystem",
53
58
  "ParticleSystemConfig",
@@ -51,6 +51,10 @@ from .particle import (
51
51
  ALPHA_FADE_MODES,
52
52
  EMISSION_SHAPES,
53
53
  PARTICLE_SHAPES,
54
+ AlphaFadeMode,
55
+ EmissionShape,
56
+ FieldQuality,
57
+ ParticleShape,
54
58
  ParticleSystemConfig,
55
59
  parse_particle_dict,
56
60
  parse_particle_file,
@@ -105,6 +109,10 @@ __all__ = [
105
109
  "ALPHA_FADE_MODES",
106
110
  "EMISSION_SHAPES",
107
111
  "PARTICLE_SHAPES",
112
+ "AlphaFadeMode",
113
+ "EmissionShape",
114
+ "FieldQuality",
115
+ "ParticleShape",
108
116
  "ParticleSystemConfig",
109
117
  "parse_particle_dict",
110
118
  "parse_particle_file",
@@ -9,6 +9,7 @@ Provides parsing of collision data from JSON into data classes:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import math
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any, Dict, List, Optional, Tuple, Union
14
15
 
@@ -34,6 +35,13 @@ class CollisionPolygon:
34
35
  world_vertices = [(tile_x + vx * scale, tile_y + vy * scale) for vx, vy in self.vertices]
35
36
  return CollisionPolygon(vertices=world_vertices, one_way=self.one_way)
36
37
 
38
+ def scaled(self, scale: float) -> "CollisionPolygon":
39
+ """Return a copy with all vertices multiplied by *scale*."""
40
+ return CollisionPolygon(
41
+ vertices=[(vx * scale, vy * scale) for vx, vy in self.vertices],
42
+ one_way=self.one_way,
43
+ )
44
+
37
45
  def is_valid(self) -> bool:
38
46
  """Check if polygon has at least 3 vertices"""
39
47
  return len(self.vertices) >= 3
@@ -130,6 +138,14 @@ class RectangleShape:
130
138
  top = y + self.offset[1]
131
139
  return (left, top, left + self.width, top + self.height)
132
140
 
141
+ def scaled(self, scale: float) -> "RectangleShape":
142
+ """Return a copy with dimensions and offset multiplied by *scale*."""
143
+ return RectangleShape(
144
+ width=self.width * scale,
145
+ height=self.height * scale,
146
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
147
+ )
148
+
133
149
 
134
150
  @dataclass
135
151
  class CircleShape:
@@ -142,6 +158,13 @@ class CircleShape:
142
158
  """Get center position in world space"""
143
159
  return (x + self.offset[0], y + self.offset[1])
144
160
 
161
+ def scaled(self, scale: float) -> "CircleShape":
162
+ """Return a copy with radius and offset multiplied by *scale*."""
163
+ return CircleShape(
164
+ radius=self.radius * scale,
165
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
166
+ )
167
+
145
168
 
146
169
  @dataclass
147
170
  class CapsuleShape:
@@ -159,6 +182,14 @@ class CapsuleShape:
159
182
  """Get bottom circle center in world space"""
160
183
  return (x + self.offset[0], y + self.offset[1] + self.height)
161
184
 
185
+ def scaled(self, scale: float) -> "CapsuleShape":
186
+ """Return a copy with radius, height, and offset multiplied by *scale*."""
187
+ return CapsuleShape(
188
+ radius=self.radius * scale,
189
+ height=self.height * scale,
190
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
191
+ )
192
+
162
193
 
163
194
  CharacterShapeType = Union[RectangleShape, CircleShape, CapsuleShape, CollisionPolygon]
164
195
 
@@ -173,6 +204,16 @@ class CharacterCollision:
173
204
  collision_layer: int = 1
174
205
  collision_mask: int = 0xFFFFFFFF
175
206
 
207
+ def scaled(self, scale: float) -> "CharacterCollision":
208
+ """Return a copy whose shape is scaled by *scale* (typically ``render_scale``)."""
209
+ return CharacterCollision(
210
+ name=self.name,
211
+ shape=self.shape.scaled(scale),
212
+ properties=dict(self.properties),
213
+ collision_layer=self.collision_layer,
214
+ collision_mask=self.collision_mask,
215
+ )
216
+
176
217
 
177
218
  @dataclass
178
219
  class ObjectCollisionRegionData:
@@ -255,19 +296,28 @@ def parse_tileset_collision(data: JsonDict) -> TilesetCollision:
255
296
  raise CollisionParseError(f"Invalid tileset collision data: {e}") from e
256
297
 
257
298
 
258
- def parse_character_collision(data: JsonDict) -> CharacterCollision:
299
+ def parse_character_collision(data: JsonDict, render_scale: float = 1.0) -> CharacterCollision:
259
300
  """
260
301
  Parse character collision data from dictionary.
261
302
 
262
303
  Args:
263
304
  data: Dictionary loaded from .collision.json file
305
+ render_scale: Multiplier applied to shape dimensions and offsets
306
+ (no-op when 1.0).
264
307
 
265
308
  Returns:
266
309
  CharacterCollision object
267
310
 
268
311
  Raises:
269
- CollisionParseError: If data format is invalid
312
+ CollisionParseError: If data format is invalid or render_scale is not
313
+ finite and greater than zero.
270
314
  """
315
+ if (
316
+ not isinstance(render_scale, (int, float))
317
+ or not math.isfinite(render_scale)
318
+ or render_scale <= 0
319
+ ):
320
+ raise CollisionParseError(f"render_scale must be finite and > 0, got {render_scale!r}")
271
321
  try:
272
322
  name = data["name"]
273
323
  shape_data = data["shape"]
@@ -312,7 +362,7 @@ def parse_character_collision(data: JsonDict) -> CharacterCollision:
312
362
  properties=properties,
313
363
  collision_layer=collision_layer,
314
364
  collision_mask=collision_mask,
315
- )
365
+ ).scaled(render_scale)
316
366
  except (KeyError, ValueError, TypeError) as e:
317
367
  raise CollisionParseError(f"Invalid character collision data: {e}") from e
318
368
 
@@ -55,6 +55,7 @@ def load_tileset_collision(
55
55
 
56
56
  def load_character_collision(
57
57
  collision_path: Union[str, Path],
58
+ render_scale: float = 1.0,
58
59
  ) -> CharacterCollision | None:
59
60
  """
60
61
  Load character collision data from a collision JSON file.
@@ -64,6 +65,8 @@ def load_character_collision(
64
65
 
65
66
  Args:
66
67
  collision_path: Direct path to the .collision.json file.
68
+ render_scale: Multiplier applied to shape dimensions and offsets
69
+ (no-op when 1.0).
67
70
 
68
71
  Returns:
69
72
  CharacterCollision object, or None if the file does not exist.
@@ -79,7 +82,7 @@ def load_character_collision(
79
82
  try:
80
83
  with open(collision_path, "r", encoding="utf-8") as f:
81
84
  data = json.load(f)
82
- return parse_character_collision(data)
85
+ return parse_character_collision(data, render_scale=render_scale)
83
86
  except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
84
87
  raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
85
88
 
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import math
4
5
  from dataclasses import dataclass, field
5
6
  from pathlib import Path
6
- from typing import Any, Dict, List, Optional, Tuple, Union
7
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast, get_args
7
8
 
8
9
  from .map_parse import (
9
10
  JsonDict,
@@ -18,16 +19,21 @@ from .map_parse import (
18
19
  )
19
20
 
20
21
 
22
+ EmissionShape = Literal["point", "rect", "circle", "line"]
23
+ ParticleShape = Literal["circle", "square", "diamond", "star", "sparkle", "smoke", "heart", "line", "fog"]
24
+ AlphaFadeMode = Literal["none", "fade_out", "fade_in", "fade_both"]
25
+ FieldQuality = Literal["low", "medium", "high"]
26
+
21
27
  EMISSION_SHAPES = ["point", "rect", "circle", "line"]
22
- PARTICLE_SHAPES = ["circle", "square", "diamond", "star", "sparkle", "smoke", "heart", "line"]
23
- ALPHA_FADE_MODES = ["none", "fade_out", "fade_in", "fade_both"]
28
+ PARTICLE_SHAPES = list(get_args(ParticleShape))
29
+ ALPHA_FADE_MODES = list(get_args(AlphaFadeMode))
24
30
 
25
31
 
26
32
  @dataclass
27
33
  class ParticleSystemConfig:
28
34
  name: str
29
- emission_shape: str = "point"
30
- particle_shape: str = "circle"
35
+ emission_shape: EmissionShape = "point"
36
+ particle_shape: ParticleShape = "circle"
31
37
  particle_size_min: int = 2
32
38
  particle_size_max: int = 6
33
39
  spawn_rate: int = 20
@@ -51,7 +57,9 @@ class ParticleSystemConfig:
51
57
  start_scale: float = 1.0
52
58
  end_scale: float = 0.3
53
59
  rotation_speed: float = 0.0
54
- alpha_fade: str = "fade_out"
60
+ alpha_fade: AlphaFadeMode = "fade_out"
61
+ fade_peak_alpha: Optional[int] = None
62
+ wrap: bool = False
55
63
 
56
64
  def apply_render_scale(self, scale: float) -> None:
57
65
  """Scale dimensionful fields by *scale* (typically ``render_scale``).
@@ -70,8 +78,45 @@ class ParticleSystemConfig:
70
78
  self.gravity_x *= scale
71
79
  self.gravity_y *= scale
72
80
 
81
+ def fill_area(self, w: float, h: float) -> float:
82
+ """Effective fill area of the emission shape over a ``w x h`` rect.
83
+
84
+ Rect and line fill their whole band; circle fills only the
85
+ inscribed disc (``pi * (min(w, h) / 2) ** 2``), so density math
86
+ stays honest for non-rect shapes. ``point`` has no area and
87
+ raises.
88
+ """
89
+ if self.emission_shape == "circle":
90
+ radius = min(w, h) / 2
91
+ return math.pi * radius * radius
92
+ if self.emission_shape == "point":
93
+ raise ValueError(
94
+ "emission_shape 'point' has no fill area; use rect/circle/line "
95
+ "or a wider emission rect"
96
+ )
97
+ return w * h
98
+
99
+ def count_for_coverage(self, coverage: float, w: float, h: float) -> int:
100
+ """Particles needed so sheets cover ``coverage`` of the ``w x h`` area.
101
+
102
+ ``coverage`` is dimensionless (0.5 = half the area). Count is
103
+ ``coverage * fill_area / mean_particle_area``, where the particle
104
+ area uses the shape's fill factor (circle sheets are roughly
105
+ ``pi/4`` of their bounding square; everything else uses the full
106
+ square). Not capped by ``max_particles`` — cap it yourself or use
107
+ ``ParticleSystem.emit_field``, which does.
108
+ """
109
+ if coverage <= 0:
110
+ raise ValueError(f"coverage must be > 0, got {coverage}")
111
+ mean_size = (self.particle_size_min + self.particle_size_max) / 2
112
+ if mean_size <= 0:
113
+ return 0
114
+ fill = math.pi / 4 if self.particle_shape == "circle" else 1.0
115
+ area = self.fill_area(w, h)
116
+ return max(0, round(coverage * area / (mean_size * mean_size * fill)))
117
+
73
118
  def to_dict(self) -> JsonDict:
74
- return {
119
+ d = {
75
120
  "emission_shape": self.emission_shape,
76
121
  "particle_shape": self.particle_shape,
77
122
  "particle_size_min": self.particle_size_min,
@@ -98,7 +143,11 @@ class ParticleSystemConfig:
98
143
  "end_scale": self.end_scale,
99
144
  "rotation_speed": self.rotation_speed,
100
145
  "alpha_fade": self.alpha_fade,
146
+ "wrap": self.wrap,
101
147
  }
148
+ if self.fade_peak_alpha is not None:
149
+ d["fade_peak_alpha"] = self.fade_peak_alpha
150
+ return d
102
151
 
103
152
  @classmethod
104
153
  def from_dict(cls, d: JsonDict, name: str = "") -> "ParticleSystemConfig":
@@ -106,18 +155,18 @@ class ParticleSystemConfig:
106
155
  return d.get(key, default)
107
156
 
108
157
  cfg = cls(name=name)
109
- cfg.emission_shape = str(g("emission_shape", "point"))
158
+ cfg.emission_shape = cast(EmissionShape, str(g("emission_shape", "point")))
110
159
  if cfg.emission_shape not in EMISSION_SHAPES:
111
160
  cfg.emission_shape = "point"
112
- cfg.particle_shape = str(g("particle_shape", "circle"))
161
+ cfg.particle_shape = cast(ParticleShape, str(g("particle_shape", "circle")))
113
162
  if cfg.particle_shape not in PARTICLE_SHAPES:
114
163
  cfg.particle_shape = "circle"
115
- cfg.alpha_fade = str(g("alpha_fade", "fade_out"))
164
+ cfg.alpha_fade = cast(AlphaFadeMode, str(g("alpha_fade", "fade_out")))
116
165
  if cfg.alpha_fade not in ALPHA_FADE_MODES:
117
166
  cfg.alpha_fade = "fade_out"
118
167
  cfg.particle_size_min = max(1, int(g("particle_size_min", 2)))
119
168
  cfg.particle_size_max = max(cfg.particle_size_min, int(g("particle_size_max", 6)))
120
- cfg.spawn_rate = max(1, int(g("spawn_rate", 20)))
169
+ cfg.spawn_rate = max(0, int(g("spawn_rate", 20)))
121
170
  cfg.max_particles = max(1, int(g("max_particles", 100)))
122
171
  cfg.lifetime_min = max(0.1, float(g("lifetime_min", 0.5)))
123
172
  cfg.lifetime_max = max(cfg.lifetime_min, float(g("lifetime_max", 2.0)))
@@ -138,6 +187,12 @@ class ParticleSystemConfig:
138
187
  cfg.start_scale = max(0.1, float(g("start_scale", 1.0)))
139
188
  cfg.end_scale = max(0.1, float(g("end_scale", 0.3)))
140
189
  cfg.rotation_speed = float(g("rotation_speed", 0))
190
+ cfg.wrap = bool(g("wrap", False))
191
+ raw_peak = d.get("fade_peak_alpha")
192
+ if raw_peak is None:
193
+ cfg.fade_peak_alpha = None
194
+ else:
195
+ cfg.fade_peak_alpha = max(0, min(255, int(raw_peak)))
141
196
  return cfg
142
197
 
143
198
 
@@ -26,9 +26,14 @@ from .renderer import LayerRenderStats, TileLayerRenderer
26
26
  from .area_node import AreaNode
27
27
  from . import navigation
28
28
  from .particles import (
29
+ FOG_PROFILE,
30
+ FieldLayerSpec,
31
+ FieldProfile,
29
32
  Particle,
30
33
  ParticleEmitter,
31
34
  ParticleEmitterNode,
35
+ ParticleField,
36
+ ParticleFieldLayer,
32
37
  ParticleRenderer,
33
38
  ParticleSystem,
34
39
  SpriteBatchRenderer,
@@ -64,9 +69,14 @@ __all__ = [
64
69
  "load_map_objects",
65
70
  "load_object_collision",
66
71
  "load_tileset_collision",
72
+ "FOG_PROFILE",
73
+ "FieldLayerSpec",
74
+ "FieldProfile",
67
75
  "Particle",
68
76
  "ParticleEmitter",
69
77
  "ParticleEmitterNode",
78
+ "ParticleField",
79
+ "ParticleFieldLayer",
70
80
  "ParticleRenderer",
71
81
  "ParticleSystem",
72
82
  "PhysicsWorld",
@@ -20,6 +20,9 @@ class SpriteAnimationSet:
20
20
  json_path: Optional[Path] = None
21
21
  grid_offset_x: int = 0
22
22
  grid_offset_y: int = 0
23
+ render_scale: float = 1.0
24
+ grid_columns: int = 0
25
+ grid_rows: int = 0
23
26
 
24
27
  @classmethod
25
28
  def load(
@@ -28,6 +31,7 @@ class SpriteAnimationSet:
28
31
  *,
29
32
  spritesheet_path: Optional[PathLike] = None,
30
33
  extra_search_base: Optional[Path] = None,
34
+ render_scale: float = 1.0,
31
35
  ) -> "SpriteAnimationSet":
32
36
  path = Path(json_path)
33
37
  library = parse_animation_file(path)
@@ -59,6 +63,29 @@ class SpriteAnimationSet:
59
63
  except pygame.error as e:
60
64
  raise AnimationParseError(f"Failed to load image {image_path}: {e}") from e
61
65
 
66
+ tw, th = library.tile_size
67
+ ox, oy = library.grid_offset
68
+ grid_columns = max(1, (surface.get_width() - ox) // tw)
69
+ grid_rows = max(1, (surface.get_height() - oy) // th)
70
+
71
+ if render_scale != 1.0:
72
+ if render_scale <= 0:
73
+ raise ValueError(f"render_scale must be > 0, got {render_scale}")
74
+ w, h = surface.get_size()
75
+ sw, sh = int(w * render_scale), int(h * render_scale)
76
+ stw, sth = int(tw * render_scale), int(th * render_scale)
77
+ if sw < 1 or sh < 1 or stw < 1 or sth < 1:
78
+ raise ValueError(f"render_scale {render_scale} produces zero-sized atlas cells")
79
+ if (
80
+ int(ox * render_scale) + grid_columns * stw > sw
81
+ or int(oy * render_scale) + grid_rows * sth > sh
82
+ ):
83
+ raise ValueError(
84
+ f"render_scale {render_scale} cannot maintain atlas grid cell bounds"
85
+ )
86
+ surface = pygame.transform.scale(surface, (sw, sh))
87
+ library = _scaled_library(library, render_scale)
88
+
62
89
  return cls(
63
90
  library=library,
64
91
  surface=surface,
@@ -66,6 +93,9 @@ class SpriteAnimationSet:
66
93
  json_path=path,
67
94
  grid_offset_x=library.grid_offset[0],
68
95
  grid_offset_y=library.grid_offset[1],
96
+ render_scale=render_scale,
97
+ grid_columns=grid_columns,
98
+ grid_rows=grid_rows,
69
99
  )
70
100
 
71
101
  def get_content_bounds(self, clip_name: str) -> Optional[Rect]:
@@ -88,15 +118,10 @@ class SpriteAnimationSet:
88
118
 
89
119
  def get_image(self, variant_id: int, *, copy_surface: bool = True) -> Optional[Surface]:
90
120
  tw, th = self.library.tile_size
91
- if tw <= 0 or th <= 0:
92
- return None
93
- available_w = self.surface.get_width() - self.grid_offset_x
94
- available_h = self.surface.get_height() - self.grid_offset_y
95
- if available_w < tw or available_h < th:
121
+ if tw <= 0 or th <= 0 or self.grid_columns <= 0 or self.grid_rows <= 0:
96
122
  return None
97
- cols = max(1, available_w // tw)
98
- col = variant_id % cols
99
- row = variant_id // cols
123
+ col = variant_id % self.grid_columns
124
+ row = variant_id // self.grid_columns
100
125
  src = Rect(self.grid_offset_x + col * tw, self.grid_offset_y + row * th, tw, th)
101
126
  if not self.surface.get_rect().contains(src):
102
127
  return None
@@ -161,3 +186,19 @@ class AnimationPlayer:
161
186
  if variant not in self._frame_cache:
162
187
  self._frame_cache[variant] = self.animation_set.get_image(variant, copy_surface=True)
163
188
  return self._frame_cache[variant]
189
+
190
+
191
+ def _scaled_library(library: AnimationLibrary, scale: float) -> AnimationLibrary:
192
+ """Return a copy of *library* whose grid geometry is scaled by *scale*.
193
+
194
+ Kept in lockstep with the scaled spritesheet surface so that
195
+ :meth:`SpriteAnimationSet.get_image` continues to address cells
196
+ correctly.
197
+ """
198
+ return AnimationLibrary(
199
+ animations=library.animations,
200
+ spritesheet_path=library.spritesheet_path,
201
+ tile_size=(int(library.tile_size[0] * scale), int(library.tile_size[1] * scale)),
202
+ grid_offset=(int(library.grid_offset[0] * scale), int(library.grid_offset[1] * scale)),
203
+ trim_transparent=library.trim_transparent,
204
+ )