diff --git a/CLAUDE.md b/CLAUDE.md index 640b28c..940c477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -609,34 +609,29 @@ echo 'import mcrfpy; print("Test"); scene = mcrfpy.Scene("test"); scene.activate ### Understanding Key Macros and Patterns -#### RET_PY_INSTANCE Macro (UIDrawable.h) -This macro handles converting C++ UI objects to their Python equivalents: +#### Returning a C++ object to Python (#369) + +**Never `tp_alloc` a wrapper for a C++ object that might already have one.** A fresh +wrapper breaks object identity (`x.parent is x.parent` -> False) and silently downgrades +a Python subclass to its base type. `RET_PY_INSTANCE`, which older docs described here, +no longer exists; use the cache-aware converters instead: + ```cpp -RET_PY_INSTANCE(target); -// Expands to a switch on target->derived_type() that: -// 1. Allocates the correct Python object type (Frame, Caption, Sprite, Grid) -// 2. Sets the shared_ptr data member -// 3. Returns the PyObject* +// Drawables (Frame, Caption, Sprite, GridView, Line, Circle, Arc, Viewport3D) +PyObject* obj = UIDrawable::pyobject_for(drawable_shared_ptr); // UIDrawable.h + +// Entities -- convertEntityToPython(), file-local to UIEntityCollection.cpp +// GridData -- PyGridData::pyobject_for() ``` +Each looks the object up in `PythonObjectCache` by `serial_number` and returns the live +wrapper if one exists, only allocating (and re-registering) on a miss. `tp_alloc` is +correct **only** in a type's own `tp_init`, where the object is genuinely new. + #### Collection Patterns - `UICollection` wraps `std::vector>` -- `UIEntityCollection` wraps `std::list>` -- Different containers require different iteration code (vector vs list) - -#### Python Object Creation Patterns -```cpp -// Pattern 1: Using tp_alloc (most common) -auto o = (PyUIFrameObject*)type->tp_alloc(type, 0); -o->data = std::make_shared(); - -// Pattern 2: Getting type from module -auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); -auto o = (PyUIEntityObject*)type->tp_alloc(type, 0); - -// Pattern 3: Direct shared_ptr assignment -iterObj->data = self->data; // Shares the C++ object -``` +- `UIEntityCollection` wraps `std::vector>` (#329: was a + `std::list`; indexing is O(1) now, so don't reintroduce `std::advance`) ### Working Directory Structure ``` diff --git a/api/manifest.json b/api/manifest.json index a7bec6f..d86e7d0 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -1 +1 @@ -{"commit":"36e74e0","dirty":false,"functions":{"bresenham":{"doc_sha":"ee2d0f7a54a664f5","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"bresenham(start, end, include_start=True, include_end=True) -> list[tuple[int, int]]"},"end_benchmark":{"doc_sha":"5d783c5dabb7a4e9","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"end_benchmark() -> str"},"exit":{"doc_sha":"7572d4d6b1a1c8a4","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"exit() -> None"},"find":{"doc_sha":"364b95807edcf8b4","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"find(name: str, scene: str = None) -> UIDrawable | None"},"find_all":{"doc_sha":"b4a278f16b172131","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"find_all(pattern: str, scene: str = None) -> list"},"get_metrics":{"doc_sha":"017f3b466a3911c8","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"get_metrics() -> dict"},"lock":{"doc_sha":"e42989cabc81d080","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"lock() -> _LockContext"},"log_benchmark":{"doc_sha":"4564bf7321366a0a","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"log_benchmark(message: str) -> None"},"set_dev_console":{"doc_sha":"940ed19ff4f5243f","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"set_dev_console(enabled: bool) -> None"},"set_scale":{"doc_sha":"b565b441a326af48","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"set_scale(multiplier: float) -> None"},"start_benchmark":{"doc_sha":"783957f3517518fe","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"start_benchmark() -> None"},"step":{"doc_sha":"90dec8ed10ee644f","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"step(dt: float = None) -> float"}},"objects":{"AStarPath":{"doc_sha":"60dd12bb4a54f3dd","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"destination":{"doc_sha":"62a81c7267ad9ed9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"c7dd130b8d4ef800","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"peek":{"doc_sha":"14054f10b4d9dda0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"peek() -> Vector"},"remaining":{"doc_sha":"c193adc4bbfa3564","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"walk":{"doc_sha":"3fb64b239109d301","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"walk() -> Vector"}}},"Alignment":{"doc_sha":"77d93d03c8994f84","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Arc":{"doc_sha":"8323f4b7101509e9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"abf1643dda106bf8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"color":{"doc_sha":"bdf932405b56c319","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"end_angle":{"doc_sha":"74f9704909a3a23d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"0cc0684f26d8a660","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"f2ef657aaf8b6c68","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"6612fa35c43918e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"radius":{"doc_sha":"f9c2310b775f1e5a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"start_angle":{"doc_sha":"6b91f175ebe5ecb4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"thickness":{"doc_sha":"f8eba89682554e6d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"67dd517aa153c158","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"AutoRuleSet":{"doc_sha":"49ab716632a44f06","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply":{"doc_sha":"701b6f038cfd6809","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply(discrete_map: DiscreteMap, tile_layer: TileLayer, seed: int = 0) -> None"},"grid_size":{"doc_sha":"570f4c81acb781d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"group_count":{"doc_sha":"892fc283939b3e0c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"4f5ca90860649b64","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve":{"doc_sha":"f8fff4e6e6488e3f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve(discrete_map: DiscreteMap, seed: int = 0) -> list[int]"},"rule_count":{"doc_sha":"9b9d5a7c1475dcfa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"terrain_enum":{"doc_sha":"eb365bf99bbcca37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"terrain_enum() -> IntEnum"},"value_count":{"doc_sha":"3467d158dd5af073","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"values":{"doc_sha":"b3199e059fd1ca2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"BSP":{"doc_sha":"7e971e0542cf4006","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"adjacency":{"doc_sha":"9373aa85408486ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bounds":{"doc_sha":"9c41e7403f2a0b2f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear":{"doc_sha":"2e4ffd3360ac858e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> BSP"},"find":{"doc_sha":"fccfac484a2b889c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find(pos: tuple[int, int] | list | Vector) -> BSPNode | None"},"get_leaf":{"doc_sha":"d8ba680ec2ad4fd1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_leaf(index: int) -> BSPNode"},"leaves":{"doc_sha":"439ec78ec02e6df8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"leaves() -> Iterator[BSPNode]"},"pos":{"doc_sha":"b8b27f4ad76e2870","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"root":{"doc_sha":"d1e3d9741dab383d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"621f11f9a37dd89d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height). Read-only."},"split_once":{"doc_sha":"0d7c0c33156bc4e5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"split_once(horizontal: bool, position: int) -> BSP"},"split_recursive":{"doc_sha":"fa516b79833e5094","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"split_recursive(depth: int, min_size: tuple[int, int], max_ratio: float = 1.5, seed: int | None = None) -> BSP"},"to_heightmap":{"doc_sha":"a0f03cab7a0f2d1b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(size: tuple[int, int] | None = None, select: str = 'leaves', shrink: int = 0, value: float = 1.0) -> HeightMap"},"traverse":{"doc_sha":"57da83e0f37686aa","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"traverse(order: Traversal = Traversal.LEVEL_ORDER) -> Iterator[BSPNode]"}}},"Behavior":{"doc_sha":"09206365e6c37ecc","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Billboard":{"doc_sha":"38614d1d19942bab","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"facing":{"doc_sha":"f82e2d7d8087804a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"dc5ed39915ab896c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"phi":{"doc_sha":"1450c89fb5b47929","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"0d5fec7fd4701ea6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"c9d55c4579681854","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"170c20c841b586fc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"b6d846566b6ccf93","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"theta":{"doc_sha":"c214ecbcb099228b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"adf8070be9a09eba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"CallableBinding":{"doc_sha":"711bf0f149ccae7a","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"callable":{"doc_sha":"96d3d31a6b7c56d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_valid":{"doc_sha":"bc69c90e13475ae6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"value":{"doc_sha":"8f7d9eb4f520c017","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Caption":{"doc_sha":"8cd46324baa46a0d","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"af7ba90df95e4b79","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"font":{"doc_sha":"fbdafdb33921aa13","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"font_size":{"doc_sha":"7894149ec4efec6e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"79a7d48503e657d1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"25b029c86a618fee","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"ca1a98d700ff1888","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"6580b31092048bcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"388919803d270fd3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"44e4967e15212959","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"85abb564299b703d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"text":{"doc_sha":"41628142b287defe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"8472810bca891509","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"528487f8deaaf789","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"114ecec940477107","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Circle":{"doc_sha":"077981a1fbdfb6e3","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"d1e80ccd6bf706b9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"46be64336e296c50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"20bebfa7432ed339","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"b43e33c2d49a3565","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"0cc0684f26d8a660","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"3a340282000297e0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"20e9fd42a7ba4bb8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"3fbed883fee308fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"5f9d88ff075d2cfc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"radius":{"doc_sha":"00a41207192c55a8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Color":{"doc_sha":"f1f664405900332f","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"a":{"doc_sha":"ef61630ad34f8e2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"b":{"doc_sha":"2e2e91e93833fdd0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_hex":{"doc_sha":"24d1ff553133bb7e","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_hex(hex_string: str) -> Color"},"g":{"doc_sha":"716ced374bcc2dc4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"lerp":{"doc_sha":"290e204f3538e406","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"lerp(other: Color, t: float) -> Color"},"r":{"doc_sha":"ef64cae6296f68cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_hex":{"doc_sha":"d0cf65b284158877","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_hex() -> str"}}},"ColorLayer":{"doc_sha":"f8a87b6184c9a9b6","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_gradient":{"doc_sha":"20390111f0652ae0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_gradient(source: HeightMap, range: tuple, color_low: Color, color_high: Color) -> ColorLayer"},"apply_perspective":{"doc_sha":"a909e1b1c0373bc0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_perspective(entity: Entity, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None"},"apply_ranges":{"doc_sha":"37fc5a26fda9cc0c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> ColorLayer"},"apply_threshold":{"doc_sha":"59cc9b72322cc677","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(source: HeightMap, range: tuple, color: Color) -> ColorLayer"},"at":{"doc_sha":"fef47313dcef9247","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(pos: tuple | Vector) or (x: int, y: int) -> Color"},"clear_perspective":{"doc_sha":"fec237fe4cbda340","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_perspective() -> None"},"draw_fov":{"doc_sha":"ff965b12c888d092","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"draw_fov(source: tuple, radius: int | None = None, fov: FOV | None = None, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None"},"edit":{"doc_sha":"b0edb9c8f4c1fe76","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"edit() -> context manager"},"fill":{"doc_sha":"f6ff376fd71ae90b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(color: Color) -> None"},"fill_rect":{"doc_sha":"bc8b20da214aa37d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_rect(pos: tuple, size: tuple, color: Color) -> None"},"grid":{"doc_sha":"b965c55d57de78fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"eac88eb0f7b16cbf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"dd282cc9273e2e36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set":{"doc_sha":"36801cc04606ee0b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(pos, color: Color) -> None"},"update_perspective":{"doc_sha":"c9caac507fbc4921","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_perspective() -> None"},"visible":{"doc_sha":"ecea81ec16e31c19","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"eb13c1b4de11ce63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"DijkstraMap":{"doc_sha":"822ccf5b086d37ec","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"descent_step":{"doc_sha":"fd7b649f1f3eb586","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"descent_step(pos: Vector | tuple) -> Vector | None"},"distance":{"doc_sha":"92b40108bb47bf88","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distance(pos: Vector | tuple) -> float | None"},"invert":{"doc_sha":"3e5ae145dafc2c1d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"invert() -> DijkstraMap"},"path_from":{"doc_sha":"3dcf638bff837f2d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_from(pos: Vector | tuple) -> AStarPath"},"root":{"doc_sha":"95824bac9c46ad83","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"step_from":{"doc_sha":"ac9bc93f671d3936","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"step_from(pos: Vector | tuple) -> Vector | None"},"to_heightmap":{"doc_sha":"0ba542e6509b510b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(size=None, unreachable=-1.0) -> HeightMap"}}},"DiscreteMap":{"doc_sha":"4d6d00ffa3ac62a9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add":{"doc_sha":"1e5a5b83f555fa87","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add(other: DiscreteMap | int, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_and":{"doc_sha":"1644af83b8699792","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_and(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_or":{"doc_sha":"c8262af2fdccdeeb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_or(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_xor":{"doc_sha":"01809471595bf8e7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_xor(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bool":{"doc_sha":"a999f0f09edf7bf3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bool(condition: int | set | callable) -> DiscreteMap"},"clear":{"doc_sha":"b04d176e9c7bf9d2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> DiscreteMap"},"copy_from":{"doc_sha":"efea5a0aa9a8d9c9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_from(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"count":{"doc_sha":"5e3303ddc3a9e3ae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count(value: int) -> int"},"count_range":{"doc_sha":"ac4e0298919fd16e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_range(min_val: int, max_val: int) -> int"},"enum_type":{"doc_sha":"f27f1ad46e2d5afe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill":{"doc_sha":"7f358bdcc5c2c9f0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(value: int, *, pos=None, size=None) -> DiscreteMap"},"from_bytes":{"doc_sha":"b1c09c737d43ffa9","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data: bytes, size: tuple[int, int], *, enum: type = None) -> DiscreteMap"},"from_heightmap":{"doc_sha":"0e89bb4262b19ea3","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_heightmap(hmap: HeightMap, mapping: list[tuple[tuple[float,float], int]], *, enum=None) -> DiscreteMap"},"get":{"doc_sha":"a1097b27b43d6a27","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y) or (pos) -> int | Enum"},"histogram":{"doc_sha":"b2d9df4f14aedd51","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"histogram() -> dict[int, int]"},"invert":{"doc_sha":"0f52aecfe57ff499","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"invert() -> DiscreteMap"},"mask":{"doc_sha":"0d9628616f4592f4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"mask() -> memoryview"},"max":{"doc_sha":"144265dd0da983a8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"max(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"min":{"doc_sha":"ff90ceef60a45959","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"min_max":{"doc_sha":"22018caaf1bdb60e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min_max() -> tuple[int, int]"},"multiply":{"doc_sha":"430abcb2b647132f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply(factor: float, *, pos=None, size=None) -> DiscreteMap"},"set":{"doc_sha":"8439c14e5785f7e4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(x: int, y: int, value: int) -> None"},"size":{"doc_sha":"fc6c37c9d3916c5d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height) of the map. Read-only."},"subtract":{"doc_sha":"aeae8042663b9973","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"subtract(other: DiscreteMap | int, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"to_bytes":{"doc_sha":"4d7c4b5939a546bd","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_bytes() -> bytes"},"to_heightmap":{"doc_sha":"b0744db8f889b5d9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(mapping: dict[int, float] = None) -> HeightMap"}}},"Drawable":{"doc_sha":"bb28a8d677f24b33","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"move":{"doc_sha":"4a8f9ca3692c6b58","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resize":{"doc_sha":"980bdc9065fc077b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Easing":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Entity":{"doc_sha":"7231aaf2e3b107d0","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_label":{"doc_sha":"be06d30bbd5a41ca","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_label(label: str) -> None"},"animate":{"doc_sha":"a06820cecef57a9a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"at":{"doc_sha":"b5775513388a1464","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x: int, y: int) -> GridPoint | None"},"behavior_type":{"doc_sha":"745936b7029480fd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_pos":{"doc_sha":"f8feaba9f3652ca0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_x":{"doc_sha":"8bdc1199bfb15967","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_y":{"doc_sha":"8fd78a55ede4b259","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"default_behavior":{"doc_sha":"f0a1ea99426408dd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"die":{"doc_sha":"1369fecbc5a1862a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"die() -> None"},"draw_pos":{"doc_sha":"2769bf67eec13406","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"find_path":{"doc_sha":"1a672b91a00eb597","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find_path(target, diagonal_cost: float = 1.41, collide: str = None) -> AStarPath | None"},"grid":{"doc_sha":"d02c7b8c3beb5662","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"d2e9f06a9d9f0a3c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_x":{"doc_sha":"4108b9ec31aff2c0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_y":{"doc_sha":"26f7190f34a208b2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"has_label":{"doc_sha":"0e65dc586b3c8d18","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"has_label(label: str) -> bool"},"index":{"doc_sha":"74e24820bb380594","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"index() -> int"},"labels":{"doc_sha":"4e16ef3312d5b4e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"move_speed":{"doc_sha":"a3b3867a6d3b830a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"708f75f696069d69","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"6caae59e941bebf1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"path_to":{"doc_sha":"5b11e33c92caac2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_to(x: int, y: int) -> list"},"perspective_map":{"doc_sha":"f61894673d8c27a7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"172ff9a95e621eaf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"remove_label":{"doc_sha":"a4a541d7e9cf7275","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_label(label: str) -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"set_behavior":{"doc_sha":"328826510c72f049","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_behavior(type, waypoints=None, turns: int = 0, path=None, pathfinder=None) -> None"},"shader":{"doc_sha":"04503c544358f827","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sight_radius":{"doc_sha":"4a9ec6c3788b093a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_grid":{"doc_sha":"a5ff2c3e5190bc41","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"ca285ed1c6f460df","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset":{"doc_sha":"a92dae4a2555fa7b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset_x":{"doc_sha":"7ba6504a9bbbf681","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset_y":{"doc_sha":"24d4546e182bb2c8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"step":{"doc_sha":"8877861183e50020","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"target_label":{"doc_sha":"85acb11a7e713a41","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"1588a0d4779fd069","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_height":{"doc_sha":"7f2ef7cb6d425730","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_size":{"doc_sha":"1a647ababfde2daa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_width":{"doc_sha":"5d72ddf874e9db54","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"turn_order":{"doc_sha":"eba665bc9c7e75ee","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"d302457c5aaa495e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"update_visibility":{"doc_sha":"d096cd998ed99b49","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_visibility() -> None"},"visible":{"doc_sha":"2bca3a265b44b270","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible_entities":{"doc_sha":"2482fbbecbfac7ea","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"visible_entities(fov=None, radius: int | None = None) -> list[Entity]"},"x":{"doc_sha":"4ba6a677ea35e7ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"52b85f7dc8a7cb27","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Entity3D":{"doc_sha":"0daee46cf6343e9c","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"anim_clip":{"doc_sha":"f69294fc08fc1d6c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_frame":{"doc_sha":"41bdd9b6f4407d16","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_loop":{"doc_sha":"b267a372c1aef2cd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_paused":{"doc_sha":"e100f0be03f09b09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_speed":{"doc_sha":"33c581c9533b5169","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_time":{"doc_sha":"6b811c83dd2a2184","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"fcf44af080ce16a7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property, target, duration, easing=None, delta=False, callback=None, conflict_mode=None)"},"at":{"doc_sha":"69d6101c79794f90","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x, z) -> dict"},"auto_animate":{"doc_sha":"867ca7a9c2b5a4ab","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear_path":{"doc_sha":"989bb0a431b58e17","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_path()"},"color":{"doc_sha":"ea9ab21659b6e392","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"follow_path":{"doc_sha":"f514b7092f561550","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"follow_path(path)"},"grid_pos":{"doc_sha":"f20411521f420193","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"idle_clip":{"doc_sha":"855c97611053652c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_moving":{"doc_sha":"b31c1090228993e4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"model":{"doc_sha":"26ae36cf867d07e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"acea3a6577d65fbc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_anim_complete":{"doc_sha":"5cc47894576d983d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Callback(entity, clip_name) when non-looping animation ends."},"path_to":{"doc_sha":"d6fe3936cc8e2d84","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_to(x, z) or path_to(pos=(x, z)) -> list"},"pos":{"doc_sha":"2488492030e9df40","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"5b603e8c652baf50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"b06782214d92e3f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"teleport":{"doc_sha":"d53364830cdc737a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"teleport(x, z) or teleport(pos=(x, z))"},"update_visibility":{"doc_sha":"61886fb70daedf6b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_visibility()"},"viewport":{"doc_sha":"e9f9154f00b47f9a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"bc1ae0f4c6b5cb11","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"walk_clip":{"doc_sha":"c95d11b4e1901f28","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"world_pos":{"doc_sha":"bcc6673be9243dd5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"EntityCollection3D":{"doc_sha":"55864ac691527ca9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"append":{"doc_sha":"43922f93aff7d723","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"append(entity)"},"clear":{"doc_sha":"f44ea077b10186d7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear()"},"extend":{"doc_sha":"4fd2e7e83437c7ae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"extend(iterable)"},"find":{"doc_sha":"e4182a3a3317aaae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find(name) -> Entity3D or None"},"pop":{"doc_sha":"cbcc90e2a433f277","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pop(index=-1) -> Entity3D"},"remove":{"doc_sha":"b2f8fb276b91ec1e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove(entity)"}}},"EntityCollection3DIter":{"doc_sha":"d71ec48b224bfacc","kind":"class","lifecycle":{"since":"0.2.8"},"members":{}},"FOV":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Font":{"doc_sha":"245b4e9b0ca66fc9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"family":{"doc_sha":"0cf26d3038432764","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"efc970576a584cde","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Frame":{"doc_sha":"4d53c3cb9e9e4611","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cache_subtree":{"doc_sha":"0b8d1175192c1ce2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"b2cf42011b86727c","kind":"property","lifecycle":{"modified":{"commit":"4c0953c","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"clip_children":{"doc_sha":"d12b2c4d1d2bb6cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"8cfbe5aa6dd6da27","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"2f5b05882c4ad39f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"9c8707ad589e5d8a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"ced8010da828cc93","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"c07b6dec9da5b9ea","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"cd98fcaf39f0bc56","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"55a118e7889fd555","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"bd51299002a099c5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"4ed17c625f2289e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"fabaa45fd789c7fd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Grid":{"doc_sha":"f88e43e992d43a86","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_rotation":{"doc_sha":"bc2cd502f52817e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"913ef5c36eaf9635","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_camera":{"doc_sha":"00d37ccdedcd3a2e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"center_camera(pos: tuple = None) -> None"},"center_x":{"doc_sha":"fbb0ff08116f3c80","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_y":{"doc_sha":"1bda27633025fb12","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"9566d92d5619ee21","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fill_color":{"doc_sha":"f26fb5da3de1789a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_data":{"doc_sha":"fdf0084b8456fd45","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"bd5a06618a50efad","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered_cell":{"doc_sha":"85c87c8aa013d0c4","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_cell_click":{"doc_sha":"ada11ea920c71f29","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_enter":{"doc_sha":"6ae7882d4f14f183","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_exit":{"doc_sha":"729303b3a70b2a36","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_click":{"doc_sha":"4e33bd16b2dee20a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"perspective":{"doc_sha":"8854eef3677c4423","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"perspective_enabled":{"doc_sha":"abd2223cd551e1ca","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"pos":{"doc_sha":"7f556820c51df2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"cfff2bccd482c2ef","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"texture":{"doc_sha":"b9bfb54e5571bfc9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"ec55a5a578c4d993","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"2449b0f04153753c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"7958c0625706f2d2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"zoom":{"doc_sha":"e56c4b61e0303f09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"GridData":{"doc_sha":"793d86e4e51839f6","kind":"class","lifecycle":{"since":"0.2.8-dev"},"members":{"add_layer":{"doc_sha":"ad48373d656e6a88","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"},"apply_ranges":{"doc_sha":"f74ca906e65a4bec","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> GridData"},"apply_threshold":{"doc_sha":"823c3779c6610389","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData"},"at":{"doc_sha":"0f12b1f70eaec14b","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"at(x: int, y: int) -> GridPoint"},"clear_dijkstra_maps":{"doc_sha":"d470811b59885b93","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"clear_dijkstra_maps() -> None"},"compute_fov":{"doc_sha":"2522197d48d43d98","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None"},"entities":{"doc_sha":"5c361e2150645749","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"entities_in_radius":{"doc_sha":"820857d7995c8fe6","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"entities_in_radius(pos: tuple | Vector, radius: float) -> list"},"find_path":{"doc_sha":"3de3f1a19ba24df8","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"find_path(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0) -> AStarPath | None"},"fov":{"doc_sha":"bb929647d575d246","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fov_radius":{"doc_sha":"bf76a3199e6d7f0b","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"get_dijkstra_map":{"doc_sha":"af44def04f5c942e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap"},"grid_h":{"doc_sha":"f96615d57a28b090","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"grid_size":{"doc_sha":"f1aff956f432c1fe","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"grid_w":{"doc_sha":"e2f7f55bad731ce6","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"is_in_fov":{"doc_sha":"3f871a007bbff04c","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"is_in_fov(x: int, y: int) -> bool"},"layer":{"doc_sha":"38d9c25b2b0b172b","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"layer(name: str) -> ColorLayer | TileLayer | None"},"layers":{"doc_sha":"9d6207bfc4909360","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"remove_layer":{"doc_sha":"7876426544d2ec0d","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"},"step":{"doc_sha":"38146d2fc84d70f3","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"step(n: int = 1, turn_order: int = None) -> None"},"texture":{"doc_sha":"a2911dd21b916bc0","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""}}},"GridView":{"doc_sha":"f88e43e992d43a86","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_rotation":{"doc_sha":"bc2cd502f52817e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"913ef5c36eaf9635","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_camera":{"doc_sha":"00d37ccdedcd3a2e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"center_camera(pos: tuple = None) -> None"},"center_x":{"doc_sha":"fbb0ff08116f3c80","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_y":{"doc_sha":"1bda27633025fb12","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"9566d92d5619ee21","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fill_color":{"doc_sha":"f26fb5da3de1789a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_data":{"doc_sha":"fdf0084b8456fd45","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"bd5a06618a50efad","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered_cell":{"doc_sha":"85c87c8aa013d0c4","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_cell_click":{"doc_sha":"ada11ea920c71f29","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_enter":{"doc_sha":"6ae7882d4f14f183","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_exit":{"doc_sha":"729303b3a70b2a36","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_click":{"doc_sha":"4e33bd16b2dee20a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"perspective":{"doc_sha":"8854eef3677c4423","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"perspective_enabled":{"doc_sha":"abd2223cd551e1ca","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"pos":{"doc_sha":"7f556820c51df2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"cfff2bccd482c2ef","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"texture":{"doc_sha":"b9bfb54e5571bfc9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"ec55a5a578c4d993","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"2449b0f04153753c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"7958c0625706f2d2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"zoom":{"doc_sha":"e56c4b61e0303f09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"HeightMap":{"doc_sha":"e5f86ba9c89f2a5a","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add":{"doc_sha":"d420dd760d7228af","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"add_bsp":{"doc_sha":"b70f06e432c91a7a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_bsp(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, shrink: int = 0, value: float = 1.0) -> HeightMap"},"add_constant":{"doc_sha":"f382eb3f3e667cb4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_constant(value: float, *, pos=None, size=None) -> HeightMap"},"add_hill":{"doc_sha":"1ad2406960701682","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_hill(center, radius: float, height: float) -> HeightMap"},"add_noise":{"doc_sha":"3c3c15026ff43959","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_noise(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, mode: str = 'fbm', octaves: int = 4, scale: float = 1.0) -> HeightMap"},"add_voronoi":{"doc_sha":"b689af51f6867b78","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_voronoi(num_points: int, coefficients: tuple = (1.0, -0.5), seed: int = None) -> HeightMap"},"clamp":{"doc_sha":"60c08680fb281402","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clamp(min: float = 0.0, max: float = 1.0, *, pos=None, size=None) -> HeightMap"},"clear":{"doc_sha":"2297d98f231b6b7b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> HeightMap"},"copy_from":{"doc_sha":"80d64f3f6e76f8a7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_from(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"count_in_range":{"doc_sha":"7af3d60f28d614b6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_in_range(range: tuple[float, float]) -> int"},"dig_bezier":{"doc_sha":"f3327d6ddc165496","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dig_bezier(points: tuple, start_radius: float, end_radius: float, start_height: float, end_height: float) -> HeightMap"},"dig_hill":{"doc_sha":"be55e494c4e50c3a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dig_hill(center, radius: float, target_height: float) -> HeightMap"},"fill":{"doc_sha":"4e998a0b1d85c4f2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(value: float, *, pos=None, size=None) -> HeightMap"},"get":{"doc_sha":"f55fcc655d43dc50","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y) or (pos) -> float"},"get_interpolated":{"doc_sha":"5c3e0267d9cbeda2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_interpolated(x, y) or (pos) -> float"},"get_normal":{"doc_sha":"6d4212add4e533f2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_normal(x, y, water_level=0.0) or (pos, water_level=0.0) -> tuple[float, float, float]"},"get_slope":{"doc_sha":"4876967f738c7d67","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_slope(x, y) or (pos) -> float"},"inverse":{"doc_sha":"f6c4de6cd6a9ca0f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"inverse() -> HeightMap"},"lerp":{"doc_sha":"967cacc180df98bc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"lerp(other: HeightMap, t: float, *, pos=None, source_pos=None, size=None) -> HeightMap"},"max":{"doc_sha":"141515f8b1f23390","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"max(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"mid_point_displacement":{"doc_sha":"bfcbfe796d73410f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"mid_point_displacement(roughness: float = 0.5, seed: int = None) -> HeightMap"},"min":{"doc_sha":"c51e934ea5047d91","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"min_max":{"doc_sha":"4e0b3cb7472a7cfe","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min_max() -> tuple[float, float]"},"multiply":{"doc_sha":"83d2fcea22463d18","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"multiply_bsp":{"doc_sha":"21f401c651344592","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply_bsp(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, shrink: int = 0, value: float = 1.0) -> HeightMap"},"multiply_noise":{"doc_sha":"177d1d06a401af54","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply_noise(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, mode: str = 'fbm', octaves: int = 4, scale: float = 1.0) -> HeightMap"},"normalize":{"doc_sha":"93ce05bf80d158ed","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize(min: float = 0.0, max: float = 1.0, *, pos=None, size=None) -> HeightMap"},"rain_erosion":{"doc_sha":"4dbe6bd42d9f6e5b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"rain_erosion(drops: int, erosion: float = 0.1, sedimentation: float = 0.05, seed: int = None) -> HeightMap"},"scale":{"doc_sha":"c85d9324b84c43ff","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"scale(factor: float, *, pos=None, size=None) -> HeightMap"},"size":{"doc_sha":"31846e24f4028b35","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height) of the heightmap. Read-only."},"smooth":{"doc_sha":"243be30aa73da43b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"smooth(iterations: int = 1) -> HeightMap"},"sparse_kernel":{"doc_sha":"e3b28d2c3913ac74","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sparse_kernel(weights: dict[tuple[int, int], float]) -> HeightMap"},"sparse_kernel_from":{"doc_sha":"ebeed84050ee0b2f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sparse_kernel_from(source: HeightMap, weights: dict[tuple[int, int], float]) -> None"},"subtract":{"doc_sha":"8e209f74aaad794d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"subtract(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"threshold":{"doc_sha":"946f6a1fb850decb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"threshold(range: tuple[float, float]) -> HeightMap"},"threshold_binary":{"doc_sha":"6ebe7bd7cbc112c2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"threshold_binary(range: tuple[float, float], value: float = 1.0) -> HeightMap"}}},"Heuristic":{"doc_sha":"7582db951ab67fc5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"InputState":{"doc_sha":"c7b7558edda29fea","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Key":{"doc_sha":"bc4feb60367e4be7","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Keyboard":{"doc_sha":"afbde6bcf7bfe1c6","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"alt":{"doc_sha":"051cb7dbc470d328","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"ctrl":{"doc_sha":"c062912b0b816bd2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shift":{"doc_sha":"614beb0d432c2ec0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"system":{"doc_sha":"2292efb47472f5e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"LdtkProject":{"doc_sha":"d80a5fdda811cb33","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"enums":{"doc_sha":"5697f9e75980e2be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"level":{"doc_sha":"33b80035be788d03","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"level(name: str) -> dict"},"level_names":{"doc_sha":"9479f6c2bd85608d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"ruleset":{"doc_sha":"ce72d154e5dd391c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"ruleset(name: str) -> AutoRuleSet"},"ruleset_names":{"doc_sha":"28983e9702e0dc81","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tileset":{"doc_sha":"30eabe3003acb244","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tileset(name: str) -> TileSetFile"},"tileset_names":{"doc_sha":"77a45b51f49743ef","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"version":{"doc_sha":"e6d3d49ad9edfad8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Line":{"doc_sha":"8d778faa93b5d0d5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"color":{"doc_sha":"f7aca0b82eaae97e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"end":{"doc_sha":"cc14ff1baa1b61fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"feeb7cb507a4692d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"49cecfb458b010a1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"733a48ea3a730e60","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"start":{"doc_sha":"817049419558ea17","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"thickness":{"doc_sha":"b6c7f3e979028709","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"cecb3704206d35f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Model3D":{"doc_sha":"be7fb5003ed48d84","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"animation_clips":{"doc_sha":"0d15ad9839919fe5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bone_count":{"doc_sha":"6e389fb2496946bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bounds":{"doc_sha":"4ddfb8f6ae5a8928","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cube":{"doc_sha":"b49160efa8b0f71e","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"cube(size=1.0) -> Model3D"},"has_skeleton":{"doc_sha":"4e9ac073dac101b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"mesh_count":{"doc_sha":"49f8fe0ece668d77","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"b577d22f9a18de0c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"plane":{"doc_sha":"5e31b94e295b4429","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"plane(width=1.0, depth=1.0, segments=1) -> Model3D"},"sphere":{"doc_sha":"b385d8aaf0efd485","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"sphere(radius=0.5, segments=16, rings=12) -> Model3D"},"triangle_count":{"doc_sha":"b67c98fac8ffc8dd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vertex_count":{"doc_sha":"8b76e70b26a820df","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Mouse":{"doc_sha":"e64f561c8a08bdec","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"grabbed":{"doc_sha":"3da420bb9cac434f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"left":{"doc_sha":"a4c7b98babc093cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"middle":{"doc_sha":"1306b1240c6f64e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"2ecba0a68d64976a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"right":{"doc_sha":"08d5534c1c034b39","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"834527eb5579f3e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"c142f98ee94bf3f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"5095db9e9e163e92","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"MouseButton":{"doc_sha":"a5a7584949d18f01","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Music":{"doc_sha":"e13e6ad378ec0e91","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"duration":{"doc_sha":"25607c5fa852d501","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"loop":{"doc_sha":"a165a91f8ff771f5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"4eb6d242151147dc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"play":{"doc_sha":"f724039e0cdb2847","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play() -> None"},"playing":{"doc_sha":"4451d007747fa3b1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"position":{"doc_sha":"608a5f6d4ed927bc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"ee5d7818bae0a255","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"stop":{"doc_sha":"f3289bbb98a4da1f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"volume":{"doc_sha":"749fc9d89252cb0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"NoiseSource":{"doc_sha":"3bfa3b53984c85cb","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"algorithm":{"doc_sha":"ba813d6c022f15f0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"dimensions":{"doc_sha":"f534d20928d8d67a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fbm":{"doc_sha":"d13c8670fc68b9df","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fbm(pos: tuple[float, ...], octaves: int = 4) -> float"},"get":{"doc_sha":"3d7776fffd7a0a39","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(pos: tuple[float, ...]) -> float"},"hurst":{"doc_sha":"11682e6dc42f2417","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"lacunarity":{"doc_sha":"4d07f7079f3938de","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sample":{"doc_sha":"63fd0e680000d0f0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sample(size: tuple[int, int], world_origin: tuple[float, float] = (0.0, 0.0), world_size: tuple[float, float] = None, mode: str = 'fbm', octaves: int = 4) -> HeightMap"},"seed":{"doc_sha":"17bc1e90a8ce6b23","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"turbulence":{"doc_sha":"800c841f913b3780","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"turbulence(pos: tuple[float, ...], octaves: int = 4) -> float"}}},"Perspective":{"doc_sha":"e1c8553ea2a32a40","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"PropertyBinding":{"doc_sha":"a23eb43edd46bb66","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"is_valid":{"doc_sha":"40ada14485c362a7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"property":{"doc_sha":"0dad06f4e661db2d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"target":{"doc_sha":"3b15e1922fd910c9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"value":{"doc_sha":"80d28b4e329837d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Scene":{"doc_sha":"a156c42d77b20a3e","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"activate":{"doc_sha":"54ae3cd3cf652660","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"activate(transition: Transition = None, duration: float = None) -> None"},"active":{"doc_sha":"a59f70d3c12a7cfe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"f21fb480dac856b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"7feed093cdf9334a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_key":{"doc_sha":"40ece618402a45f3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"9e60a4ae9bdb493b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"50e29b0dd34a76d5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"eef1e2dce9ee8894","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"register":{"doc_sha":"7603337801a7a498","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"register() -> None"},"registered":{"doc_sha":"d9ff54f488ed5b87","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"unregister":{"doc_sha":"fda6be9891d1f502","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"unregister() -> None"},"visible":{"doc_sha":"a7f321f4013dfb9b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Shader":{"doc_sha":"aded56cb37cba9d8","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"dynamic":{"doc_sha":"43d8bc587c3997e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_valid":{"doc_sha":"fcdf95eedfa8479e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set_uniform":{"doc_sha":"bdaf877e5bc28f21","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_uniform(name: str, value: float|tuple) -> None"},"source":{"doc_sha":"dbd462698aadf450","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Sound":{"doc_sha":"8f31cca1781580d5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"buffer":{"doc_sha":"c79f4e433d4300ac","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"duration":{"doc_sha":"48daac900b6eaf36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"loop":{"doc_sha":"a624dff0e72a5337","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"005627aca21692d3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"pitch":{"doc_sha":"80b38bfc202b4291","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"play":{"doc_sha":"ae090ee8c0320797","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play() -> None"},"play_varied":{"doc_sha":"fd9b5cd6db3cb1cf","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play_varied(pitch_range: float = 0.1, volume_range: float = 3.0) -> None"},"playing":{"doc_sha":"8a0c491fa38061d5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"1736ca7393d96c48","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"stop":{"doc_sha":"f3289bbb98a4da1f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"volume":{"doc_sha":"749fc9d89252cb0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"SoundBuffer":{"doc_sha":"426618cde68541be","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"bit_crush":{"doc_sha":"66ae55272fd9d182","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bit_crush(bits: int, rate_divisor: int) -> SoundBuffer"},"channels":{"doc_sha":"f3948c1ceb5d7aec","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"concat":{"doc_sha":"7ee10bfe76aae5e2","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"concat(buffers: list[SoundBuffer], overlap: float = 0.0) -> SoundBuffer"},"distortion":{"doc_sha":"a36442ca725e5cc6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distortion(drive: float) -> SoundBuffer"},"duration":{"doc_sha":"bc1c03f781e0372b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"echo":{"doc_sha":"1b93f79c671068ab","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"echo(delay_ms: float, feedback: float, wet: float) -> SoundBuffer"},"from_samples":{"doc_sha":"556326f1f2cda4a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_samples(data: bytes, channels: int, sample_rate: int) -> SoundBuffer"},"gain":{"doc_sha":"22fc3aad26b084a5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"gain(factor: float) -> SoundBuffer"},"high_pass":{"doc_sha":"7d8af12720a4bbae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"high_pass(cutoff_hz: float) -> SoundBuffer"},"low_pass":{"doc_sha":"82cdc215e7da9310","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"low_pass(cutoff_hz: float) -> SoundBuffer"},"mix":{"doc_sha":"4df14cf741427da9","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"mix(buffers: list[SoundBuffer]) -> SoundBuffer"},"normalize":{"doc_sha":"5476b16208b1ac7b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize() -> SoundBuffer"},"pitch_shift":{"doc_sha":"ee9373f2d71fdaa8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pitch_shift(factor: float) -> SoundBuffer"},"reverb":{"doc_sha":"b6d0dcf1e5f75a42","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"reverb(room_size: float, damping: float, wet: float) -> SoundBuffer"},"reverse":{"doc_sha":"eb70bfa5da4c860d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"reverse() -> SoundBuffer"},"sample_count":{"doc_sha":"5e96b7263eade6af","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sample_rate":{"doc_sha":"a41417a71e10a2d6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sfxr":{"doc_sha":"5f45a4b7afc74066","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"sfxr(preset: str = None, seed: int = None, **params) -> SoundBuffer"},"sfxr_mutate":{"doc_sha":"68b5b653de4f55dc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sfxr_mutate(amount: float = 0.05, seed: int = None) -> SoundBuffer"},"sfxr_params":{"doc_sha":"7fd7a2356600014a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"slice":{"doc_sha":"84099d0f46e6b7ad","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"slice(start: float, end: float) -> SoundBuffer"},"tone":{"doc_sha":"eaec1748a9089774","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"tone(frequency: float, duration: float, waveform: str = 'sine', ...) -> SoundBuffer"}}},"Sprite":{"doc_sha":"a687c4f393725428","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"55a118e7889fd555","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"f91a66d322ee1a75","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale_x":{"doc_sha":"95f47a809a8ac927","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale_y":{"doc_sha":"8567691c22a9b5f8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"826af9fc52ae5cef","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"df3e7671926aa0a3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"528487f8deaaf789","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"114ecec940477107","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Texture":{"doc_sha":"da368b536d9f844e","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"composite":{"doc_sha":"0bf406582f618af3","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"composite(layers: list[Texture], sprite_width: int, sprite_height: int, name: str = '') -> Texture"},"display_height":{"doc_sha":"0dc1ccea45ff4e40","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_offset_x":{"doc_sha":"0ca738fa6559aabb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_offset_y":{"doc_sha":"83b5bfadd26f4e79","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_width":{"doc_sha":"cfc6525a8a0b5bb7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"979f5a384770d87f","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data: bytes, width: int, height: int, sprite_width: int, sprite_height: int, name: str = '') -> Texture"},"hsl_shift":{"doc_sha":"95f19de7a67646e3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"hsl_shift(hue_shift: float, sat_shift: float = 0.0, lit_shift: float = 0.0) -> Texture"},"sheet_height":{"doc_sha":"59d4fb71cae72fba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sheet_width":{"doc_sha":"54ef54eb9c39a907","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"51bee1cdb17257ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_count":{"doc_sha":"d1ed79d8b59eca99","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_height":{"doc_sha":"19b6a36f45f965e8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_width":{"doc_sha":"6f9276522c3b2512","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileLayer":{"doc_sha":"5551e3bf9e668d89","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_ranges":{"doc_sha":"e443e268532fdd2f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> TileLayer"},"apply_threshold":{"doc_sha":"2253f4367656d073","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(source: HeightMap, range: tuple, tile: int) -> TileLayer"},"at":{"doc_sha":"19975fea19b3fadb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(pos: tuple | Vector) or (x: int, y: int) -> int"},"edit":{"doc_sha":"47c32fda0e88780a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"edit() -> context manager"},"fill":{"doc_sha":"5c8eca820794a605","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(index: int) -> None"},"fill_rect":{"doc_sha":"adfc5ff6d6e91cc8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_rect(pos: tuple, size: tuple, index: int) -> None"},"grid":{"doc_sha":"b965c55d57de78fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"eac88eb0f7b16cbf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"dd282cc9273e2e36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set":{"doc_sha":"2b242556ce84080f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(pos, index: int) -> None"},"texture":{"doc_sha":"1d211a219f88c72c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"ecea81ec16e31c19","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"eb13c1b4de11ce63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileMapFile":{"doc_sha":"b26cb3870e41a2c9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_to_tile_layer":{"doc_sha":"436e4fa972bd79d0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_to_tile_layer(tile_layer: TileLayer, layer_name: str, tileset_index: int = 0) -> None"},"height":{"doc_sha":"72aa81aefdab3ddb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"object_layer":{"doc_sha":"e658613bcbbe9b3a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"object_layer(name: str) -> list[dict]"},"object_layer_names":{"doc_sha":"7af1cc1454cd97f9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"orientation":{"doc_sha":"2ee759a156b5c903","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"properties":{"doc_sha":"f2cb52eb132891d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve_gid":{"doc_sha":"4a72f1c9f61cec27","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve_gid(gid: int) -> tuple[int, int]"},"tile_height":{"doc_sha":"cc47760c48b8c9be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_layer_data":{"doc_sha":"399fd655e6402442","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tile_layer_data(name: str) -> list[int]"},"tile_layer_names":{"doc_sha":"e225e2d4b71155e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_width":{"doc_sha":"c109808c1e2d3ca1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tileset":{"doc_sha":"a3e35f368c3cc89d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tileset(index: int) -> tuple[int, TileSetFile]"},"tileset_count":{"doc_sha":"e7347387a4bcfeae","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"width":{"doc_sha":"acef1b7138a85569","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileSetFile":{"doc_sha":"ec83598f02a06258","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"columns":{"doc_sha":"5ba187696ce9596e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"image_source":{"doc_sha":"f21faacc0392eec3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"6e1b3cfd5b59af6a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"5e0a76b40f4db151","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"properties":{"doc_sha":"e0382abd62d297a6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"spacing":{"doc_sha":"950416fc0b8f1837","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_count":{"doc_sha":"df5ccd0141400827","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_height":{"doc_sha":"71a327fd061c1cf2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_info":{"doc_sha":"b33077f44c79e050","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tile_info(tile_id: int) -> dict | None"},"tile_width":{"doc_sha":"888787b1ef69f5d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_texture":{"doc_sha":"610adac758051370","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_texture() -> Texture"},"wang_set":{"doc_sha":"c3b15db4611c250a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"wang_set(name: str) -> WangSet"},"wang_sets":{"doc_sha":"71541a1c5777b29f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Timer":{"doc_sha":"646feff7bdb817f5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"active":{"doc_sha":"0ac0f4a607819264","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"callback":{"doc_sha":"a943e7e99b75fa22","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"interval":{"doc_sha":"e32658ee97f94823","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"98a62482b59640f6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"once":{"doc_sha":"7d5a7d20bae3793e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"bfe544cc3c9f1cda","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"paused":{"doc_sha":"060df10cbfc34662","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"remaining":{"doc_sha":"e223a64ed6a1ec63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"restart":{"doc_sha":"a8c305ae5017cce0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"restart() -> None"},"resume":{"doc_sha":"66ab0cd39f579ef9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resume() -> None"},"start":{"doc_sha":"0b2b51d812995d34","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"start() -> None"},"stop":{"doc_sha":"9f69f5e68512116c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"stopped":{"doc_sha":"bffdb8aa4589c28b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Transition":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Traversal":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Trigger":{"doc_sha":"3f698ad8b187be9f","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Vector":{"doc_sha":"0c8ae9e2ef387065","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"angle":{"doc_sha":"a0ca5f1f4a89d780","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"angle() -> float"},"copy":{"doc_sha":"120e723fd513e072","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy() -> Vector"},"distance_to":{"doc_sha":"4f5d26bbe8434f65","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distance_to(other: Vector) -> float"},"dot":{"doc_sha":"d69442ea289adfb8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dot(other: Vector) -> float"},"floor":{"doc_sha":"c7e81a90c5420968","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"floor() -> Vector"},"int":{"doc_sha":"3d97cae0ad8a963c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"magnitude":{"doc_sha":"75f61a474afe3121","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"magnitude() -> float"},"magnitude_squared":{"doc_sha":"c976db5b540f827b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"magnitude_squared() -> float"},"normalize":{"doc_sha":"04b468da3fc60858","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize() -> Vector"},"x":{"doc_sha":"0e322bdd857e652b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"bbac84f15b6740d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Viewport3D":{"doc_sha":"c41e58effff86261","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_billboard":{"doc_sha":"ae2357f78a17a437","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_billboard(billboard)"},"add_layer":{"doc_sha":"b4c0274f9217aaa1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_layer(name, z_index=0) -> dict"},"add_mesh":{"doc_sha":"588dbdf9a48f17e5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_mesh(layer_name, model, pos, rotation=0, scale=1.0) -> int"},"add_voxel_layer":{"doc_sha":"47f17324425647e1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_voxel_layer(voxel_grid, z_index=0)"},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"apply_heightmap":{"doc_sha":"be8e92a25726238d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_heightmap(heightmap, y_scale=1.0)"},"apply_terrain_colors":{"doc_sha":"cf0e47c185484e70","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_terrain_colors(layer_name, r_map, g_map, b_map)"},"apply_threshold":{"doc_sha":"3adda69e435613d7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(heightmap, min_height, max_height, walkable=True)"},"at":{"doc_sha":"7ec76d7602627cb9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x, z) -> VoxelPoint"},"bg_color":{"doc_sha":"c7e7141bd1cadaea","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"billboard_count":{"doc_sha":"4b432c5c560ee44f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"billboard_count() -> int"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"build_terrain":{"doc_sha":"5be2a61f78907326","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"build_terrain(layer_name, heightmap, y_scale=1.0, cell_size=1.0) -> int"},"camera_pos":{"doc_sha":"7bfead8afc2b8312","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_target":{"doc_sha":"3ecf7bb4dbdf27e4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_size":{"doc_sha":"8c3fa657c8fdc2aa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear_billboards":{"doc_sha":"63d6d64c39b46264","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_billboards()"},"clear_meshes":{"doc_sha":"1d6eb71f274020cf","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_meshes(layer_name)"},"clear_voxel_nav_region":{"doc_sha":"56901862e4dc595a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_voxel_nav_region(voxel_grid)"},"compute_fov":{"doc_sha":"93c68ed57619564e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"compute_fov(origin, radius=10) -> list"},"enable_affine":{"doc_sha":"96ba76ed43593940","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_dither":{"doc_sha":"da6733dd08751b72","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_fog":{"doc_sha":"ca6c5ffcef454868","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_vertex_snap":{"doc_sha":"7401ad5dcd82024c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"entities":{"doc_sha":"c2b185a3b718cc6b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"find_path":{"doc_sha":"ebd174d40557cc19","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find_path(start, end) -> list"},"fog_color":{"doc_sha":"3eb0cdbd75c5a94c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fog_far":{"doc_sha":"90406f8cae40bb58","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fog_near":{"doc_sha":"e49f6dc79b22a794","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"follow":{"doc_sha":"975d2b945069be26","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"follow(entity, distance=10, height=5, smoothing=1.0)"},"fov":{"doc_sha":"7f5d69a1c423f69d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"get_billboard":{"doc_sha":"38b0410480ab7d4d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_billboard(index) -> Billboard"},"get_layer":{"doc_sha":"92223f274b5312c3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_layer(name) -> dict or None"},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"7b4dbfa47f259f02","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"d7308cd01fffb4be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_in_fov":{"doc_sha":"81d50fbbe2df4c8e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"is_in_fov(x, z) -> bool"},"layer_count":{"doc_sha":"8bfbb28860b18d51","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"layer_count() -> int"},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"orbit_camera":{"doc_sha":"29e02de46a996495","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"orbit_camera(angle=0, distance=10, height=5)"},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"place_blocking":{"doc_sha":"9252fd1d594a3867","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"place_blocking(grid_pos, footprint, walkable=False, transparent=False)"},"pos":{"doc_sha":"c1693d7dd0fe875d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"project_all_voxels_to_nav":{"doc_sha":"ac9e0ca27b557d65","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_all_voxels_to_nav(headroom=2)"},"project_voxel_to_nav":{"doc_sha":"1fdf58bc686f7511","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_voxel_to_nav(voxel_grid, headroom=2)"},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"remove_billboard":{"doc_sha":"bf68668896f86bb0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_billboard(billboard)"},"remove_layer":{"doc_sha":"f5fafdfe3d153aab","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_layer(name) -> bool"},"remove_voxel_layer":{"doc_sha":"b88a956cacc361cb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_voxel_layer(voxel_grid) -> bool"},"render_resolution":{"doc_sha":"4b141c5648005f04","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"screen_to_world":{"doc_sha":"ba31510737afd516","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"screen_to_world(x, y, y_plane=0.0) -> tuple or None"},"set_grid_size":{"doc_sha":"ee7d1626547614c8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_grid_size(width, depth)"},"set_slope_cost":{"doc_sha":"dac6d3cd319f1016","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_slope_cost(max_slope=0.5, cost_multiplier=1.0)"},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"voxel_layer_count":{"doc_sha":"c1ca5c90881bbaae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"voxel_layer_count() -> int"},"w":{"doc_sha":"4c5bcc34301022a0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"cd81d9dc457285aa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"406570900a1cf520","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"VoxelGrid":{"doc_sha":"6706e3f5276ce104","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_material":{"doc_sha":"27dec1c992cf7b99","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_material(name, color=Color(255,255,255), sprite_index=-1, transparent=False, path_cost=1.0) -> int"},"cell_size":{"doc_sha":"81b167d608b2cec5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear":{"doc_sha":"8ebe20948a33a84d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> None"},"copy_region":{"doc_sha":"e56c5d28d7fd4e86","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_region(min_coord, max_coord) -> VoxelRegion"},"count_material":{"doc_sha":"c9ef38e1f2f29ab8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_material(material) -> int"},"count_non_air":{"doc_sha":"275d512b30f5134a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_non_air() -> int"},"depth":{"doc_sha":"21671acfc312aeeb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill":{"doc_sha":"fff2fcdddba5ee8c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(material) -> None"},"fill_box":{"doc_sha":"7c3d982a275f095d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_box(min_coord, max_coord, material) -> None"},"fill_box_hollow":{"doc_sha":"432e8b518feee0a5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_box_hollow(min_coord, max_coord, material, thickness=1) -> None"},"fill_cylinder":{"doc_sha":"0b2b0b0826381df0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_cylinder(base_pos, radius, height, material) -> None"},"fill_noise":{"doc_sha":"567355400611d920","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_noise(min_coord, max_coord, material, threshold=0.5, scale=0.1, seed=0) -> None"},"fill_sphere":{"doc_sha":"e6a85d9c72e2ea44","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_sphere(center, radius, material) -> None"},"from_bytes":{"doc_sha":"faefede0aa8ae636","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data) -> bool"},"get":{"doc_sha":"52cea4645f0ea3d0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y, z) -> int"},"get_material":{"doc_sha":"2a61f6d54086df97","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_material(id) -> dict"},"greedy_meshing":{"doc_sha":"17fffa950c043917","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"height":{"doc_sha":"f271e989afe69da3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"load":{"doc_sha":"5dcaf71057f836a2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"load(path) -> bool"},"material_count":{"doc_sha":"0ef72bd68ef0c2d0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"offset":{"doc_sha":"75230579ca06d8f5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"paste_region":{"doc_sha":"1f44bacefd614cca","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"paste_region(region, position, skip_air=True) -> None"},"project_column":{"doc_sha":"9bacd59e3a7c5758","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_column(x, z, headroom=2) -> dict"},"rebuild_mesh":{"doc_sha":"fa60051037c3328c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"rebuild_mesh() -> None"},"rotation":{"doc_sha":"5b603e8c652baf50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"save":{"doc_sha":"bb7090694292f8e3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"save(path) -> bool"},"set":{"doc_sha":"e9223501162aa9c6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(x, y, z, material) -> None"},"size":{"doc_sha":"2d283d0584913b16","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height, depth) of the grid. Read-only."},"to_bytes":{"doc_sha":"f9a751e75bfc0478","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_bytes() -> bytes"},"vertex_count":{"doc_sha":"4df863704c32ecb7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"7ff1afdbd4bf86a3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"width":{"doc_sha":"0977aff160584918","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"VoxelRegion":{"doc_sha":"0cda0630ba7cafef","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"depth":{"doc_sha":"05b3223f7df8ab65","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"height":{"doc_sha":"04834a15ca591472","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"75c252385afbc498","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height, depth) of the region. Read-only."},"width":{"doc_sha":"d10d49e58037417d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"WangSet":{"doc_sha":"320e161ef3d08a5d","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply":{"doc_sha":"2aa8c0ba2d6549d2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply(discrete_map: DiscreteMap, tile_layer: TileLayer) -> None"},"color_count":{"doc_sha":"13b244a538aec70f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"colors":{"doc_sha":"6d84b3c2dd048b1a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"8b4560b705e5724f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve":{"doc_sha":"fe2fa09430c4bda6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve(discrete_map: DiscreteMap) -> list[int]"},"terrain_enum":{"doc_sha":"b8c46ec1a2b03eb6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"terrain_enum() -> IntEnum"},"type":{"doc_sha":"ea3706e2c65ba7d3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Window":{"doc_sha":"6021e9b58544c378","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"center":{"doc_sha":"91fe1b0d6ca2e4f1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"center() -> None"},"framerate_limit":{"doc_sha":"de182ed271fbb4d3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fullscreen":{"doc_sha":"a517f5331026f2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"game_resolution":{"doc_sha":"3a0dba59fecf5a95","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"get":{"doc_sha":"544bdc10ab93d067","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"get() -> Window"},"resolution":{"doc_sha":"548abee10c4fbcaf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scaling_mode":{"doc_sha":"57e03f9ffc9291ae","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"screenshot":{"doc_sha":"5102ab9c4c2c8358","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"screenshot(filename: str = None) -> bytes | None"},"title":{"doc_sha":"0d98279e5ec1b8b1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"bad7f81ec2e48a31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vsync":{"doc_sha":"0335eb1fba386e37","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}}},"schema":1,"seeded":false,"version":"0.2.8-dev"} +{"commit":"5323428","dirty":true,"functions":{"bresenham":{"doc_sha":"ee2d0f7a54a664f5","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"bresenham(start, end, include_start=True, include_end=True) -> list[tuple[int, int]]"},"end_benchmark":{"doc_sha":"5d783c5dabb7a4e9","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"end_benchmark() -> str"},"exit":{"doc_sha":"7572d4d6b1a1c8a4","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"exit() -> None"},"find":{"doc_sha":"364b95807edcf8b4","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"find(name: str, scene: str = None) -> UIDrawable | None"},"find_all":{"doc_sha":"b4a278f16b172131","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"find_all(pattern: str, scene: str = None) -> list"},"get_metrics":{"doc_sha":"a0bbee3858baa4a9","kind":"function","lifecycle":{"modified":{"commit":"464af6a","version":"0.2.8-dev"},"since":"0.2.8"},"signature":"get_metrics() -> dict"},"lock":{"doc_sha":"e42989cabc81d080","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"lock() -> _LockContext"},"log_benchmark":{"doc_sha":"4564bf7321366a0a","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"log_benchmark(message: str) -> None"},"set_dev_console":{"doc_sha":"940ed19ff4f5243f","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"set_dev_console(enabled: bool) -> None"},"set_scale":{"doc_sha":"b565b441a326af48","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"set_scale(multiplier: float) -> None"},"start_benchmark":{"doc_sha":"783957f3517518fe","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"start_benchmark() -> None"},"step":{"doc_sha":"90dec8ed10ee644f","kind":"function","lifecycle":{"since":"0.2.8"},"signature":"step(dt: float = None) -> float"}},"module_attributes":{"animations":{"doc_sha":"30d25725feac9eb6","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":true},"current_scene":{"doc_sha":"ab496ea99fec2fa8","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":false},"default_transition":{"doc_sha":"73fb568e9cb25540","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":false},"default_transition_duration":{"doc_sha":"ae57530eded49138","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":false},"save_dir":{"doc_sha":"96ffa5a27d178b14","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":true},"scenes":{"doc_sha":"8021c54383d3cb8e","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":true},"timers":{"doc_sha":"48e2aa7747d8f527","kind":"attribute","lifecycle":{"since":"0.2.8-dev"},"readonly":true}},"objects":{"AStarPath":{"doc_sha":"60dd12bb4a54f3dd","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"destination":{"doc_sha":"62a81c7267ad9ed9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"c7dd130b8d4ef800","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"peek":{"doc_sha":"14054f10b4d9dda0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"peek() -> Vector"},"remaining":{"doc_sha":"c193adc4bbfa3564","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"walk":{"doc_sha":"3fb64b239109d301","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"walk() -> Vector"}}},"Alignment":{"doc_sha":"77d93d03c8994f84","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Arc":{"doc_sha":"8323f4b7101509e9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"abf1643dda106bf8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"color":{"doc_sha":"bdf932405b56c319","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"end_angle":{"doc_sha":"74f9704909a3a23d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"0cc0684f26d8a660","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"f2ef657aaf8b6c68","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"6612fa35c43918e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"radius":{"doc_sha":"f9c2310b775f1e5a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"start_angle":{"doc_sha":"6b91f175ebe5ecb4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"thickness":{"doc_sha":"f8eba89682554e6d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"67dd517aa153c158","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"AutoRuleSet":{"doc_sha":"49ab716632a44f06","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply":{"doc_sha":"701b6f038cfd6809","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply(discrete_map: DiscreteMap, tile_layer: TileLayer, seed: int = 0) -> None"},"grid_size":{"doc_sha":"570f4c81acb781d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"group_count":{"doc_sha":"892fc283939b3e0c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"4f5ca90860649b64","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve":{"doc_sha":"f8fff4e6e6488e3f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve(discrete_map: DiscreteMap, seed: int = 0) -> list[int]"},"rule_count":{"doc_sha":"9b9d5a7c1475dcfa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"terrain_enum":{"doc_sha":"eb365bf99bbcca37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"terrain_enum() -> IntEnum"},"value_count":{"doc_sha":"3467d158dd5af073","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"values":{"doc_sha":"b3199e059fd1ca2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"BSP":{"doc_sha":"7e971e0542cf4006","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"adjacency":{"doc_sha":"9373aa85408486ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bounds":{"doc_sha":"9c41e7403f2a0b2f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear":{"doc_sha":"2e4ffd3360ac858e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> BSP"},"find":{"doc_sha":"fccfac484a2b889c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find(pos: tuple[int, int] | list | Vector) -> BSPNode | None"},"get_leaf":{"doc_sha":"d8ba680ec2ad4fd1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_leaf(index: int) -> BSPNode"},"leaves":{"doc_sha":"439ec78ec02e6df8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"leaves() -> Iterator[BSPNode]"},"pos":{"doc_sha":"b8b27f4ad76e2870","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"root":{"doc_sha":"d1e3d9741dab383d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"621f11f9a37dd89d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height). Read-only."},"split_once":{"doc_sha":"0d7c0c33156bc4e5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"split_once(horizontal: bool, position: int) -> BSP"},"split_recursive":{"doc_sha":"fa516b79833e5094","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"split_recursive(depth: int, min_size: tuple[int, int], max_ratio: float = 1.5, seed: int | None = None) -> BSP"},"to_heightmap":{"doc_sha":"a0f03cab7a0f2d1b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(size: tuple[int, int] | None = None, select: str = 'leaves', shrink: int = 0, value: float = 1.0) -> HeightMap"},"traverse":{"doc_sha":"57da83e0f37686aa","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"traverse(order: Traversal = Traversal.LEVEL_ORDER) -> Iterator[BSPNode]"}}},"Behavior":{"doc_sha":"09206365e6c37ecc","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Billboard":{"doc_sha":"38614d1d19942bab","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"facing":{"doc_sha":"f82e2d7d8087804a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"dc5ed39915ab896c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"phi":{"doc_sha":"1450c89fb5b47929","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"0d5fec7fd4701ea6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"c9d55c4579681854","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"170c20c841b586fc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"b6d846566b6ccf93","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"theta":{"doc_sha":"c214ecbcb099228b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"adf8070be9a09eba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"CallableBinding":{"doc_sha":"711bf0f149ccae7a","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"callable":{"doc_sha":"96d3d31a6b7c56d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_valid":{"doc_sha":"bc69c90e13475ae6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"value":{"doc_sha":"8f7d9eb4f520c017","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Caption":{"doc_sha":"8cd46324baa46a0d","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"af7ba90df95e4b79","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"font":{"doc_sha":"fbdafdb33921aa13","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"font_size":{"doc_sha":"7894149ec4efec6e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"79a7d48503e657d1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"25b029c86a618fee","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"ca1a98d700ff1888","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"6580b31092048bcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"388919803d270fd3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"44e4967e15212959","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"85abb564299b703d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"text":{"doc_sha":"41628142b287defe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"8472810bca891509","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"528487f8deaaf789","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"114ecec940477107","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Circle":{"doc_sha":"077981a1fbdfb6e3","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"d1e80ccd6bf706b9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"46be64336e296c50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"20bebfa7432ed339","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"b43e33c2d49a3565","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"0cc0684f26d8a660","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"3a340282000297e0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"20e9fd42a7ba4bb8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"3fbed883fee308fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"5f9d88ff075d2cfc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"radius":{"doc_sha":"00a41207192c55a8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Color":{"doc_sha":"f1f664405900332f","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"a":{"doc_sha":"ef61630ad34f8e2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"b":{"doc_sha":"2e2e91e93833fdd0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_hex":{"doc_sha":"24d1ff553133bb7e","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_hex(hex_string: str) -> Color"},"g":{"doc_sha":"716ced374bcc2dc4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"lerp":{"doc_sha":"290e204f3538e406","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"lerp(other: Color, t: float) -> Color"},"r":{"doc_sha":"ef64cae6296f68cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_hex":{"doc_sha":"d0cf65b284158877","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_hex() -> str"}}},"ColorLayer":{"doc_sha":"f8a87b6184c9a9b6","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_gradient":{"doc_sha":"20390111f0652ae0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_gradient(source: HeightMap, range: tuple, color_low: Color, color_high: Color) -> ColorLayer"},"apply_perspective":{"doc_sha":"a909e1b1c0373bc0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_perspective(entity: Entity, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None"},"apply_ranges":{"doc_sha":"37fc5a26fda9cc0c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> ColorLayer"},"apply_threshold":{"doc_sha":"59cc9b72322cc677","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(source: HeightMap, range: tuple, color: Color) -> ColorLayer"},"at":{"doc_sha":"fef47313dcef9247","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(pos: tuple | Vector) or (x: int, y: int) -> Color"},"clear_perspective":{"doc_sha":"fec237fe4cbda340","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_perspective() -> None"},"draw_fov":{"doc_sha":"ff965b12c888d092","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"draw_fov(source: tuple, radius: int | None = None, fov: FOV | None = None, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None"},"edit":{"doc_sha":"b0edb9c8f4c1fe76","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"edit() -> context manager"},"fill":{"doc_sha":"f6ff376fd71ae90b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(color: Color) -> None"},"fill_rect":{"doc_sha":"bc8b20da214aa37d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_rect(pos: tuple, size: tuple, color: Color) -> None"},"grid":{"doc_sha":"b965c55d57de78fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"eac88eb0f7b16cbf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"dd282cc9273e2e36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set":{"doc_sha":"36801cc04606ee0b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(pos, color: Color) -> None"},"update_perspective":{"doc_sha":"c9caac507fbc4921","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_perspective() -> None"},"visible":{"doc_sha":"ecea81ec16e31c19","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"eb13c1b4de11ce63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"DijkstraMap":{"doc_sha":"822ccf5b086d37ec","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"descent_step":{"doc_sha":"fd7b649f1f3eb586","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"descent_step(pos: Vector | tuple) -> Vector | None"},"distance":{"doc_sha":"92b40108bb47bf88","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distance(pos: Vector | tuple) -> float | None"},"invert":{"doc_sha":"3e5ae145dafc2c1d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"invert() -> DijkstraMap"},"path_from":{"doc_sha":"3dcf638bff837f2d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_from(pos: Vector | tuple) -> AStarPath"},"root":{"doc_sha":"95824bac9c46ad83","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"step_from":{"doc_sha":"ac9bc93f671d3936","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"step_from(pos: Vector | tuple) -> Vector | None"},"to_heightmap":{"doc_sha":"0ba542e6509b510b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(size=None, unreachable=-1.0) -> HeightMap"}}},"DiscreteMap":{"doc_sha":"4d6d00ffa3ac62a9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add":{"doc_sha":"1e5a5b83f555fa87","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add(other: DiscreteMap | int, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_and":{"doc_sha":"1644af83b8699792","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_and(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_or":{"doc_sha":"c8262af2fdccdeeb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_or(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bitwise_xor":{"doc_sha":"01809471595bf8e7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bitwise_xor(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"bool":{"doc_sha":"a999f0f09edf7bf3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bool(condition: int | set | callable) -> DiscreteMap"},"clear":{"doc_sha":"b04d176e9c7bf9d2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> DiscreteMap"},"copy_from":{"doc_sha":"efea5a0aa9a8d9c9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_from(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"count":{"doc_sha":"5e3303ddc3a9e3ae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count(value: int) -> int"},"count_range":{"doc_sha":"ac4e0298919fd16e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_range(min_val: int, max_val: int) -> int"},"enum_type":{"doc_sha":"f27f1ad46e2d5afe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill":{"doc_sha":"7f358bdcc5c2c9f0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(value: int, *, pos=None, size=None) -> DiscreteMap"},"from_bytes":{"doc_sha":"b1c09c737d43ffa9","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data: bytes, size: tuple[int, int], *, enum: type = None) -> DiscreteMap"},"from_heightmap":{"doc_sha":"0e89bb4262b19ea3","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_heightmap(hmap: HeightMap, mapping: list[tuple[tuple[float,float], int]], *, enum=None) -> DiscreteMap"},"get":{"doc_sha":"a1097b27b43d6a27","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y) or (pos) -> int | Enum"},"histogram":{"doc_sha":"b2d9df4f14aedd51","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"histogram() -> dict[int, int]"},"invert":{"doc_sha":"0f52aecfe57ff499","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"invert() -> DiscreteMap"},"mask":{"doc_sha":"0d9628616f4592f4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"mask() -> memoryview"},"max":{"doc_sha":"144265dd0da983a8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"max(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"min":{"doc_sha":"ff90ceef60a45959","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min(other: DiscreteMap, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"min_max":{"doc_sha":"22018caaf1bdb60e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min_max() -> tuple[int, int]"},"multiply":{"doc_sha":"430abcb2b647132f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply(factor: float, *, pos=None, size=None) -> DiscreteMap"},"set":{"doc_sha":"8439c14e5785f7e4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(x: int, y: int, value: int) -> None"},"size":{"doc_sha":"fc6c37c9d3916c5d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height) of the map. Read-only."},"subtract":{"doc_sha":"aeae8042663b9973","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"subtract(other: DiscreteMap | int, *, pos=None, source_pos=None, size=None) -> DiscreteMap"},"to_bytes":{"doc_sha":"4d7c4b5939a546bd","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_bytes() -> bytes"},"to_heightmap":{"doc_sha":"b0744db8f889b5d9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_heightmap(mapping: dict[int, float] = None) -> HeightMap"}}},"Drawable":{"doc_sha":"bb28a8d677f24b33","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"move":{"doc_sha":"4a8f9ca3692c6b58","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resize":{"doc_sha":"980bdc9065fc077b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Easing":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Entity":{"doc_sha":"7231aaf2e3b107d0","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_label":{"doc_sha":"be06d30bbd5a41ca","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_label(label: str) -> None"},"animate":{"doc_sha":"a06820cecef57a9a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"at":{"doc_sha":"b5775513388a1464","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x: int, y: int) -> GridPoint | None"},"behavior_type":{"doc_sha":"745936b7029480fd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_pos":{"doc_sha":"f8feaba9f3652ca0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_x":{"doc_sha":"8bdc1199bfb15967","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_y":{"doc_sha":"8fd78a55ede4b259","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"default_behavior":{"doc_sha":"f0a1ea99426408dd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"die":{"doc_sha":"1369fecbc5a1862a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"die() -> None"},"draw_pos":{"doc_sha":"2769bf67eec13406","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"find_path":{"doc_sha":"1a672b91a00eb597","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find_path(target, diagonal_cost: float = 1.41, collide: str = None) -> AStarPath | None"},"grid":{"doc_sha":"d02c7b8c3beb5662","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"d2e9f06a9d9f0a3c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_x":{"doc_sha":"4108b9ec31aff2c0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_y":{"doc_sha":"26f7190f34a208b2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"has_label":{"doc_sha":"0e65dc586b3c8d18","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"has_label(label: str) -> bool"},"index":{"doc_sha":"74e24820bb380594","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"index() -> int"},"labels":{"doc_sha":"4e16ef3312d5b4e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"move_speed":{"doc_sha":"a3b3867a6d3b830a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"708f75f696069d69","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"6caae59e941bebf1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"path_to":{"doc_sha":"5b11e33c92caac2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_to(x: int, y: int) -> list"},"perspective_map":{"doc_sha":"f61894673d8c27a7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"172ff9a95e621eaf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"remove_label":{"doc_sha":"a4a541d7e9cf7275","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_label(label: str) -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"set_behavior":{"doc_sha":"328826510c72f049","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_behavior(type, waypoints=None, turns: int = 0, path=None, pathfinder=None) -> None"},"shader":{"doc_sha":"04503c544358f827","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sight_radius":{"doc_sha":"4a9ec6c3788b093a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_grid":{"doc_sha":"a5ff2c3e5190bc41","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"ca285ed1c6f460df","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset":{"doc_sha":"a92dae4a2555fa7b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset_x":{"doc_sha":"7ba6504a9bbbf681","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_offset_y":{"doc_sha":"24d4546e182bb2c8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"step":{"doc_sha":"8877861183e50020","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"target_label":{"doc_sha":"85acb11a7e713a41","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"1588a0d4779fd069","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_height":{"doc_sha":"7f2ef7cb6d425730","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_size":{"doc_sha":"1a647ababfde2daa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_width":{"doc_sha":"5d72ddf874e9db54","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"turn_order":{"doc_sha":"eba665bc9c7e75ee","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"d302457c5aaa495e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"update_visibility":{"doc_sha":"d096cd998ed99b49","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_visibility() -> None"},"visible":{"doc_sha":"2bca3a265b44b270","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible_entities":{"doc_sha":"2482fbbecbfac7ea","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"visible_entities(fov=None, radius: int | None = None) -> list[Entity]"},"x":{"doc_sha":"4ba6a677ea35e7ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"52b85f7dc8a7cb27","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Entity3D":{"doc_sha":"0daee46cf6343e9c","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"anim_clip":{"doc_sha":"f69294fc08fc1d6c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_frame":{"doc_sha":"41bdd9b6f4407d16","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_loop":{"doc_sha":"b267a372c1aef2cd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_paused":{"doc_sha":"e100f0be03f09b09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_speed":{"doc_sha":"33c581c9533b5169","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"anim_time":{"doc_sha":"6b811c83dd2a2184","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"fcf44af080ce16a7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property, target, duration, easing=None, delta=False, callback=None, conflict_mode=None)"},"at":{"doc_sha":"69d6101c79794f90","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x, z) -> dict"},"auto_animate":{"doc_sha":"867ca7a9c2b5a4ab","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear_path":{"doc_sha":"989bb0a431b58e17","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_path()"},"color":{"doc_sha":"ea9ab21659b6e392","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"follow_path":{"doc_sha":"f514b7092f561550","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"follow_path(path)"},"grid_pos":{"doc_sha":"f20411521f420193","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"idle_clip":{"doc_sha":"855c97611053652c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_moving":{"doc_sha":"b31c1090228993e4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"model":{"doc_sha":"26ae36cf867d07e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"acea3a6577d65fbc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_anim_complete":{"doc_sha":"5cc47894576d983d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Callback(entity, clip_name) when non-looping animation ends."},"path_to":{"doc_sha":"d6fe3936cc8e2d84","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"path_to(x, z) or path_to(pos=(x, z)) -> list"},"pos":{"doc_sha":"2488492030e9df40","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"5b603e8c652baf50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"b06782214d92e3f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"teleport":{"doc_sha":"d53364830cdc737a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"teleport(x, z) or teleport(pos=(x, z))"},"update_visibility":{"doc_sha":"61886fb70daedf6b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"update_visibility()"},"viewport":{"doc_sha":"e9f9154f00b47f9a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"bc1ae0f4c6b5cb11","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"walk_clip":{"doc_sha":"c95d11b4e1901f28","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"world_pos":{"doc_sha":"bcc6673be9243dd5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"EntityCollection3D":{"doc_sha":"55864ac691527ca9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"append":{"doc_sha":"43922f93aff7d723","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"append(entity)"},"clear":{"doc_sha":"f44ea077b10186d7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear()"},"extend":{"doc_sha":"4fd2e7e83437c7ae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"extend(iterable)"},"find":{"doc_sha":"e4182a3a3317aaae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find(name) -> Entity3D or None"},"pop":{"doc_sha":"cbcc90e2a433f277","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pop(index=-1) -> Entity3D"},"remove":{"doc_sha":"b2f8fb276b91ec1e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove(entity)"}}},"EntityCollection3DIter":{"doc_sha":"d71ec48b224bfacc","kind":"class","lifecycle":{"since":"0.2.8"},"members":{}},"FOV":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Font":{"doc_sha":"245b4e9b0ca66fc9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"family":{"doc_sha":"0cf26d3038432764","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"efc970576a584cde","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Frame":{"doc_sha":"4d53c3cb9e9e4611","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cache_subtree":{"doc_sha":"0b8d1175192c1ce2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"b2cf42011b86727c","kind":"property","lifecycle":{"modified":{"commit":"4c0953c","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"clip_children":{"doc_sha":"d12b2c4d1d2bb6cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill_color":{"doc_sha":"8cfbe5aa6dd6da27","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"2f5b05882c4ad39f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"9c8707ad589e5d8a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"ced8010da828cc93","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline":{"doc_sha":"c07b6dec9da5b9ea","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"outline_color":{"doc_sha":"cd98fcaf39f0bc56","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"55a118e7889fd555","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"bd51299002a099c5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"4ed17c625f2289e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"fabaa45fd789c7fd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Grid":{"doc_sha":"f88e43e992d43a86","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_rotation":{"doc_sha":"bc2cd502f52817e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"913ef5c36eaf9635","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_camera":{"doc_sha":"00d37ccdedcd3a2e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"center_camera(pos: tuple = None) -> None"},"center_x":{"doc_sha":"fbb0ff08116f3c80","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_y":{"doc_sha":"1bda27633025fb12","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"9566d92d5619ee21","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fill_color":{"doc_sha":"f26fb5da3de1789a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_data":{"doc_sha":"fdf0084b8456fd45","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"bd5a06618a50efad","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered_cell":{"doc_sha":"85c87c8aa013d0c4","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_cell_click":{"doc_sha":"ada11ea920c71f29","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_enter":{"doc_sha":"6ae7882d4f14f183","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_exit":{"doc_sha":"729303b3a70b2a36","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_click":{"doc_sha":"4e33bd16b2dee20a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"perspective":{"doc_sha":"8854eef3677c4423","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"perspective_enabled":{"doc_sha":"abd2223cd551e1ca","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"pos":{"doc_sha":"7f556820c51df2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"cfff2bccd482c2ef","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"texture":{"doc_sha":"b9bfb54e5571bfc9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"ec55a5a578c4d993","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"2449b0f04153753c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"7958c0625706f2d2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"zoom":{"doc_sha":"e56c4b61e0303f09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"GridData":{"doc_sha":"793d86e4e51839f6","kind":"class","lifecycle":{"since":"0.2.8-dev"},"members":{"add_layer":{"doc_sha":"ad48373d656e6a88","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"},"apply_ranges":{"doc_sha":"f74ca906e65a4bec","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> GridData"},"apply_threshold":{"doc_sha":"823c3779c6610389","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData"},"at":{"doc_sha":"0f12b1f70eaec14b","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"at(x: int, y: int) -> GridPoint"},"clear_dijkstra_maps":{"doc_sha":"d470811b59885b93","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"clear_dijkstra_maps() -> None"},"compute_fov":{"doc_sha":"2522197d48d43d98","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None"},"entities":{"doc_sha":"5c361e2150645749","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"entities_in_radius":{"doc_sha":"820857d7995c8fe6","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"entities_in_radius(pos: tuple | Vector, radius: float) -> list"},"find_path":{"doc_sha":"3de3f1a19ba24df8","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"find_path(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0) -> AStarPath | None"},"fov":{"doc_sha":"bb929647d575d246","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fov_radius":{"doc_sha":"bf76a3199e6d7f0b","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"get_dijkstra_map":{"doc_sha":"af44def04f5c942e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap"},"grid_h":{"doc_sha":"f96615d57a28b090","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"grid_size":{"doc_sha":"f1aff956f432c1fe","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"grid_w":{"doc_sha":"e2f7f55bad731ce6","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"is_in_fov":{"doc_sha":"3f871a007bbff04c","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"is_in_fov(x: int, y: int) -> bool"},"layer":{"doc_sha":"38d9c25b2b0b172b","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"layer(name: str) -> ColorLayer | TileLayer | None"},"layers":{"doc_sha":"9d6207bfc4909360","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"remove_layer":{"doc_sha":"7876426544d2ec0d","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"},"step":{"doc_sha":"38146d2fc84d70f3","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"step(n: int = 1, turn_order: int = None) -> None"},"texture":{"doc_sha":"a2911dd21b916bc0","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""}}},"GridView":{"doc_sha":"f88e43e992d43a86","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_rotation":{"doc_sha":"bc2cd502f52817e9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center":{"doc_sha":"913ef5c36eaf9635","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_camera":{"doc_sha":"00d37ccdedcd3a2e","kind":"method","lifecycle":{"since":"0.2.8-dev"},"signature":"center_camera(pos: tuple = None) -> None"},"center_x":{"doc_sha":"fbb0ff08116f3c80","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"center_y":{"doc_sha":"1bda27633025fb12","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"9566d92d5619ee21","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"fill_color":{"doc_sha":"f26fb5da3de1789a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_data":{"doc_sha":"fdf0084b8456fd45","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"bd5a06618a50efad","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered_cell":{"doc_sha":"85c87c8aa013d0c4","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_cell_click":{"doc_sha":"ada11ea920c71f29","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_enter":{"doc_sha":"6ae7882d4f14f183","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_cell_exit":{"doc_sha":"729303b3a70b2a36","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"on_click":{"doc_sha":"4e33bd16b2dee20a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"perspective":{"doc_sha":"8854eef3677c4423","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"perspective_enabled":{"doc_sha":"abd2223cd551e1ca","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"pos":{"doc_sha":"7f556820c51df2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"cfff2bccd482c2ef","kind":"property","lifecycle":{"since":"0.2.8-dev"},"signature":""},"texture":{"doc_sha":"b9bfb54e5571bfc9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"w":{"doc_sha":"ec55a5a578c4d993","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"2449b0f04153753c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"7958c0625706f2d2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"0cb3df5278d2658b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"zoom":{"doc_sha":"e56c4b61e0303f09","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"HeightMap":{"doc_sha":"e5f86ba9c89f2a5a","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add":{"doc_sha":"d420dd760d7228af","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"add_bsp":{"doc_sha":"b70f06e432c91a7a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_bsp(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, shrink: int = 0, value: float = 1.0) -> HeightMap"},"add_constant":{"doc_sha":"f382eb3f3e667cb4","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_constant(value: float, *, pos=None, size=None) -> HeightMap"},"add_hill":{"doc_sha":"1ad2406960701682","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_hill(center, radius: float, height: float) -> HeightMap"},"add_noise":{"doc_sha":"3c3c15026ff43959","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_noise(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, mode: str = 'fbm', octaves: int = 4, scale: float = 1.0) -> HeightMap"},"add_voronoi":{"doc_sha":"b689af51f6867b78","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_voronoi(num_points: int, coefficients: tuple = (1.0, -0.5), seed: int = None) -> HeightMap"},"clamp":{"doc_sha":"60c08680fb281402","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clamp(min: float = 0.0, max: float = 1.0, *, pos=None, size=None) -> HeightMap"},"clear":{"doc_sha":"2297d98f231b6b7b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> HeightMap"},"copy_from":{"doc_sha":"80d64f3f6e76f8a7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_from(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"count_in_range":{"doc_sha":"7af3d60f28d614b6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_in_range(range: tuple[float, float]) -> int"},"dig_bezier":{"doc_sha":"f3327d6ddc165496","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dig_bezier(points: tuple, start_radius: float, end_radius: float, start_height: float, end_height: float) -> HeightMap"},"dig_hill":{"doc_sha":"be55e494c4e50c3a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dig_hill(center, radius: float, target_height: float) -> HeightMap"},"fill":{"doc_sha":"4e998a0b1d85c4f2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(value: float, *, pos=None, size=None) -> HeightMap"},"get":{"doc_sha":"f55fcc655d43dc50","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y) or (pos) -> float"},"get_interpolated":{"doc_sha":"5c3e0267d9cbeda2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_interpolated(x, y) or (pos) -> float"},"get_normal":{"doc_sha":"6d4212add4e533f2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_normal(x, y, water_level=0.0) or (pos, water_level=0.0) -> tuple[float, float, float]"},"get_slope":{"doc_sha":"4876967f738c7d67","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_slope(x, y) or (pos) -> float"},"inverse":{"doc_sha":"f6c4de6cd6a9ca0f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"inverse() -> HeightMap"},"lerp":{"doc_sha":"967cacc180df98bc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"lerp(other: HeightMap, t: float, *, pos=None, source_pos=None, size=None) -> HeightMap"},"max":{"doc_sha":"141515f8b1f23390","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"max(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"mid_point_displacement":{"doc_sha":"bfcbfe796d73410f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"mid_point_displacement(roughness: float = 0.5, seed: int = None) -> HeightMap"},"min":{"doc_sha":"c51e934ea5047d91","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"min_max":{"doc_sha":"4e0b3cb7472a7cfe","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"min_max() -> tuple[float, float]"},"multiply":{"doc_sha":"83d2fcea22463d18","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"multiply_bsp":{"doc_sha":"21f401c651344592","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply_bsp(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, shrink: int = 0, value: float = 1.0) -> HeightMap"},"multiply_noise":{"doc_sha":"177d1d06a401af54","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"multiply_noise(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, mode: str = 'fbm', octaves: int = 4, scale: float = 1.0) -> HeightMap"},"normalize":{"doc_sha":"93ce05bf80d158ed","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize(min: float = 0.0, max: float = 1.0, *, pos=None, size=None) -> HeightMap"},"rain_erosion":{"doc_sha":"4dbe6bd42d9f6e5b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"rain_erosion(drops: int, erosion: float = 0.1, sedimentation: float = 0.05, seed: int = None) -> HeightMap"},"scale":{"doc_sha":"c85d9324b84c43ff","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"scale(factor: float, *, pos=None, size=None) -> HeightMap"},"size":{"doc_sha":"31846e24f4028b35","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height) of the heightmap. Read-only."},"smooth":{"doc_sha":"243be30aa73da43b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"smooth(iterations: int = 1) -> HeightMap"},"sparse_kernel":{"doc_sha":"e3b28d2c3913ac74","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sparse_kernel(weights: dict[tuple[int, int], float]) -> HeightMap"},"sparse_kernel_from":{"doc_sha":"ebeed84050ee0b2f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sparse_kernel_from(source: HeightMap, weights: dict[tuple[int, int], float]) -> None"},"subtract":{"doc_sha":"8e209f74aaad794d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"subtract(other: HeightMap, *, pos=None, source_pos=None, size=None) -> HeightMap"},"threshold":{"doc_sha":"946f6a1fb850decb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"threshold(range: tuple[float, float]) -> HeightMap"},"threshold_binary":{"doc_sha":"6ebe7bd7cbc112c2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"threshold_binary(range: tuple[float, float], value: float = 1.0) -> HeightMap"}}},"Heuristic":{"doc_sha":"7582db951ab67fc5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"InputState":{"doc_sha":"c7b7558edda29fea","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Key":{"doc_sha":"bc4feb60367e4be7","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Keyboard":{"doc_sha":"afbde6bcf7bfe1c6","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"alt":{"doc_sha":"051cb7dbc470d328","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"ctrl":{"doc_sha":"c062912b0b816bd2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shift":{"doc_sha":"614beb0d432c2ec0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"system":{"doc_sha":"2292efb47472f5e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"LdtkProject":{"doc_sha":"d80a5fdda811cb33","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"enums":{"doc_sha":"5697f9e75980e2be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"level":{"doc_sha":"33b80035be788d03","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"level(name: str) -> dict"},"level_names":{"doc_sha":"9479f6c2bd85608d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"ruleset":{"doc_sha":"ce72d154e5dd391c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"ruleset(name: str) -> AutoRuleSet"},"ruleset_names":{"doc_sha":"28983e9702e0dc81","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tileset":{"doc_sha":"30eabe3003acb244","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tileset(name: str) -> TileSetFile"},"tileset_names":{"doc_sha":"77a45b51f49743ef","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"version":{"doc_sha":"e6d3d49ad9edfad8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Line":{"doc_sha":"8d778faa93b5d0d5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"color":{"doc_sha":"f7aca0b82eaae97e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"end":{"doc_sha":"cc14ff1baa1b61fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"feeb7cb507a4692d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"49cecfb458b010a1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"733a48ea3a730e60","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"start":{"doc_sha":"817049419558ea17","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"thickness":{"doc_sha":"b6c7f3e979028709","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"cecb3704206d35f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Model3D":{"doc_sha":"be7fb5003ed48d84","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"animation_clips":{"doc_sha":"0d15ad9839919fe5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bone_count":{"doc_sha":"6e389fb2496946bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"bounds":{"doc_sha":"4ddfb8f6ae5a8928","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cube":{"doc_sha":"b49160efa8b0f71e","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"cube(size=1.0) -> Model3D"},"has_skeleton":{"doc_sha":"4e9ac073dac101b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"mesh_count":{"doc_sha":"49f8fe0ece668d77","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"b577d22f9a18de0c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"plane":{"doc_sha":"5e31b94e295b4429","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"plane(width=1.0, depth=1.0, segments=1) -> Model3D"},"sphere":{"doc_sha":"b385d8aaf0efd485","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"sphere(radius=0.5, segments=16, rings=12) -> Model3D"},"triangle_count":{"doc_sha":"b67c98fac8ffc8dd","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vertex_count":{"doc_sha":"8b76e70b26a820df","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Mouse":{"doc_sha":"e64f561c8a08bdec","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"grabbed":{"doc_sha":"3da420bb9cac434f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"left":{"doc_sha":"a4c7b98babc093cb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"middle":{"doc_sha":"1306b1240c6f64e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"2ecba0a68d64976a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"right":{"doc_sha":"08d5534c1c034b39","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"834527eb5579f3e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"c142f98ee94bf3f7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"5095db9e9e163e92","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"MouseButton":{"doc_sha":"a5a7584949d18f01","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Music":{"doc_sha":"e13e6ad378ec0e91","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"duration":{"doc_sha":"25607c5fa852d501","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"loop":{"doc_sha":"a165a91f8ff771f5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"4eb6d242151147dc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"play":{"doc_sha":"f724039e0cdb2847","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play() -> None"},"playing":{"doc_sha":"4451d007747fa3b1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"position":{"doc_sha":"608a5f6d4ed927bc","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"ee5d7818bae0a255","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"stop":{"doc_sha":"f3289bbb98a4da1f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"volume":{"doc_sha":"749fc9d89252cb0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"NoiseSource":{"doc_sha":"3bfa3b53984c85cb","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"algorithm":{"doc_sha":"ba813d6c022f15f0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"dimensions":{"doc_sha":"f534d20928d8d67a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fbm":{"doc_sha":"d13c8670fc68b9df","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fbm(pos: tuple[float, ...], octaves: int = 4) -> float"},"get":{"doc_sha":"3d7776fffd7a0a39","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(pos: tuple[float, ...]) -> float"},"hurst":{"doc_sha":"11682e6dc42f2417","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"lacunarity":{"doc_sha":"4d07f7079f3938de","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sample":{"doc_sha":"63fd0e680000d0f0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sample(size: tuple[int, int], world_origin: tuple[float, float] = (0.0, 0.0), world_size: tuple[float, float] = None, mode: str = 'fbm', octaves: int = 4) -> HeightMap"},"seed":{"doc_sha":"17bc1e90a8ce6b23","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"turbulence":{"doc_sha":"800c841f913b3780","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"turbulence(pos: tuple[float, ...], octaves: int = 4) -> float"}}},"Perspective":{"doc_sha":"e1c8553ea2a32a40","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"PropertyBinding":{"doc_sha":"a23eb43edd46bb66","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"is_valid":{"doc_sha":"40ada14485c362a7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"property":{"doc_sha":"0dad06f4e661db2d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"target":{"doc_sha":"3b15e1922fd910c9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"value":{"doc_sha":"80d28b4e329837d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Scene":{"doc_sha":"a156c42d77b20a3e","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"activate":{"doc_sha":"54ae3cd3cf652660","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"activate(transition: Transition = None, duration: float = None) -> None"},"active":{"doc_sha":"a59f70d3c12a7cfe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"children":{"doc_sha":"f21fb480dac856b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"7feed093cdf9334a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_key":{"doc_sha":"40ece618402a45f3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"9e60a4ae9bdb493b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"50e29b0dd34a76d5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"eef1e2dce9ee8894","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"register":{"doc_sha":"7603337801a7a498","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"register() -> None"},"registered":{"doc_sha":"d9ff54f488ed5b87","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"unregister":{"doc_sha":"fda6be9891d1f502","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"unregister() -> None"},"visible":{"doc_sha":"a7f321f4013dfb9b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Shader":{"doc_sha":"aded56cb37cba9d8","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"dynamic":{"doc_sha":"43d8bc587c3997e3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_valid":{"doc_sha":"fcdf95eedfa8479e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set_uniform":{"doc_sha":"bdaf877e5bc28f21","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_uniform(name: str, value: float|tuple) -> None"},"source":{"doc_sha":"dbd462698aadf450","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Sound":{"doc_sha":"8f31cca1781580d5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"buffer":{"doc_sha":"c79f4e433d4300ac","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"duration":{"doc_sha":"48daac900b6eaf36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"loop":{"doc_sha":"a624dff0e72a5337","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"005627aca21692d3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"pitch":{"doc_sha":"80b38bfc202b4291","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"play":{"doc_sha":"ae090ee8c0320797","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play() -> None"},"play_varied":{"doc_sha":"fd9b5cd6db3cb1cf","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"play_varied(pitch_range: float = 0.1, volume_range: float = 3.0) -> None"},"playing":{"doc_sha":"8a0c491fa38061d5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"1736ca7393d96c48","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"stop":{"doc_sha":"f3289bbb98a4da1f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"volume":{"doc_sha":"749fc9d89252cb0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"SoundBuffer":{"doc_sha":"426618cde68541be","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"bit_crush":{"doc_sha":"66ae55272fd9d182","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"bit_crush(bits: int, rate_divisor: int) -> SoundBuffer"},"channels":{"doc_sha":"f3948c1ceb5d7aec","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"concat":{"doc_sha":"7ee10bfe76aae5e2","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"concat(buffers: list[SoundBuffer], overlap: float = 0.0) -> SoundBuffer"},"distortion":{"doc_sha":"a36442ca725e5cc6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distortion(drive: float) -> SoundBuffer"},"duration":{"doc_sha":"bc1c03f781e0372b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"echo":{"doc_sha":"1b93f79c671068ab","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"echo(delay_ms: float, feedback: float, wet: float) -> SoundBuffer"},"from_samples":{"doc_sha":"556326f1f2cda4a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_samples(data: bytes, channels: int, sample_rate: int) -> SoundBuffer"},"gain":{"doc_sha":"22fc3aad26b084a5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"gain(factor: float) -> SoundBuffer"},"high_pass":{"doc_sha":"7d8af12720a4bbae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"high_pass(cutoff_hz: float) -> SoundBuffer"},"low_pass":{"doc_sha":"82cdc215e7da9310","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"low_pass(cutoff_hz: float) -> SoundBuffer"},"mix":{"doc_sha":"4df14cf741427da9","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"mix(buffers: list[SoundBuffer]) -> SoundBuffer"},"normalize":{"doc_sha":"5476b16208b1ac7b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize() -> SoundBuffer"},"pitch_shift":{"doc_sha":"ee9373f2d71fdaa8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pitch_shift(factor: float) -> SoundBuffer"},"reverb":{"doc_sha":"b6d0dcf1e5f75a42","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"reverb(room_size: float, damping: float, wet: float) -> SoundBuffer"},"reverse":{"doc_sha":"eb70bfa5da4c860d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"reverse() -> SoundBuffer"},"sample_count":{"doc_sha":"5e96b7263eade6af","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sample_rate":{"doc_sha":"a41417a71e10a2d6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sfxr":{"doc_sha":"5f45a4b7afc74066","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"sfxr(preset: str = None, seed: int = None, **params) -> SoundBuffer"},"sfxr_mutate":{"doc_sha":"68b5b653de4f55dc","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"sfxr_mutate(amount: float = 0.05, seed: int = None) -> SoundBuffer"},"sfxr_params":{"doc_sha":"7fd7a2356600014a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"slice":{"doc_sha":"84099d0f46e6b7ad","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"slice(start: float, end: float) -> SoundBuffer"},"tone":{"doc_sha":"eaec1748a9089774","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"tone(frequency: float, duration: float, waveform: str = 'sine', ...) -> SoundBuffer"}}},"Sprite":{"doc_sha":"a687c4f393725428","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"align":{"doc_sha":"58657c3cfa010f31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_pos":{"doc_sha":"9df77e9a996208ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"e32208817299dfe9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"horiz_margin":{"doc_sha":"0ef227a4cae6e675","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"ab774a7da8cfe9bf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"name":{"doc_sha":"90a2ab0ec07c00eb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"origin":{"doc_sha":"270bdf366c74bb1d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pos":{"doc_sha":"55a118e7889fd555","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"rotate_with_camera":{"doc_sha":"5c3af880218d3a08","kind":"property","lifecycle":{"modified":{"commit":"4bb1966","version":"0.2.8-dev"},"since":"0.2.8"},"signature":""},"rotation":{"doc_sha":"61ba3e66a0841a08","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale":{"doc_sha":"f91a66d322ee1a75","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale_x":{"doc_sha":"95f47a809a8ac927","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scale_y":{"doc_sha":"8567691c22a9b5f8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"shader":{"doc_sha":"418952064319ffb5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_index":{"doc_sha":"826af9fc52ae5cef","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"texture":{"doc_sha":"df3e7671926aa0a3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"uniforms":{"doc_sha":"b1f2699da618d002","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vert_margin":{"doc_sha":"e2042be923bc6fcb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"528487f8deaaf789","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"114ecec940477107","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Texture":{"doc_sha":"da368b536d9f844e","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"composite":{"doc_sha":"0bf406582f618af3","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"composite(layers: list[Texture], sprite_width: int, sprite_height: int, name: str = '') -> Texture"},"display_height":{"doc_sha":"0dc1ccea45ff4e40","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_offset_x":{"doc_sha":"0ca738fa6559aabb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_offset_y":{"doc_sha":"83b5bfadd26f4e79","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"display_width":{"doc_sha":"cfc6525a8a0b5bb7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"979f5a384770d87f","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data: bytes, width: int, height: int, sprite_width: int, sprite_height: int, name: str = '') -> Texture"},"hsl_shift":{"doc_sha":"95f19de7a67646e3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"hsl_shift(hue_shift: float, sat_shift: float = 0.0, lit_shift: float = 0.0) -> Texture"},"sheet_height":{"doc_sha":"59d4fb71cae72fba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sheet_width":{"doc_sha":"54ef54eb9c39a907","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"source":{"doc_sha":"51bee1cdb17257ba","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_count":{"doc_sha":"d1ed79d8b59eca99","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_height":{"doc_sha":"19b6a36f45f965e8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"sprite_width":{"doc_sha":"6f9276522c3b2512","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileLayer":{"doc_sha":"5551e3bf9e668d89","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_ranges":{"doc_sha":"e443e268532fdd2f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_ranges(source: HeightMap, ranges: list) -> TileLayer"},"apply_threshold":{"doc_sha":"2253f4367656d073","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(source: HeightMap, range: tuple, tile: int) -> TileLayer"},"at":{"doc_sha":"19975fea19b3fadb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(pos: tuple | Vector) or (x: int, y: int) -> int"},"edit":{"doc_sha":"47c32fda0e88780a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"edit() -> context manager"},"fill":{"doc_sha":"5c8eca820794a605","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(index: int) -> None"},"fill_rect":{"doc_sha":"adfc5ff6d6e91cc8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_rect(pos: tuple, size: tuple, index: int) -> None"},"grid":{"doc_sha":"b965c55d57de78fa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"eac88eb0f7b16cbf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"dd282cc9273e2e36","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"set":{"doc_sha":"2b242556ce84080f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(pos, index: int) -> None"},"texture":{"doc_sha":"1d211a219f88c72c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"ecea81ec16e31c19","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"eb13c1b4de11ce63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileMapFile":{"doc_sha":"b26cb3870e41a2c9","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply_to_tile_layer":{"doc_sha":"436e4fa972bd79d0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_to_tile_layer(tile_layer: TileLayer, layer_name: str, tileset_index: int = 0) -> None"},"height":{"doc_sha":"72aa81aefdab3ddb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"object_layer":{"doc_sha":"e658613bcbbe9b3a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"object_layer(name: str) -> list[dict]"},"object_layer_names":{"doc_sha":"7af1cc1454cd97f9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"orientation":{"doc_sha":"2ee759a156b5c903","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"properties":{"doc_sha":"f2cb52eb132891d8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve_gid":{"doc_sha":"4a72f1c9f61cec27","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve_gid(gid: int) -> tuple[int, int]"},"tile_height":{"doc_sha":"cc47760c48b8c9be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_layer_data":{"doc_sha":"399fd655e6402442","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tile_layer_data(name: str) -> list[int]"},"tile_layer_names":{"doc_sha":"e225e2d4b71155e7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_width":{"doc_sha":"c109808c1e2d3ca1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tileset":{"doc_sha":"a3e35f368c3cc89d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tileset(index: int) -> tuple[int, TileSetFile]"},"tileset_count":{"doc_sha":"e7347387a4bcfeae","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"width":{"doc_sha":"acef1b7138a85569","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"TileSetFile":{"doc_sha":"ec83598f02a06258","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"columns":{"doc_sha":"5ba187696ce9596e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"image_source":{"doc_sha":"f21faacc0392eec3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"margin":{"doc_sha":"6e1b3cfd5b59af6a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"5e0a76b40f4db151","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"properties":{"doc_sha":"e0382abd62d297a6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"spacing":{"doc_sha":"950416fc0b8f1837","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_count":{"doc_sha":"df5ccd0141400827","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_height":{"doc_sha":"71a327fd061c1cf2","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"tile_info":{"doc_sha":"b33077f44c79e050","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"tile_info(tile_id: int) -> dict | None"},"tile_width":{"doc_sha":"888787b1ef69f5d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_texture":{"doc_sha":"610adac758051370","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_texture() -> Texture"},"wang_set":{"doc_sha":"c3b15db4611c250a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"wang_set(name: str) -> WangSet"},"wang_sets":{"doc_sha":"71541a1c5777b29f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Timer":{"doc_sha":"646feff7bdb817f5","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"active":{"doc_sha":"0ac0f4a607819264","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"callback":{"doc_sha":"a943e7e99b75fa22","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"interval":{"doc_sha":"e32658ee97f94823","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"98a62482b59640f6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"once":{"doc_sha":"7d5a7d20bae3793e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"pause":{"doc_sha":"bfe544cc3c9f1cda","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"pause() -> None"},"paused":{"doc_sha":"060df10cbfc34662","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"remaining":{"doc_sha":"e223a64ed6a1ec63","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"restart":{"doc_sha":"a8c305ae5017cce0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"restart() -> None"},"resume":{"doc_sha":"66ab0cd39f579ef9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resume() -> None"},"start":{"doc_sha":"0b2b51d812995d34","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"start() -> None"},"stop":{"doc_sha":"9f69f5e68512116c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"stop() -> None"},"stopped":{"doc_sha":"bffdb8aa4589c28b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Transition":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Traversal":{"doc_sha":"e3b0c44298fc1c14","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Trigger":{"doc_sha":"3f698ad8b187be9f","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"as_integer_ratio":{"doc_sha":"3eb115fab0d0e0ec","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_count":{"doc_sha":"f276da700b5a2e37","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"bit_length":{"doc_sha":"15b29db62fd0fa6c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"conjugate":{"doc_sha":"6517a002faad7c75","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"denominator":{"doc_sha":"5051eb1570768851","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"from_bytes":{"doc_sha":"64672607865643a7","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"($type, /, bytes, byteorder='big', *, signed=False)"},"imag":{"doc_sha":"ef78ea410df39b26","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_integer":{"doc_sha":"6176763fbac2fd22","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /)"},"numerator":{"doc_sha":"ed255748ce2eda7c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"real":{"doc_sha":"d44497f1990af6fe","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"to_bytes":{"doc_sha":"b452319e48e64764","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"($self, /, length=1, byteorder='big', *, signed=False)"}}},"Vector":{"doc_sha":"0c8ae9e2ef387065","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"angle":{"doc_sha":"a0ca5f1f4a89d780","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"angle() -> float"},"copy":{"doc_sha":"120e723fd513e072","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy() -> Vector"},"distance_to":{"doc_sha":"4f5d26bbe8434f65","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"distance_to(other: Vector) -> float"},"dot":{"doc_sha":"d69442ea289adfb8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"dot(other: Vector) -> float"},"floor":{"doc_sha":"c7e81a90c5420968","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"floor() -> Vector"},"int":{"doc_sha":"3d97cae0ad8a963c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"magnitude":{"doc_sha":"75f61a474afe3121","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"magnitude() -> float"},"magnitude_squared":{"doc_sha":"c976db5b540f827b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"magnitude_squared() -> float"},"normalize":{"doc_sha":"04b468da3fc60858","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"normalize() -> Vector"},"x":{"doc_sha":"0e322bdd857e652b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"bbac84f15b6740d9","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Viewport3D":{"doc_sha":"c41e58effff86261","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_billboard":{"doc_sha":"ae2357f78a17a437","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_billboard(billboard)"},"add_layer":{"doc_sha":"b4c0274f9217aaa1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_layer(name, z_index=0) -> dict"},"add_mesh":{"doc_sha":"588dbdf9a48f17e5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_mesh(layer_name, model, pos, rotation=0, scale=1.0) -> int"},"add_voxel_layer":{"doc_sha":"47f17324425647e1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_voxel_layer(voxel_grid, z_index=0)"},"animate":{"doc_sha":"400df4f5893261c5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation"},"apply_heightmap":{"doc_sha":"be8e92a25726238d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_heightmap(heightmap, y_scale=1.0)"},"apply_terrain_colors":{"doc_sha":"cf0e47c185484e70","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_terrain_colors(layer_name, r_map, g_map, b_map)"},"apply_threshold":{"doc_sha":"3adda69e435613d7","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply_threshold(heightmap, min_height, max_height, walkable=True)"},"at":{"doc_sha":"7ec76d7602627cb9","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"at(x, z) -> VoxelPoint"},"bg_color":{"doc_sha":"c7e7141bd1cadaea","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"billboard_count":{"doc_sha":"4b432c5c560ee44f","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"billboard_count() -> int"},"bounds":{"doc_sha":"d3186ac3b3ff8bed","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"build_terrain":{"doc_sha":"5be2a61f78907326","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"build_terrain(layer_name, heightmap, y_scale=1.0, cell_size=1.0) -> int"},"camera_pos":{"doc_sha":"7bfead8afc2b8312","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"camera_target":{"doc_sha":"3ecf7bb4dbdf27e4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"cell_size":{"doc_sha":"8c3fa657c8fdc2aa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear_billboards":{"doc_sha":"63d6d64c39b46264","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_billboards()"},"clear_meshes":{"doc_sha":"1d6eb71f274020cf","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_meshes(layer_name)"},"clear_voxel_nav_region":{"doc_sha":"56901862e4dc595a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear_voxel_nav_region(voxel_grid)"},"compute_fov":{"doc_sha":"93c68ed57619564e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"compute_fov(origin, radius=10) -> list"},"enable_affine":{"doc_sha":"96ba76ed43593940","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_dither":{"doc_sha":"da6733dd08751b72","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_fog":{"doc_sha":"ca6c5ffcef454868","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"enable_vertex_snap":{"doc_sha":"7401ad5dcd82024c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"entities":{"doc_sha":"c2b185a3b718cc6b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"find_path":{"doc_sha":"ebd174d40557cc19","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"find_path(start, end) -> list"},"fog_color":{"doc_sha":"3eb0cdbd75c5a94c","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fog_far":{"doc_sha":"90406f8cae40bb58","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fog_near":{"doc_sha":"e49f6dc79b22a794","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"follow":{"doc_sha":"975d2b945069be26","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"follow(entity, distance=10, height=5, smoothing=1.0)"},"fov":{"doc_sha":"7f5d69a1c423f69d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"get_billboard":{"doc_sha":"38b0410480ab7d4d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_billboard(index) -> Billboard"},"get_layer":{"doc_sha":"92223f274b5312c3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_layer(name) -> dict or None"},"global_bounds":{"doc_sha":"aa4de9a20df55c2b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"global_position":{"doc_sha":"b8dabbecc9fccd2a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"grid_size":{"doc_sha":"7b4dbfa47f259f02","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"h":{"doc_sha":"d7308cd01fffb4be","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"hovered":{"doc_sha":"af0d353ce08f7be0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"is_in_fov":{"doc_sha":"81d50fbbe2df4c8e","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"is_in_fov(x, z) -> bool"},"layer_count":{"doc_sha":"8bfbb28860b18d51","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"layer_count() -> int"},"move":{"doc_sha":"5c74f429970a2d2b","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"move(dx, dy) or (delta) -> None"},"on_click":{"doc_sha":"b3b8de4e294d71b6","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_enter":{"doc_sha":"48c996802e262396","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_exit":{"doc_sha":"c14ef0d6d6d40b0e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"on_move":{"doc_sha":"9c3e26818b5b25b0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"opacity":{"doc_sha":"c1a7ef41e074519e","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"orbit_camera":{"doc_sha":"29e02de46a996495","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"orbit_camera(angle=0, distance=10, height=5)"},"parent":{"doc_sha":"171b0de4e295c55b","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"place_blocking":{"doc_sha":"9252fd1d594a3867","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"place_blocking(grid_pos, footprint, walkable=False, transparent=False)"},"pos":{"doc_sha":"c1693d7dd0fe875d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"project_all_voxels_to_nav":{"doc_sha":"ac9e0ca27b557d65","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_all_voxels_to_nav(headroom=2)"},"project_voxel_to_nav":{"doc_sha":"1fdf58bc686f7511","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_voxel_to_nav(voxel_grid, headroom=2)"},"realign":{"doc_sha":"8dfa7333a49aa697","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"realign() -> None"},"remove_billboard":{"doc_sha":"bf68668896f86bb0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_billboard(billboard)"},"remove_layer":{"doc_sha":"f5fafdfe3d153aab","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_layer(name) -> bool"},"remove_voxel_layer":{"doc_sha":"b88a956cacc361cb","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"remove_voxel_layer(voxel_grid) -> bool"},"render_resolution":{"doc_sha":"4b141c5648005f04","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resize":{"doc_sha":"005a04f1d129ebe5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resize(width, height) or (size) -> None"},"screen_to_world":{"doc_sha":"ba31510737afd516","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"screen_to_world(x, y, y_plane=0.0) -> tuple or None"},"set_grid_size":{"doc_sha":"ee7d1626547614c8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_grid_size(width, depth)"},"set_slope_cost":{"doc_sha":"dac6d3cd319f1016","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set_slope_cost(max_slope=0.5, cost_multiplier=1.0)"},"visible":{"doc_sha":"49221a4ae830f3b8","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"voxel_layer_count":{"doc_sha":"c1ca5c90881bbaae","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"voxel_layer_count() -> int"},"w":{"doc_sha":"4c5bcc34301022a0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"x":{"doc_sha":"cd81d9dc457285aa","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"y":{"doc_sha":"406570900a1cf520","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"z_index":{"doc_sha":"328d87ae44d9fb71","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"VoxelGrid":{"doc_sha":"6706e3f5276ce104","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"add_material":{"doc_sha":"27dec1c992cf7b99","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"add_material(name, color=Color(255,255,255), sprite_index=-1, transparent=False, path_cost=1.0) -> int"},"cell_size":{"doc_sha":"81b167d608b2cec5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"clear":{"doc_sha":"8ebe20948a33a84d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"clear() -> None"},"copy_region":{"doc_sha":"e56c5d28d7fd4e86","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"copy_region(min_coord, max_coord) -> VoxelRegion"},"count_material":{"doc_sha":"c9ef38e1f2f29ab8","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_material(material) -> int"},"count_non_air":{"doc_sha":"275d512b30f5134a","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"count_non_air() -> int"},"depth":{"doc_sha":"21671acfc312aeeb","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fill":{"doc_sha":"fff2fcdddba5ee8c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill(material) -> None"},"fill_box":{"doc_sha":"7c3d982a275f095d","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_box(min_coord, max_coord, material) -> None"},"fill_box_hollow":{"doc_sha":"432e8b518feee0a5","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_box_hollow(min_coord, max_coord, material, thickness=1) -> None"},"fill_cylinder":{"doc_sha":"0b2b0b0826381df0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_cylinder(base_pos, radius, height, material) -> None"},"fill_noise":{"doc_sha":"567355400611d920","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_noise(min_coord, max_coord, material, threshold=0.5, scale=0.1, seed=0) -> None"},"fill_sphere":{"doc_sha":"e6a85d9c72e2ea44","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"fill_sphere(center, radius, material) -> None"},"from_bytes":{"doc_sha":"faefede0aa8ae636","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"from_bytes(data) -> bool"},"get":{"doc_sha":"52cea4645f0ea3d0","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get(x, y, z) -> int"},"get_material":{"doc_sha":"2a61f6d54086df97","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"get_material(id) -> dict"},"greedy_meshing":{"doc_sha":"17fffa950c043917","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"height":{"doc_sha":"f271e989afe69da3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"load":{"doc_sha":"5dcaf71057f836a2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"load(path) -> bool"},"material_count":{"doc_sha":"0ef72bd68ef0c2d0","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"offset":{"doc_sha":"75230579ca06d8f5","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"paste_region":{"doc_sha":"1f44bacefd614cca","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"paste_region(region, position, skip_air=True) -> None"},"project_column":{"doc_sha":"9bacd59e3a7c5758","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"project_column(x, z, headroom=2) -> dict"},"rebuild_mesh":{"doc_sha":"fa60051037c3328c","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"rebuild_mesh() -> None"},"rotation":{"doc_sha":"5b603e8c652baf50","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"save":{"doc_sha":"bb7090694292f8e3","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"save(path) -> bool"},"set":{"doc_sha":"e9223501162aa9c6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"set(x, y, z, material) -> None"},"size":{"doc_sha":"2d283d0584913b16","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height, depth) of the grid. Read-only."},"to_bytes":{"doc_sha":"f9a751e75bfc0478","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"to_bytes() -> bytes"},"vertex_count":{"doc_sha":"4df863704c32ecb7","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"7ff1afdbd4bf86a3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"width":{"doc_sha":"0977aff160584918","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"VoxelRegion":{"doc_sha":"0cda0630ba7cafef","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"depth":{"doc_sha":"05b3223f7df8ab65","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"height":{"doc_sha":"04834a15ca591472","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"size":{"doc_sha":"75c252385afbc498","kind":"property","lifecycle":{"since":"0.2.8"},"signature":"Dimensions (width, height, depth) of the region. Read-only."},"width":{"doc_sha":"d10d49e58037417d","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"WangSet":{"doc_sha":"320e161ef3d08a5d","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"apply":{"doc_sha":"2aa8c0ba2d6549d2","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"apply(discrete_map: DiscreteMap, tile_layer: TileLayer) -> None"},"color_count":{"doc_sha":"13b244a538aec70f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"colors":{"doc_sha":"6d84b3c2dd048b1a","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"name":{"doc_sha":"8b4560b705e5724f","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"resolve":{"doc_sha":"fe2fa09430c4bda6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"resolve(discrete_map: DiscreteMap) -> list[int]"},"terrain_enum":{"doc_sha":"b8c46ec1a2b03eb6","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"terrain_enum() -> IntEnum"},"type":{"doc_sha":"ea3706e2c65ba7d3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}},"Window":{"doc_sha":"6021e9b58544c378","kind":"class","lifecycle":{"since":"0.2.8"},"members":{"center":{"doc_sha":"91fe1b0d6ca2e4f1","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"center() -> None"},"framerate_limit":{"doc_sha":"de182ed271fbb4d3","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"fullscreen":{"doc_sha":"a517f5331026f2c4","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"game_resolution":{"doc_sha":"3a0dba59fecf5a95","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"get":{"doc_sha":"544bdc10ab93d067","kind":"classmethod","lifecycle":{"since":"0.2.8"},"signature":"get() -> Window"},"resolution":{"doc_sha":"548abee10c4fbcaf","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"scaling_mode":{"doc_sha":"57e03f9ffc9291ae","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"screenshot":{"doc_sha":"5102ab9c4c2c8358","kind":"method","lifecycle":{"since":"0.2.8"},"signature":"screenshot(filename: str = None) -> bytes | None"},"title":{"doc_sha":"0d98279e5ec1b8b1","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"visible":{"doc_sha":"bad7f81ec2e48a31","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""},"vsync":{"doc_sha":"0335eb1fba386e37","kind":"property","lifecycle":{"since":"0.2.8"},"signature":""}}}},"schema":1,"seeded":false,"version":"0.2.8-dev"} diff --git a/src/CommandLineParser.cpp b/src/CommandLineParser.cpp index 343c7c3..48be54d 100644 --- a/src/CommandLineParser.cpp +++ b/src/CommandLineParser.cpp @@ -86,6 +86,12 @@ CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& confi continue; } + if (arg == "--run-forever") { + config.run_forever = true; + current_arg++; + continue; + } + if (arg == "--audio-off") { config.audio_enabled = false; current_arg++; diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index 18761e3..6658704 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -394,8 +394,9 @@ void GameEngine::run() void GameEngine::doFrame() { - // Reset per-frame metrics - metrics.resetPerFrame(); + // #341: simulation metrics belong to this frame's sim pass; render counters are + // cleared/published around the render pass below. + metrics.beginSimFrame(); currentScene()->update(); testTimers(); @@ -426,6 +427,8 @@ void GameEngine::doFrame() { } + metrics.beginRender(); // #341 + // Handle scene transitions if (transition.type != TransitionType::None) { @@ -453,6 +456,8 @@ void GameEngine::doFrame() currentScene()->render(); } + metrics.endRender(); // #341: publish this frame's counters for get_metrics() + // Update and render profiler overlay (if enabled) if (profilerOverlay && !headless) { profilerOverlay->update(metrics); @@ -923,12 +928,20 @@ float GameEngine::step(float dt) { simulation_time += static_cast(dt * 1000.0f); // Convert seconds to ms } - // Update animations with the dt in seconds - if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check - AnimationManager::getInstance().update(actual_dt); - } + // #350: step() is a full SIMULATION frame -- everything doFrame() does except + // render and input, which are deliberately not on the clock (render costs zero + // simulation time; see renderScene()). Previously step() advanced only animations + // and timers, so under step() a Scene.update() override never fired, scene + // transitions never progressed or completed, and current_frame never advanced -- + // headless behaved measurably differently from a real frame. + metrics.beginSimFrame(); - // Test timers with the new simulation time + // C++ scene update + currentScene()->update(); + + // Test timers with the new simulation time. + // Kept on simulation_time (not runtime): step() is the deterministic headless + // clock, which is the whole point of driving time explicitly from a test. auto it = timers.begin(); while (it != timers.end()) { auto timer = it->second; @@ -946,13 +959,46 @@ float GameEngine::step(float dt) { } } + // Python Scene.update(dt) hook -- the originally-filed omission + { + ScopedTimer pyTimer(metrics.pythonScriptTime); + McRFPy_API::updatePythonScenes(actual_dt); + } + + // Update animations with the dt in seconds + if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check + ScopedTimer animTimer(metrics.animationTime); + AnimationManager::getInstance().update(actual_dt); + } + + // Advance scene transitions, and finalize them when complete. Without this a + // transition started headlessly would never end, and the scene never changed. + if (transition.type != TransitionType::None) { + transition.update(actual_dt); + if (transition.isComplete()) { + scene = transition.toScene; + transition.type = TransitionType::None; + McRFPy_API::triggerSceneChange(transition.fromScene, transition.toScene); + } + } + + metrics.endSimFrame(); + metrics.updateFrameTime(actual_dt * 1000.0f); // ms + currentFrame++; + return actual_dt; } // #153 - Force render the current scene (for synchronous screenshots) +// Render the current state on demand, advancing NO simulation time (#341/#350). +// Rendering is orthogonal to the clock: any state can be drawn at any moment. This is +// the path a screenshot takes, and -- since step() deliberately never renders -- it is +// how a headless script gets real render metrics without the clock moving. void GameEngine::renderScene() { if (!render_target) return; + metrics.beginRender(); + // Handle scene transitions if (transition.type != TransitionType::None) { transition.update(0); // Don't advance transition time, just render current state @@ -963,6 +1009,8 @@ void GameEngine::renderScene() { currentScene()->render(); } + metrics.endRender(); + // For RenderTexture (headless), we need to call display() if (headless && headless_renderer) { headless_renderer->display(); diff --git a/src/GameEngine.h b/src/GameEngine.h index f7ca4a4..d2e2562 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -53,37 +53,90 @@ struct ProfilingMetrics { static constexpr int HISTORY_SIZE = 60; float frameTimeHistory[HISTORY_SIZE] = {0}; int historyIndex = 0; + int historyCount = 0; // #341: frames recorded so far, capped at HISTORY_SIZE + + /** + * @brief Per-frame values from the last COMPLETED frame (#341). + * + * The counters above are live accumulators: they are zeroed at the top of the + * frame and filled during render(), which runs AFTER every Python callback. + * A script calling get_metrics() therefore always read zeros. These published + * copies hold the previous frame's totals, which is what Python should see. + * + * In-engine consumers that run after render() (ProfilerOverlay, BenchmarkLogger) + * keep reading the live values -- they are fresher and already correct there. + */ + struct PublishedFrame { + int drawCalls = 0; + int uiElements = 0; + int visibleElements = 0; + int gridCellsRendered = 0; + int entitiesRendered = 0; + int totalEntities = 0; + float gridRenderTime = 0.0f; + float entityRenderTime = 0.0f; + float fovOverlayTime = 0.0f; + float pythonScriptTime = 0.0f; + float animationTime = 0.0f; + } published; void updateFrameTime(float deltaMs) { frameTime = deltaMs; frameTimeHistory[historyIndex] = deltaMs; historyIndex = (historyIndex + 1) % HISTORY_SIZE; + if (historyCount < HISTORY_SIZE) ++historyCount; - // Calculate average + // Average over frames actually recorded. Dividing by the full HISTORY_SIZE + // while the zero-initialized buffer is still filling diluted the average and + // reported a wildly inflated FPS for the first 60 frames (#341). float sum = 0.0f; - for (int i = 0; i < HISTORY_SIZE; ++i) { + for (int i = 0; i < historyCount; ++i) { sum += frameTimeHistory[i]; } - avgFrameTime = sum / HISTORY_SIZE; + avgFrameTime = historyCount > 0 ? sum / historyCount : 0.0f; fps = avgFrameTime > 0 ? static_cast(1000.0f / avgFrameTime) : 0; } - void resetPerFrame() { + // #341/#350: simulation and rendering are separate passes with separate metrics. + // + // Rendering costs zero simulation time and can happen at any moment (a screenshot + // renders arbitrary state without advancing the clock), so the render counters are + // owned by the render pass and the simulation timings by the step/frame pass. + // Keeping them separate means a step() cannot wipe the counters from the last + // render, and a render cannot disturb the simulation clock. + + void beginRender() { drawCalls = 0; uiElements = 0; visibleElements = 0; - - // Reset per-frame timing metrics - gridRenderTime = 0.0f; - entityRenderTime = 0.0f; - fovOverlayTime = 0.0f; - pythonScriptTime = 0.0f; - animationTime = 0.0f; - - // Reset per-frame counters gridCellsRendered = 0; entitiesRendered = 0; totalEntities = 0; + gridRenderTime = 0.0f; + entityRenderTime = 0.0f; + fovOverlayTime = 0.0f; + } + + void endRender() { + published.drawCalls = drawCalls; + published.uiElements = uiElements; + published.visibleElements = visibleElements; + published.gridCellsRendered = gridCellsRendered; + published.entitiesRendered = entitiesRendered; + published.totalEntities = totalEntities; + published.gridRenderTime = gridRenderTime; + published.entityRenderTime = entityRenderTime; + published.fovOverlayTime = fovOverlayTime; + } + + void beginSimFrame() { + pythonScriptTime = 0.0f; + animationTime = 0.0f; + } + + void endSimFrame() { + published.pythonScriptTime = pythonScriptTime; + published.animationTime = animationTime; } }; diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index 716fc74..379805e 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -1707,8 +1707,13 @@ int PyGridLayerAPI::ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* v } long z = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; - self->data->z_index = z; - // TODO: Trigger re-sort in parent grid + if (z != self->data->z_index) { + self->data->z_index = z; + // #376: the render early-out (#351) keys on content_generation, which only + // markDirty() bumps. Without this the view re-blits its stale cached texture + // and the new layer order never appears. (sortLayers() runs every render.) + self->data->markDirty(); + } return 0; } @@ -1727,7 +1732,12 @@ int PyGridLayerAPI::ColorLayer_set_visible(PyColorLayerObject* self, PyObject* v } int v = PyObject_IsTrue(value); if (v < 0) return -1; - self->data->visible = v; + if (v != self->data->visible) { + self->data->visible = v; + // #376: data mutators (fill/set/edit) markDirty(); this setter did not, so + // hiding or showing a layer left the cached render untouched in both directions. + self->data->markDirty(); + } return 0; } @@ -2391,8 +2401,10 @@ int PyGridLayerAPI::TileLayer_set_z_index(PyTileLayerObject* self, PyObject* val } long z = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; - self->data->z_index = z; - // TODO: Trigger re-sort in parent grid + if (z != self->data->z_index) { + self->data->z_index = z; + self->data->markDirty(); // #376: see ColorLayer_set_z_index + } return 0; } @@ -2411,7 +2423,10 @@ int PyGridLayerAPI::TileLayer_set_visible(PyTileLayerObject* self, PyObject* val } int v = PyObject_IsTrue(value); if (v < 0) return -1; - self->data->visible = v; + if (v != self->data->visible) { + self->data->visible = v; + self->data->markDirty(); // #376: see ColorLayer_set_visible + } return 0; } diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 649cbb4..7bdc989 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -127,108 +127,175 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args) Py_RETURN_NONE; } -// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes) -static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args) +// #151/#356: the module's dynamic attributes, as real descriptors on the module type. +// +// These were originally a PEP-562 `__getattr__` plus a string-matching `tp_setattro`. +// That worked for getattr/setattr but left them invisible to `dir(mcrfpy)`, and every +// doc/stub/manifest generator discovers module symbols via dir() -- so the single most +// common idiom in the engine (`mcrfpy.current_scene = ...`) appeared in no generated +// documentation at all. Descriptors are introspectable and carry their own docstrings. + +static PyObject* mcrfpy_get_current_scene(PyObject* self, void* closure) { - const char* name; - if (!PyArg_ParseTuple(args, "s", &name)) { + return McRFPy_API::api_get_current_scene(); +} + +static int mcrfpy_set_current_scene(PyObject* self, PyObject* value, void* closure) +{ + if (!value) { + PyErr_SetString(PyExc_AttributeError, "cannot delete 'current_scene'"); + return -1; + } + return McRFPy_API::api_set_current_scene(value); +} + +static PyObject* mcrfpy_get_scenes(PyObject* self, void* closure) +{ + return McRFPy_API::api_get_scenes(); +} + +static PyObject* mcrfpy_get_timers(PyObject* self, void* closure) +{ + return McRFPy_API::api_get_timers(); +} + +static PyObject* mcrfpy_get_animations(PyObject* self, void* closure) +{ + return McRFPy_API::api_get_animations(); +} + +static PyObject* mcrfpy_get_default_transition(PyObject* self, void* closure) +{ + return PyTransition::to_python(PyTransition::default_transition); +} + +static int mcrfpy_set_default_transition(PyObject* self, PyObject* value, void* closure) +{ + if (!value) { + PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition'"); + return -1; + } + TransitionType trans; + if (!PyTransition::from_arg(value, &trans, nullptr)) { + return -1; + } + PyTransition::default_transition = trans; + return 0; +} + +static PyObject* mcrfpy_get_default_transition_duration(PyObject* self, void* closure) +{ + return PyFloat_FromDouble(PyTransition::default_duration); +} + +static int mcrfpy_set_default_transition_duration(PyObject* self, PyObject* value, void* closure) +{ + if (!value) { + PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition_duration'"); + return -1; + } + double duration; + if (PyFloat_Check(value)) { + duration = PyFloat_AsDouble(value); + } else if (PyLong_Check(value)) { + duration = PyLong_AsDouble(value); + } else { + PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number"); + return -1; + } + if (duration < 0.0) { + PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative"); + return -1; + } + PyTransition::default_duration = static_cast(duration); + return 0; +} + +static PyObject* mcrfpy_get_save_dir(PyObject* self, void* closure) +{ +#ifdef __EMSCRIPTEN__ + return PyUnicode_FromString("/save"); +#else + return PyUnicode_FromString("save"); +#endif +} + +static PyGetSetDef mcrfpy_module_getset[] = { + {"current_scene", mcrfpy_get_current_scene, mcrfpy_set_current_scene, + MCRF_PROPERTY(current_scene, + "The active scene (Scene). Assign a Scene object, or a scene name as a string, " + "to switch scenes." + ), NULL}, + {"scenes", mcrfpy_get_scenes, NULL, + MCRF_PROPERTY(scenes, + "Tuple of all registered Scene objects (tuple, read-only)." + ), NULL}, + {"timers", mcrfpy_get_timers, NULL, + MCRF_PROPERTY(timers, + "Tuple of all active Timer objects (tuple, read-only)." + ), NULL}, + {"animations", mcrfpy_get_animations, NULL, + MCRF_PROPERTY(animations, + "Tuple of all currently running Animation objects (tuple, read-only)." + ), NULL}, + {"default_transition", mcrfpy_get_default_transition, mcrfpy_set_default_transition, + MCRF_PROPERTY(default_transition, + "Default transition (Transition) applied when switching scenes without an " + "explicit transition." + ), NULL}, + {"default_transition_duration", mcrfpy_get_default_transition_duration, + mcrfpy_set_default_transition_duration, + MCRF_PROPERTY(default_transition_duration, + "Default scene-transition duration in seconds (float). Must be non-negative." + ), NULL}, + {"save_dir", mcrfpy_get_save_dir, NULL, + MCRF_PROPERTY(save_dir, + "Directory used for persistent save data (str, read-only). '/save' under Emscripten." + ), NULL}, + {NULL} // Sentinel +}; + +// #356: PyModule_Type.__dir__ returns only the module __dict__ keys, so descriptors +// living on the module *type* would still be invisible to dir(). Override it to union +// the two -- this is what actually makes the generators (and tab-completion) see them. +static PyObject* mcrfpy_module_dir(PyObject* self, PyObject* Py_UNUSED(ignored)) +{ + PyObject* dict = PyModule_GetDict(self); // borrowed + if (!dict) return NULL; + + PyObject* names = PyDict_Keys(dict); + if (!names) return NULL; + + for (PyGetSetDef* gs = mcrfpy_module_getset; gs->name != NULL; gs++) { + PyObject* name = PyUnicode_FromString(gs->name); + if (!name || PyList_Append(names, name) < 0) { + Py_XDECREF(name); + Py_DECREF(names); + return NULL; + } + Py_DECREF(name); + } + + if (PyList_Sort(names) < 0) { + Py_DECREF(names); return NULL; } - - if (strcmp(name, "current_scene") == 0) { - return McRFPy_API::api_get_current_scene(); - } - - if (strcmp(name, "scenes") == 0) { - return McRFPy_API::api_get_scenes(); - } - - if (strcmp(name, "timers") == 0) { - return McRFPy_API::api_get_timers(); - } - - if (strcmp(name, "animations") == 0) { - return McRFPy_API::api_get_animations(); - } - - if (strcmp(name, "default_transition") == 0) { - return PyTransition::to_python(PyTransition::default_transition); - } - - if (strcmp(name, "default_transition_duration") == 0) { - return PyFloat_FromDouble(PyTransition::default_duration); - } - - if (strcmp(name, "save_dir") == 0) { -#ifdef __EMSCRIPTEN__ - return PyUnicode_FromString("/save"); -#else - return PyUnicode_FromString("save"); -#endif - } - - // Attribute not found - raise AttributeError - PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name); - return NULL; + return names; } -// #151: Custom module type with __setattr__ support for current_scene -static int mcrfpy_module_setattro(PyObject* self, PyObject* name, PyObject* value) -{ - const char* name_str = PyUnicode_AsUTF8(name); - if (!name_str) return -1; +static PyMethodDef mcrfpy_module_type_methods[] = { + {"__dir__", mcrfpy_module_dir, METH_NOARGS, + MCRF_METHOD(mcrfpy, __dir__, + MCRF_SIG("()", "list"), + MCRF_DESC("Names of the module's attributes, including the dynamic properties " + "(current_scene, scenes, timers, ...) that live as descriptors on the " + "module type rather than in the module dict."), + MCRF_RETURNS("list: sorted attribute names") + )}, + {NULL} // Sentinel +}; - if (strcmp(name_str, "current_scene") == 0) { - return McRFPy_API::api_set_current_scene(value); - } - - if (strcmp(name_str, "scenes") == 0) { - PyErr_SetString(PyExc_AttributeError, "'scenes' is read-only"); - return -1; - } - - if (strcmp(name_str, "timers") == 0) { - PyErr_SetString(PyExc_AttributeError, "'timers' is read-only"); - return -1; - } - - if (strcmp(name_str, "animations") == 0) { - PyErr_SetString(PyExc_AttributeError, "'animations' is read-only"); - return -1; - } - - if (strcmp(name_str, "default_transition") == 0) { - TransitionType trans; - if (!PyTransition::from_arg(value, &trans, nullptr)) { - return -1; - } - PyTransition::default_transition = trans; - return 0; - } - - if (strcmp(name_str, "default_transition_duration") == 0) { - double duration; - if (PyFloat_Check(value)) { - duration = PyFloat_AsDouble(value); - } else if (PyLong_Check(value)) { - duration = PyLong_AsDouble(value); - } else { - PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number"); - return -1; - } - if (duration < 0.0) { - PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative"); - return -1; - } - PyTransition::default_duration = static_cast(duration); - return 0; - } - - // For other attributes, use default module setattr - return PyObject_GenericSetAttr(self, name, value); -} - -// Custom module type that inherits from PyModule_Type but has our __setattr__ +// Custom module type: carries the dynamic properties as descriptors (#151, #356) static PyTypeObject McRFPyModuleType = { .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, .tp_name = "mcrfpy.module", @@ -246,11 +313,15 @@ static PyTypeObject McRFPyModuleType = { .tp_hash = NULL, .tp_call = NULL, .tp_str = NULL, + // tp_getattro/tp_setattro inherited from PyModule_Type: generic attribute access + // finds the tp_getset data descriptors below before touching the module dict. .tp_getattro = NULL, - .tp_setattro = mcrfpy_module_setattro, + .tp_setattro = NULL, .tp_as_buffer = NULL, .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, .tp_doc = "McRogueFace module with property support", + .tp_methods = mcrfpy_module_type_methods, + .tp_getset = mcrfpy_module_getset, }; static PyMethodDef mcrfpyMethods[] = { @@ -307,7 +378,10 @@ static PyMethodDef mcrfpyMethods[] = { MCRF_METHOD(mcrfpy, get_metrics, MCRF_SIG("()", "dict"), MCRF_DESC("Get current performance metrics."), - MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in seconds), avg_frame_time (average frame time), fps (frames per second), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (number of grid cells rendered this frame), entities_rendered (number of entities rendered this frame), total_entities (total entity count across all grids)") + MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in MILLISECONDS), avg_frame_time (rolling mean frame time over the last 60 frames, in milliseconds), fps (frames per second, derived from avg_frame_time -- a rolling average, not an instantaneous rate), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds), grid_render_time (grid rendering time in ms), entity_render_time (entity rendering time in ms), fov_overlay_time (FOV overlay rendering time in ms), python_time (Python script execution time in ms), animation_time (animation processing time in ms), grid_cells_rendered (grid cell draws this frame, counted per layer), entities_rendered (number of entities drawn this frame), total_entities (total entity count across all rendered grids)") + MCRF_NOTE("All per-frame counters and timing breakdowns describe the last COMPLETED frame. " + "Python callbacks run before the frame is rendered, so the in-progress frame's " + "values are not available yet; frame_time, fps, runtime and current_frame are live.") )}, {"set_dev_console", McRFPy_API::_setDevConsole, METH_VARARGS, @@ -349,12 +423,8 @@ static PyMethodDef mcrfpyMethods[] = { MCRF_NOTE("Messages appear in the 'logs' array of each frame in the output JSON.") )}, - // #151: Module-level attribute access for current_scene and scenes - {"__getattr__", mcrfpy_module_getattr, METH_VARARGS, - MCRF_METHOD(mcrfpy, __getattr__, - MCRF_SIG("(name: str)", "object"), - MCRF_DESC("Module-level attribute access for dynamic properties (current_scene, scenes).") - )}, + // #356: the module's dynamic attributes (current_scene, scenes, timers, ...) are + // real descriptors on McRFPyModuleType now, not a PEP-562 __getattr__ shim. // #219: Thread synchronization {"lock", PyLock::lock, METH_NOARGS, @@ -1609,54 +1679,16 @@ static void find_in_collection(std::vector>* collect // Check this element's name if (name_matches_pattern(drawable->name, pattern)) { - // Convert to Python object using RET_PY_INSTANCE logic - PyObject* py_obj = nullptr; - - switch (drawable->derived_type()) { - case PyObjectsEnum::UIFRAME: { - auto frame = std::static_pointer_cast(drawable); - auto type = &mcrfpydef::PyUIFrameType; - auto o = (PyUIFrameObject*)type->tp_alloc(type, 0); - if (o) { - o->data = frame; - py_obj = (PyObject*)o; - } - break; - } - case PyObjectsEnum::UICAPTION: { - auto caption = std::static_pointer_cast(drawable); - auto type = &mcrfpydef::PyUICaptionType; - auto o = (PyUICaptionObject*)type->tp_alloc(type, 0); - if (o) { - o->data = caption; - py_obj = (PyObject*)o; - } - break; - } - case PyObjectsEnum::UISPRITE: { - auto sprite = std::static_pointer_cast(drawable); - auto type = &mcrfpydef::PyUISpriteType; - auto o = (PyUISpriteObject*)type->tp_alloc(type, 0); - if (o) { - o->data = sprite; - py_obj = (PyObject*)o; - } - break; - } - case PyObjectsEnum::UIGRIDVIEW: { - auto gridview = std::static_pointer_cast(drawable); - auto type = &mcrfpydef::PyUIGridViewType; - auto o = (PyUIGridViewObject*)type->tp_alloc(type, 0); - if (o) { - o->data = gridview; - py_obj = (PyObject*)o; - } - break; - } - default: - break; + // #369: go through the cache-aware converter so find() returns the caller's + // existing wrapper (and Python subclass), not a fresh identity-less one. + PyObject* py_obj = UIDrawable::pyobject_for(drawable); + if (py_obj == Py_None) { + Py_DECREF(py_obj); + py_obj = nullptr; + } else if (!py_obj) { + PyErr_Clear(); // unknown derived type: skip, as the old switch did } - + if (py_obj) { if (find_all) { PyList_Append(results, py_obj); @@ -1842,22 +1874,27 @@ PyObject* McRFPy_API::_getMetrics(PyObject* self, PyObject* args) { PyDict_SetItemString(dict, "avg_frame_time", PyFloat_FromDouble(game->metrics.avgFrameTime)); PyDict_SetItemString(dict, "fps", PyLong_FromLong(game->metrics.fps)); + // #341: report the last COMPLETED frame's per-frame values, not the live + // accumulators. Python callbacks run before render(), and the accumulators are + // zeroed at the top of the frame -- so reading them live always returned 0. + const auto& pub = game->metrics.published; + // Add draw call metrics - PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(game->metrics.drawCalls)); - PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(game->metrics.uiElements)); - PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(game->metrics.visibleElements)); + PyDict_SetItemString(dict, "draw_calls", PyLong_FromLong(pub.drawCalls)); + PyDict_SetItemString(dict, "ui_elements", PyLong_FromLong(pub.uiElements)); + PyDict_SetItemString(dict, "visible_elements", PyLong_FromLong(pub.visibleElements)); // #144 - Add detailed timing breakdown (in milliseconds) - PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(game->metrics.gridRenderTime)); - PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(game->metrics.entityRenderTime)); - PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(game->metrics.fovOverlayTime)); - PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(game->metrics.pythonScriptTime)); - PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(game->metrics.animationTime)); + PyDict_SetItemString(dict, "grid_render_time", PyFloat_FromDouble(pub.gridRenderTime)); + PyDict_SetItemString(dict, "entity_render_time", PyFloat_FromDouble(pub.entityRenderTime)); + PyDict_SetItemString(dict, "fov_overlay_time", PyFloat_FromDouble(pub.fovOverlayTime)); + PyDict_SetItemString(dict, "python_time", PyFloat_FromDouble(pub.pythonScriptTime)); + PyDict_SetItemString(dict, "animation_time", PyFloat_FromDouble(pub.animationTime)); // #144 - Add grid-specific metrics - PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(game->metrics.gridCellsRendered)); - PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(game->metrics.entitiesRendered)); - PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(game->metrics.totalEntities)); + PyDict_SetItemString(dict, "grid_cells_rendered", PyLong_FromLong(pub.gridCellsRendered)); + PyDict_SetItemString(dict, "entities_rendered", PyLong_FromLong(pub.entitiesRendered)); + PyDict_SetItemString(dict, "total_entities", PyLong_FromLong(pub.totalEntities)); // Add general metrics PyDict_SetItemString(dict, "current_frame", PyLong_FromLong(game->getFrame())); diff --git a/src/McRogueFaceConfig.h b/src/McRogueFaceConfig.h index ed4e9d2..8a0d09a 100644 --- a/src/McRogueFaceConfig.h +++ b/src/McRogueFaceConfig.h @@ -32,6 +32,13 @@ struct McRogueFaceConfig { // Auto-exit when no timers remain (for --headless --exec automation) bool auto_exit_after_exec = false; + // Keep running after --exec scripts finish, instead of exiting (#350). + // Headless --exec normally exits when the scripts are done: step() is the only + // headless clock, so the engine cannot advance timers on its own and would + // otherwise spin forever. Pass --run-forever for a long-lived headless process + // (a server, a REPL host) that drives itself. + bool run_forever = false; + // Exception handling: exit on first Python callback exception (default: true) // Use --continue-after-exceptions to disable bool exit_on_exception = true; diff --git a/src/Profiler.h b/src/Profiler.h index fefcc08..7365ae8 100644 --- a/src/Profiler.h +++ b/src/Profiler.h @@ -44,6 +44,38 @@ public: ScopedTimer& operator=(const ScopedTimer&) = delete; }; +/** + * @brief Like ScopedTimer, but ADDS its elapsed time to the target (#341). + * + * ScopedTimer assigns, which is correct for a metric measured once per frame + * (workTime, pythonScriptTime). It is wrong for anything measured once per + * *object* per frame -- with two grid views on screen, assignment means the + * second view's time overwrites the first's rather than summing. Use this for + * any per-frame total accumulated across multiple call sites. + * + * The target must be zeroed once per frame (ProfilingMetrics::resetPerFrame). + */ +class ScopedAccumTimer { +private: + std::chrono::high_resolution_clock::time_point start; + float& target_ms; + +public: + explicit ScopedAccumTimer(float& target) + : target_ms(target) + { + start = std::chrono::high_resolution_clock::now(); + } + + ~ScopedAccumTimer() { + auto end = std::chrono::high_resolution_clock::now(); + target_ms += std::chrono::duration(end - start).count(); + } + + ScopedAccumTimer(const ScopedAccumTimer&) = delete; + ScopedAccumTimer& operator=(const ScopedAccumTimer&) = delete; +}; + /** * @brief Accumulating timer that adds elapsed time to existing value * diff --git a/src/Scene.cpp b/src/Scene.cpp index 0b6abab..76d703b 100644 --- a/src/Scene.cpp +++ b/src/Scene.cpp @@ -8,6 +8,11 @@ Scene::Scene(GameEngine* g) game = g; ui_elements = std::make_shared>>(); } + +Scene::~Scene() +{ + UIDrawable::releaseChildPins(ui_elements); // #373 +} void Scene::registerAction(int code, std::string name) { actions[code] = name; diff --git a/src/Scene.h b/src/Scene.h index a87b5c7..df3ab6e 100644 --- a/src/Scene.h +++ b/src/Scene.h @@ -29,6 +29,9 @@ protected: public: //Scene(); Scene(GameEngine*); + // #373: releases the Python-identity pins of ui_elements. Virtual because Scene is + // a polymorphic base held and deleted by GameEngine through a Scene*. + virtual ~Scene(); virtual void update() = 0; virtual void render() = 0; virtual void doAction(std::string, std::string) = 0; diff --git a/src/UICollection.cpp b/src/UICollection.cpp index 59d51a9..967b0f1 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -17,130 +17,35 @@ using namespace mcrfpydef; -// Local helper function to convert UIDrawable to appropriate Python object +// #369: promoted to UIDrawable::pyobject_for so .parent, find(), and collection +// indexing all hand back the same wrapper for the same C++ object. static PyObject* convertDrawableToPython(std::shared_ptr drawable) { - if (!drawable) { - Py_RETURN_NONE; + return UIDrawable::pyobject_for(drawable); +} + +// #377: the parent link is the inverse of collection membership, so EVERY mutator +// has to maintain it -- not just append(), which was the only one that did. These +// two helpers are the single place that knows how: a scene collection parents via +// setParentScene() (it has no owner drawable, so owner.lock() is null and +// setParent() would silently unparent the child), a drawable-owned one via +// setParent(). +// +// #373: they are also the pin points. setParent()/setParentScene() re-evaluate the +// strong ref that keeps a Python subclass wrapper alive while its C++ object is in +// a collection, so linking and unlinking here is what makes the pin correct. +static void link_child(PyUICollectionObject* self, std::shared_ptr drawable) { + if (!self->scene_name.empty()) { + drawable->setParentScene(self->scene_name); + } else { + drawable->setParent(self->owner.lock()); } +} - // Check cache first - if (drawable->serial_number != 0) { - PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number); - if (cached) { - return cached; // Already INCREF'd by lookup - } - } - - PyTypeObject* type = nullptr; - PyObject* obj = nullptr; - - switch (drawable->derived_type()) { - case PyObjectsEnum::UIFRAME: - { - type = &PyUIFrameType; - auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UICAPTION: - { - type = &PyUICaptionType; - auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->font = nullptr; - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UISPRITE: - { - type = &PyUISpriteType; - auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UIGRIDVIEW: - { - type = &PyUIGridViewType; - auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UILINE: - { - type = &PyUILineType; - auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UICIRCLE: - { - type = &PyUICircleType; - auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UIARC: - { - type = &PyUIArcType; - auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UIVIEWPORT3D: - { - type = &PyViewport3DType; - auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(drawable); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - default: - PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type"); - return nullptr; - } - - // Re-register in cache if the object has a serial number - // This handles the case where the original Python wrapper was GC'd - // but the C++ object persists (e.g., inline-created objects added to collections) - if (obj && drawable->serial_number != 0) { - PyObject* weakref = PyWeakref_NewRef(obj, NULL); - if (weakref) { - PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref); - Py_DECREF(weakref); - } - } - - return obj; +// Call BEFORE erasing from the vector: setParent(nullptr) may drop the last strong +// ref to the wrapper, whose dealloc releases its shared_ptr to the drawable. While +// the vector still holds the element, `drawable` cannot be freed under us. +static void unlink_child(std::shared_ptr drawable) { + drawable->setParent(nullptr); } // Helper to extract shared_ptr from any UIDrawable Python subclass. @@ -229,8 +134,12 @@ PyObject* UICollection::getitem(PyUICollectionObject* self, Py_ssize_t index) { PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer"); return NULL; } - while (index < 0) index += self->data->size(); - if (index > self->data->size() - 1) + // Same two traps as setitem(): the old `while (index < 0) index += size()` spun + // forever on an empty collection, and `index > size() - 1` underflowed to SIZE_MAX + // when size() was 0 -- so the bounds check passed and we indexed an empty vector. + Py_ssize_t size = static_cast(vec->size()); + if (index < 0) index += size; + if (index < 0 || index >= size) { PyErr_SetString(PyExc_IndexError, "UICollection index out of range"); return NULL; @@ -248,19 +157,21 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject return -1; } - // Handle negative indexing - while (index < 0) index += self->data->size(); - + // Handle negative indexing. NOT a `while (index < 0) index += size()` loop: on an + // empty collection that adds zero forever, and `children[-1] = x` hung the engine. + Py_ssize_t size = static_cast(self->data->size()); + if (index < 0) index += size; + // Bounds check - if (index >= self->data->size()) { + if (index < 0 || index >= size) { PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range"); return -1; } - + // Handle deletion if (value == NULL) { // #122: Clear the parent before removing - (*self->data)[index]->setParent(nullptr); + unlink_child((*self->data)[index]); self->data->erase(self->data->begin() + index); // #288: Invalidate parent Frame's render cache auto owner_ptr = self->owner.lock(); @@ -276,21 +187,30 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject PyErr_SetString(PyExc_TypeError, "UICollection can only contain Drawable objects"); return -1; } - int old_z_index = (*vec)[index]->z_index; // Preserve the z_index + // #183: Remove from old parent, which may be a scene rather than a drawable. + // Do this BEFORE indexing: if the incoming drawable is already a member of THIS + // collection, detaching it erases it from `vec` and shifts every later element, + // so an index validated against the old size could write past the end. + new_drawable->removeFromParent(); + + if (index >= static_cast(vec->size())) { + PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range"); + return -1; + } + + // Hold a strong ref: unlinking may release the last ref to the old element's + // Python wrapper, whose dealloc drops the wrapper's shared_ptr to it. + std::shared_ptr old_drawable = (*vec)[index]; + int old_z_index = old_drawable->z_index; // Preserve the z_index // #122: Clear parent of old element - (*vec)[index]->setParent(nullptr); - - // #122: Remove new drawable from its old parent if it has one - if (auto old_parent = new_drawable->getParent()) { - new_drawable->removeFromParent(); - } + unlink_child(old_drawable); // Preserve the z_index of the replaced element new_drawable->z_index = old_z_index; - // #122: Set new parent - new_drawable->setParent(self->owner.lock()); + // #377: parent to the scene, not to a null owner, when this is a scene collection + link_child(self, new_drawable); // Replace the element (*vec)[index] = new_drawable; @@ -487,10 +407,16 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj // Sort in descending order and delete std::sort(indices.begin(), indices.end(), std::greater()); for (Py_ssize_t idx : indices) { + // #377: unlink before erasing -- a slice delete used to leave the + // removed child pointing at a parent that no longer contains it + unlink_child((*self->data)[idx]); self->data->erase(self->data->begin() + idx); } } else { // Contiguous slice - can delete in one go + for (Py_ssize_t i = start; i < stop; i++) { + unlink_child((*self->data)[i]); + } self->data->erase(self->data->begin() + start, self->data->begin() + stop); } @@ -539,38 +465,67 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj new_items.push_back(drawable); } - // Now perform the assignment + if (step != 1 && slicelength != value_len) { + PyErr_Format(PyExc_ValueError, + "attempt to assign sequence of size %zd to extended slice of size %zd", + value_len, slicelength); + return -1; + } + + // #377: build the post-assignment contents as an independent vector before + // touching anything. An incoming item may already be a member of THIS + // collection, and detaching it from its old parent mutates the very vector + // we would otherwise be indexing into -- shifting `start`/`stop` out from + // under the erase. Assembling a copy first makes aliasing a non-issue. + // + // `previous` also holds a strong ref to every displaced element, so + // unlinking one (which may release the last ref to its Python wrapper, + // whose dealloc drops the wrapper's shared_ptr) cannot free it under us. + std::vector> previous = *self->data; + std::vector> result = previous; + if (step == 1) { - // Contiguous slice if (slicelength != value_len) { - // Need to resize - auto it_start = self->data->begin() + start; - auto it_stop = self->data->begin() + stop; - self->data->erase(it_start, it_stop); - self->data->insert(self->data->begin() + start, new_items.begin(), new_items.end()); + result.erase(result.begin() + start, result.begin() + stop); + result.insert(result.begin() + start, new_items.begin(), new_items.end()); } else { - // Same size, just replace for (Py_ssize_t i = 0; i < slicelength; i++) { - // Preserve z_index - new_items[i]->z_index = (*self->data)[start + i]->z_index; - (*self->data)[start + i] = new_items[i]; + new_items[i]->z_index = previous[start + i]->z_index; // Preserve z_index + result[start + i] = new_items[i]; } } } else { - // Extended slice - if (slicelength != value_len) { - PyErr_Format(PyExc_ValueError, - "attempt to assign sequence of size %zd to extended slice of size %zd", - value_len, slicelength); - return -1; - } for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { - // Preserve z_index - new_items[i]->z_index = (*self->data)[cur]->z_index; - (*self->data)[cur] = new_items[i]; + new_items[i]->z_index = previous[cur]->z_index; // Preserve z_index + result[cur] = new_items[i]; } } - + + auto contains_ptr = [](const std::vector>& v, + const UIDrawable* p) { + return std::any_of(v.begin(), v.end(), + [p](const std::shared_ptr& e) { return e.get() == p; }); + }; + + // Detach incoming items from whatever parent they had. Safe to do now: + // this can only mutate `*self->data`, which we are about to overwrite. + for (auto& item : new_items) { + item->removeFromParent(); + } + + *self->data = result; + + // Anything the slice displaced is no longer a member -- drop its parent link. + for (auto& old_item : previous) { + if (!contains_ptr(result, old_item.get())) { + unlink_child(old_item); + } + } + for (auto& item : result) { + link_child(self, item); + } + + // Mark scene as needing resort after slice assignment McRFPy_API::markSceneNeedsSort(); // #288: Invalidate parent Frame's render cache @@ -639,12 +594,7 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o) drawable->z_index = new_z_index; // #183: Set new parent - either scene or drawable - if (!self->scene_name.empty()) { - drawable->setParentScene(self->scene_name); - } else { - std::shared_ptr owner_ptr = self->owner.lock(); - drawable->setParent(owner_ptr); - } + link_child(self, drawable); self->data->push_back(drawable); @@ -696,7 +646,9 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable) drawable->removeFromParent(); drawable->z_index = current_z_index; - drawable->setParent(owner_ptr); + // #377: was setParent(owner_ptr), which unparented the drawable outright on a + // scene collection (no owner drawable => owner.lock() is null) + link_child(self, drawable); self->data->push_back(drawable); Py_DECREF(item); @@ -741,7 +693,7 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o) for (auto it = vec->begin(); it != vec->end(); ++it) { if (it->get() == search_drawable.get()) { // #122: Clear the parent before removing - (*it)->setParent(nullptr); + unlink_child(*it); vec->erase(it); McRFPy_API::markSceneNeedsSort(); // #288: Invalidate parent Frame's render cache @@ -791,7 +743,7 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args) std::shared_ptr drawable = (*vec)[index]; // #122: Clear the parent before removing - drawable->setParent(nullptr); + unlink_child(drawable); // Remove from vector vec->erase(vec->begin() + index); @@ -831,6 +783,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args) return NULL; } + // #183: Remove from old parent, which may be a scene rather than a drawable. + // Do this BEFORE resolving the index: if the drawable is already a member of + // THIS collection, detaching it erases it from `vec`, and an index clamped + // against the old size could then land one past the end. + drawable->removeFromParent(); + // Handle negative indexing and clamping (Python list.insert behavior) Py_ssize_t size = static_cast(vec->size()); if (index < 0) { @@ -842,13 +800,8 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args) index = size; } - // #122: Remove from old parent if it has one - if (auto old_parent = drawable->getParent()) { - drawable->removeFromParent(); - } - - // #122: Set new parent - drawable->setParent(self->owner.lock()); + // #377: parent to the scene, not to a null owner, when this is a scene collection + link_child(self, drawable); // Insert at position vec->insert(vec->begin() + index, drawable); diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 2eed62d..d223de6 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -8,6 +8,7 @@ #include "UILine.h" #include "UICircle.h" #include "UIArc.h" +#include "3d/Viewport3D.h" #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" @@ -942,6 +943,8 @@ void UIDrawable::setParent(std::shared_ptr new_parent) { parent = new_parent; parent_scene.clear(); // #183: Clear scene parent when setting drawable parent + updatePyIdentityPin(); // #373 + // Apply alignment when parent is set (if alignment is configured) if (new_parent && align_type != AlignmentType::NONE) { applyAlignment(); @@ -952,16 +955,63 @@ void UIDrawable::setParentScene(const std::string& scene_name) { parent.reset(); // #183: Clear drawable parent when setting scene parent parent_scene = scene_name; + updatePyIdentityPin(); // #373 + // Apply alignment when scene parent is set (if alignment is configured) if (!scene_name.empty() && align_type != AlignmentType::NONE) { applyAlignment(); } } +// #373: hold a strong ref to the Python wrapper while, and only while, this drawable +// is a member of a children collection. See the comment on py_identity in UIDrawable.h. +void UIDrawable::updatePyIdentityPin() { + // Only subclasses carry state worth preserving, and only they can lose anything + // when a wrapper is re-created from the cache miss. + if (!is_python_subclass) return; + + const bool in_collection = !parent.expired() || !parent_scene.empty(); + + if (in_collection) { + if (py_identity) return; // already pinned + if (serial_number == 0 || !Py_IsInitialized()) return; + // The wrapper necessarily exists: is_python_subclass is only ever set from a + // subclass's tp_init, so Python allocated one. A cache miss here would mean the + // wrapper was already collected, and there is nothing left to pin. + py_identity = PythonObjectCache::getInstance().lookup(serial_number); // new ref + } else { + releasePyIdentity(); + } +} + +void UIDrawable::releaseChildPins(const std::shared_ptr>>& children) { + if (!children) return; + for (auto& child : *children) { + if (child) child->releasePyIdentity(); + } +} + +void UIDrawable::releasePyIdentity() { + if (!py_identity) return; + // Null the member BEFORE the DECREF: the wrapper's dealloc drops its shared_ptr to + // this drawable, which can re-enter here if that was the last strong ref. + PyObject* tmp = py_identity; + py_identity = nullptr; + if (Py_IsInitialized()) { + Py_DECREF(tmp); + } +} + std::shared_ptr UIDrawable::getParent() const { return parent.lock(); } +// #373: this drops the parent link, so it also drops the Python-identity pin -- which +// may release the last reference to the wrapper, whose dealloc drops the wrapper's +// shared_ptr to this drawable. Callers must therefore hold their own strong ref for the +// duration (every one does: they come through a local shared_ptr from extractDrawable). +// updatePyIdentityPin() is the LAST statement on both paths so that even in that case +// no member of a freed `this` is touched afterwards. void UIDrawable::removeFromParent() { // #183: Handle scene parent removal if (!parent_scene.empty()) { @@ -975,6 +1025,7 @@ void UIDrawable::removeFromParent() { } } parent_scene.clear(); + updatePyIdentityPin(); return; } @@ -1015,6 +1066,7 @@ void UIDrawable::removeFromParent() { } parent.reset(); + updatePyIdentityPin(); } // #102 - Global position calculation @@ -1284,6 +1336,133 @@ PyObject* UIDrawable::get_uniforms(PyObject* self, void* closure) { return (PyObject*)collection; } +// #369: single cache-aware conversion from a C++ drawable to its Python wrapper. +// Every path that hands a drawable back to Python must go through here, or object +// identity breaks (`x.parent is x.parent` -> False) and Python subclasses are lost. +PyObject* UIDrawable::pyobject_for(std::shared_ptr drawable) { + if (!drawable) { + Py_RETURN_NONE; + } + + // A live wrapper wins: it carries the caller's subclass and identity. + if (drawable->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number); + if (cached) { + return cached; // lookup() already INCREF'd + } + } + + PyTypeObject* type = nullptr; + PyObject* obj = nullptr; + + switch (drawable->derived_type()) { + case PyObjectsEnum::UIFRAME: + { + type = &mcrfpydef::PyUIFrameType; + auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UICAPTION: + { + type = &mcrfpydef::PyUICaptionType; + auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->font = nullptr; + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UISPRITE: + { + type = &mcrfpydef::PyUISpriteType; + auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIGRIDVIEW: + { + type = &mcrfpydef::PyUIGridViewType; + auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UILINE: + { + type = &mcrfpydef::PyUILineType; + auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UICIRCLE: + { + type = &mcrfpydef::PyUICircleType; + auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIARC: + { + type = &mcrfpydef::PyUIArcType; + auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIVIEWPORT3D: + { + type = &mcrfpydef::PyViewport3DType; + auto pyObj = (PyViewport3DObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + default: + PyErr_SetString(PyExc_TypeError, "Unknown UIDrawable derived type"); + return nullptr; + } + + // Register the fresh wrapper so the next lookup returns this same object. + // Covers C++-created drawables whose original wrapper was GC'd. + if (obj && drawable->serial_number != 0) { + PyObject* weakref = PyWeakref_NewRef(obj, NULL); + if (weakref) { + PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref); + Py_DECREF(weakref); + } + } + + return obj; +} + // Python API - get parent drawable PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); @@ -1299,67 +1478,7 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { // Scene not found in python_scenes (shouldn't happen, but fall through to None) } - auto parent_ptr = drawable->getParent(); - if (!parent_ptr) { - Py_RETURN_NONE; - } - - // Convert parent to Python object using the cache/conversion system - // Re-use the pattern from UICollection - PyTypeObject* type = nullptr; - PyObject* obj = nullptr; - - switch (parent_ptr->derived_type()) { - case PyObjectsEnum::UIFRAME: - { - type = &mcrfpydef::PyUIFrameType; - auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(parent_ptr); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UICAPTION: - { - type = &mcrfpydef::PyUICaptionType; - auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(parent_ptr); - pyObj->font = nullptr; - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UISPRITE: - { - type = &mcrfpydef::PyUISpriteType; - auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(parent_ptr); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - case PyObjectsEnum::UIGRIDVIEW: - { - type = &mcrfpydef::PyUIGridViewType; - auto pyObj = (PyUIGridViewObject*)type->tp_alloc(type, 0); - if (pyObj) { - pyObj->data = std::static_pointer_cast(parent_ptr); - pyObj->weakreflist = NULL; - } - obj = (PyObject*)pyObj; - break; - } - default: - Py_RETURN_NONE; - } - - return obj; + return UIDrawable::pyobject_for(drawable->getParent()); } // Python API - set parent drawable (or None to remove from parent) diff --git a/src/UIDrawable.h b/src/UIDrawable.h index b236bb5..ebcebbc 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -177,9 +177,49 @@ public: // Remove this drawable from its current parent's children (or scene) void removeFromParent(); + // #373: strong ref to this drawable's Python wrapper, held for exactly as long as + // the drawable is a member of a children collection (a Frame's, a GridView's, or a + // Scene's). PythonObjectCache holds only weakrefs, so without this a subclass + // wrapper that Python stops referencing is collected while C++ still owns the + // object -- and the next .parent / find() / indexing lookup misses the cache and + // hands back a freshly allocated BASE-type wrapper, silently dropping the subclass + // and every attribute the user set on it. + // + // Membership is the boundary, exactly as grid membership is for UIEntity (#266). + // It is represented by the parent link, so the pin is re-evaluated in setParent() + // and setParentScene() -- the two choke points every collection mutator goes + // through. Only subclassed drawables are pinned: a base-type wrapper carries no + // state of its own (the types have no __dict__), so re-creating one is unobservable. + // + // The pin is a reference cycle (wrapper -> shared_ptr -> drawable -> wrapper) that + // Python's GC cannot see through the C++ side, so it MUST be released on the way + // out. Every exit is covered: unlinking calls setParent(nullptr), and an owner + // destroyed while still holding children releases its children's pins first. + PyObject* py_identity = nullptr; + + // Acquire or drop py_identity to match the current parent link. Idempotent. + void updatePyIdentityPin(); + + // Unconditionally drop the pin. Called by an owner tearing down its children vector. + void releasePyIdentity(); + + // #373: an owner (Frame, GridView, Scene) destroyed while it still holds children + // must drop their pins. Nothing calls setParent(nullptr) on that path -- the vector + // simply dies -- so without this the wrapper <-> drawable cycle would strand the + // entire subtree in memory. Releasing a child's pin cannot free it here: the vector + // still holds a strong ref. It dies with the vector, and its own destructor then + // releases the next level down. + static void releaseChildPins(const std::shared_ptr>>& children); + // Get the global (screen) position by walking up the parent chain (#102) sf::Vector2f get_global_position() const; + // #369: the single cache-aware C++ -> Python wrapper conversion for drawables. + // Returns the live wrapper for this drawable if one exists (preserving object + // identity and any Python subclass), otherwise allocates one and registers it. + // Returns a new reference, or nullptr with an exception set. + static PyObject* pyobject_for(std::shared_ptr drawable); + // Python API for parent/global_position static PyObject* get_parent(PyObject* self, void* closure); static int set_parent(PyObject* self, PyObject* value, void* closure); diff --git a/src/UIEntityCollection.cpp b/src/UIEntityCollection.cpp index ad402aa..07aec38 100644 --- a/src/UIEntityCollection.cpp +++ b/src/UIEntityCollection.cpp @@ -12,6 +12,40 @@ #include #include +// #369: single cache-aware conversion from a C++ entity to its Python wrapper. +// Raw tp_alloc here is not merely an identity bug as it is for drawables: a duplicate +// wrapper's tp_dealloc unconditionally clears UIEntity::pyobject, destroying the #266 +// subclass-identity ref held by the *original* wrapper. Every site must use this. +static PyObject* convertEntityToPython(std::shared_ptr entity) { + if (!entity) { + Py_RETURN_NONE; + } + + if (entity->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number); + if (cached) { + return cached; // lookup() already INCREF'd + } + } + + PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; + auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!o) return NULL; + + o->data = entity; + o->weakreflist = NULL; + + if (entity->serial_number != 0) { + PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL); + if (weakref) { + PythonObjectCache::getInstance().registerObject(entity->serial_number, weakref); + Py_DECREF(weakref); + } + } + + return (PyObject*)o; +} + // ============================================================================ // UIEntityCollectionIter implementation // ============================================================================ @@ -44,23 +78,7 @@ PyObject* UIEntityCollectionIter::next(PyUIEntityCollectionIterObject* self) auto target = (*self->data)[self->index]; ++self->index; - // Check cache first to preserve derived class identity - if (target->serial_number != 0) { - PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number); - if (cached) { - return cached; // Already INCREF'd by lookup - } - } - - // Otherwise create and return a new Python Entity object - PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; - - auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); - if (!o) return NULL; - - o->data = std::static_pointer_cast(target); - o->weakreflist = NULL; - return (PyObject*)o; + return convertEntityToPython(std::static_pointer_cast(target)); } PyObject* UIEntityCollectionIter::repr(PyUIEntityCollectionIterObject* self) @@ -102,35 +120,7 @@ PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize // #329 - O(1) random access (was std::advance over a std::list, O(n)) auto target = (*vec)[index]; - // Check cache first to preserve derived class - if (target->serial_number != 0) { - PyObject* cached = PythonObjectCache::getInstance().lookup(target->serial_number); - if (cached) { - return cached; // Already INCREF'd by lookup - } - } - - // Create a new base Entity object - PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; - - auto o = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); - if (!o) return NULL; - - o->data = std::static_pointer_cast(target); - o->weakreflist = NULL; - - // Re-register in cache if the entity has a serial number - // This handles the case where the original Python wrapper was GC'd - // but the C++ object persists (e.g., inline-created objects added to collections) - if (target->serial_number != 0) { - PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL); - if (weakref) { - PythonObjectCache::getInstance().registerObject(target->serial_number, weakref); - Py_DECREF(weakref); - } - } - - return (PyObject*)o; + return convertEntityToPython(std::static_pointer_cast(target)); } int UIEntityCollection::setitem(PyUIEntityCollectionObject* self, Py_ssize_t index, PyObject* value) { @@ -244,19 +234,15 @@ PyObject* UIEntityCollection::concat(PyUIEntityCollectionObject* self, PyObject* return NULL; } - PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; - // Add all elements from self Py_ssize_t idx = 0; for (const auto& entity : *self->data) { - auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + PyObject* obj = convertEntityToPython(entity); if (!obj) { Py_DECREF(result_list); return NULL; } - obj->data = entity; - obj->weakreflist = NULL; - PyList_SET_ITEM(result_list, idx++, (PyObject*)obj); + PyList_SET_ITEM(result_list, idx++, obj); } // Add all elements from other @@ -358,21 +344,13 @@ PyObject* UIEntityCollection::subscript(PyUIEntityCollectionObject* self, PyObje return NULL; } - PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; - - auto it = self->data->begin(); for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) { - auto cur_it = it; - std::advance(cur_it, cur); - - auto obj = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + PyObject* obj = convertEntityToPython((*self->data)[cur]); if (!obj) { Py_DECREF(result_list); return NULL; } - obj->data = *cur_it; - obj->weakreflist = NULL; - PyList_SET_ITEM(result_list, i, (PyObject*)obj); + PyList_SET_ITEM(result_list, i, obj); } return result_list; diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index 20bc3d3..e850e56 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -81,6 +81,7 @@ UIFrame::UIFrame(float _x, float _y, float _w, float _h) UIFrame::~UIFrame() { + UIDrawable::releaseChildPins(children); // #373 children.reset(); } diff --git a/src/UIGridPathfinding.cpp b/src/UIGridPathfinding.cpp index 441b578..d6bd5b4 100644 --- a/src/UIGridPathfinding.cpp +++ b/src/UIGridPathfinding.cpp @@ -65,6 +65,15 @@ float DijkstraMap::getDistance(int x, int y) const { return TCOD_dijkstra_get_distance(tcod_dijkstra, x, y); } +// #375: emit the path in origin->destination order, matching find_path()'s convention +// (excludes the origin, includes the root). +// +// libtcod builds its internal list as [pos, ..., root_adjacent] and +// TCOD_dijkstra_path_walk() pops from the BACK, so walking yields +// [root_adjacent, ..., pos] -- the exact reverse of what path_from's docstring, +// AStarPath.origin/.destination, and step_from all promise. stepFrom() inherited the +// error and returned a cell adjacent to the ROOT rather than to the query position, +// i.e. an arbitrarily long teleport. std::vector DijkstraMap::getPathFrom(int x, int y) const { std::vector path; if (!tcod_dijkstra) return path; @@ -75,6 +84,18 @@ std::vector DijkstraMap::getPathFrom(int x, int y) const { path.push_back(sf::Vector2i(px, py)); } } + if (path.empty()) return path; // already at a root, or unreachable + + std::reverse(path.begin(), path.end()); // -> [pos, ..., root_adjacent] + path.erase(path.begin()); // drop the origin + + // The walk stops one cell short of the root. Append the root this path descends + // into -- via descentStep, so the multi-root case lands on the correct root. + bool ok = false; + sf::Vector2i last = path.empty() ? sf::Vector2i(x, y) : path.back(); + sf::Vector2i root_cell = descentStep(last.x, last.y, &ok); + if (ok) path.push_back(root_cell); + return path; } @@ -84,15 +105,11 @@ sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const { return sf::Vector2i(-1, -1); } - if (!TCOD_dijkstra_path_set(tcod_dijkstra, x, y)) { - if (valid) *valid = false; - return sf::Vector2i(-1, -1); - } - - int px, py; - if (TCOD_dijkstra_path_walk(tcod_dijkstra, &px, &py)) { + // #375: the first cell of the corrected path -- guaranteed adjacent to (x, y). + std::vector path = getPathFrom(x, y); + if (!path.empty()) { if (valid) *valid = true; - return sf::Vector2i(px, py); + return path.front(); } if (valid) *valid = false; diff --git a/src/UIGridView.cpp b/src/UIGridView.cpp index f379590..83ee383 100644 --- a/src/UIGridView.cpp +++ b/src/UIGridView.cpp @@ -33,6 +33,9 @@ UIGridView::UIGridView() } UIGridView::~UIGridView() { + // #373: drop the Python-identity pins of our overlay children before the vector dies + UIDrawable::releaseChildPins(children); + // #362: drop ourselves from the GridData's view registry. This is the only // place guaranteed to run exactly once per view: tp_dealloc's unregister is // gated on data.use_count() <= 1 (the #251 pattern), so a view that outlives @@ -289,16 +292,33 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target) int y_limit = top_edge + height_sq + 2; if (y_limit > grid_data->grid_h) y_limit = grid_data->grid_h; + // #341: re-instrument the grid render counters. These were declared and reset + // every frame but never incremented anywhere -- the increments were lost in the + // GridView/chunk refactor, so grid_cells_rendered/entities_rendered/ + // grid_render_time have read 0 ever since. Accumulating (not assigning) so that + // two views over one map sum rather than clobber each other. + auto& metrics = Resources::game->metrics; + ScopedAccumTimer gridTimer(metrics.gridRenderTime); + + // Cells actually inside the viewport window, per layer drawn. + const int x_start = std::max(0, static_cast(left_edge)); + const int y_start = std::max(0, static_cast(top_edge)); + const int visible_cells = std::max(0, x_limit - x_start) * std::max(0, y_limit - y_start); + // Render layers below entities (z_index <= 0) grid_data->sortLayers(); + int layers_drawn = 0; for (auto& layer : grid_data->layers) { if (layer->z_index > 0) break; // #257: z_index=0 is ground level (below entities) layer->render(*activeTexture, left_spritepixels, top_spritepixels, left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); + ++layers_drawn; } // Render entities if (grid_data->entities) { + ScopedAccumTimer entityTimer(metrics.entityRenderTime); + metrics.totalEntities += static_cast(grid_data->entities->size()); for (auto& e : *grid_data->entities) { if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 || e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) { @@ -310,6 +330,7 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target) (e->position.x*cell_width - left_spritepixels + e->sprite_offset.x) * zoom, (e->position.y*cell_height - top_spritepixels + e->sprite_offset.y) * zoom); drawent.render(pixel_pos, *activeTexture); + ++metrics.entitiesRendered; } } @@ -318,8 +339,12 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target) if (layer->z_index <= 0) continue; // #257: skip ground-level and below layer->render(*activeTexture, left_spritepixels, top_spritepixels, left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); + ++layers_drawn; } + // One "cell rendered" per cell per layer drawn -- i.e. cell draw operations. + metrics.gridCellsRendered += visible_cells * layers_drawn; + // Children (grid-world pixel coordinates; owned by this view -- #364) if (children && !children->empty()) { if (children_need_sort) { @@ -343,7 +368,9 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target) // Perspective overlay (#355: state owned by the view -- see get/set_perspective) if (perspective_enabled) { - ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime); + // #341: accumulate -- with two perspective views on screen, assignment made + // the second view's time silently replace the first's. + ScopedAccumTimer fovTimer(Resources::game->metrics.fovOverlayTime); auto entity = perspective_entity.lock(); sf::RectangleShape overlay; overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); diff --git a/src/main.cpp b/src/main.cpp index a20a013..fe9362a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -215,9 +215,37 @@ int run_python_interpreter(const McRogueFaceConfig& config) else if (!config.exec_scripts.empty()) { // Execute startup scripts on the existing engine (not in constructor to prevent double-execution) engine->executeStartupScripts(); - if (config.headless) { - engine->setAutoExitAfterExec(true); + + // A script that called sys.exit() has already decided the outcome. + if (McRFPy_API::shouldExit()) { + int code = McRFPy_API::exit_code.load(); + McRFPy_API::api_shutdown(); + delete engine; + return code; } + + // #350: headless --exec that falls off the end without exiting is an error. + // + // step() is the only headless clock, so the run loop cannot advance timers on + // its own: entering it here used to spin forever with a frozen clock, and any + // headless script with a pending timer hung until it was killed. Worse, a + // script that died during setup registered no timers, auto-exited 0, and was + // scored as a passing test. + // + // An --exec script must state its outcome with sys.exit(). If a long-lived + // headless process is genuinely wanted (a server, a REPL host), say so with + // --run-forever. + if (config.headless && !config.run_forever) { + std::cerr << "mcrogueface: --exec scripts finished without calling sys.exit().\n" + << " In headless mode the engine cannot advance time on its own: " + << "mcrfpy.step() is the clock.\n" + << " End the script with sys.exit(0), or pass --run-forever to keep " + << "the process alive.\n"; + McRFPy_API::api_shutdown(); + delete engine; + return 1; + } + engine->run(); McRFPy_API::api_shutdown(); delete engine; diff --git a/tests/demo/cookbook_showcase.py b/tests/demo/cookbook_showcase.py index 9095414..9c75ce9 100644 --- a/tests/demo/cookbook_showcase.py +++ b/tests/demo/cookbook_showcase.py @@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/cookbook_ In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed! """ import mcrfpy +import docs_output from mcrfpy import automation import sys import os # Output directory - in the docs site images folder -OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" +OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path # Tile sprites from the labeled tileset TILES = { diff --git a/tests/demo/demo_main.py b/tests/demo/demo_main.py index 728fcdd..4661804 100644 --- a/tests/demo/demo_main.py +++ b/tests/demo/demo_main.py @@ -40,6 +40,7 @@ class DemoRunner: def __init__(self): self.screens = [] + self.menu = None self.current_index = 0 self.headless = self._detect_headless() self.screenshot_dir = os.path.join(os.path.dirname(__file__), "screenshots") @@ -65,7 +66,10 @@ class DemoRunner: def create_menu(self): """Create the main menu screen.""" - menu = mcrfpy.Scene("menu") + # #372: was a local, but run_interactive() referenced `menu` -- NameError on + # every interactive launch. Keep it on the runner. + self.menu = mcrfpy.Scene("menu") + menu = self.menu ui = menu.children # Title @@ -104,46 +108,30 @@ class DemoRunner: ui.append(instr) def run_headless(self): - """Run in headless mode - generate all screenshots.""" - print(f"Generating {len(self.screens)} demo screenshots...") + """Run in headless mode - generate all screenshots. - # Ensure screenshot directory exists + #372/#350: this used to drive a Timer and wait for render frames. Headless + timers only advance under mcrfpy.step(), so that loop never fired. It is also + unnecessary: a screenshot renders the current state synchronously and costs + zero simulation time, so we can just activate each scene and shoot it. + """ + print(f"Generating {len(self.screens)} demo screenshots...") os.makedirs(self.screenshot_dir, exist_ok=True) - # Use timer to take screenshots after game loop renders each scene - self.current_index = 0 - self.render_wait = 0 + for i, screen in enumerate(self.screens): + # #372: was `mcrfpy.current_scene = screen` -- a DemoScreen, not a Scene, + # which raised TypeError and broke the headless path outright. + screen.scene.activate() + filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name()) + automation.screenshot(filename) + print(f" [{i+1}/{len(self.screens)}] {filename}") - def screenshot_cycle(timer, runtime): - if self.render_wait == 0: - # Set scene and wait for render - if self.current_index >= len(self.screens): - print("Done!") - sys.exit(0) - return - screen = self.screens[self.current_index] - mcrfpy.current_scene = screen - self.render_wait = 1 - elif self.render_wait < 2: - # Wait additional frame - self.render_wait += 1 - else: - # Take screenshot - screen = self.screens[self.current_index] - filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name()) - automation.screenshot(filename) - print(f" [{self.current_index+1}/{len(self.screens)}] {filename}") - self.current_index += 1 - self.render_wait = 0 - if self.current_index >= len(self.screens): - print("Done!") - sys.exit(0) - - self.screenshot_timer = mcrfpy.Timer("screenshot", screenshot_cycle, 50) + print("Done!") def run_interactive(self): """Run in interactive mode with menu.""" self.create_menu() + menu = self.menu def handle_key(key, state): if state != mcrfpy.InputState.PRESSED: @@ -156,7 +144,8 @@ class DemoRunner: if key in _num_key_map: idx = _num_key_map[key] if idx < len(self.screens): - mcrfpy.setScene(self.screens[idx].scene_name) + # #372: mcrfpy.setScene() no longer exists. + self.screens[idx].scene.activate() # ESC returns to menu elif key == mcrfpy.Key.ESCAPE: @@ -166,14 +155,10 @@ class DemoRunner: elif key == mcrfpy.Key.Q: sys.exit(0) - # Register keyboard handler on menu scene - menu.activate() + # The same handler serves the menu and every demo scene, so ESC always works. menu.on_key = handle_key - - # Also register keyboard handler on all demo scenes for screen in self.screens: - mcrfpy.current_scene = screen - menu.on_key = handle_key + screen.scene.on_key = handle_key # Start on menu menu.activate() @@ -185,6 +170,8 @@ def main(): if runner.headless: runner.run_headless() + # #350: a headless --exec script must declare its outcome. + sys.exit(0) else: runner.run_interactive() diff --git a/tests/demo/docs_output.py b/tests/demo/docs_output.py new file mode 100644 index 0000000..3054693 --- /dev/null +++ b/tests/demo/docs_output.py @@ -0,0 +1,40 @@ +"""Where the showcase scripts write their generated images (#372). + +These scripts used to hardcode /opt/goblincorps/repos/mcrogueface.github.io -- an +absolute path outside the repo, on one developer's machine. Anyone else running them +got PermissionError, which is part of why they rotted unnoticed. + +Resolution order: + 1. $MCRF_DOCS_REPO -- explicit checkout of mcrogueface.github.io + 2. ../mcrogueface.github.io -- the usual side-by-side checkout + 3. tests/demo/screenshots/ -- in-repo default, always writable +""" +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE)) + + +def docs_repo(): + """Root of the documentation site checkout, or None if there isn't one.""" + env = os.environ.get("MCRF_DOCS_REPO") + if env and os.path.isdir(env): + return env + sibling = os.path.join(os.path.dirname(_REPO_ROOT), "mcrogueface.github.io") + if os.path.isdir(sibling): + return sibling + return None + + +def image_dir(*parts): + """Directory for generated images, created if needed. + + parts: subpath under the docs site's images/ (e.g. "cookbook", "tutorials"). + """ + repo = docs_repo() + if repo: + target = os.path.join(repo, "images", *parts) + else: + target = os.path.join(_HERE, "screenshots", *parts) + os.makedirs(target, exist_ok=True) + return target diff --git a/tests/demo/new_features_showcase.py b/tests/demo/new_features_showcase.py index 29cdee0..3cea7d6 100644 --- a/tests/demo/new_features_showcase.py +++ b/tests/demo/new_features_showcase.py @@ -6,11 +6,12 @@ Generates screenshots for the new API cookbook recipes. Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/new_features_showcase.py """ import mcrfpy +import docs_output from mcrfpy import automation import sys import os -OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" +OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path def screenshot_alignment(): diff --git a/tests/demo/procgen_showcase.py b/tests/demo/procgen_showcase.py index 2cdba0a..1f2981c 100644 --- a/tests/demo/procgen_showcase.py +++ b/tests/demo/procgen_showcase.py @@ -4,10 +4,12 @@ Uses Frame-based visualization since Grid cell colors use ColorLayer API. """ import mcrfpy +import os +import docs_output from mcrfpy import automation import sys -OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" +OUTPUT_DIR = docs_output.image_dir("cookbook") # #372: was a hardcoded absolute path # Simple PRNG _seed = 42 diff --git a/tests/demo/screens/animation_demo.py b/tests/demo/screens/animation_demo.py index ec857fb..dfb5e15 100644 --- a/tests/demo/screens/animation_demo.py +++ b/tests/demo/screens/animation_demo.py @@ -10,20 +10,21 @@ class AnimationDemo(DemoScreen): self.add_title("Animation System") self.add_description("Smooth property animation with multiple easing functions") - # Create frames to animate + # Create frames to animate. + # #372: easings are the mcrfpy.Easing enum now, not magic strings. easing_types = [ - ("linear", mcrfpy.Color(255, 100, 100)), - ("easeIn", mcrfpy.Color(100, 255, 100)), - ("easeOut", mcrfpy.Color(100, 100, 255)), - ("easeInOut", mcrfpy.Color(255, 255, 100)), + ("linear", mcrfpy.Easing.LINEAR, mcrfpy.Color(255, 100, 100)), + ("easeIn", mcrfpy.Easing.EASE_IN, mcrfpy.Color(100, 255, 100)), + ("easeOut", mcrfpy.Easing.EASE_OUT, mcrfpy.Color(100, 100, 255)), + ("easeInOut", mcrfpy.Easing.EASE_IN_OUT, mcrfpy.Color(255, 255, 100)), ] self.frames = [] - for i, (easing, color) in enumerate(easing_types): + for i, (easing_name, easing, color) in enumerate(easing_types): y = 140 + i * 60 # Label - label = mcrfpy.Caption(text=easing, pos=(50, y + 5)) + label = mcrfpy.Caption(text=easing_name, pos=(50, y + 5)) label.fill_color = mcrfpy.Color(200, 200, 200) self.ui.append(label) @@ -42,9 +43,9 @@ class AnimationDemo(DemoScreen): # Start animations for each frame (they'll animate when viewed interactively) for frame, easing in self.frames: - # Animate x to 560 over 2 seconds (starts from current x=150) - anim = mcrfpy.Animation("x", 560.0, 2.0, easing) - anim.start(frame) + # #372: mcrfpy.Animation is no longer exported -- animations are constructed + # by the target itself. Animate x to 560 over 2s (starts from current x=150). + frame.animate("x", 560.0, 2.0, easing) # Property animations section prop_frame = mcrfpy.Frame(pos=(50, 400), size=(300, 100)) @@ -66,7 +67,7 @@ class AnimationDemo(DemoScreen): prop_frame.children.append(props_line2) # Code example - positioned below other elements - code = """# Animation: (property, target, duration, easing) -anim = mcrfpy.Animation("x", 500.0, 2.0, "easeInOut") -anim.start(frame) # Animate frame.x to 500 over 2 seconds""" + code = """# Animate via the target: (property, target, duration, easing) +frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT) +# -> frame.x eases to 500 over 2 seconds""" self.add_code_example(code, x=50, y=520) diff --git a/tests/demo/screens/base.py b/tests/demo/screens/base.py index 82f4d79..2a7ef5b 100644 --- a/tests/demo/screens/base.py +++ b/tests/demo/screens/base.py @@ -9,8 +9,11 @@ class DemoScreen: def __init__(self, scene_name): self.scene_name = scene_name - _scene = mcrfpy.Scene(scene_name) - self.ui = mcrfpy.sceneUI(scene_name) + # #372: mcrfpy.sceneUI() was removed with the pre-Scene-object API. The Scene + # owns its UI collection directly, and the demos need the Scene anyway to + # activate themselves. + self.scene = mcrfpy.Scene(scene_name) + self.ui = self.scene.children def setup(self): """Override to set up the screen content.""" diff --git a/tests/demo/screens/grid_demo.py b/tests/demo/screens/grid_demo.py index 434c139..3efc2fd 100644 --- a/tests/demo/screens/grid_demo.py +++ b/tests/demo/screens/grid_demo.py @@ -15,7 +15,8 @@ class GridDemo(DemoScreen): grid.fill_color = mcrfpy.Color(20, 20, 40) # Add a color layer for the checkerboard pattern (z_index=-1 = below entities) - color_layer = grid.add_layer("color", z_index=-1) + # #372: add_layer() takes a layer OBJECT and no keyword arguments. + color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Center camera on middle of grid (in pixel coordinates: cells * cell_size / 2) # For 15x10 grid with 16x16 cells: center = (15*16/2, 10*16/2) = (120, 80) @@ -28,13 +29,13 @@ class GridDemo(DemoScreen): point = grid.at(x, y) # Checkerboard pattern if (x + y) % 2 == 0: - color_layer.set(x, y, mcrfpy.Color(40, 40, 60)) + color_layer.set((x, y), mcrfpy.Color(40, 40, 60)) else: - color_layer.set(x, y, mcrfpy.Color(30, 30, 50)) + color_layer.set((x, y), mcrfpy.Color(30, 30, 50)) # Border if x == 0 or x == 14 or y == 0 or y == 9: - color_layer.set(x, y, mcrfpy.Color(80, 60, 40)) + color_layer.set((x, y), mcrfpy.Color(80, 60, 40)) point.walkable = False # Add some children to the grid @@ -74,7 +75,7 @@ class GridDemo(DemoScreen): # Code example code = """# Grid with layers grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(320, 240), layers={}) -layer = grid.add_layer("color", z_index=-1) # Below entities -layer.set(5, 5, mcrfpy.Color(255, 0, 0)) # Red tile +layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Below entities +layer.set((5, 5), mcrfpy.Color(255, 0, 0)) # Red tile grid.children.append(mcrfpy.Caption("Label", pos=(80, 48)))""" self.add_code_example(code, y=420) diff --git a/tests/demo/screenshots/demo_03_grid_system.png b/tests/demo/screenshots/demo_03_grid_system.png index 3f24695..1573b41 100644 Binary files a/tests/demo/screenshots/demo_03_grid_system.png and b/tests/demo/screenshots/demo_03_grid_system.png differ diff --git a/tests/demo/screenshots/demo_04_animation_system.png b/tests/demo/screenshots/demo_04_animation_system.png index 123d05c..6db81df 100644 Binary files a/tests/demo/screenshots/demo_04_animation_system.png and b/tests/demo/screenshots/demo_04_animation_system.png differ diff --git a/tests/demo/simple_showcase.py b/tests/demo/simple_showcase.py index eeb63a1..8f8048b 100644 --- a/tests/demo/simple_showcase.py +++ b/tests/demo/simple_showcase.py @@ -9,12 +9,13 @@ NOTE: In headless mode, automation.screenshot() is SYNCHRONOUS - it renders and captures immediately. No timer dance needed! """ import mcrfpy +import docs_output from mcrfpy import automation import sys import os # Output -OUTPUT_PATH = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials/part_01_grid_movement.png" +OUTPUT_PATH = os.path.join(docs_output.image_dir("tutorials"), "part_01_grid_movement.png") # #372 # Tile sprites from the labeled tileset PLAYER_KNIGHT = 84 diff --git a/tests/demo/tutorial_screenshots.py b/tests/demo/tutorial_screenshots.py index ee14e03..823a3d3 100644 --- a/tests/demo/tutorial_screenshots.py +++ b/tests/demo/tutorial_screenshots.py @@ -8,13 +8,14 @@ Usage: Extracts code from tutorial markdown files and generates screenshots. """ import mcrfpy +import docs_output from mcrfpy import automation import sys import os import re # Paths -DOCS_REPO = "/opt/goblincorps/repos/mcrogueface.github.io" +DOCS_REPO = docs_output.docs_repo() or os.path.dirname(os.path.abspath(__file__)) # #372 TUTORIAL_DIR = os.path.join(DOCS_REPO, "tutorial") OUTPUT_DIR = os.path.join(DOCS_REPO, "images", "tutorials") diff --git a/tests/demo/tutorial_showcase.py b/tests/demo/tutorial_showcase.py index 6f22445..1d31521 100644 --- a/tests/demo/tutorial_showcase.py +++ b/tests/demo/tutorial_showcase.py @@ -8,12 +8,13 @@ Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/tutorial_ In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed! """ import mcrfpy +import docs_output from mcrfpy import automation import sys import os # Output directory -OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials" +OUTPUT_DIR = docs_output.image_dir("tutorials") # #372: was a hardcoded absolute path # Tile meanings from the labeled tileset - the FUN sprites! TILES = { diff --git a/tests/integration/astar_vs_dijkstra.py b/tests/integration/astar_vs_dijkstra.py index f85fb0b..16fd7fb 100644 --- a/tests/integration/astar_vs_dijkstra.py +++ b/tests/integration/astar_vs_dijkstra.py @@ -1,9 +1,19 @@ #!/usr/bin/env python3 """ -A* vs Dijkstra Visual Comparison -================================= +A* vs Dijkstra Comparison +========================= -Shows the difference between A* (single target) and Dijkstra (multi-target). +Verifies that A* (single target) and Dijkstra (multi-target flood) agree on the +cost of the shortest route through an obstacle course, that both produce legal, +contiguous, walkable paths, and that the Dijkstra distance field is consistent +with the paths it hands back. + +API notes (updated for the current engine): + * Pathfinding lives on GridData: grid.find_path(start, end) -> AStarPath|None, + grid.get_dijkstra_map(root=...) -> DijkstraMap (.distance(pos), .path_from(pos)). + The old grid.compute_astar_path / compute_dijkstra / get_dijkstra_path are gone. + * GridPoint has no .color -- cell coloring is done with a ColorLayer. + * add_layer() takes a layer OBJECT and no keyword arguments. """ import mcrfpy @@ -17,31 +27,41 @@ DIJKSTRA_COLOR = mcrfpy.Color(0, 150, 255) # Blue for Dijkstra START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end +GRID_W, GRID_H = 30, 20 + # Global state grid = None color_layer = None -mode = "ASTAR" start_pos = (5, 10) end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall +failures = [] + +def check(condition, message): + """Record a failed assertion instead of aborting, so we report every problem.""" + if condition: + print(f" PASS: {message}") + else: + print(f" FAIL: {message}") + failures.append(message) + def create_map(): """Create a map with obstacles to show pathfinding differences""" global grid, color_layer - pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison") - - # Create grid - grid = mcrfpy.Grid(grid_w=30, grid_h=20) + # Create grid (view + backing GridData) + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400)) grid.fill_color = mcrfpy.Color(0, 0, 0) - # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) + # Add color layer for cell coloring (GridPoint.color no longer exists) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid.grid_data.add_layer(color_layer) # Initialize all as floor - for y in range(20): - for x in range(30): - grid.at(x, y).walkable = True - color_layer.set(x, y, FLOOR_COLOR) + for y in range(GRID_H): + for x in range(GRID_W): + grid.grid_data.at(x, y).walkable = True + color_layer.set((x, y), FLOOR_COLOR) # Create obstacles that make A* and Dijkstra differ obstacles = [ @@ -55,172 +75,179 @@ def create_map(): [(25, y) for y in range(5, 15)], ] + walls = set() for obstacle_group in obstacles: for x, y in obstacle_group: - grid.at(x, y).walkable = False - color_layer.set(x, y, WALL_COLOR) + grid.grid_data.at(x, y).walkable = False + color_layer.set((x, y), WALL_COLOR) + walls.add((x, y)) # Mark start and end - color_layer.set(start_pos[0], start_pos[1], START_COLOR) - color_layer.set(end_pos[0], end_pos[1], END_COLOR) + color_layer.set(start_pos, START_COLOR) + color_layer.set(end_pos, END_COLOR) -def clear_paths(): - """Clear path highlighting""" - for y in range(20): - for x in range(30): - cell = grid.at(x, y) - if cell.walkable: - color_layer.set(x, y, FLOOR_COLOR) + # Dijkstra maps are cached; walkability just changed, so invalidate. + grid.grid_data.clear_dijkstra_maps() + return walls - # Restore start and end colors - color_layer.set(start_pos[0], start_pos[1], START_COLOR) - color_layer.set(end_pos[0], end_pos[1], END_COLOR) +def validate_path(cells, label, origin, destination): + """A path must be contiguous, walkable, and actually arrive at `destination`. + + Both find_path() and DijkstraMap.path_from() use the same convention (#375): + the returned path EXCLUDES the origin and ENDS at the destination. The two + differ only in which endpoint is which -- A* is asked start->end, while a + Dijkstra map rooted at start is queried from end, so it walks end->start. + """ + check(len(cells) > 0, f"{label}: path is non-empty") + if not cells: + return + + check(cells[-1] == destination, + f"{label}: path ends at {destination} (got {cells[-1]})") + check(origin not in cells, f"{label}: path excludes its origin {origin}") + + # The first cell must be a single step from the origin. + prev = origin + contiguous = True + walkable = True + for (x, y) in cells: + dx, dy = abs(x - prev[0]), abs(y - prev[1]) + if max(dx, dy) != 1: + contiguous = False + if not grid.grid_data.at(x, y).walkable: + walkable = False + prev = (x, y) + check(contiguous, f"{label}: every step moves exactly one cell (incl. from start)") + check(walkable, f"{label}: every cell on the path is walkable") + +def path_cells(path): + return [(int(v.x), int(v.y)) for v in path] def show_astar(): - """Show A* path""" - clear_paths() + """Compute + color the A* path. Returns its cells.""" + path = grid.grid_data.find_path(start_pos, end_pos) + check(path is not None, "A*: a path exists through the obstacle course") + if path is None: + return [] - # Compute A* path - path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1]) + check((int(path.destination.x), int(path.destination.y)) == end_pos, + "A*: .destination is the requested end cell") - # Color the path - for i, (x, y) in enumerate(path): + # NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path, + # so .remaining must be read before draining it. + expected = path.remaining + cells = path_cells(path) + check(expected == len(cells), + f"A*: .remaining ({expected}) matches the iterated length ({len(cells)})") + check(path.remaining == 0, "A*: iterating the path consumes it (.remaining -> 0)") + + for (x, y) in cells: if (x, y) != start_pos and (x, y) != end_pos: - color_layer.set(x, y, ASTAR_COLOR) + color_layer.set((x, y), ASTAR_COLOR) - status_text.text = f"A* Path: {len(path)} steps (optimized for single target)" + status_text.text = f"A* Path: {len(cells)} steps (optimized for single target)" status_text.fill_color = ASTAR_COLOR + return cells -def show_dijkstra(): - """Show Dijkstra exploration""" - clear_paths() +def show_dijkstra(walls): + """Compute the Dijkstra flood from start, color it, return the path cells.""" + dmap = grid.grid_data.get_dijkstra_map(root=start_pos) + check((int(dmap.root.x), int(dmap.root.y)) == start_pos, + "Dijkstra: map root is the start cell") - # Compute Dijkstra from start - grid.compute_dijkstra(start_pos[0], start_pos[1]) + # The distance field must be defined on reachable floor and undefined on walls. + check(dmap.distance(start_pos) == 0.0, "Dijkstra: distance to the root itself is 0") + end_dist = dmap.distance(end_pos) + check(end_dist is not None and end_dist > 0, + f"Dijkstra: end cell is reachable (distance={end_dist})") + + wall_distances_defined = [w for w in walls if dmap.distance(w) is not None] + check(not wall_distances_defined, + f"Dijkstra: unwalkable cells have no distance ({len(wall_distances_defined)} leaked)") # Color cells by distance (showing exploration) max_dist = 40.0 - for y in range(20): - for x in range(30): - if grid.at(x, y).walkable: - dist = grid.get_dijkstra_distance(x, y) + for y in range(GRID_H): + for x in range(GRID_W): + if grid.grid_data.at(x, y).walkable: + dist = dmap.distance((x, y)) if dist is not None and dist < max_dist: - # Color based on distance intensity = int(255 * (1 - dist / max_dist)) - color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity)) + color_layer.set((x, y), mcrfpy.Color(0, intensity // 2, intensity)) - # Get the actual path - path = grid.get_dijkstra_path(end_pos[0], end_pos[1]) + # Get the actual path back to the root + cells = path_cells(dmap.path_from(end_pos)) - # Highlight the actual path more brightly - for x, y in path: + for (x, y) in cells: if (x, y) != start_pos and (x, y) != end_pos: - color_layer.set(x, y, DIJKSTRA_COLOR) + color_layer.set((x, y), DIJKSTRA_COLOR) - # Restore start and end - color_layer.set(start_pos[0], start_pos[1], START_COLOR) - color_layer.set(end_pos[0], end_pos[1], END_COLOR) + color_layer.set(start_pos, START_COLOR) + color_layer.set(end_pos, END_COLOR) - status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)" + status_text.text = f"Dijkstra: {len(cells)} steps (explores all directions)" status_text.fill_color = DIJKSTRA_COLOR + return cells, dmap -def show_both(): - """Show both paths overlaid""" - clear_paths() +def show_both(astar_cells, dijkstra_cells, dmap): + """Compare the two routes. Different cells are fine; different COST is not.""" + print(f" A* : {astar_cells}") + print(f" Dijkstra: {dijkstra_cells}") - # Get both paths - astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1]) - grid.compute_dijkstra(start_pos[0], start_pos[1]) - dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1]) + # Both algorithms are optimal, so they must agree on the number of steps + # even if they break ties through different cells. + check(len(astar_cells) == len(dijkstra_cells), + f"A* and Dijkstra agree on path length (A*={len(astar_cells)}, " + f"Dijkstra={len(dijkstra_cells)})") - print(astar_path, dijkstra_path) + # The Dijkstra distance field must be monotonically increasing along the + # A* route - i.e. the flood is consistent with the single-target search. + monotonic = True + prev = dmap.distance(start_pos) + for cell in astar_cells: + d = dmap.distance(cell) + if d is None or d <= prev: + monotonic = False + break + prev = d + check(monotonic, "Dijkstra distance increases at every step along the A* path") - # Color Dijkstra path first (blue) - for x, y in dijkstra_path: - if (x, y) != start_pos and (x, y) != end_pos: - color_layer.set(x, y, DIJKSTRA_COLOR) + different_cells = [c for c in dijkstra_cells if c not in astar_cells] - # Then A* path (green) - will overwrite shared cells - for x, y in astar_path: - if (x, y) != start_pos and (x, y) != end_pos: - color_layer.set(x, y, ASTAR_COLOR) - - # Mark differences - different_cells = [] - for cell in dijkstra_path: - if cell not in astar_path: - different_cells.append(cell) - - status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps" + status_text.text = (f"Both paths: A*={len(astar_cells)} steps, " + f"Dijkstra={len(dijkstra_cells)} steps") if different_cells: info_text.text = f"Paths differ at {len(different_cells)} cells" else: info_text.text = "Paths are identical" + print(f" {info_text.text}") -def handle_keypress(key_str, state): - """Handle keyboard input""" - global mode - if state == mcrfpy.InputState.RELEASED: return - print(key_str) - if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q: - print("\nExiting...") - sys.exit(0) - elif key_str == mcrfpy.Key.A or key_str == mcrfpy.Key.NUM_1: - mode = "ASTAR" - show_astar() - elif key_str == mcrfpy.Key.D or key_str == mcrfpy.Key.NUM_2: - mode = "DIJKSTRA" - show_dijkstra() - elif key_str == mcrfpy.Key.B or key_str == mcrfpy.Key.NUM_3: - mode = "BOTH" - show_both() - elif key_str == mcrfpy.Key.SPACE: - # Refresh current mode - if mode == "ASTAR": - show_astar() - elif mode == "DIJKSTRA": - show_dijkstra() - else: - show_both() - -# Create the demo +# --------------------------------------------------------------------------- print("A* vs Dijkstra Pathfinding Comparison") print("=====================================") -print("Controls:") -print(" A or 1 - Show A* path (green)") -print(" D or 2 - Show Dijkstra (blue gradient)") -print(" B or 3 - Show both paths") -print(" Q/ESC - Quit") -print() print("A* is optimized for single-target pathfinding") print("Dijkstra explores in all directions (good for multiple targets)") +print() -create_map() +walls = create_map() # Set up UI +pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison") ui = pathfinding_comparison.children ui.append(grid) -# Scale and position -grid.size = (600, 400) # 30*20, 20*20 -grid.pos = (100, 100) - -# Add title title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -# Add status -status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both") +status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra") status_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status_text) -# Add info info_text = mcrfpy.Caption(pos=(100, 520), text="") info_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info_text) -# Add legend legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra") legend1.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend1) @@ -229,11 +256,38 @@ legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor") legend2.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend2) -# Set scene and input pathfinding_comparison.activate() -pathfinding_comparison.on_key = handle_keypress -# Show initial A* path -show_astar() +# Sanity: the ColorLayer really is the coloring mechanism now. +color_layer.set((0, 0), START_COLOR) +_c = color_layer.at((0, 0)) +check((_c.r, _c.g, _c.b) == (255, 100, 100), + "ColorLayer.set/.at round-trips a cell color") +color_layer.set((0, 0), FLOOR_COLOR) -print("\nDemo ready!") +print("\n[A*]") +astar_cells = show_astar() +# A* is asked start -> end. +validate_path(astar_cells, "A*", origin=start_pos, destination=end_pos) + +print("\n[Dijkstra]") +dijkstra_cells, dmap = show_dijkstra(walls) +# The Dijkstra map is ROOTED at start_pos and queried FROM end_pos, so its path +# runs end -> start: the origin and destination are the mirror of A*'s. +validate_path(dijkstra_cells, "Dijkstra", origin=end_pos, destination=start_pos) + +print("\n[Both]") +show_both(astar_cells, dijkstra_cells, dmap) + +# Force a render so the colored comparison is actually drawn. +mcrfpy.automation.screenshot("astar_vs_dijkstra.png") + +print() +if failures: + print(f"FAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/integration/debug_visibility.py b/tests/integration/debug_visibility.py index 91f6a57..177061f 100644 --- a/tests/integration/debug_visibility.py +++ b/tests/integration/debug_visibility.py @@ -1,14 +1,30 @@ #!/usr/bin/env python3 -"""Debug visibility crash""" +"""Debug visibility crash + +Originally probed entity.gridstate / entity.update_visibility() / grid.perspective +for crashes. Updated to the current API (#313/#361): + - entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap: + UNKNOWN / DISCOVERED / VISIBLE, indexed by (x, y)) + - grid.perspective -> now takes an Entity or None, not an int index +""" import mcrfpy import sys +failures = [] + +def check(cond, msg): + if cond: + print(f" ok: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + print("Debug visibility...") # Create scene and grid debug = mcrfpy.Scene("debug") -grid = mcrfpy.Grid(grid_w=5, grid_h=5) +grid = mcrfpy.Grid(grid_size=(5, 5)) # Initialize grid print("Initializing grid...") @@ -20,39 +36,66 @@ for y in range(5): # Create entity print("Creating entity...") -entity = mcrfpy.Entity((2, 2), grid=grid) +entity = mcrfpy.Entity(grid_pos=(2, 2)) entity.sprite_index = 64 -print(f"Entity at ({entity.x}, {entity.y})") +grid.entities.append(entity) +# .x/.y are PIXEL coords now; .grid_x/.grid_y are the canonical cell coords +print(f"Entity at cell ({entity.grid_x}, {entity.grid_y}), pixels ({entity.x}, {entity.y})") +check((entity.grid_x, entity.grid_y) == (2, 2), "entity sits at its grid_pos (2, 2)") -# Check gridstate -print(f"\nGridstate length: {len(entity.gridstate)}") -print(f"Expected: {5 * 5}") +# A second entity, so visible_entities() has something to find +neighbor = mcrfpy.Entity(grid_pos=(4, 4)) +grid.entities.append(neighbor) -# Try to access gridstate -print("\nChecking gridstate access...") -try: - if len(entity.gridstate) > 0: - state = entity.gridstate[0] - print(f"First state: visible={state.visible}, discovered={state.discovered}") -except Exception as e: - print(f"Error accessing gridstate: {e}") +# Check perspective_map (successor to gridstate) +pmap = entity.perspective_map +print(f"\nPerspective map size: {pmap.size}") +check(pmap.size == (5, 5), "perspective_map covers every grid cell (5x5)") -# Try update_visibility +# Access the map before any FOV is computed: everything is unknown +print("\nChecking perspective_map access...") +Perspective = pmap.enum_type +check(pmap.get((0, 0)) == Perspective.UNKNOWN, + "cells start UNKNOWN before update_visibility()") +check(pmap.histogram() == {int(Perspective.UNKNOWN): 25}, + "all 25 cells start UNKNOWN") + +# update_visibility must populate the map (open 5x5 room, sight_radius 10) print("\nTrying update_visibility...") -try: - entity.update_visibility() - print("update_visibility succeeded") -except Exception as e: - print(f"Error in update_visibility: {e}") +entity.update_visibility() +check(pmap.get((2, 2)) == Perspective.VISIBLE, + "entity's own cell is VISIBLE after update_visibility()") +check(pmap.get((0, 0)) == Perspective.VISIBLE, + "far corner of an open, transparent room is VISIBLE") +check(pmap.histogram() == {int(Perspective.VISIBLE): 25}, + "all 25 cells of the open room are VISIBLE") +# visible_entities() returns OTHER entities only. Compare by cell, not identity: +# it hands back fresh wrappers rather than cached ones (see notes on UIEntity.cpp:1334). +seen = [(e.grid_x, e.grid_y) for e in entity.visible_entities()] +check(seen == [(4, 4)], + "visible_entities() sees the neighbor at (4, 4) and excludes self") -# Try perspective +# Perspective now takes an Entity (or None), not an index print("\nTesting perspective...") print(f"Initial perspective: {grid.perspective}") +check(grid.perspective is None, "grid.perspective defaults to None (omniscient)") + +grid.perspective = entity +print(f"Set perspective to entity: {grid.perspective}") +check(grid.perspective is entity, "grid.perspective round-trips the Entity") + +grid.perspective = None +check(grid.perspective is None, "grid.perspective can be cleared back to None") + try: grid.perspective = 0 - print(f"Set perspective to 0: {grid.perspective}") -except Exception as e: - print(f"Error setting perspective: {e}") + check(False, "grid.perspective rejects a non-Entity (int index is gone)") +except TypeError: + check(True, "grid.perspective rejects a non-Entity (int index is gone)") print("\nTest complete") -sys.exit(0) \ No newline at end of file +if failures: + print(f"FAIL: {len(failures)} check(s) failed") + sys.exit(1) +print("PASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_all_paths.py b/tests/integration/dijkstra_all_paths.py index aa7db71..2746b10 100644 --- a/tests/integration/dijkstra_all_paths.py +++ b/tests/integration/dijkstra_all_paths.py @@ -5,6 +5,10 @@ Dijkstra Demo - Shows ALL Path Combinations (Including Invalid) Cycles through every possible entity pair to demonstrate both valid paths and properly handled invalid paths (empty lists). + +Entity 1 is sealed inside a 2x2 pocket of walls, so every path to or from +it must come back as an empty list; the two reachable entities must produce +real, contiguous, walkable paths. """ import mcrfpy @@ -20,24 +24,35 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable # Global state grid = None +grid_data = None color_layer = None entities = [] current_combo_index = 0 all_combinations = [] # All possible pairs current_path = [] +failures = [] + +dijkstra_all = mcrfpy.Scene("dijkstra_all") + +def check(condition, message): + """Record a failed expectation instead of aborting the whole run.""" + if not condition: + failures.append(message) + print(f"FAIL: {message}") + return condition def create_map(): """Create the map with entities""" - global grid, color_layer, entities, all_combinations + global grid, grid_data, color_layer, entities, all_combinations - dijkstra_all = mcrfpy.Scene("dijkstra_all") - - # Create grid - grid = mcrfpy.Grid(grid_w=14, grid_h=10) + # Create grid (grid_size= replaces the old grid_w/grid_h kwargs) + grid = mcrfpy.Grid(grid_size=(14, 10)) grid.fill_color = mcrfpy.Color(0, 0, 0) + grid_data = grid.grid_data - # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) + # Add color layer for cell coloring (GridPoint has no .color anymore) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid.add_layer(color_layer) # Map layout - Entity 1 is intentionally trapped! map_layout = [ @@ -57,14 +72,14 @@ def create_map(): entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): - cell = grid.at(x, y) + cell = grid_data.at(x, y) if char == 'W': cell.walkable = False - color_layer.set(x, y, WALL_COLOR) + color_layer.set((x, y), WALL_COLOR) else: cell.walkable = True - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) if char == 'E': entity_positions.append((x, y)) @@ -72,38 +87,41 @@ def create_map(): # Create entities entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity((x, y), grid=grid) + entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' entities.append(entity) - + print("Map Analysis:") print("=============") for i, (x, y) in enumerate(entity_positions): print(f"Entity {i+1} at ({x}, {y})") - + + check(len(entities) == 3, f"expected 3 entities from the map, got {len(entities)}") + check(entity_positions == [(10, 2), (6, 4), (0, 5)], + f"unexpected entity positions: {entity_positions}") + # Generate ALL combinations (including invalid ones) all_combinations = [] for i in range(len(entities)): for j in range(len(entities)): if i != j: # Skip self-paths all_combinations.append((i, j)) - + print(f"\nTotal path combinations to test: {len(all_combinations)}") def clear_path_colors(): """Reset all floor tiles to original color""" global current_path - for y in range(grid.grid_h): - for x in range(grid.grid_w): - cell = grid.at(x, y) - if cell.walkable: - color_layer.set(x, y, FLOOR_COLOR) + for y in range(grid_data.grid_h): + for x in range(grid_data.grid_w): + if grid_data.at(x, y).walkable: + color_layer.set((x, y), FLOOR_COLOR) current_path = [] def show_combination(index): - """Show a specific path combination (valid or invalid)""" + """Show a specific path combination (valid or invalid); returns the path""" global current_combo_index, current_path current_combo_index = index % len(all_combinations) @@ -116,44 +134,63 @@ def show_combination(index): e_from = entities[from_idx] e_to = entities[to_idx] - # Calculate path - path = e_from.path_to(int(e_to.x), int(e_to.y)) + # Calculate path (grid_x/grid_y are tile coords; .x/.y are pixels now) + path = e_from.path_to(e_to.grid_x, e_to.grid_y) current_path = path if path else [] # Always color start and end positions - color_layer.set(int(e_from.x), int(e_from.y), START_COLOR) - color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR) + color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR) + color_layer.set((e_to.grid_x, e_to.grid_y), + NO_PATH_COLOR if not path else END_COLOR) # Color the path if it exists if path: # Color intermediate steps for i, (x, y) in enumerate(path): if i > 0 and i < len(path) - 1: - color_layer.set(x, y, PATH_COLOR) + color_layer.set((x, y), PATH_COLOR) - status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = {len(path)} steps" + status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = {len(path)} steps" status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid # Show path steps path_display = [] - for i, (x, y) in enumerate(path[:5]): + for (x, y) in path[:5]: path_display.append(f"({x},{y})") if len(path) > 5: path_display.append("...") - path_text.text = "Path: " + " → ".join(path_display) + path_text.text = "Path: " + " -> ".join(path_display) else: - status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = NO PATH!" + status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = NO PATH!" status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid path_text.text = "Path: [] (No valid path exists)" # Update info - info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})" + info_text.text = f"From: Entity {from_idx+1} at ({e_from.grid_x}, {e_from.grid_y}) | To: Entity {to_idx+1} at ({e_to.grid_x}, {e_to.grid_y})" + + return path + +def validate_path(path, e_from, e_to, label): + """A valid path must be contiguous, walkable, and connect the endpoints""" + check(path[-1] == (e_to.grid_x, e_to.grid_y), + f"{label}: path does not end at the target: {path[-1]}") + # path_to() excludes the source cell; the first step must be adjacent to it + sx, sy = e_from.grid_x, e_from.grid_y + check((sx, sy) not in path, f"{label}: path should not include the source cell") + check(max(abs(path[0][0] - sx), abs(path[0][1] - sy)) == 1, + f"{label}: first step {path[0]} is not adjacent to source ({sx}, {sy})") + for (x, y) in path: + check(grid_data.at(x, y).walkable, + f"{label}: path crosses unwalkable cell ({x}, {y})") + for (ax, ay), (bx, by) in zip(path, path[1:]): + check(max(abs(ax - bx), abs(ay - by)) == 1, + f"{label}: non-contiguous step ({ax},{ay}) -> ({bx},{by})") def handle_keypress(key_str, state): - """Handle keyboard input""" + """Handle keyboard input (interactive mode only)""" global current_combo_index if state == mcrfpy.InputState.RELEASED: return - + if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q: print("\nExiting...") sys.exit(0) @@ -164,8 +201,8 @@ def handle_keypress(key_str, state): elif key_str == mcrfpy.Key.R: show_combination(current_combo_index) else: - num_keys = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2, - mcrfpy.Key.NUM_4: 3, mcrfpy.Key.NUM_5: 4, mcrfpy.Key.NUM_6: 5} + num_keys = {mcrfpy.Key.Num1: 0, mcrfpy.Key.Num2: 1, mcrfpy.Key.Num3: 2, + mcrfpy.Key.Num4: 3, mcrfpy.Key.Num5: 4, mcrfpy.Key.Num6: 5} if key_str in num_keys: combo_num = num_keys[key_str] if combo_num < len(all_combinations): @@ -214,12 +251,12 @@ controls.fill_color = mcrfpy.Color(150, 150, 150) ui.append(controls) # Add legend -legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)") +legend = mcrfpy.Caption(pos=(120, 560), text="Red Start->Blue End (valid) | Red Start->Red End (invalid)") legend.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend) # Expected results info -expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail") +expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1->2, 1->3, 2->1, 3->1 will fail") expected.fill_color = mcrfpy.Color(255, 150, 150) ui.append(expected) @@ -227,14 +264,36 @@ ui.append(expected) dijkstra_all.activate() dijkstra_all.on_key = handle_keypress -# Show first combination -show_combination(0) +print("\nExpected results:") +print(" Path 1: Entity 1->2 = NO PATH (Entity 1 is trapped)") +print(" Path 2: Entity 1->3 = NO PATH (Entity 1 is trapped)") +print(" Path 3: Entity 2->1 = NO PATH (Entity 1 is trapped)") +print(" Path 4: Entity 2->3 = Valid path") +print(" Path 5: Entity 3->1 = NO PATH (Entity 1 is trapped)") +print(" Path 6: Entity 3->2 = Valid path") +print() -print("\nDemo ready!") -print("Expected results:") -print(" Path 1: Entity 1→2 = NO PATH (Entity 1 is trapped)") -print(" Path 2: Entity 1→3 = NO PATH (Entity 1 is trapped)") -print(" Path 3: Entity 2→1 = NO PATH (Entity 1 is trapped)") -print(" Path 4: Entity 2→3 = Valid path") -print(" Path 5: Entity 3→1 = NO PATH (Entity 1 is trapped)") -print(" Path 6: Entity 3→2 = Valid path") \ No newline at end of file +# Drive every combination and verify the outcome. Entity index 0 is trapped, +# so any pair involving it must yield an empty list; the other pairs must be +# real paths. +for combo_index, (from_idx, to_idx) in enumerate(all_combinations): + path = show_combination(combo_index) + label = f"Entity {from_idx+1}->{to_idx+1}" + if 0 in (from_idx, to_idx): + check(path == [], + f"{label}: expected NO PATH (entity 1 is walled in), got {path}") + print(f" {label}: [] (correctly unreachable)") + else: + if check(bool(path), f"{label}: expected a valid path, got {path}"): + validate_path(path, entities[from_idx], entities[to_idx], label) + print(f" {label}: {len(path)} steps, ends at {path[-1]}") + +# Render once so the coloring path is exercised too (rendering costs no sim time) +mcrfpy.automation.screenshot("dijkstra_all_paths.png") + +if failures: + print(f"\n{len(failures)} check(s) failed") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_cycle_paths.py b/tests/integration/dijkstra_cycle_paths.py index 6086174..b9440f6 100644 --- a/tests/integration/dijkstra_cycle_paths.py +++ b/tests/integration/dijkstra_cycle_paths.py @@ -4,6 +4,14 @@ Dijkstra Demo - Cycles Through Different Path Combinations ========================================================== Shows paths between different entity pairs, skipping impossible paths. + +Headless verification of the original demo's claims: + - Entity 1 is walled into a pocket and can reach nobody. + - Entities 2 and 3 can reach each other (both directions), so exactly two + path combinations exist. + - Each generated path is a contiguous chain of walkable cells ending on the + target entity. + - Cycling through the combinations recolors the ColorLayer accordingly. """ import mcrfpy @@ -18,24 +26,33 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue # Global state grid = None +grid_data = None color_layer = None entities = [] current_path_index = 0 path_combinations = [] current_path = [] +failures = [] + +def check(condition, message): + """Record a failed assertion without aborting the demo""" + if not condition: + failures.append(message) + print(f" FAIL: {message}") + return condition def create_map(): """Create the map with entities""" - global grid, color_layer, entities + global grid, grid_data, color_layer, entities - dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle") - - # Create grid - grid = mcrfpy.Grid(grid_w=14, grid_h=10) + # Create grid (grid_size= replaces the old grid_w=/grid_h= kwargs) + grid = mcrfpy.Grid(grid_size=(14, 10)) + grid_data = grid.grid_data grid.fill_color = mcrfpy.Color(0, 0, 0) - # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) + # Add color layer for cell coloring. Cells no longer have a .color; + # ColorLayer objects are constructed then attached (add_layer takes no kwargs). + color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Map layout map_layout = [ @@ -55,14 +72,14 @@ def create_map(): entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): - cell = grid.at(x, y) + cell = grid_data.at(x, y) if char == 'W': cell.walkable = False - color_layer.set(x, y, WALL_COLOR) + color_layer.set((x, y), WALL_COLOR) else: cell.walkable = True - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) if char == 'E': entity_positions.append((x, y)) @@ -70,58 +87,89 @@ def create_map(): # Create entities entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity((x, y), grid=grid) + entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' entities.append(entity) - + print("Entities created:") for i, (x, y) in enumerate(entity_positions): print(f" Entity {i+1} at ({x}, {y})") - + + check(entity_positions == [(10, 2), (6, 4), (0, 5)], + f"expected entities at [(10,2),(6,4),(0,5)], got {entity_positions}") + # Check which entity is trapped print("\nChecking accessibility:") + reachability = [] for i, e in enumerate(entities): # Try to path to each other entity can_reach = [] for j, other in enumerate(entities): if i != j: - path = e.path_to(int(other.x), int(other.y)) + path = e.path_to(other.grid_x, other.grid_y) if path: can_reach.append(j+1) - + + reachability.append(can_reach) if not can_reach: - print(f" Entity {i+1} at ({int(e.x)}, {int(e.y)}) is TRAPPED!") + print(f" Entity {i+1} at ({e.grid_x}, {e.grid_y}) is TRAPPED!") else: print(f" Entity {i+1} can reach entities: {can_reach}") - + + # Entity 1 sits in a sealed pocket; entities 2 and 3 share the open area. + check(reachability[0] == [], "Entity 1 should be trapped (no reachable entities)") + check(reachability[1] == [3], f"Entity 2 should reach only Entity 3, got {reachability[1]}") + check(reachability[2] == [2], f"Entity 3 should reach only Entity 2, got {reachability[2]}") + # Generate valid path combinations (excluding trapped entity) global path_combinations path_combinations = [] - + # Only paths between entities 2 and 3 (indices 1 and 2) will work # since entity 1 (index 0) is trapped if len(entities) >= 3: # Entity 2 to Entity 3 - path = entities[1].path_to(int(entities[2].x), int(entities[2].y)) + path = entities[1].path_to(entities[2].grid_x, entities[2].grid_y) if path: path_combinations.append((1, 2, path)) - + # Entity 3 to Entity 2 - path = entities[2].path_to(int(entities[1].x), int(entities[1].y)) + path = entities[2].path_to(entities[1].grid_x, entities[1].grid_y) if path: path_combinations.append((2, 1, path)) - + print(f"\nFound {len(path_combinations)} valid paths") + check(len(path_combinations) == 2, f"expected 2 valid path combinations, got {len(path_combinations)}") + +def validate_path(from_idx, to_idx, path): + """A path must be a contiguous chain of walkable cells ending on the target""" + e_from = entities[from_idx] + e_to = entities[to_idx] + + check(len(path) > 0, f"path {from_idx+1}->{to_idx+1} is empty") + if not path: + return + + check(tuple(path[-1]) == (e_to.grid_x, e_to.grid_y), + f"path {from_idx+1}->{to_idx+1} does not end on target: {path[-1]}") + + # path_to() excludes the entity's own cell, so walk from the entity position + prev = (e_from.grid_x, e_from.grid_y) + for (x, y) in path: + check(grid_data.at(x, y).walkable, f"path crosses non-walkable cell ({x}, {y})") + step = max(abs(x - prev[0]), abs(y - prev[1])) + check(step == 1, f"path is not contiguous: {prev} -> ({x}, {y})") + prev = (x, y) def clear_path_colors(): """Reset all floor tiles to original color""" global current_path - for y in range(grid.grid_h): - for x in range(grid.grid_w): - cell = grid.at(x, y) + for y in range(grid_data.grid_h): + for x in range(grid_data.grid_w): + cell = grid_data.at(x, y) if cell.walkable: - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) current_path = [] @@ -147,16 +195,16 @@ def show_path(index): current_path = path if path: # Color start and end - color_layer.set(int(e_from.x), int(e_from.y), START_COLOR) - color_layer.set(int(e_to.x), int(e_to.y), END_COLOR) + color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR) + color_layer.set((e_to.grid_x, e_to.grid_y), END_COLOR) - # Color intermediate steps - for i, (x, y) in enumerate(path): - if i > 0 and i < len(path) - 1: - color_layer.set(x, y, PATH_COLOR) + # Color intermediate steps. path_to() excludes the entity's own cell, + # so every element except the last (the target) is an intermediate step. + for (x, y) in path[:-1]: + color_layer.set((x, y), PATH_COLOR) # Update status - status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} ({len(path)} steps)" + status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} ({len(path)} steps)" # Update path display path_display = [] @@ -164,7 +212,7 @@ def show_path(index): path_display.append(f"({x},{y})") if len(path) > 5: path_display.append("...") - path_text.text = "Path: " + " → ".join(path_display) if path_display else "Path: None" + path_text.text = "Path: " + " -> ".join(path_display) if path_display else "Path: None" def handle_keypress(key_str, state): """Handle keyboard input""" @@ -186,6 +234,8 @@ print("==========================") print("Note: Entity 1 is trapped by walls!") print() +dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle") + create_map() # Set up UI @@ -231,9 +281,34 @@ if path_combinations: else: status_text.text = "No valid paths! Entity 1 is trapped!" -print("\nDemo ready!") -print("Controls:") -print(" SPACE or N - Next path") -print(" P - Previous path") -print(" R - Refresh current path") -print(" Q - Quit") +# --- Headless verification: cycle every combination the demo would show --- +print("\nCycling paths:") +for i in range(len(path_combinations)): + show_path(i) + from_idx, to_idx, path = path_combinations[current_path_index] + print(f" {status_text.text}") + check(current_path_index == i, f"show_path({i}) selected index {current_path_index}") + validate_path(from_idx, to_idx, path) + + # The colored overlay must reflect the displayed path + e_from, e_to = entities[from_idx], entities[to_idx] + check(color_layer.at((e_from.grid_x, e_from.grid_y)) == START_COLOR, + "start cell not colored START_COLOR") + check(color_layer.at((e_to.grid_x, e_to.grid_y)) == END_COLOR, + "end cell not colored END_COLOR") + for (x, y) in path[:-1]: + check(color_layer.at((x, y)) == PATH_COLOR, f"path cell ({x},{y}) not colored PATH_COLOR") + +# Cycling wraps around (what SPACE/N does) +show_path(len(path_combinations)) +check(current_path_index == 0, "cycling past the last path should wrap to 0") + +# Render once to prove the scene is drawable with the layers attached +mcrfpy.automation.screenshot("dijkstra_cycle_paths.png") + +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_debug.py b/tests/integration/dijkstra_debug.py index cf5045d..8849b18 100644 --- a/tests/integration/dijkstra_debug.py +++ b/tests/integration/dijkstra_debug.py @@ -18,40 +18,49 @@ ENTITY_COLORS = [ # Global state grid = None +grid_data = None color_layer = None entities = [] -first_point = None -second_point = None +failures = [] + +WALLS = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)] + +def check(condition, message): + """Record a failed check instead of dying at the first one.""" + if not condition: + print(f" FAIL: {message}") + failures.append(message) + return condition def create_simple_map(): """Create a simple test map""" - global grid, color_layer, entities - - dijkstra_debug = mcrfpy.Scene("dijkstra_debug") + global grid, grid_data, color_layer, entities # Small grid for easy debugging - grid = mcrfpy.Grid(grid_w=10, grid_h=10) + grid = mcrfpy.Grid(grid_size=(10, 10)) grid.fill_color = mcrfpy.Color(0, 0, 0) + # Pathfinding + layers live on the shared GridData (#313/#361) + grid_data = grid.grid_data # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid_data.add_layer(color_layer) print("Initializing 10x10 grid...") # Initialize all as floor for y in range(10): for x in range(10): - grid.at(x, y).walkable = True - grid.at(x, y).transparent = True - color_layer.set(x, y, FLOOR_COLOR) + grid_data.at(x, y).walkable = True + grid_data.at(x, y).transparent = True + color_layer.set((x, y), FLOOR_COLOR) # Add a simple wall print("Adding walls at:") - walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)] - for x, y in walls: + for x, y in WALLS: print(f" Wall at ({x}, {y})") - grid.at(x, y).walkable = False - color_layer.set(x, y, WALL_COLOR) + grid_data.at(x, y).walkable = False + color_layer.set((x, y), WALL_COLOR) # Create 3 entities entity_positions = [(2, 5), (8, 5), (5, 8)] @@ -60,7 +69,7 @@ def create_simple_map(): print("\nCreating entities at:") for i, (x, y) in enumerate(entity_positions): print(f" Entity {i+1} at ({x}, {y})") - entity = mcrfpy.Entity((x, y), grid=grid) + entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' entities.append(entity) @@ -70,70 +79,95 @@ def test_path_highlighting(): """Test path highlighting with debug output""" print("\n" + "="*50) print("Testing path highlighting...") - + # Select first two entities e1 = entities[0] e2 = entities[1] - - print(f"\nEntity 1 position: ({e1.x}, {e1.y})") - print(f"Entity 2 position: ({e2.x}, {e2.y})") - + + # NOTE: entity.x/.y are PIXEL coordinates now; tile coords are grid_x/grid_y. + print(f"\nEntity 1 position: ({e1.grid_x}, {e1.grid_y})") + print(f"Entity 2 position: ({e2.grid_x}, {e2.grid_y})") + # Use entity.path_to() print("\nCalling entity.path_to()...") - path = e1.path_to(int(e2.x), int(e2.y)) - + path = e1.path_to(e2.grid_x, e2.grid_y) + print(f"Path returned: {path}") print(f"Path length: {len(path)} steps") - + + check(len(path) > 0, "entity.path_to() returned an empty path") if path: + check(tuple(path[-1]) == (e2.grid_x, e2.grid_y), + f"path does not end at the target: {path[-1]}") + print("\nHighlighting path cells:") for i, (x, y) in enumerate(path): print(f" Step {i}: ({x}, {y})") # Get current color for debugging - cell = grid.at(x, y) - old_c = color_layer.at(x, y) + cell = grid_data.at(x, y) + old_c = color_layer.at((x, y)) old_color = (old_c.r, old_c.g, old_c.b) # Set new color - color_layer.set(x, y, PATH_COLOR) - new_c = color_layer.at(x, y) + color_layer.set((x, y), PATH_COLOR) + new_c = color_layer.at((x, y)) new_color = (new_c.r, new_c.g, new_c.b) print(f" Color changed from {old_color} to {new_color}") print(f" Walkable: {cell.walkable}") - + check(cell.walkable, f"path routed through non-walkable cell ({x}, {y})") + check((x, y) not in WALLS, f"path routed through a wall at ({x}, {y})") + # Also test grid's Dijkstra methods print("\n" + "-"*30) print("Testing grid Dijkstra methods...") - - grid.compute_dijkstra(int(e1.x), int(e1.y)) - grid_path = grid.get_dijkstra_path(int(e2.x), int(e2.y)) - distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y)) - + + # compute_dijkstra/get_dijkstra_path/get_dijkstra_distance were replaced by + # GridData.get_dijkstra_map() -> DijkstraMap.path_from()/.distance() + dmap = grid_data.get_dijkstra_map(root=(e1.grid_x, e1.grid_y)) + grid_path = list(dmap.path_from((e2.grid_x, e2.grid_y))) + distance = dmap.distance((e2.grid_x, e2.grid_y)) + print(f"Grid path: {grid_path}") print(f"Grid distance: {distance}") - + + check(distance is not None, "Dijkstra distance to entity 2 is unreachable") + check(len(grid_path) > 0, "Dijkstra path_from() returned an empty path") + for x, y in grid_path: + check(grid_data.at(x, y).walkable, + f"Dijkstra path routed through non-walkable cell ({x}, {y})") + # Dijkstra distance must be at least the straight-line-blocked A* step count + check(distance is None or distance >= len(path) - 0.001, + f"Dijkstra distance {distance} shorter than A* path length {len(path)}") + # Verify colors were set print("\nVerifying cell colors after highlighting:") for x, y in path[:3]: # Check first 3 cells - c = color_layer.at(x, y) + c = color_layer.at((x, y)) color = (c.r, c.g, c.b) expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b) match = color == expected print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}") + check(match, f"ColorLayer did not retain PATH_COLOR at ({x}, {y})") -def handle_keypress(scene_name, keycode): + # Cells that were never on the path must keep their original color + wall_c = color_layer.at(WALLS[0]) + check((wall_c.r, wall_c.g, wall_c.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b), + "wall cell color was clobbered by path highlighting") + +def handle_keypress(key, action): """Simple keypress handler""" - if keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC + if key == mcrfpy.Key.Q or key == mcrfpy.Key.Escape: print("\nExiting debug...") sys.exit(0) - elif keycode == 32: # Space + elif key == mcrfpy.Key.Space: print("\nSpace pressed - retesting path highlighting...") test_path_highlighting() # Create the map print("Dijkstra Debug Test") print("===================") +dijkstra_debug = mcrfpy.Scene("dijkstra_debug") grid = create_simple_map() # Initial path test @@ -161,6 +195,17 @@ ui.append(info) dijkstra_debug.on_key = handle_keypress dijkstra_debug.activate() -print("\nScene ready. The path should be highlighted in cyan.") -print("If you don't see the path, there may be a rendering issue.") -print("Press SPACE to retest, Q to quit.") \ No newline at end of file +# Render once to prove the highlighted grid actually draws (the original test's +# "if you don't see the path there may be a rendering issue" concern). +mcrfpy.step(0.016) +mcrfpy.automation.screenshot("dijkstra_debug.png") + +print("\n" + "="*50) +if failures: + print(f"FAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_interactive.py b/tests/integration/dijkstra_interactive.py index c494093..627d95a 100644 --- a/tests/integration/dijkstra_interactive.py +++ b/tests/integration/dijkstra_interactive.py @@ -1,20 +1,26 @@ #!/usr/bin/env python3 """ -Dijkstra Pathfinding Interactive Demo -===================================== +Dijkstra Pathfinding Interactive Demo (headless self-test) +========================================================== -Interactive visualization showing Dijkstra pathfinding between entities. +Visualization + verification of Dijkstra pathfinding between entities. -Controls: +Controls (when run with a window): - Press 1/2/3 to select the first entity -- Press A/B/C to select the second entity +- Press A/B/C to select the second entity - Space to clear selection - Q or ESC to quit -The path between selected entities is automatically highlighted. +The path between selected entities is highlighted on a ColorLayer. + +Under --headless the same selection logic is driven programmatically through the +key handler and the resulting paths / distances / highlight colors are asserted. +Entity 1 lives in a sealed room, so it must be unreachable from the others; +entities 2 and 3 are separated by a long wall and must be reachable around it. """ import mcrfpy +from mcrfpy import automation import sys # Colors - using more distinct values @@ -29,55 +35,65 @@ ENTITY_COLORS = [ # Global state grid = None +grid_data = None color_layer = None entities = [] first_point = None second_point = None +last_path = None +failures = [] + +def check(condition, message): + """Record a failed assertion without aborting the run""" + if not condition: + failures.append(message) + print(f"FAIL: {message}") + return condition + +# Define the map layout from user's specification +# . = floor, W = wall, E = entity position +map_layout = [ + "..............", # Row 0 + "..W.....WWWW..", # Row 1 + "..W.W...W.EW..", # Row 2 + "..W.....W..W..", # Row 3 + "..W...E.WWWW..", # Row 4 + "E.W...........", # Row 5 + "..W...........", # Row 6 + "..W...........", # Row 7 + "..W.WWW.......", # Row 8 + "..............", # Row 9 +] def create_map(): """Create the interactive map with the layout specified by the user""" - global grid, color_layer, entities - - dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive") + global grid, grid_data, color_layer, entities # Create grid - 14x10 as specified - grid = mcrfpy.Grid(grid_w=14, grid_h=10) + grid = mcrfpy.Grid(grid_size=(14, 10)) grid.fill_color = mcrfpy.Color(0, 0, 0) + grid_data = grid.grid_data - # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) - - # Define the map layout from user's specification - # . = floor, W = wall, E = entity position - map_layout = [ - "..............", # Row 0 - "..W.....WWWW..", # Row 1 - "..W.W...W.EW..", # Row 2 - "..W.....W..W..", # Row 3 - "..W...E.WWWW..", # Row 4 - "E.W...........", # Row 5 - "..W...........", # Row 6 - "..W...........", # Row 7 - "..W.WWW.......", # Row 8 - "..............", # Row 9 - ] + # Add color layer for cell coloring (GridPoint has no .color anymore) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid_data.add_layer(color_layer) # Create the map entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): - cell = grid.at(x, y) + cell = grid_data.at(x, y) if char == 'W': # Wall cell.walkable = False cell.transparent = False - color_layer.set(x, y, WALL_COLOR) + color_layer.set((x, y), WALL_COLOR) else: # Floor cell.walkable = True cell.transparent = True - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) if char == 'E': # Entity position @@ -86,25 +102,32 @@ def create_map(): # Create entities at marked positions entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity((x, y), grid=grid) + entity = mcrfpy.Entity(grid_pos=(x, y)) entity.sprite_index = 49 + i # '1', '2', '3' + grid_data.entities.append(entity) entities.append(entity) return grid +def entity_cell(entity): + """Logical cell of an entity as an (x, y) int tuple""" + cp = entity.cell_pos + return (int(cp.x), int(cp.y)) + def clear_path_highlight(): """Clear any existing path highlighting""" # Reset all floor tiles to original color - for y in range(grid.grid_h): - for x in range(grid.grid_w): - cell = grid.at(x, y) - if cell.walkable: - color_layer.set(x, y, FLOOR_COLOR) + for y in range(grid_data.grid_h): + for x in range(grid_data.grid_w): + if grid_data.at(x, y).walkable: + color_layer.set((x, y), FLOOR_COLOR) def highlight_path(): - """Highlight the path between selected entities""" + """Highlight the path between selected entities. Returns the path (may be empty).""" + global last_path + last_path = None if first_point is None or second_point is None: - return + return None # Clear previous highlighting clear_path_highlight() @@ -112,72 +135,67 @@ def highlight_path(): # Get entities entity1 = entities[first_point] entity2 = entities[second_point] + start = entity_cell(entity1) + end = entity_cell(entity2) - # Compute Dijkstra from first entity - grid.compute_dijkstra(int(entity1.x), int(entity1.y)) + # Compute Dijkstra from first entity; walkability is static, but clear the + # cache so each selection recomputes against the current grid. + grid_data.clear_dijkstra_maps() + dijkstra = grid_data.get_dijkstra_map(root=start) - # Get path to second entity - path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y)) + distance = dijkstra.distance(end) + path = [] if distance is None else [(int(v.x), int(v.y)) for v in dijkstra.path_from(end)] if path: # Highlight the path for x, y in path: - cell = grid.at(x, y) - if cell.walkable: - color_layer.set(x, y, PATH_COLOR) + if grid_data.at(x, y).walkable: + color_layer.set((x, y), PATH_COLOR) # Also highlight start and end with entity colors - color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point]) - color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point]) + color_layer.set(start, ENTITY_COLORS[first_point]) + color_layer.set(end, ENTITY_COLORS[second_point]) - # Update info - distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y)) - info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units" + info_text.text = (f"Path: Entity {first_point+1} to Entity {second_point+1} - " + f"{len(path)} steps, {distance:.1f} units") else: info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}" -def handle_keypress(scene_name, keycode): + print(f" {info_text.text}") + last_path = path + return path + +def handle_keypress(key, action): """Handle keyboard input""" global first_point, second_point - + + if action != mcrfpy.InputState.PRESSED: + return + # Number keys for first entity - if keycode == 49: # '1' - first_point = 0 - status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" + if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3): + first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key] + status_text.text = (f"First: Entity {first_point+1} | " + f"Second: {f'Entity {second_point+1}' if second_point is not None else '?'}") highlight_path() - elif keycode == 50: # '2' - first_point = 1 - status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" - highlight_path() - elif keycode == 51: # '3' - first_point = 2 - status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" - highlight_path() - + # Letter keys for second entity - elif keycode == 65 or keycode == 97: # 'A' or 'a' - second_point = 0 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1" + elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C): + second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key] + status_text.text = (f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | " + f"Second: Entity {second_point+1}") highlight_path() - elif keycode == 66 or keycode == 98: # 'B' or 'b' - second_point = 1 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2" - highlight_path() - elif keycode == 67 or keycode == 99: # 'C' or 'c' - second_point = 2 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3" - highlight_path() - + # Clear selection - elif keycode == 32: # Space + elif key == mcrfpy.Key.SPACE: first_point = None second_point = None clear_path_highlight() status_text.text = "Press 1/2/3 for first entity, A/B/C for second" info_text.text = "Space to clear, Q to quit" - + # Quit - elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC + elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE): print("\nExiting Dijkstra interactive demo...") sys.exit(0) @@ -190,6 +208,8 @@ print(" A/B/C - Select second entity") print(" Space - Clear selection") print(" Q/ESC - Quit") +dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive") + # Create map grid = create_map() @@ -221,14 +241,14 @@ legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue") legend1.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend1) -legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path") +legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Green=Path") legend2.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend2) # Mark entity positions with colored indicators for i, entity in enumerate(entities): - marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10), - text=str(i+1)) + ex, ey = entity_cell(entity) + marker = mcrfpy.Caption(pos=(120 + ex * 40 + 15, 60 + ey * 40 + 10), text=str(i+1)) marker.fill_color = ENTITY_COLORS[i] marker.outline = 1 marker.outline_color = mcrfpy.Color(0, 0, 0) @@ -243,4 +263,77 @@ dijkstra_interactive.activate() print("\nVisualization ready!") print("Entities are at:") for i, entity in enumerate(entities): - print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})") \ No newline at end of file + print(f" Entity {i+1}: {entity_cell(entity)}") + +# --- Verification: drive the same selection logic the keyboard would --- + +check(len(entities) == 3, f"expected 3 entities from map layout, got {len(entities)}") +check(entity_cell(entities[0]) == (10, 2), f"entity 1 at {entity_cell(entities[0])}, expected (10, 2)") +check(entity_cell(entities[1]) == (6, 4), f"entity 2 at {entity_cell(entities[1])}, expected (6, 4)") +check(entity_cell(entities[2]) == (0, 5), f"entity 3 at {entity_cell(entities[2])}, expected (0, 5)") + +def press(key): + handle_keypress(key, mcrfpy.InputState.PRESSED) + +# Entity 2 -> Entity 3: reachable, but only by going around the long x=2 wall. +print("\nSelect: first=2 (key '2'), second=C (entity 3)") +press(mcrfpy.Key.NUM_2) +press(mcrfpy.Key.C) +path_2_3 = last_path +check(path_2_3,"expected a Dijkstra path between entity 2 (6,4) and entity 3 (0,5)") +if path_2_3: + # The map is ROOTED at the first selection (entity 2) and queried FROM the second + # (entity 3), so the walk runs entity3 -> entity2: per #375 it excludes its origin + # (0,5) and ends at the root (6,4). + check(path_2_3[-1] == (6, 4), + f"path should end at the root, entity 2 (6,4); ended at {path_2_3[-1]}") + check((0, 5) not in path_2_3, "path should exclude its origin, entity 3 (0,5)") + check(all(grid_data.at(x, y).walkable for x, y in path_2_3), + "Dijkstra path crosses a non-walkable cell") + # The wall at x=2 spans rows 1..8, so the path must detour through row 0 or row 9 + check(any(y in (0, 9) for x, y in path_2_3), + "path should detour around the x=2 wall via row 0 or row 9") + # Path cells must have been repainted with PATH_COLOR (except the entity endpoints) + mid = path_2_3[len(path_2_3) // 2] + if mid not in ((0, 5), (6, 4)): + c = color_layer.at(*mid) + check((c.r, c.g, c.b) == (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b), + f"path cell {mid} not highlighted with PATH_COLOR, got ({c.r},{c.g},{c.b})") + ec = color_layer.at(0, 5) + check((ec.r, ec.g, ec.b) == (ENTITY_COLORS[2].r, ENTITY_COLORS[2].g, ENTITY_COLORS[2].b), + "destination entity cell not painted with its entity color") + +# Entity 1 is sealed inside the walled room: no path in or out. +print("\nSelect: first=1 (key '1'), second=C (entity 3)") +press(mcrfpy.Key.NUM_1) +press(mcrfpy.Key.C) +path_1_3 = last_path +check(not path_1_3, f"entity 1 is walled in; expected no path to entity 3, got {path_1_3}") +check("No path" in info_text.text, f"info text should report no path, got {info_text.text!r}") + +grid_data.clear_dijkstra_maps() +d_sealed = grid_data.get_dijkstra_map(root=entity_cell(entities[0])).distance(entity_cell(entities[1])) +check(d_sealed is None, f"distance from sealed entity 1 to entity 2 should be None, got {d_sealed}") + +# Space clears the selection and restores floor colors +print("\nSelect: Space (clear)") +press(mcrfpy.Key.SPACE) +check(first_point is None and second_point is None, "Space should clear both selections") +fc = color_layer.at(0, 0) +check((fc.r, fc.g, fc.b) == (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b), + "clear_path_highlight should restore FLOOR_COLOR on walkable cells") +wc = color_layer.at(2, 1) +check((wc.r, wc.g, wc.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b), + "wall cells must keep WALL_COLOR after clearing the path") + +# Render once (headless render is free of sim time) to prove the scene draws. +press(mcrfpy.Key.NUM_2) +press(mcrfpy.Key.C) +automation.screenshot("dijkstra_interactive.png") + +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_interactive_enhanced.py b/tests/integration/dijkstra_interactive_enhanced.py index ccf1fa6..3bb1899 100644 --- a/tests/integration/dijkstra_interactive_enhanced.py +++ b/tests/integration/dijkstra_interactive_enhanced.py @@ -1,23 +1,36 @@ #!/usr/bin/env python3 """ -Enhanced Dijkstra Pathfinding Interactive Demo -============================================== +Enhanced Dijkstra Pathfinding Interactive Demo (headless-driven test) +==================================================================== Interactive visualization with entity pathfinding animations. -Controls: +Controls (when run with a window): - Press 1/2/3 to select the first entity -- Press A/B/C to select the second entity +- Press A/B/C to select the second entity - Space to clear selection - M to make selected entity move along path - P to pause/resume animation - R to reset entity positions - Q or ESC to quit + +Under --headless --exec the same handlers are driven programmatically and the +resulting paths / animation / reset are asserted (see run_checks() at the bottom). """ import mcrfpy import sys -import math + +failures = [] + + +def check(label, condition, detail=""): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label} {detail}") + failures.append(label) + # Colors WALL_COLOR = mcrfpy.Color(60, 30, 30) @@ -30,6 +43,9 @@ ENTITY_COLORS = [ mcrfpy.Color(100, 100, 255), # Entity 3 - Blue ] +GRID_W = 14 +GRID_H = 10 + # Global state grid = None color_layer = None @@ -42,33 +58,33 @@ animation_progress = 0.0 animation_speed = 2.0 # cells per second original_positions = [] # Store original entity positions +# Define the map layout from user's specification +# . = floor, W = wall, E = entity position +map_layout = [ + "..............", # Row 0 + "..W.....WWWW..", # Row 1 + "..W.W...W.EW..", # Row 2 (entity 1 is sealed inside the room) + "..W.....W..W..", # Row 3 + "..W...E.WWWW..", # Row 4 + "E.W...........", # Row 5 + "..W...........", # Row 6 + "..W...........", # Row 7 + "..W.WWW.......", # Row 8 + "..............", # Row 9 +] + + def create_map(): """Create the interactive map with the layout specified by the user""" global grid, color_layer, entities, original_positions - dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced") - # Create grid - 14x10 as specified - grid = mcrfpy.Grid(grid_w=14, grid_h=10) + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H)) grid.fill_color = mcrfpy.Color(0, 0, 0) - # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) - - # Define the map layout from user's specification - # . = floor, W = wall, E = entity position - map_layout = [ - "..............", # Row 0 - "..W.....WWWW..", # Row 1 - "..W.W...W.EW..", # Row 2 - "..W.....W..W..", # Row 3 - "..W...E.WWWW..", # Row 4 - "E.W...........", # Row 5 - "..W...........", # Row 6 - "..W...........", # Row 7 - "..W.WWW.......", # Row 8 - "..............", # Row 9 - ] + # Add color layer for cell coloring (GridPoint.color is gone; use a ColorLayer) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid.add_layer(color_layer) # Create the map entity_positions = [] @@ -80,12 +96,12 @@ def create_map(): # Wall cell.walkable = False cell.transparent = False - color_layer.set(x, y, WALL_COLOR) + color_layer.set((x, y), WALL_COLOR) else: # Floor cell.walkable = True cell.transparent = True - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) if char == 'E': # Entity position @@ -95,26 +111,28 @@ def create_map(): entities = [] original_positions = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity((x, y), grid=grid) + entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' entities.append(entity) original_positions.append((x, y)) return grid + def clear_path_highlight(): """Clear any existing path highlighting""" global current_path # Reset all floor tiles to original color - for y in range(grid.grid_h): - for x in range(grid.grid_w): + for y in range(GRID_H): + for x in range(GRID_W): cell = grid.at(x, y) if cell.walkable: - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) current_path = [] + def highlight_path(): """Highlight the path between selected entities using entity.path_to()""" global current_path @@ -129,8 +147,8 @@ def highlight_path(): entity1 = entities[first_point] entity2 = entities[second_point] - # Use the new path_to method! - path = entity1.path_to(int(entity2.x), int(entity2.y)) + # Use the path_to method! (entity.x/.y are PIXELS now; cells are grid_x/grid_y) + path = entity1.path_to(entity2.grid_x, entity2.grid_y) if path: current_path = path @@ -141,13 +159,13 @@ def highlight_path(): if cell.walkable: # Use gradient for path visualization if i < len(path) - 1: - color_layer.set(x, y, PATH_COLOR) + color_layer.set((x, y), PATH_COLOR) else: - color_layer.set(x, y, VISITED_COLOR) + color_layer.set((x, y), VISITED_COLOR) # Highlight start and end with entity colors - color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point]) - color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point]) + color_layer.set((entity1.grid_x, entity1.grid_y), ENTITY_COLORS[first_point]) + color_layer.set((entity2.grid_x, entity2.grid_y), ENTITY_COLORS[second_point]) # Update info info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps" @@ -155,102 +173,96 @@ def highlight_path(): info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}" current_path = [] + def animate_movement(dt): """Animate entity movement along path""" global animation_progress, animating, current_path - + if not animating or not current_path or first_point is None: return - + entity = entities[first_point] - + # Update animation progress animation_progress += animation_speed * dt - + # Calculate current position along path path_index = int(animation_progress) - + if path_index >= len(current_path): # Animation complete animating = False animation_progress = 0.0 - # Snap to final position + # Snap to final position (grid_pos = logical cell, draw_pos = visual) if current_path: final_x, final_y = current_path[-1] - entity.x = float(final_x) - entity.y = float(final_y) + entity.grid_pos = (final_x, final_y) + entity.draw_pos = (float(final_x), float(final_y)) return - + # Interpolate between path points if path_index < len(current_path) - 1: curr_x, curr_y = current_path[path_index] next_x, next_y = current_path[path_index + 1] - + # Calculate interpolation factor t = animation_progress - path_index - - # Smooth interpolation - entity.x = curr_x + (next_x - curr_x) * t - entity.y = curr_y + (next_y - curr_y) * t + + # Smooth interpolation (draw_pos is the fractional render position) + entity.draw_pos = (curr_x + (next_x - curr_x) * t, + curr_y + (next_y - curr_y) * t) + entity.grid_pos = (curr_x, curr_y) else: # At last point - entity.x, entity.y = current_path[path_index] + entity.grid_pos = current_path[path_index] + entity.draw_pos = (float(current_path[path_index][0]), + float(current_path[path_index][1])) -def handle_keypress(scene_name, keycode): - """Handle keyboard input""" + +def handle_keypress(key, action): + """Handle keyboard input (#184: handlers receive Key and InputState enums)""" global first_point, second_point, animating, animation_progress - + + if action != mcrfpy.InputState.PRESSED: + return + # Number keys for first entity - if keycode == 49: # '1' - first_point = 0 - status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" + if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3): + first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key] + status_text.text = f"First: Entity {first_point+1} | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" highlight_path() - elif keycode == 50: # '2' - first_point = 1 - status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" - highlight_path() - elif keycode == 51: # '3' - first_point = 2 - status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}" - highlight_path() - + # Letter keys for second entity - elif keycode == 65 or keycode == 97: # 'A' or 'a' - second_point = 0 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1" + elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C): + second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key] + status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity {second_point+1}" highlight_path() - elif keycode == 66 or keycode == 98: # 'B' or 'b' - second_point = 1 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2" - highlight_path() - elif keycode == 67 or keycode == 99: # 'C' or 'c' - second_point = 2 - status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3" - highlight_path() - + # Movement control - elif keycode == 77 or keycode == 109: # 'M' or 'm' + elif key == mcrfpy.Key.M: if current_path and first_point is not None: animating = True animation_progress = 0.0 control_text.text = "Animation: MOVING (press P to pause)" - + # Pause/Resume - elif keycode == 80 or keycode == 112: # 'P' or 'p' + elif key == mcrfpy.Key.P: animating = not animating control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})" - + # Reset positions - elif keycode == 82 or keycode == 114: # 'R' or 'r' + elif key == mcrfpy.Key.R: animating = False animation_progress = 0.0 for i, entity in enumerate(entities): - entity.x, entity.y = original_positions[i] + entity.grid_pos = original_positions[i] + entity.draw_pos = (float(original_positions[i][0]), + float(original_positions[i][1])) control_text.text = "Entities reset to original positions" highlight_path() # Re-highlight path after reset - + # Clear selection - elif keycode == 32: # Space + elif key == mcrfpy.Key.SPACE: first_point = None second_point = None animating = False @@ -259,16 +271,27 @@ def handle_keypress(scene_name, keycode): status_text.text = "Press 1/2/3 for first entity, A/B/C for second" info_text.text = "Space to clear, Q to quit" control_text.text = "Press M to move, P to pause, R to reset" - - # Quit - elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC - print("\nExiting enhanced Dijkstra demo...") - sys.exit(0) -# Timer callback for animation -def update_animation(timer, dt): + # Quit + elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE): + print("\nExiting enhanced Dijkstra demo...") + sys.exit(0 if not failures else 1) + + +# Timer callback for animation. Callback is (timer, runtime_ms) -- runtime is +# cumulative, so derive the frame delta ourselves. +last_runtime_ms = 0.0 +timer_ticks = 0 + + +def update_animation(timer, runtime_ms): """Update animation state""" - animate_movement(dt / 1000.0) # Convert ms to seconds + global last_runtime_ms, timer_ticks + dt_ms = runtime_ms - last_runtime_ms + last_runtime_ms = runtime_ms + timer_ticks += 1 + animate_movement(dt_ms / 1000.0) # Convert ms to seconds + # Create the visualization print("Enhanced Dijkstra Pathfinding Demo") @@ -282,6 +305,8 @@ print(" R - Reset entity positions") print(" Space - Clear selection") print(" Q/ESC - Quit") +dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced") + # Create map grid = create_map() @@ -324,7 +349,7 @@ ui.append(legend2) # Mark entity positions with colored indicators for i, entity in enumerate(entities): - marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10), + marker = mcrfpy.Caption(pos=(120 + entity.grid_x * 40 + 15, 60 + entity.grid_y * 40 + 10), text=str(i+1)) marker.fill_color = ENTITY_COLORS[i] marker.outline = 1 @@ -343,4 +368,86 @@ dijkstra_enhanced.activate() print("\nVisualization ready!") print("Entities are at:") for i, entity in enumerate(entities): - print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})") \ No newline at end of file + print(f" Entity {i+1}: ({entity.grid_x}, {entity.grid_y})") + + +def press(key): + """Simulate a key press through the scene's real handler""" + handle_keypress(key, mcrfpy.InputState.PRESSED) + + +def run_checks(): + """Headless driver: exercise the same code paths the keyboard drives.""" + print("\n1. Map construction") + check("three entities placed at 'E' markers", len(entities) == 3, + f"got {len(entities)}") + check("entity positions match layout", + original_positions == [(10, 2), (6, 4), (0, 5)], + f"got {original_positions}") + check("walls are not walkable", not grid.at(2, 1).walkable) + check("floors are walkable", grid.at(0, 0).walkable) + # grid.layer(name) returns a wrapper around the same layer (not the same PyObject) + check("color layer attached", grid.layer("color").name == "color") + + print("\n2. path_to() between entity 2 and entity 3 (reachable around the wall)") + press(mcrfpy.Key.NUM_2) # first = entity 2 @ (6,4) + press(mcrfpy.Key.C) # second = entity 3 @ (0,5) + check("path found", len(current_path) > 0, "path_to returned empty") + if current_path: + check("path ends at entity 3", tuple(current_path[-1]) == (0, 5), + f"got {current_path[-1]}") + walkable = all(grid.at(x, y).walkable for x, y in current_path) + check("every path cell is walkable", walkable) + contiguous = all( + max(abs(current_path[i+1][0] - current_path[i][0]), + abs(current_path[i+1][1] - current_path[i][1])) == 1 + for i in range(len(current_path) - 1)) + check("path steps are contiguous", contiguous) + check("info caption reports the path", "Path:" in info_text.text, + info_text.text) + + print("\n3. path_to() into the sealed room (entity 1) has no solution") + saved_path = list(current_path) + press(mcrfpy.Key.A) # second = entity 1 @ (10,2), walled in + check("no path reported", len(current_path) == 0, + f"got {len(current_path)} steps") + check("info caption reports no path", "No path" in info_text.text, + info_text.text) + + print("\n4. M drives the entity along the path (timer + step())") + press(mcrfpy.Key.C) # back to the reachable target + check("path restored", current_path == saved_path) + press(mcrfpy.Key.M) + check("animating flag set", animating is True) + + # mcrfpy.step() is the headless clock; the 16ms timer fires once per step. + for _ in range(400): + mcrfpy.step(0.05) + if not animating: + break + check("animation timer fired", timer_ticks > 0, f"ticks={timer_ticks}") + check("animation completed", animating is False) + entity2 = entities[1] + check("entity 2 arrived at entity 3's cell", + (entity2.grid_x, entity2.grid_y) == (0, 5), + f"got ({entity2.grid_x}, {entity2.grid_y})") + + print("\n5. R resets entities to their original positions") + press(mcrfpy.Key.R) + positions = [(e.grid_x, e.grid_y) for e in entities] + check("positions restored", positions == original_positions, + f"got {positions}") + + print("\n6. Space clears the selection and the highlight") + press(mcrfpy.Key.SPACE) + check("selection cleared", + first_point is None and second_point is None and current_path == []) + + +run_checks() + +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed: {failures}") + sys.exit(1) +print("\nPASS") +sys.exit(0) diff --git a/tests/integration/dijkstra_test.py b/tests/integration/dijkstra_test.py index 65e7842..d365557 100644 --- a/tests/integration/dijkstra_test.py +++ b/tests/integration/dijkstra_test.py @@ -4,27 +4,45 @@ Dijkstra Pathfinding Test - Headless ==================================== Tests all Dijkstra functionality and generates a screenshot. + +API notes (updated for the current engine): + - Dijkstra maps come from grid.get_dijkstra_map(root=...) -> DijkstraMap. + The old compute_dijkstra / get_dijkstra_distance / get_dijkstra_path + methods are gone; DijkstraMap.distance(pos) and .path_from(pos) replace them. + - GridPoint has no .color; per-cell coloring is done with a ColorLayer. + - Entity.x/.y are pixel coordinates; the logical cell is entity.cell_pos. + - Headless has no clock of its own: mcrfpy.step(dt) drives timers. """ import mcrfpy from mcrfpy import automation import sys +failures = [] + +def check(cond, msg): + if not cond: + failures.append(msg) + print(f"FAIL: {msg}") + return cond + def create_test_map(): """Create a test map with obstacles""" - dijkstra_test = mcrfpy.Scene("dijkstra_test") - # Create grid - grid = mcrfpy.Grid(grid_w=20, grid_h=12) + grid = mcrfpy.Grid(grid_size=(20, 12)) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Per-cell coloring now lives on a ColorLayer, not on GridPoint + color_layer = mcrfpy.ColorLayer(name="cells") + grid.add_layer(color_layer) + # Initialize all cells as walkable floor for y in range(12): for x in range(20): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - + color_layer.set((x, y), mcrfpy.Color(200, 200, 220)) + # Add walls to create interesting paths walls = [ # Vertical wall in the middle @@ -35,11 +53,11 @@ def create_test_map(): # Some scattered obstacles (5, 2), (15, 2), (5, 9), (15, 9) ] - + for x, y in walls: grid.at(x, y).walkable = False - grid.at(x, y).color = mcrfpy.Color(60, 30, 30) - + color_layer.set((x, y), mcrfpy.Color(60, 30, 30)) + # Place test entities entities = [] positions = [(2, 2), (17, 2), (9, 10)] @@ -48,78 +66,125 @@ def create_test_map(): mcrfpy.Color(100, 255, 100), # Green mcrfpy.Color(100, 100, 255) # Blue ] - + for i, (x, y) in enumerate(positions): - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity(grid_pos=(x, y)) entity.sprite_index = 49 + i # '1', '2', '3' grid.entities.append(entity) entities.append(entity) # Mark entity positions - grid.at(x, y).color = colors[i] - - return grid, entities + color_layer.set((x, y), colors[i]) -def test_dijkstra(grid, entities): + # Walkability was mutated after grid construction; drop any cached maps + grid.clear_dijkstra_maps() + + return grid, color_layer, entities, walls + + +def test_dijkstra(grid, color_layer, entities, walls): """Test Dijkstra pathfinding between all entity pairs""" results = [] - + for i in range(len(entities)): for j in range(len(entities)): if i != j: # Compute Dijkstra from entity i e1 = entities[i] e2 = entities[j] - grid.compute_dijkstra(int(e1.x), int(e1.y)) - + src = (int(e1.cell_pos.x), int(e1.cell_pos.y)) + dst = (int(e2.cell_pos.x), int(e2.cell_pos.y)) + + dmap = grid.get_dijkstra_map(root=src) + check(dmap.root == mcrfpy.Vector(src[0], src[1]), + f"dijkstra map root should be {src}, got {dmap.root}") + # Get distance and path to entity j - distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y)) - path = grid.get_dijkstra_path(int(e2.x), int(e2.y)) - + distance = dmap.distance(dst) + path = list(dmap.path_from(dst)) + + check(distance is not None, f"no distance from {src} to {dst}") + check(len(path) > 0, f"no path from {src} to {dst}") + if path: + # Root is reachable and the path is a real, connected walk + check(distance > 0, f"distance {src}->{dst} should be positive") + # Number of steps can't be fewer than the Chebyshev distance + cheb = max(abs(src[0] - dst[0]), abs(src[1] - dst[1])) + check(len(path) >= cheb, + f"path {src}->{dst} too short: {len(path)} < {cheb}") + + cells = [(int(v.x), int(v.y)) for v in path] + for cx, cy in cells: + check(grid.at(cx, cy).walkable, + f"path {src}->{dst} crosses unwalkable cell {(cx, cy)}") + check((cx, cy) not in walls, + f"path {src}->{dst} crosses wall {(cx, cy)}") + + # Consecutive path cells must be adjacent (8-way) + for a, b in zip(cells, cells[1:]): + check(max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1, + f"path {src}->{dst} jumps from {a} to {b}") + + # The map is ROOTED at src and queried FROM dst, so the walk runs + # dst -> src. Per the #375 convention (shared with find_path) it + # EXCLUDES its origin (dst) and ENDS at its destination (src). + check(cells[-1] == src, + f"path from {dst} should end at the root {src}, ended at {cells[-1]}") + check(dst not in cells, + f"path from {dst} should exclude its origin {dst}") + check(max(abs(cells[0][0] - dst[0]), abs(cells[0][1] - dst[1])) == 1, + f"path from {dst} should begin one step away, began at {cells[0]}") + results.append(f"Path {i+1}→{j+1}: {len(path)} steps, {distance:.1f} units") - + # Color one interesting path if i == 0 and j == 2: # Path from 1 to 3 - for x, y in path[1:-1]: # Skip endpoints - if grid.at(x, y).walkable: - grid.at(x, y).color = mcrfpy.Color(200, 250, 220) + for cx, cy in cells: + if (cx, cy) not in (src, dst) and grid.at(cx, cy).walkable: + color_layer.set((cx, cy), mcrfpy.Color(200, 250, 220)) else: results.append(f"Path {i+1}→{j+1}: No path found!") - + + # Walls are unreachable: distance must be None, not a bogus number + root = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y)) + dmap = grid.get_dijkstra_map(root=root) + check(dmap.distance((10, 4)) is None, + "distance to an unwalkable wall cell should be None") + check(dmap.distance(root) == 0.0, + f"distance from root to itself should be 0, got {dmap.distance(root)}") + + # Dijkstra distances are symmetric on a uniform-cost grid + a = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y)) + b = (int(entities[1].cell_pos.x), int(entities[1].cell_pos.y)) + d_ab = grid.get_dijkstra_map(root=a).distance(b) + d_ba = grid.get_dijkstra_map(root=b).distance(a) + check(abs(d_ab - d_ba) < 0.01, + f"distance should be symmetric: {a}->{b}={d_ab}, {b}->{a}={d_ba}") + return results + def run_test(timer, runtime): - """Timer callback to run tests and take screenshot""" - # Run pathfinding tests - results = test_dijkstra(grid, entities) + """Timer callback to run tests""" + global results + results = test_dijkstra(grid, color_layer, entities, walls) # Update display with results y_pos = 380 for result in results: - caption = mcrfpy.Caption(result, 50, y_pos) + caption = mcrfpy.Caption(text=result, pos=(50, y_pos)) caption.fill_color = mcrfpy.Color(200, 200, 200) ui.append(caption) y_pos += 20 - # Take screenshot (one-shot timer) - screenshot_timer = mcrfpy.Timer("screenshot", lambda t, rt: take_screenshot(), 500, once=True) - -def take_screenshot(): - """Take screenshot and exit""" - try: - automation.screenshot("dijkstra_test.png") - print("Screenshot saved: dijkstra_test.png") - except Exception as e: - print(f"Screenshot failed: {e}") - - # Exit - sys.exit(0) # Create test map print("Creating Dijkstra pathfinding test...") -grid, entities = create_test_map() +grid, color_layer, entities, walls = create_test_map() +results = None # Set up UI +dijkstra_test = mcrfpy.Scene("dijkstra_test") ui = dijkstra_test.children ui.append(grid) @@ -133,14 +198,40 @@ title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add legend -legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3") +legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1->3") legend.fill_color = mcrfpy.Color(180, 180, 180) ui.append(legend) # Set scene dijkstra_test.activate() -# Run test after scene loads (one-shot timer) +# Run test after scene loads (one-shot timer). Headless has no clock of its own, +# so drive the timer forward explicitly with mcrfpy.step(). test_timer = mcrfpy.Timer("test", run_test, 100, once=True) +print("Running Dijkstra tests...") +for _ in range(10): + mcrfpy.step(0.05) + if results is not None: + break -print("Running Dijkstra tests...") \ No newline at end of file +check(results is not None, "timer callback never fired; Dijkstra tests did not run") +if results: + for line in results: + print(line) + check(len(results) == 6, f"expected 6 entity-pair results, got {len(results)}") + check(all("No path found" not in r for r in results), + "every entity pair should be mutually reachable") + +# Rendering is orthogonal to sim time; force a render for the screenshot +try: + automation.screenshot("dijkstra_test.png") + print("Screenshot saved: dijkstra_test.png") +except Exception as e: + check(False, f"Screenshot failed: {e}") + +if failures: + print(f"FAILED ({len(failures)} checks)") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/integration/force_non_interactive.py b/tests/integration/force_non_interactive.py index 1c7218a..d30da92 100644 --- a/tests/integration/force_non_interactive.py +++ b/tests/integration/force_non_interactive.py @@ -1,29 +1,67 @@ #!/usr/bin/env python3 -"""Force Python to be non-interactive""" +"""Verify that --exec scripts run Python non-interactively. + +Historically this file *forced* non-interactive mode (deleting sys.ps1/ps2 and +mangling termios) because the embedded interpreter could drop into a REPL and +hang the engine. That workaround is gone; the property it was protecting is now +an engine guarantee, so this asserts it instead of forcing it: + + - the interpreter is not in REPL mode (no sys.ps1 / sys.ps2) + - it will not drop into a REPL after the script (-i / inspect not set) + - stdin is not an interactive terminal, and is not consumed by the engine + - the script, not a prompt, decides when the process ends (#350 exit contract) +""" import sys import os +import mcrfpy -print("Attempting to force non-interactive mode...") +failures = [] -# Remove ps1/ps2 if they exist -if hasattr(sys, 'ps1'): - delattr(sys, 'ps1') -if hasattr(sys, 'ps2'): - delattr(sys, 'ps2') -# Set environment variable -os.environ['PYTHONSTARTUP'] = '' +def check(label, condition, detail=""): + if condition: + print(" ok : %s" % label) + else: + print(" FAIL : %s %s" % (label, detail)) + failures.append(label) -# Try to set stdin to non-interactive -try: - import fcntl - import termios - # Make stdin non-interactive by removing ICANON flag - attrs = termios.tcgetattr(0) - attrs[3] = attrs[3] & ~termios.ICANON - termios.tcsetattr(0, termios.TCSANOW, attrs) - print("Modified terminal attributes") -except: - print("Could not modify terminal attributes") -print("Script complete") \ No newline at end of file +print("Checking non-interactive execution of --exec scripts...") + +# 1. REPL prompts only exist when the interpreter is interactive. Their presence +# would mean the engine handed us an interactive interpreter. +check("sys.ps1 absent", not hasattr(sys, "ps1")) +check("sys.ps2 absent", not hasattr(sys, "ps2")) + +# 2. Interpreter flags: -i / PYTHONINSPECT would make Python drop to a prompt +# *after* the script finishes, hanging a headless run. +check("sys.flags.interactive == 0", sys.flags.interactive == 0, + "got %r" % (sys.flags.interactive,)) +check("sys.flags.inspect == 0", sys.flags.inspect == 0, + "got %r" % (sys.flags.inspect,)) +check("PYTHONINSPECT not set in env", not os.environ.get("PYTHONINSPECT")) + +# 3. stdin must not be an interactive terminal driving a REPL. +# NOTE: deliberately no blocking read here -- under a test runner stdin is an +# open pipe that never sends EOF, so sys.stdin.read() would hang forever. The +# thing worth asserting is that no prompt is bound to it. +check("stdin is not a tty", sys.stdin is None or not sys.stdin.isatty()) + +# 4. The engine must not be running its own read-eval-print loop over our script: +# module scope is ordinary script scope, and the headless clock only advances +# when we ask it to (#350). One step() must not spin into an interactive loop. +scene = mcrfpy.Scene("force_non_interactive") +mcrfpy.current_scene = scene +fired = [] +mcrfpy.Timer("tick", lambda timer, runtime: fired.append(runtime), 50) +for _ in range(4): + mcrfpy.step(0.05) +check("headless clock advances under script control", len(fired) >= 1, + "timer fired %d time(s)" % len(fired)) + +if failures: + print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures))) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/integration/interactive_visibility.py b/tests/integration/interactive_visibility.py index 56c83db..5ad71ee 100644 --- a/tests/integration/interactive_visibility.py +++ b/tests/integration/interactive_visibility.py @@ -1,40 +1,62 @@ #!/usr/bin/env python3 """ -Interactive Visibility Demo -========================== +Interactive Visibility Test +=========================== -Controls: - - WASD: Move the player (green @) - - Arrow keys: Move enemy (red E) - - Tab: Cycle perspective (Omniscient → Player → Enemy → Omniscient) - - Space: Update visibility for current entity - - R: Reset positions +Originally an interactive demo (WASD moves the player, arrows move the enemy, +Tab cycles perspective, Space recomputes visibility, R resets). Headless mode +never delivers real keystrokes, so the same key handler is now driven with +mcrfpy.automation.keyDown/keyUp and the resulting state is asserted. + +Covers: + - walls block movement (walkable) and sight (transparent) + - Entity.update_visibility() / Entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE) + - moving an entity discovers new cells and demotes VISIBLE -> DISCOVERED + - Grid.perspective cycling (None -> player -> enemy -> None) + +API notes (current contract): + - grid.add_layer() takes a layer OBJECT, no kwargs; GridPoint has no .color, + so cell coloring goes through a ColorLayer. + - Grid.perspective is an Entity (or None), not an integer index. """ import mcrfpy +from mcrfpy import automation import sys +GRID_W, GRID_H = 30, 20 + +failures = [] + + +def check(cond, msg): + if not cond: + failures.append(msg) + print(f"FAIL: {msg}") + return cond + + # Create scene and grid visibility_demo = mcrfpy.Scene("visibility_demo") -grid = mcrfpy.Grid(grid_w=30, grid_h=20) +grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(50, 100), size=(900, 600)) grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background -# Add color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# Add color layer for cell coloring (GridPoint.color no longer exists) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.grid_data.add_layer(color_layer) # Initialize grid - all walkable and transparent -for y in range(20): - for x in range(30): +for y in range(GRID_H): + for x in range(GRID_W): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color + color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color # Create walls walls = [ - # Central cross - [(15, y) for y in range(8, 12)], - [(x, 10) for x in range(13, 18)], + # Central cross - a solid wall separating left half from right half + [(15, y) for y in range(0, GRID_H)], # Rooms # Top-left room @@ -54,33 +76,42 @@ walls = [ [(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)], ] +wall_cells = set() for wall_group in walls: for x, y in wall_group: - if 0 <= x < 30 and 0 <= y < 20: + if 0 <= x < GRID_W and 0 <= y < GRID_H: cell = grid.at(x, y) cell.walkable = False cell.transparent = False - color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color + color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color + wall_cells.add((x, y)) # Create entities -player = mcrfpy.Entity((5, 10), grid=grid) +player = mcrfpy.Entity(grid_pos=(5, 10)) player.sprite_index = 64 # @ -enemy = mcrfpy.Entity((25, 10), grid=grid) +grid.grid_data.entities.append(player) +enemy = mcrfpy.Entity(grid_pos=(25, 10)) enemy.sprite_index = 69 # E +grid.grid_data.entities.append(enemy) + +# Short FOV radius: the default (10) is wide enough that the player's start cell +# stays in FOV from the far side of the room, so cells could never demote from +# VISIBLE to DISCOVERED. update_visibility() reads GridData.fov_radius (Entity. +# sight_radius only feeds the TARGET trigger). +grid.grid_data.fov_radius = 4 # Update initial visibility player.update_visibility() enemy.update_visibility() -# Global state -current_perspective = -1 +# Global state: perspective is now an Entity | None, not an int index +perspectives = [None, player, enemy] perspective_names = ["Omniscient", "Player", "Enemy"] +current_perspective = 0 # UI Setup ui = visibility_demo.children ui.append(grid) -grid.pos = (50, 100) -grid.size = (900, 600) # 30*30, 20*30 # Title title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo") @@ -92,10 +123,6 @@ perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient") perspective_label.fill_color = mcrfpy.Color(200, 200, 200) ui.append(perspective_label) -controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset") -controls.fill_color = mcrfpy.Color(150, 150, 150) -ui.append(controls) - player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)") player_info.fill_color = mcrfpy.Color(100, 255, 100) ui.append(player_info) @@ -104,41 +131,41 @@ enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)") enemy_info.fill_color = mcrfpy.Color(255, 100, 100) ui.append(enemy_info) + # Helper functions def move_entity(entity, dx, dy): """Move entity if target is walkable""" - new_x = int(entity.x + dx) - new_y = int(entity.y + dy) - - if 0 <= new_x < 30 and 0 <= new_y < 20: + new_x = int(entity.grid_x + dx) + new_y = int(entity.grid_y + dy) + + if 0 <= new_x < GRID_W and 0 <= new_y < GRID_H: cell = grid.at(new_x, new_y) if cell.walkable: - entity.x = new_x - entity.y = new_y + entity.grid_pos = (new_x, new_y) entity.update_visibility() return True return False + def update_info(): """Update info displays""" - player_info.text = f"Player: ({int(player.x)}, {int(player.y)})" - enemy_info.text = f"Enemy: ({int(enemy.x)}, {int(enemy.y)})" + player_info.text = f"Player: ({player.grid_x}, {player.grid_y})" + enemy_info.text = f"Enemy: ({enemy.grid_x}, {enemy.grid_y})" + def cycle_perspective(): - """Cycle through perspectives""" + """Cycle through perspectives: Omniscient -> Player -> Enemy -> Omniscient""" global current_perspective - - # Cycle: -1 → 0 → 1 → -1 - current_perspective = (current_perspective + 2) % 3 - 1 - - grid.perspective = current_perspective - name = perspective_names[current_perspective + 1] - perspective_label.text = f"Perspective: {name}" + current_perspective = (current_perspective + 1) % 3 + grid.perspective = perspectives[current_perspective] + perspective_label.text = f"Perspective: {perspective_names[current_perspective]}" + # Key handlers def handle_keys(key, state): """Handle keyboard input""" - if state == mcrfpy.InputState.RELEASED: return + if state == mcrfpy.InputState.RELEASED: + return # Player movement (WASD) if key == mcrfpy.Key.W: move_entity(player, 0, -1) @@ -167,37 +194,120 @@ def handle_keys(key, state): elif key == mcrfpy.Key.SPACE: player.update_visibility() enemy.update_visibility() - print("Updated visibility for both entities") # R to reset elif key == mcrfpy.Key.R: - player.x, player.y = 5, 10 - enemy.x, enemy.y = 25, 10 + player.grid_pos = (5, 10) + enemy.grid_pos = (25, 10) player.update_visibility() enemy.update_visibility() - update_info() - print("Reset positions") - # Q to quit - elif key == mcrfpy.Key.Q: - print("Exiting...") - sys.exit(0) - update_info() -# Set scene first -visibility_demo.activate() -# Register key handler (operates on current scene) +# Set scene first, then register key handler +visibility_demo.activate() visibility_demo.on_key = handle_keys -print("Interactive Visibility Demo") -print("===========================") -print("WASD: Move player (green @)") -print("Arrows: Move enemy (red E)") -print("Tab: Cycle perspective") -print("Space: Update visibility") -print("R: Reset positions") -print("Q: Quit") -print("\nCurrent perspective: Omniscient (shows all)") -print("Try moving entities and switching perspectives!") + +def press(key): + """Deliver a real key event to the scene handler.""" + automation.keyDown(key) + automation.keyUp(key) + + +UNKNOWN = mcrfpy.Perspective.UNKNOWN +DISCOVERED = mcrfpy.Perspective.DISCOVERED +VISIBLE = mcrfpy.Perspective.VISIBLE + + +def seen(entity, x, y): + return entity.perspective_map.get(x, y) + + +def main(): + # --- Initial visibility: player sees its own cell, and cannot see through + # the solid wall at x=15 into the enemy's half of the map. + check(seen(player, 5, 10) == VISIBLE, "player should see its own cell") + check(seen(player, 25, 10) == UNKNOWN, + f"player should not see across the wall; got {seen(player, 25, 10)}") + check(seen(enemy, 25, 10) == VISIBLE, "enemy should see its own cell") + check(seen(enemy, 5, 10) == UNKNOWN, + f"enemy should not see across the wall; got {seen(enemy, 5, 10)}") + check(player not in enemy.visible_entities(), + "wall should hide the player from the enemy") + + # --- WASD moves the player; cells it leaves behind stay DISCOVERED. + press('d') + check((player.grid_x, player.grid_y) == (6, 10), + f"'d' should move player right; got {(player.grid_x, player.grid_y)}") + press('s') + check((player.grid_x, player.grid_y) == (6, 11), + f"'s' should move player down; got {(player.grid_x, player.grid_y)}") + check(player_info.text == "Player: (6, 11)", f"caption not updated: {player_info.text}") + + # --- Walls block movement: walk the player into the central wall. + while player.grid_x < 14: + before = player.grid_x + press('d') + check(player.grid_x == before + 1, "player should walk freely up to the wall") + check((player.grid_x, player.grid_y) == (14, 11), "player should be adjacent to the wall") + press('d') + check((player.grid_x, player.grid_y) == (14, 11), + f"wall must block movement; player reached {(player.grid_x, player.grid_y)}") + + # --- Having walked across the room, previously-seen cells are remembered + # but no longer visible. + check(seen(player, 5, 10) == DISCOVERED, + f"start cell should be remembered, not visible; got {seen(player, 5, 10)}") + check(seen(player, 14, 11) == VISIBLE, "player's current cell should be visible") + check(seen(player, 25, 10) == UNKNOWN, "the wall still hides the enemy's half") + + # --- Arrow keys move the enemy. + press('left') + check((enemy.grid_x, enemy.grid_y) == (24, 10), + f"left arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}") + press('up') + check((enemy.grid_x, enemy.grid_y) == (24, 9), + f"up arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}") + + # --- Space recomputes visibility without moving anything. + pos_before = (player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y) + press('space') + check((player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y) == pos_before, + "space must not move entities") + check(seen(player, 14, 11) == VISIBLE, "player still sees its own cell after space") + + # --- Tab cycles perspective: Omniscient -> Player -> Enemy -> Omniscient. + check(grid.perspective is None, "grid starts omniscient") + press('tab') + check(grid.perspective is player, f"tab 1 -> player; got {grid.perspective}") + check(perspective_label.text == "Perspective: Player", perspective_label.text) + press('tab') + check(grid.perspective is enemy, f"tab 2 -> enemy; got {grid.perspective}") + press('tab') + check(grid.perspective is None, f"tab 3 -> omniscient; got {grid.perspective}") + check(perspective_label.text == "Perspective: Omniscient", perspective_label.text) + + # --- R resets positions and visibility. + press('r') + check((player.grid_x, player.grid_y) == (5, 10), "R resets the player") + check((enemy.grid_x, enemy.grid_y) == (25, 10), "R resets the enemy") + check(seen(player, 5, 10) == VISIBLE, "player sees its cell again after reset") + check(seen(player, 14, 11) == DISCOVERED, + f"cells left behind stay remembered after reset; got {seen(player, 14, 11)}") + + # --- Perspective rendering path must not crash. + grid.perspective = player + automation.screenshot("interactive_visibility.png") + grid.perspective = None + + if failures: + print(f"FAIL: {len(failures)} check(s) failed") + sys.exit(1) + print("PASS") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/simple_interactive_visibility.py b/tests/integration/simple_interactive_visibility.py index 76a9dd2..0eeaefa 100644 --- a/tests/integration/simple_interactive_visibility.py +++ b/tests/integration/simple_interactive_visibility.py @@ -4,15 +4,28 @@ import mcrfpy import sys +failures = [] + +def check(cond, msg): + if cond: + print(f" ok: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + # Create scene and grid print("Creating scene...") vis_test = mcrfpy.Scene("vis_test") print("Creating grid...") -grid = mcrfpy.Grid(grid_w=10, grid_h=10) +grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(300, 300)) # Add color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# (GridPoint has no .color anymore; per-cell color lives in a ColorLayer) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.add_layer(color_layer) +check(grid.layer("color") is not None, "color layer attached to grid") +check(tuple(color_layer.grid_size) == (10, 10), "color layer auto-resized to grid") # Initialize grid print("Initializing grid...") @@ -21,29 +34,61 @@ for y in range(10): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) + color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) + +_c = color_layer.at(3, 7) +check((_c.r, _c.g, _c.b) == (100, 100, 120), "color layer stores per-cell color") + +# An opaque wall so visibility has something to actually occlude +for y in range(10): + wall = grid.at(2, y) + wall.transparent = False + wall.walkable = False # Create entity print("Creating entity...") -entity = mcrfpy.Entity((5, 5), grid=grid) +entity = mcrfpy.Entity(grid_pos=(5, 5)) entity.sprite_index = 64 +grid.entities.append(entity) +check(entity.grid is grid.grid_data, "entity.grid is the shared GridData (#313/#361)") print("Updating visibility...") entity.update_visibility() +# entity.gridstate -> entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE) +pmap = entity.perspective_map +check(pmap.get((5, 5)) == mcrfpy.Perspective.VISIBLE, "entity's own cell is VISIBLE") +check(pmap.get((9, 5)) == mcrfpy.Perspective.VISIBLE, "open cell on entity's side is VISIBLE") +check(pmap.get((0, 5)) == mcrfpy.Perspective.UNKNOWN, "cell behind the wall is UNKNOWN") +check(grid.is_in_fov((5, 5)), "grid.is_in_fov agrees the entity's cell is lit") + # Set up UI print("Setting up UI...") ui = vis_test.children ui.append(grid) -grid.pos = (50, 50) -grid.size = (300, 300) +check(len(ui) == 1, "grid appended to scene UI") # Test perspective +# perspective is now an Entity (fog-of-war source) or None (omniscient), +# not the old integer entity index (-1 == omniscient). print("Testing perspective...") -grid.perspective = -1 # Omniscient +grid.perspective = entity +check(grid.perspective is entity, "perspective bound to entity") +check(grid.perspective_enabled, "perspective mode enabled") + +grid.perspective = None # Omniscient print(f"Perspective set to: {grid.perspective}") +check(grid.perspective is None, "perspective cleared") +check(not grid.perspective_enabled, "omniscient: perspective mode disabled") print("Setting scene...") vis_test.activate() +check(mcrfpy.current_scene is vis_test, "scene activated") -print("Ready!") \ No newline at end of file +print("Ready!") + +if failures: + print(f"FAIL ({len(failures)} checks failed)") + sys.exit(1) +print("PASS") +sys.exit(0) diff --git a/tests/integration/simple_visibility_test.py b/tests/integration/simple_visibility_test.py index db874fc..98256a0 100644 --- a/tests/integration/simple_visibility_test.py +++ b/tests/integration/simple_visibility_test.py @@ -1,39 +1,87 @@ #!/usr/bin/env python3 -"""Simple visibility test without entity append""" +"""Simple visibility test without entity append + +Original intent: verify an Entity associated with a Grid has per-entity visibility +memory that is initialized, that entity.at(x, y) reports per-cell visibility state, +and that entity.update_visibility() recomputes it. + +API updates (current contract): + - entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap: + UNKNOWN / DISCOVERED / VISIBLE, lazily allocated once the entity has a grid) + - the per-cell object no longer carries .visible / .discovered; entity.at(x, y) + returns a GridPoint only when the cell is currently visible, else None. + Discovered-but-not-visible cells are read from perspective_map directly. + - Entity(grid_pos=...) + grid.entities.append(entity) replaces Entity((x,y), grid=grid) +""" import mcrfpy import sys print("Simple visibility test...") +failures = [] + + +def check(label, condition): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label}") + failures.append(label) + + # Create scene and grid simple = mcrfpy.Scene("simple") print("Scene created") -grid = mcrfpy.Grid(grid_w=5, grid_h=5) +grid = mcrfpy.Grid(grid_size=(5, 5)) print("Grid created") +# Column x == 1 is an opaque wall; everything else is open floor. +for x in range(5): + for y in range(5): + point = grid.at(x, y) + point.walkable = True + point.transparent = (x != 1) + # Create entity with grid association -entity = mcrfpy.Entity((2, 2), grid=grid) -print(f"Entity created at ({entity.x}, {entity.y})") +entity = mcrfpy.Entity(grid_pos=(3, 2)) +grid.entities.append(entity) +print(f"Entity created at {entity.cell_pos}") -# Check if gridstate is initialized -print(f"Gridstate length: {len(entity.gridstate)}") +# Check that per-entity visibility memory is initialized +state_map = entity.perspective_map +print(f"Perspective map size: {state_map.size}") +check("perspective_map allocated", state_map is not None) +check("perspective_map matches grid size", tuple(state_map.size) == (5, 5)) +check("perspective_map starts all UNKNOWN", state_map.count(mcrfpy.Perspective.UNKNOWN) == 25) -# Try to access at method -try: - state = entity.at(0, 0) - print(f"at(0,0) returned: {state}") - print(f"visible: {state.visible}, discovered: {state.discovered}") -except Exception as e: - print(f"Error in at(): {e}") +# at() before any FOV computation: nothing is visible yet +check("at(0, 0) is None before update_visibility", entity.at(0, 0) is None) -# Try update_visibility -try: - entity.update_visibility() - print("update_visibility() succeeded") -except Exception as e: - print(f"Error in update_visibility(): {e}") +# Compute visibility from (3, 2) +entity.update_visibility() +print("update_visibility() succeeded") + +check("own cell is VISIBLE", state_map[3, 2] == mcrfpy.Perspective.VISIBLE) +check("at(3, 2) returns the visible GridPoint", entity.at(3, 2) is not None) +check("cell behind the wall stays UNKNOWN", state_map[0, 2] == mcrfpy.Perspective.UNKNOWN) +check("at(0, 2) is None (occluded)", entity.at(0, 2) is None) + +# Move across the wall: previously seen cells must be remembered as DISCOVERED, +# not still reported as visible. +entity.grid_pos = (0, 2) +entity.update_visibility() +print(f"Entity moved to {entity.cell_pos}") + +check("new cell is VISIBLE", state_map[0, 2] == mcrfpy.Perspective.VISIBLE) +check("old cell demoted to DISCOVERED", state_map[3, 2] == mcrfpy.Perspective.DISCOVERED) +check("at(3, 2) is None once occluded again", entity.at(3, 2) is None) + +if failures: + print(f"Test complete - {len(failures)} FAILURE(S): {failures}") + sys.exit(1) print("Test complete") -sys.exit(0) \ No newline at end of file +print("PASS") +sys.exit(0) diff --git a/tests/integration/trace_interactive.py b/tests/integration/trace_interactive.py index 714ae7c..eed4bb9 100644 --- a/tests/integration/trace_interactive.py +++ b/tests/integration/trace_interactive.py @@ -1,23 +1,107 @@ #!/usr/bin/env python3 -"""Trace interactive mode by monkey-patching""" +"""Tripwire test: the engine must never consult the REPL prompt while running. + +Originally this was a debugging aid -- it monkey-patched sys.ps1 with an object +that printed a stack trace when accessed, then did "nothing else, let the game +run", so a human could see whether the engine dropped into an interactive REPL. +It never asserted anything and never declared an exit status. + +The tripwire is still the right instrument; it just needs to be checked. sys.ps1 +is only read by a read-eval-print loop, so any access to it during an --exec run +means the embedded interpreter fell into interactive mode (which, headless, hangs +the process on stdin). This installs the detector, drives a full headless run +past it (#350: mcrfpy.step() is the clock; screenshot() forces a render), and +fails if the tripwire was touched. + +Sibling force_non_interactive.py asserts the *static* non-interactive properties +(flags, absent prompts, stdin); this one watches the *running* engine. +""" import sys +import os +import traceback import mcrfpy -# Monkey-patch to detect interactive mode -original_ps1 = None -if hasattr(sys, 'ps1'): - original_ps1 = sys.ps1 - +failures = [] +accesses = [] + + +def check(label, condition, detail=""): + if condition: + print(" ok : %s" % label) + else: + print(" FAIL : %s %s" % (label, detail)) + failures.append(label) + + class PS1Detector: - def __repr__(self): - import traceback - print("\n!!! sys.ps1 accessed! Stack trace:") + """Screams (and records) if anything asks for the interactive prompt.""" + + def _trip(self, how): + accesses.append(how) + print("\n!!! sys.ps1 accessed via %s! Stack trace:" % how) traceback.print_stack() return ">>> " - -# Set our detector -sys.ps1 = PS1Detector() -print("Trace script loaded, ps1 detector installed") + def __repr__(self): + return self._trip("__repr__") -# Do nothing else - let the game run \ No newline at end of file + def __str__(self): + return self._trip("__str__") + + +# The engine must not have installed a prompt of its own before we get here. +check("sys.ps1 not preinstalled by engine", not hasattr(sys, "ps1")) + +detector = PS1Detector() +sys.ps1 = detector +print("ps1 detector installed") + +# Now let the engine actually run. In headless mode the engine advances only when +# we drive it, so "letting the game run" means stepping the clock ourselves and +# forcing a render -- both of which are the paths that historically could reenter +# the interpreter. +scene = mcrfpy.Scene("trace_interactive") +mcrfpy.current_scene = scene + +ticks = [] +mcrfpy.Timer("tick", lambda timer, runtime: ticks.append(runtime), 50) + +frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) +scene.children.append(frame) +frame.animate("x", 200.0, 0.2, mcrfpy.Easing.EASE_IN_OUT) + +for _ in range(10): + mcrfpy.step(0.05) + +shot = "trace_interactive_render.png" +mcrfpy.automation.screenshot(shot) +if os.path.exists(shot): + os.remove(shot) + +# The run must have been real, or the tripwire proves nothing. +check("headless clock advanced (timer fired)", len(ticks) >= 1, + "timer fired %d time(s)" % len(ticks)) +check("animation advanced during run", frame.x > 10.0, + "frame.x = %r" % (frame.x,)) + +# The tripwire itself: nothing may have read sys.ps1 during any of that. +check("sys.ps1 never accessed during engine run", not accesses, + "accessed %d time(s): %s" % (len(accesses), accesses)) + +# And the engine must not have reconfigured the interpreter behind our back -- +# our detector should still be the installed prompt, and inspect mode still off. +check("sys.ps1 still our detector", sys.ps1 is detector) +check("sys.flags.inspect == 0", sys.flags.inspect == 0, + "got %r" % (sys.flags.inspect,)) +check("sys.flags.interactive == 0", sys.flags.interactive == 0, + "got %r" % (sys.flags.interactive,)) + +# Leave the interpreter as we found it. +del sys.ps1 + +if failures: + print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures))) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/regression/grid_backward_compat_test.py b/tests/regression/grid_backward_compat_test.py index 5c8b9ee..be2d641 100644 --- a/tests/regression/grid_backward_compat_test.py +++ b/tests/regression/grid_backward_compat_test.py @@ -1,44 +1,61 @@ -"""Regression test for Grid backward compatibility with GridView shim (#252).""" +"""Regression test for Grid/GridView/GridData compatibility (#252, updated for #313/#361). + +The old GridView "shim" model (Grid owns data, .view property auto-creates a separate +GridView object) is gone. Current contract: mcrfpy.Grid IS mcrfpy.GridView (same type); +the view owns rendering state (zoom/center/camera), and the shared map lives in +.grid_data (a mcrfpy.GridData -- cells, entities, layers; not a drawable). + +The intent of each test below is preserved; the assertions were retargeted at the +current contract. +""" import mcrfpy import sys -def test_grid_creates_view(): - """Grid auto-creates a GridView accessible via .view property.""" +def test_grid_creates_grid_data(): + """Grid auto-creates its GridData, accessible via .grid_data.""" tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) - assert grid.view is not None, "Grid should auto-create a view" - assert isinstance(grid.view, mcrfpy.GridView) - print("PASS: Grid auto-creates view") + assert grid.grid_data is not None, "Grid should auto-create its grid data" + assert isinstance(grid.grid_data, mcrfpy.GridData) + # Grid and GridView are the same type now (alias), not shim + shimmed object. + assert mcrfpy.Grid is mcrfpy.GridView, "Grid should be an alias of GridView" + assert isinstance(grid, mcrfpy.GridView) + print("PASS: Grid auto-creates grid data") def test_view_shares_grid_data(): - """GridView created by shim shares the same grid data.""" + """A GridView constructed over an existing Grid shares the same GridData.""" tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) - view = grid.view + view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(160, 160)) - # View's grid should be the same Grid - assert view.grid is grid, "view.grid should be the same Grid" - assert view.grid.grid_w == 10 + # The view must share -- not copy -- the source grid's data. + assert view.grid_data is grid.grid_data, "view should share the Grid's GridData" + assert view.grid_data.grid_w == 10 + # Passing the GridData itself is equivalent. + view2 = mcrfpy.GridView(grid=grid.grid_data, pos=(0, 0), size=(160, 160)) + assert view2.grid_data is grid.grid_data, "GridView(grid=GridData) should share it" print("PASS: view shares grid data") -def test_rendering_property_sync(): - """Setting rendering properties on Grid syncs to the view.""" +def test_rendering_properties(): + """Rendering properties live on the view (Grid) itself and round-trip.""" tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) - view = grid.view grid.zoom = 3.0 - assert abs(view.zoom - 3.0) < 0.01, f"View zoom should sync: {view.zoom}" + assert abs(grid.zoom - 3.0) < 0.01, f"zoom should round-trip: {grid.zoom}" grid.center_x = 200.0 - assert abs(view.center.x - 200.0) < 0.01, f"View center_x should sync: {view.center.x}" + assert abs(grid.center.x - 200.0) < 0.01, f"center.x should track center_x: {grid.center.x}" grid.center_y = 150.0 - assert abs(view.center.y - 150.0) < 0.01, f"View center_y should sync: {view.center.y}" + assert abs(grid.center.y - 150.0) < 0.01, f"center.y should track center_y: {grid.center.y}" grid.camera_rotation = 45.0 - # camera_rotation syncs through set_float_member - print("PASS: rendering properties sync to view") + assert abs(grid.camera_rotation - 45.0) < 0.01, f"camera_rotation should round-trip: {grid.camera_rotation}" + + # Rendering state is view-only; it must not have leaked onto the shared map. + assert not hasattr(grid.grid_data, "zoom"), "GridData must not carry rendering state" + print("PASS: rendering properties live on the view") def test_grid_still_works_in_scene(): """Grid can still be appended to scenes and works as before.""" @@ -52,7 +69,8 @@ def test_grid_still_works_in_scene(): # Grid is in the scene as itself (not substituted) assert len(scene.children) == 1 retrieved = scene.children[0] - assert type(retrieved).__name__ == "Grid", f"Expected Grid, got {type(retrieved).__name__}" + assert retrieved is grid, "scene.children should return the same object (#369)" + assert type(retrieved).__name__ == "GridView", f"Expected GridView, got {type(retrieved).__name__}" print("PASS: Grid works in scene as before") def test_grid_subclass_preserved(): @@ -80,47 +98,66 @@ def test_entity_operations_unaffected(): grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320)) scene.children.append(grid) + data = grid.grid_data for y in range(20): for x in range(20): - grid.at(x, y).walkable = True - grid.at(x, y).transparent = True + data.at(x, y).walkable = True + data.at(x, y).transparent = True e = mcrfpy.Entity((5, 5), grid=grid) - assert len(grid.entities) == 1 + assert len(data.entities) == 1 assert e.cell_x == 5 assert e.cell_y == 5 - assert len(grid.at(5, 5).entities) == 1 + assert len(data.at(5, 5).entities) == 1 + # Current contract (#313/#361): entity.grid is the shared GridData, not the view. + assert e.grid is data, "entity.grid should be the GridData it lives in" print("PASS: entity operations unaffected") def test_gridview_independent_rendering(): - """A GridView can have different rendering settings from the Grid.""" + """A second GridView over the same data has independent rendering settings.""" scene = mcrfpy.Scene("test_independent") mcrfpy.current_scene = scene tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320)) - # Create an explicit GridView with different settings + # Create an explicit second view over the same grid data, with different settings view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5) scene.children.append(grid) scene.children.append(view2) + assert view2.grid_data is grid.grid_data, "both views should share one GridData" + # Grid and view2 should have different zoom assert abs(grid.zoom - 1.0) < 0.01 assert abs(view2.zoom - 0.5) < 0.01 - # Changing grid zoom doesn't affect explicit GridView + # Changing grid zoom doesn't affect the second view grid.zoom = 2.0 - assert abs(view2.zoom - 0.5) < 0.01, "Explicit GridView should keep its own zoom" + assert abs(view2.zoom - 0.5) < 0.01, "Second GridView should keep its own zoom" + assert abs(grid.zoom - 2.0) < 0.01 print("PASS: GridView independent rendering") if __name__ == "__main__": - test_grid_creates_view() - test_view_shares_grid_data() - test_rendering_property_sync() - test_grid_still_works_in_scene() - test_grid_subclass_preserved() - test_entity_operations_unaffected() - test_gridview_independent_rendering() + tests = [ + test_grid_creates_grid_data, + test_view_shares_grid_data, + test_rendering_properties, + test_grid_still_works_in_scene, + test_grid_subclass_preserved, + test_entity_operations_unaffected, + test_gridview_independent_rendering, + ] + failures = 0 + for t in tests: + try: + t() + except AssertionError as e: + failures += 1 + print(f"FAIL: {t.__name__}: {e}") + if failures: + print(f"FAILED: {failures} of {len(tests)} tests failed") + sys.exit(1) print("All backward compatibility tests passed") + print("PASS") sys.exit(0) diff --git a/tests/regression/issue_123_chunk_system_test.py b/tests/regression/issue_123_chunk_system_test.py index 321eb0b..ab943e0 100644 --- a/tests/regression/issue_123_chunk_system_test.py +++ b/tests/regression/issue_123_chunk_system_test.py @@ -12,9 +12,18 @@ while small grids use the original flat storage. Verifies that: NOTE: This test uses ColorLayer for color operations since cell.color is no longer supported. The chunk system affects internal storage, which ColorLayer also uses. + +API notes (current contract): +- add_layer() takes a layer OBJECT and no keyword arguments: + grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) +- ColorLayer.set()/at() take a POSITION tuple, not separate x/y args to set(). +- GridPoint no longer has .tilesprite; cell read/write coverage is preserved + by exercising .walkable (a real per-cell field that lives in the same + chunked storage the issue is about). """ import mcrfpy +from mcrfpy import automation import sys def test_small_grid(): @@ -23,23 +32,28 @@ def test_small_grid(): # Small grid should use flat storage grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400)) - color_layer = grid.add_layer("color", z_index=-1) + color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Set some cells for y in range(50): for x in range(50): cell = grid.at(x, y) - color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255)) - cell.tilesprite = -1 + color_layer.set((x, y), mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255)) + cell.walkable = ((x + y) % 2 == 0) # Verify cells expected_r = (25 * 5) % 256 expected_g = (25 * 5) % 256 - color = color_layer.at(25, 25) + color = color_layer.at((25, 25)) if color.r != expected_r or color.g != expected_g: print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})") return False + # Verify per-cell (non-color) storage round-trips + if not grid.at(24, 24).walkable or grid.at(25, 24).walkable: + print("FAIL: Small grid cell walkable mismatch") + return False + print(" Small grid: PASS") return True @@ -49,7 +63,7 @@ def test_large_grid(): # Large grid should use chunk storage (100 > 64) grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) - color_layer = grid.add_layer("color", z_index=-1) + color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Set cells across multiple chunks # Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks @@ -65,15 +79,18 @@ def test_large_grid(): for x, y in test_points: cell = grid.at(x, y) - color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255)) - cell.tilesprite = -1 + color_layer.set((x, y), mcrfpy.Color(x, y, 100, 255)) + cell.walkable = False # Verify cells for x, y in test_points: - color = color_layer.at(x, y) + color = color_layer.at((x, y)) if color.r != x or color.g != y: print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})") return False + if grid.at(x, y).walkable: + print(f"FAIL: Large grid cell ({x},{y}) walkable not persisted across chunk boundary") + return False print(" Large grid cell access: PASS") return True @@ -84,7 +101,7 @@ def test_very_large_grid(): # 500x500 = 250,000 cells, should use ~64 chunks (8x8) grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400)) - color_layer = grid.add_layer("color", z_index=-1) + color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Set some cells at various positions test_points = [ @@ -98,11 +115,11 @@ def test_very_large_grid(): ] for x, y in test_points: - color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255)) + color_layer.set((x, y), mcrfpy.Color(x % 256, y % 256, 200, 255)) # Verify for x, y in test_points: - color = color_layer.at(x, y) + color = color_layer.at((x, y)) if color.r != (x % 256) or color.g != (y % 256): print(f"FAIL: Very large grid cell ({x},{y}) color mismatch") return False @@ -116,18 +133,18 @@ def test_boundary_case(): # 64x64 should use flat storage (not exceeding threshold) grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400)) - color_layer_64 = grid_64.add_layer("color", z_index=-1) - color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255)) - color = color_layer_64.at(63, 63) + color_layer_64 = grid_64.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) + color_layer_64.set((63, 63), mcrfpy.Color(255, 0, 0, 255)) + color = color_layer_64.at((63, 63)) if color.r != 255: print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}") return False # 65x65 should use chunk storage (exceeding threshold) grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400)) - color_layer_65 = grid_65.add_layer("color", z_index=-1) - color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255)) - color = color_layer_65.at(64, 64) + color_layer_65 = grid_65.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) + color_layer_65.set((64, 64), mcrfpy.Color(0, 255, 0, 255)) + color = color_layer_65.at((64, 64)) if color.g != 255: print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}") return False @@ -141,16 +158,16 @@ def test_edge_cases(): # Create 100x100 grid grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) - color_layer = grid.add_layer("color", z_index=-1) + color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) # Test all corners corners = [(0, 0), (99, 0), (0, 99), (99, 99)] for i, (x, y) in enumerate(corners): - color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255)) + color_layer.set((x, y), mcrfpy.Color(i * 60, i * 60, i * 60, 255)) for i, (x, y) in enumerate(corners): expected = i * 60 - color = color_layer.at(x, y) + color = color_layer.at((x, y)) if color.r != expected: print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}") return False @@ -158,6 +175,31 @@ def test_edge_cases(): print(" Edge cases: PASS") return True +def test_rendering(scene): + """Test that both storage modes actually render (flat + chunked on screen)""" + print("Testing rendering of flat and chunked grids...") + + small = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(200, 200)) + small_colors = small.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) + large = mcrfpy.Grid(grid_size=(100, 100), pos=(220, 10), size=(200, 200)) + large_colors = large.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) + + for i in range(50): + small_colors.set((i, i), mcrfpy.Color(255, 0, 0, 255)) + for i in range(100): + large_colors.set((i, i), mcrfpy.Color(0, 0, 255, 255)) + + scene.children.append(small) + scene.children.append(large) + + # Rendering is orthogonal to sim time; screenshot forces a render pass. + if not automation.screenshot("issue_123_chunk_render.png"): + print("FAIL: screenshot of flat + chunked grids failed") + return False + + print(" Rendering: PASS") + return True + # Main if __name__ == "__main__": print("=" * 60) @@ -173,9 +215,11 @@ if __name__ == "__main__": results.append(test_very_large_grid()) results.append(test_boundary_case()) results.append(test_edge_cases()) + results.append(test_rendering(test)) if all(results): print("\n=== ALL TESTS PASSED ===") + print("PASS") sys.exit(0) else: print("\n=== SOME TESTS FAILED ===") diff --git a/tests/regression/issue_146_fov_returns_none.py b/tests/regression/issue_146_fov_returns_none.py index 40cae15..1ecd975 100644 --- a/tests/regression/issue_146_fov_returns_none.py +++ b/tests/regression/issue_146_fov_returns_none.py @@ -22,7 +22,7 @@ print("=" * 60) test = mcrfpy.Scene("test") mcrfpy.current_scene = test ui = test.children -texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(pos=(0,0), size=(400,300), grid_size=(50, 50), texture=texture) ui.append(grid) @@ -34,13 +34,15 @@ for y in range(50): cell.walkable = True cell.transparent = True -# Add some walls to test blocking -for i in range(10, 20): - grid.at(i, 25).transparent = False - grid.at(i, 25).walkable = False +# Add a wall that actually occludes: a vertical run at x=20, spanning the +# rows around the viewer at (25,25). Cells at x < 20 on row 25 are then +# within radius but geometrically behind the wall. +for j in range(20, 31): + grid.at(20, j).transparent = False + grid.at(20, j).walkable = False print("\n--- Test 1: compute_fov() returns None ---") -result = grid.compute_fov(25, 25, radius=10) +result = grid.compute_fov((25, 25), radius=10) if result is None: print(" PASS: compute_fov() returned None") else: @@ -55,16 +57,16 @@ else: print(" FAIL: Center should be in FOV") sys.exit(1) -# Cell within radius should be visible -if grid.is_in_fov(20, 25): - print(" PASS: Cell (20,25) within radius is in FOV") +# Cell within radius and in front of the wall should be visible +if grid.is_in_fov(22, 25): + print(" PASS: Cell (22,25) within radius is in FOV") else: - print(" FAIL: Cell (20,25) should be in FOV") + print(" FAIL: Cell (22,25) should be in FOV") sys.exit(1) -# Cell behind wall should NOT be visible -if not grid.is_in_fov(15, 30): - print(" PASS: Cell (15,30) behind wall is NOT in FOV") +# Cell behind the wall (within radius) should NOT be visible +if not grid.is_in_fov(18, 25): + print(" PASS: Cell (18,25) behind wall is NOT in FOV") else: print(" FAIL: Cell behind wall should not be in FOV") sys.exit(1) @@ -89,7 +91,7 @@ for y in range(0, 200, 5): # Sample for speed times = [] for i in range(5): t0 = time.perf_counter() - grid_large.compute_fov(100, 100, radius=15) + grid_large.compute_fov((100, 100), radius=15) elapsed = (time.perf_counter() - t0) * 1000 times.append(elapsed) diff --git a/tests/regression/issue_147_grid_layers.py b/tests/regression/issue_147_grid_layers.py index 42a56b5..2cfc79e 100644 --- a/tests/regression/issue_147_grid_layers.py +++ b/tests/regression/issue_147_grid_layers.py @@ -7,6 +7,13 @@ Tests: 2. TileLayer creation and manipulation 3. Layer z_index ordering relative to entities 4. Layer management (add_layer, remove_layer, layers property) + +API notes (current contract): +- Layers are constructed standalone (TileLayer/ColorLayer ctors take kwargs) and then + attached with grid_data.add_layer(layer) -- add_layer takes an object, not kwargs. +- Layer management (add_layer / remove_layer / layer) lives on GridData + (grid.grid_data); the Grid view exposes the read-only `layers` tuple. +- Layers are looked up BY NAME, not by z_index (grid_data.layer(name)). """ import mcrfpy import sys @@ -19,21 +26,22 @@ print("=" * 60) test = mcrfpy.Scene("test") mcrfpy.current_scene = test ui = test.children -texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) # Create grid with explicit empty layers (#150 migration) -grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers={}) +grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers=[]) ui.append(grid) +grid_data = grid.grid_data print("\n--- Test 1: Initial state (no layers) ---") if len(grid.layers) == 0: - print(" PASS: Grid starts with no layers (layers={})") + print(" PASS: Grid starts with no layers (layers=[])") else: print(f" FAIL: Expected 0 layers, got {len(grid.layers)}") sys.exit(1) print("\n--- Test 2: Add ColorLayer ---") -color_layer = grid.add_layer("color", z_index=-1) +color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1)) print(f" Created: {color_layer}") if color_layer is not None: print(" PASS: ColorLayer created") @@ -63,7 +71,7 @@ else: print("\n--- Test 3: ColorLayer cell access ---") # Set a color -color_layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128)) +color_layer.set((5, 5), mcrfpy.Color(255, 0, 0, 128)) color = color_layer.at(5, 5) if color.r == 255 and color.g == 0 and color.b == 0 and color.a == 128: print(f" PASS: Color at (5,5) is {color.r}, {color.g}, {color.b}, {color.a}") @@ -81,7 +89,7 @@ else: sys.exit(1) print("\n--- Test 4: Add TileLayer ---") -tile_layer = grid.add_layer("tile", z_index=-2, texture=texture) +tile_layer = grid_data.add_layer(mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture)) print(f" Created: {tile_layer}") if tile_layer is not None: print(" PASS: TileLayer created") @@ -97,7 +105,7 @@ else: print("\n--- Test 5: TileLayer cell access ---") # Set a tile -tile_layer.set(3, 3, 42) +tile_layer.set((3, 3), 42) tile = tile_layer.at(3, 3) if tile == 42: print(f" PASS: Tile at (3,3) is {tile}") @@ -129,30 +137,34 @@ else: print(f" FAIL: Layers not sorted") sys.exit(1) -print("\n--- Test 7: Get layer by z_index ---") -layer = grid.layer(-1) -if layer is not None and layer.z_index == -1: - print(" PASS: grid.layer(-1) returns ColorLayer") +print("\n--- Test 7: Get layer by name ---") +# Layer lookup is by NAME now (grid.layer(z_index) no longer exists); z_index is still +# the ordering key, verified in Test 6. Note: layer lookups return a fresh wrapper each +# call (layers are not in PythonObjectCache), so compare identity via the shared data, +# not `is`. +layer = grid_data.layer("color") +if layer is not None and layer.z_index == -1 and layer.at(5, 5).b == color_layer.at(5, 5).b: + print(" PASS: grid_data.layer('color') returns the ColorLayer") else: - print(" FAIL: Could not get layer by z_index") + print(" FAIL: Could not get ColorLayer by name") sys.exit(1) -layer = grid.layer(-2) -if layer is not None and layer.z_index == -2: - print(" PASS: grid.layer(-2) returns TileLayer") +layer = grid_data.layer("tile") +if layer is not None and layer.z_index == -2 and layer.at(3, 3) == tile_layer.at(3, 3): + print(" PASS: grid_data.layer('tile') returns the TileLayer") else: - print(" FAIL: Could not get layer by z_index") + print(" FAIL: Could not get TileLayer by name") sys.exit(1) -layer = grid.layer(999) +layer = grid_data.layer("nonexistent") if layer is None: - print(" PASS: grid.layer(999) returns None for non-existent layer") + print(" PASS: grid_data.layer('nonexistent') returns None for non-existent layer") else: print(" FAIL: Should return None for non-existent layer") sys.exit(1) print("\n--- Test 8: Layer above entities (z_index >= 0) ---") -fog_layer = grid.add_layer("color", z_index=1) +fog_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="fog", z_index=1)) if fog_layer.z_index == 1: print(" PASS: Created layer with z_index=1 (above entities)") else: @@ -165,7 +177,7 @@ print(" PASS: Fog layer filled") print("\n--- Test 9: Remove layer ---") initial_count = len(grid.layers) -grid.remove_layer(fog_layer) +grid_data.remove_layer(fog_layer) final_count = len(grid.layers) if final_count == initial_count - 1: print(f" PASS: Layer removed ({initial_count} -> {final_count})") diff --git a/tests/regression/issue_148_layer_dirty_flags.py b/tests/regression/issue_148_layer_dirty_flags.py index b5be89b..410ca73 100644 --- a/tests/regression/issue_148_layer_dirty_flags.py +++ b/tests/regression/issue_148_layer_dirty_flags.py @@ -7,12 +7,64 @@ Tests: 2. Setting cell values marks layer dirty 3. Fill operation marks layer dirty 4. Texture change marks TileLayer dirty -5. Viewport changes (center/zoom) don't trigger re-render (static benchmark) -6. Performance improvement for static layers +5. Viewport changes (center/zoom) don't corrupt the cached texture +6. Performance / large-layer handling for static layers +7. Layer visibility toggle marks the layer dirty +8. Large grid stress test + +REPAIR NOTE (API drift): + * add_layer() no longer takes kwargs and no longer constructs the layer for you. + Construct a TileLayer/ColorLayer and attach it: grid.add_layer(layer). + * layer.set() takes a position TUPLE: layer.set((x, y), value) -- not set(x, y, value). + * assets/kenney_ice.png does not exist; using kenney_tinydungeon.png. + * step() is the clock and NEVER renders (#350); rendering is forced with + automation.screenshot(), which costs zero simulation time. + +REPAIR NOTE (real coverage): + The original file punted on the actual subject of #148 -- it said "dirty flag + behavior is internal" and only checked that the API didn't crash, so it proved + nothing about caching. Dirty-flag behavior IS observable from Python now: the + GridView caches its composed output and only re-renders when a layer's markDirty() + bumps the grid's content_generation (#351). So a mutation that fails to set the + dirty flag produces a byte-identical screenshot -- a stale cache. We render to PNG + and compare hashes to assert invalidation actually happens. """ import mcrfpy +from mcrfpy import automation import sys -import time +import os +import hashlib +import tempfile + +failures = [] + + +def check(label, condition, detail=""): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label} {detail}") + failures.append(label) + + +_shot_n = [0] + + +def render_hash(): + """Force a render (screenshot) and hash the pixels. + + step() never renders, so this is the only way to observe what the grid's cached + RenderTexture actually contains. Identical bytes across a mutation == stale cache + == the dirty flag was not set. + """ + _shot_n[0] += 1 + path = os.path.join(tempfile.gettempdir(), f"issue148_{_shot_n[0]}.png") + automation.screenshot(path) + with open(path, "rb") as f: + h = hashlib.sha256(f.read()).hexdigest() + os.remove(path) + return h + print("=" * 60) print("Issue #148 Regression Test: Layer Dirty Flags and Caching") @@ -22,126 +74,186 @@ print("=" * 60) test = mcrfpy.Scene("test") mcrfpy.current_scene = test ui = test.children -texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) # Create grid with larger size for performance testing grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture) ui.append(grid) print("\n--- Test 1: Layer creation (starts dirty) ---") -color_layer = grid.add_layer("color", z_index=-1) -# The layer should be dirty initially -# We can't directly check dirty flag from Python, but we verify the system works +# A grid built with grid_size= already carries a default tile layer; these are added +# on top of it. Layers attached at size (0,0) auto-resize to the grid. +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.add_layer(color_layer) print(" ColorLayer created successfully") -tile_layer = grid.add_layer("tile", z_index=-2, texture=texture) +tile_layer = mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture) +grid.add_layer(tile_layer) print(" TileLayer created successfully") -print(" PASS: Layers created") -print("\n--- Test 2: Fill operations work ---") -# Fill with some data +check("ColorLayer attached and retrievable by name", grid.layer("color") is not None) +check("TileLayer attached and retrievable by name", grid.layer("tile") is not None) +check("layer auto-resized to grid", color_layer.grid_size == (50, 40), + f"got {color_layer.grid_size}") + +print("\n--- Test 2: Fill operations work (and mark the layer dirty) ---") color_layer.fill(mcrfpy.Color(128, 0, 128, 64)) -print(" ColorLayer filled with purple overlay") +check("ColorLayer.fill wrote every cell", color_layer.at((0, 0)) == mcrfpy.Color(128, 0, 128, 64) + and color_layer.at((49, 39)) == mcrfpy.Color(128, 0, 128, 64), + f"got {color_layer.at((0, 0))} / {color_layer.at((49, 39))}") -tile_layer.fill(5) # Fill with tile index 5 -print(" TileLayer filled with tile index 5") -print(" PASS: Fill operations completed") +tile_layer.fill(5) +check("TileLayer.fill wrote every cell", + tile_layer.at((0, 0)) == 5 and tile_layer.at((49, 39)) == 5, + f"got {tile_layer.at((0, 0))} / {tile_layer.at((49, 39))}") print("\n--- Test 3: Cell set operations work ---") -# Set individual cells -color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128)) -color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128)) -color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128)) -color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128)) -print(" Set 4 cells in ColorLayer to yellow") +yellow = mcrfpy.Color(255, 255, 0, 128) +for cell in [(10, 10), (11, 10), (10, 11), (11, 11)]: + color_layer.set(cell, yellow) +check("ColorLayer.set updated the 4 target cells", + all(color_layer.at(c) == yellow for c in [(10, 10), (11, 10), (10, 11), (11, 11)])) +check("ColorLayer.set left neighbours untouched", + color_layer.at((12, 10)) == mcrfpy.Color(128, 0, 128, 64), + f"got {color_layer.at((12, 10))}") -tile_layer.set(15, 15, 10) -tile_layer.set(16, 15, 11) -tile_layer.set(15, 16, 10) -tile_layer.set(16, 16, 11) -print(" Set 4 cells in TileLayer to different tiles") -print(" PASS: Cell set operations completed") +for cell, idx in [((15, 15), 10), ((16, 15), 11), ((15, 16), 10), ((16, 16), 11)]: + tile_layer.set(cell, idx) +check("TileLayer.set updated the 4 target cells", + tile_layer.at((15, 15)) == 10 and tile_layer.at((16, 15)) == 11 + and tile_layer.at((15, 16)) == 10 and tile_layer.at((16, 16)) == 11) +check("TileLayer.set(-1) clears a tile", + (tile_layer.set((20, 20), -1), tile_layer.at((20, 20)))[1] == -1, + f"got {tile_layer.at((20, 20))}") print("\n--- Test 4: Texture change on TileLayer ---") -# Create a second texture and assign it -texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) +# Note: the .texture getter returns a fresh wrapper each call and Texture has no +# __eq__, so identity/equality can't be asserted -- compare the source path instead. +texture2 = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16) tile_layer.texture = texture2 -print(" Changed TileLayer texture") +check("TileLayer.texture accepts a new texture", + tile_layer.texture.source == "assets/kenney_TD_MR_IP.png", + f"got {tile_layer.texture.source}") -# Set back to original tile_layer.texture = texture -print(" Restored original texture") -print(" PASS: Texture changes work") +check("TileLayer.texture restores the original", + tile_layer.texture.source == "assets/kenney_tinydungeon.png", + f"got {tile_layer.texture.source}") -print("\n--- Test 5: Viewport changes (should use cached texture) ---") -# Pan around - these should NOT cause layer re-renders (just blit different region) +print("\n--- Test 5: Dirty flags actually invalidate the cached RenderTexture ---") +# This is the core of #148. The grid caches its composed output; a mutation that does +# not set the dirty flag yields a byte-identical render (a stale cache). +# The ColorLayer (z=-1) composites OVER the TileLayer (z=-2), so it is kept +# translucent -- an opaque fill would occlude the tile layer and make the TileLayer +# invalidation checks below vacuous. +base = render_hash() +repeat = render_hash() +check("re-rendering an unchanged grid is stable", base == repeat) + +color_layer.fill(mcrfpy.Color(255, 0, 0, 64)) +after_fill = render_hash() +check("fill() invalidates the cache (render changed)", after_fill != base) + +color_layer.set((0, 0), mcrfpy.Color(0, 255, 0, 64)) +after_set = render_hash() +check("set() invalidates the cache (render changed)", after_set != after_fill) + +tile_layer.fill(7) +after_tile_fill = render_hash() +check("TileLayer.fill() invalidates the cache", after_tile_fill != after_set) + +tile_layer.set((5, 5), 12) +after_tile_set = render_hash() +check("TileLayer.set() invalidates the cache", after_tile_set != after_tile_fill) + +# Bulk edit path (#328): the view is a 2-D int32 memoryview aliasing layer storage; +# on __exit__ the whole layer is conservatively invalidated. +with tile_layer.edit() as view: + for y in range(40): + for x in range(50): + view[y, x] = 3 +check("bulk edit() wrote through to layer storage", tile_layer.at((5, 5)) == 3, + f"got {tile_layer.at((5, 5))}") +after_edit = render_hash() +check("bulk edit() invalidates the cache", after_edit != after_tile_set) + +print("\n--- Test 6: Viewport changes (should use the cached texture) ---") original_center = grid.center -print(f" Original center: {original_center}") +original_zoom = grid.zoom +print(f" Original center: {original_center}, zoom: {original_zoom}") -# Perform multiple viewport changes for i in range(10): grid.center = (100 + i * 20, 80 + i * 10) -print(" Performed 10 center changes") +check("center round-trips after 10 changes", grid.center == (280, 170), + f"got {grid.center}") +panned = render_hash() +check("panning the viewport changes the rendered image", panned != after_edit) -# Zoom changes -original_zoom = grid.zoom for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]: grid.zoom = z -print(" Performed 6 zoom changes") +check("zoom round-trips after 6 changes", grid.zoom == 1.0, f"got {grid.zoom}") -# Restore +# Restoring the viewport must restore the exact same image: the layer content never +# changed, so this exercises the cache-hit path (blit a different region, no re-render). grid.center = original_center grid.zoom = original_zoom -print(" PASS: Viewport changes completed without crashing") - -print("\n--- Test 6: Performance benchmark ---") -# Create a large layer for performance testing -perf_grid = mcrfpy.Grid(pos=(50, 50), size=(600, 500), grid_size=(100, 80), texture=texture) -ui.append(perf_grid) -perf_layer = perf_grid.add_layer("tile", z_index=-1, texture=texture) - -# Fill with data -perf_layer.fill(1) - -# Render a frame to build cache -mcrfpy.step(0.01) - -# Subsequent viewport changes should be fast (cache hit) -start = time.time() -for i in range(5): - perf_grid.center = (200 + i * 10, 160 + i * 8) -viewport_changes = time.time() - start -print(f" 5 viewport changes: {viewport_changes*1000:.2f}ms") - -print(" PASS: Performance benchmark completed") +restored = render_hash() +check("restoring the viewport restores the identical image", restored == after_edit) print("\n--- Test 7: Layer visibility toggle ---") +# The layer's own render() early-outs on `visible`, so hiding a layer must change the +# rendered output. It only does so if the setter invalidates the grid's cache. +visible_hash = render_hash() color_layer.visible = False -print(" ColorLayer hidden") +check("visible attribute round-trips to False", color_layer.visible is False) +hidden_hash = render_hash() +check("hiding a layer changes the rendered image (dirty flag set)", + hidden_hash != visible_hash, + "-- layer.visible setter does not invalidate the grid cache (see notes)") + color_layer.visible = True -print(" ColorLayer shown") -print(" PASS: Visibility toggle works") +check("visible attribute round-trips to True", color_layer.visible is True) +reshown_hash = render_hash() +check("re-showing a layer restores the rendered image", + reshown_hash == visible_hash, + "-- layer.visible setter does not invalidate the grid cache (see notes)") print("\n--- Test 8: Large grid stress test ---") -# Test with maximum size grid to ensure texture caching works +stress_scene = mcrfpy.Scene("stress") stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture) -ui.append(stress_grid) -stress_layer = stress_grid.add_layer("color", z_index=-1) +stress_scene.children.append(stress_grid) +stress_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +stress_grid.add_layer(stress_layer) -# This would be 30,000 cells - should handle via caching +# 30,000 cells - should be handled via chunked texture caching stress_layer.fill(mcrfpy.Color(0, 100, 200, 100)) - -# Set a few specific cells for x in range(10): for y in range(10): - stress_layer.set(x, y, mcrfpy.Color(255, 0, 0, 200)) + stress_layer.set((x, y), mcrfpy.Color(255, 0, 0, 200)) -print(" Created 200x150 grid with 30,000 cells") -print(" PASS: Large grid handled successfully") +check("30,000-cell layer filled", stress_layer.at((199, 149)) == mcrfpy.Color(0, 100, 200, 100), + f"got {stress_layer.at((199, 149))}") +check("30,000-cell layer accepts per-cell sets", + stress_layer.at((9, 9)) == mcrfpy.Color(255, 0, 0, 200), + f"got {stress_layer.at((9, 9))}") + +mcrfpy.current_scene = stress_scene +mcrfpy.step(0.016) +stress_render = render_hash() +check("large grid renders without crashing", len(stress_render) == 64) + +# A static layer must be re-renderable from cache: identical bytes, no corruption. +check("static large layer renders identically from cache", + render_hash() == stress_render) print("\n" + "=" * 60) +if failures: + print(f"FAIL - {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + print("=" * 60) + sys.exit(1) + print("All tests PASSED") print("=" * 60) -print("\nNote: Dirty flag behavior is internal - tests verify API works") -print("Actual caching benefits are measured by render performance.") sys.exit(0) diff --git a/tests/regression/issue_258_gridstate_resize_test.py b/tests/regression/issue_258_gridstate_resize_test.py index a25ef34..10a1657 100644 --- a/tests/regression/issue_258_gridstate_resize_test.py +++ b/tests/regression/issue_258_gridstate_resize_test.py @@ -1,4 +1,4 @@ -"""Regression test: entity gridstate must resize when moving between grids. +"""Regression test: entity perspective map must resize when moving between grids. Issues #258-#263, #274, #276, #278: UIEntity gridstate heap overflows. @@ -7,139 +7,159 @@ moved from a small grid to a larger grid via ANY transfer method, gridstate kept the old size. Code then iterated using the new grid's dimensions, writing past the vector's end. -Fix: ensureGridstate() unconditionally checks gridstate.size() against -grid dimensions and resizes if they don't match. Applied to all transfer -methods: set_grid, append, extend, insert, setitem, slice assignment. +Fix: the per-entity visibility buffer unconditionally checks its size against +the grid dimensions and reallocates if they don't match. Exercised here through +every transfer method: set_grid, append, extend, insert, setitem, slice assign. Also tests #274: spatial_hash.remove() must be called when removing entities from grids via set_grid(None) or set_grid(other_grid). + +API NOTE (#294): entity.gridstate is now entity.perspective_map, a bounds-checked +DiscreteMap (UNKNOWN/DISCOVERED/VISIBLE) carrying its own .size, and it is *lazy* -- +a transfer marks it stale and the next update_visibility() sizes it to the new grid +(see UIEntity::updateVisibility, src/UIEntity.cpp:51). So each case below transfers, +then calls update_visibility(), then asserts the buffer matches the new grid and that +cells only reachable in the NEW (larger) grid are addressable. Under the old bug those +reads/writes ran off the end of the old buffer. """ import mcrfpy import sys +def open_grid(size): + """Grid of `size` x `size` with all cells transparent/walkable, FOV enabled.""" + grid = mcrfpy.Grid(grid_size=(size, size)) + data = grid.grid_data + for x in range(size): + for y in range(size): + point = data.at(x, y) + point.transparent = True + point.walkable = True + data.fov_radius = 4 + return grid + +def check_sized_to(entity, grid, label): + """Buffer must match the grid, and the grid's far corner must be addressable.""" + entity.update_visibility() + pmap = entity.perspective_map + w, h = grid.grid_data.grid_w, grid.grid_data.grid_h + assert pmap.size == (w, h), f"{label}: expected {(w, h)}, got {pmap.size}" + # Under #258 this read walked off the end of the stale, smaller buffer. + pmap.get(w - 1, h - 1) + def test_set_grid(): - """entity.grid = new_grid resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(50, 50)) + """entity.grid = new_grid resizes the perspective map""" + small = open_grid(10) + large = open_grid(50) entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small) small.perspective = entity - small.fov_radius = 4 - entity.update_visibility() - - gs = entity.gridstate - assert len(gs) == 100, f"Expected 100, got {len(gs)}" + check_sized_to(entity, small, "set_grid/small") entity.grid = large - gs = entity.gridstate - assert len(gs) == 2500, f"Expected 2500, got {len(gs)}" - large.perspective = entity - large.fov_radius = 8 - entity.update_visibility() + check_sized_to(entity, large, "set_grid/large") print(" PASS: set_grid") def test_append(): - """grid.entities.append(entity) resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(40, 40)) + """grid.entities.append(entity) resizes the perspective map""" + small = open_grid(10) + large = open_grid(40) entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small) - entity.update_visibility() - - gs = entity.gridstate - assert len(gs) == 100, f"Expected 100, got {len(gs)}" + check_sized_to(entity, small, "append/small") large.entities.append(entity) - gs = entity.gridstate - assert len(gs) == 1600, f"Expected 1600, got {len(gs)}" + check_sized_to(entity, large, "append/large") print(" PASS: append") def test_extend(): - """grid.entities.extend([entity]) resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(30, 30)) + """grid.entities.extend([entity]) resizes the perspective map""" + small = open_grid(10) + large = open_grid(30) entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small) - entity.update_visibility() + check_sized_to(entity, small, "extend/small") large.entities.extend([entity]) - gs = entity.gridstate - assert len(gs) == 900, f"Expected 900, got {len(gs)}" + check_sized_to(entity, large, "extend/large") print(" PASS: extend") def test_insert(): - """grid.entities.insert(0, entity) resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(25, 25)) + """grid.entities.insert(0, entity) resizes the perspective map""" + small = open_grid(10) + large = open_grid(25) entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small) - entity.update_visibility() + check_sized_to(entity, small, "insert/small") large.entities.insert(0, entity) - gs = entity.gridstate - assert len(gs) == 625, f"Expected 625, got {len(gs)}" + check_sized_to(entity, large, "insert/large") print(" PASS: insert") def test_setitem(): - """grid.entities[0] = entity resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(20, 20)) + """grid.entities[0] = entity resizes the perspective map""" + small = open_grid(10) + large = open_grid(20) entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small) - entity.update_visibility() + check_sized_to(entity, small, "setitem/small") # Need a placeholder entity in large grid first placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large) large.entities[0] = entity - gs = entity.gridstate - assert len(gs) == 400, f"Expected 400, got {len(gs)}" + assert len(large.entities) == 1 + check_sized_to(entity, large, "setitem/large") print(" PASS: setitem") def test_slice_assign(): - """grid.entities[0:1] = [entity] resizes gridstate""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(35, 35)) + """grid.entities[0:1] = [entity] resizes the perspective map""" + small = open_grid(10) + large = open_grid(35) entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small) - entity.update_visibility() + check_sized_to(entity, small, "slice/small") placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large) large.entities[0:1] = [entity] - gs = entity.gridstate - assert len(gs) == 1225, f"Expected 1225, got {len(gs)}" + check_sized_to(entity, large, "slice/large") print(" PASS: slice_assign") def test_update_visibility_after_transfer(): - """update_visibility works correctly after all transfer methods""" - grids = [mcrfpy.Grid(grid_size=(s, s)) for s in (5, 80, 3, 60, 10, 100)] + """update_visibility works correctly after repeated grow/shrink transfers""" + grids = [open_grid(s) for s in (5, 80, 3, 60, 10, 100)] entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0]) for g in grids: entity.grid = g g.perspective = entity - g.fov_radius = 4 - entity.update_visibility() - gs = entity.gridstate - expected = g.grid_w * g.grid_h - assert len(gs) == expected, f"Expected {expected}, got {len(gs)}" + check_sized_to(entity, g, f"cycle/{g.grid_data.grid_w}") print(" PASS: update_visibility_after_transfer") def test_at_after_transfer(): """entity.at(x, y) works correctly after grid transfer""" - small = mcrfpy.Grid(grid_size=(10, 10)) - large = mcrfpy.Grid(grid_size=(50, 50)) + small = open_grid(10) + large = open_grid(50) entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small) entity.update_visibility() entity.grid = large - # Access a cell that would be out of bounds for the small grid + large.perspective = entity + # Stand on a cell that is out of bounds for the small grid entirely. + entity.grid_pos = (30, 30) + entity.update_visibility() + + # at() returns the GridPoint only when the cell is VISIBLE to this entity. state = entity.at(30, 30) - assert state is not None + assert state is not None, "entity's own cell must be visible after transfer" + assert tuple(state.grid_pos) == (30, 30), f"got {tuple(state.grid_pos)}" + # Far side of the new grid: addressable (not a heap read), just not visible. + assert entity.at(5, 5) is None, "cell outside FOV must report as not visible" + assert entity.perspective_map.get(49, 49) == mcrfpy.Perspective.UNKNOWN print(" PASS: at_after_transfer") def test_set_grid_none(): """entity.grid = None properly removes entity (tests #274)""" - grid = mcrfpy.Grid(grid_size=(10, 10)) + grid = open_grid(10) entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid) assert len(grid.entities) == 1 entity.grid = None assert len(grid.entities) == 0 + assert entity.grid is None print(" PASS: set_grid_none") def test_stress(): @@ -150,26 +170,32 @@ def test_stress(): entity.grid = small_g small_g.perspective = entity entity.update_visibility() + assert entity.perspective_map.size == (5, 5) big_g = mcrfpy.Grid(grid_size=(80, 80)) entity.grid = big_g big_g.perspective = entity entity.update_visibility() + assert entity.perspective_map.size == (80, 80) frames = [mcrfpy.Frame() for _ in range(10)] del frames print(" PASS: stress") -print("Testing gridstate resize across transfer methods...") -test_set_grid() -test_append() -test_extend() -test_insert() -test_setitem() -test_slice_assign() -test_update_visibility_after_transfer() -test_at_after_transfer() -test_set_grid_none() -test_stress() -print("PASS: all gridstate resize tests passed") +print("Testing perspective map resize across transfer methods...") +try: + test_set_grid() + test_append() + test_extend() + test_insert() + test_setitem() + test_slice_assign() + test_update_visibility_after_transfer() + test_at_after_transfer() + test_set_grid_none() + test_stress() +except AssertionError as e: + print(f"FAIL: {e}") + sys.exit(1) +print("PASS: all perspective map resize tests passed") sys.exit(0) diff --git a/tests/regression/issue_264_gridpoint_dangle_test.py b/tests/regression/issue_264_gridpoint_dangle_test.py index 9dc0590..2e20d04 100644 --- a/tests/regression/issue_264_gridpoint_dangle_test.py +++ b/tests/regression/issue_264_gridpoint_dangle_test.py @@ -7,6 +7,14 @@ pointers would dangle. Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute the data address on each property access. + +API update (current contract): the per-entity GridPointState vector is gone. +Its successor is entity.perspective_map, a DiscreteMap of mcrfpy.Perspective +(UNKNOWN / DISCOVERED / VISIBLE), and entity.at(x, y) now returns the grid's +GridPoint only when that cell is VISIBLE to the entity (None otherwise). +The dangle scenario is preserved: the perspective map is reallocated when the +entity transfers to a differently-sized grid, and previously-obtained +GridPoint / DiscreteMap wrappers must remain safe to use afterward. """ import mcrfpy import sys @@ -30,39 +38,63 @@ def test_gridpoint_grid_pos(): assert pos == (7, 3), f"Expected (7, 3), got {pos}" print(" PASS: gridpoint_grid_pos") -def test_gridpointstate_access(): - """entity.at(x,y) returns working GridPointState via coordinate lookup""" +def test_perspective_map_access(): + """entity.at(x,y) / entity.perspective_map use coordinate lookup""" grid = mcrfpy.Grid(grid_size=(10, 10)) entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid) entity.update_visibility() - state = entity.at(5, 5) - # Should be accessible - assert state is not None - # visible/discovered should be boolean - assert isinstance(state.visible, bool) - assert isinstance(state.discovered, bool) - print(" PASS: gridpointstate_access") + pm = entity.perspective_map + assert pm.size == (10, 10), f"Expected (10, 10), got {pm.size}" -def test_gridpointstate_after_grid_transfer(): - """GridPointState access works after entity transfers to new grid""" + # The entity's own cell is VISIBLE; entity.at() hands back the GridPoint. + assert pm.get((5, 5)) == mcrfpy.Perspective.VISIBLE + gp = entity.at(5, 5) + assert gp is not None + assert gp.grid_pos == (5, 5), f"Expected (5, 5), got {gp.grid_pos}" + + # A cell outside the entity's FOV is UNKNOWN, and at() gates on visibility. + assert pm.get((9, 0)) == mcrfpy.Perspective.UNKNOWN + assert entity.at(9, 0) is None + print(" PASS: perspective_map_access") + +def test_perspective_map_after_grid_transfer(): + """Perspective/GridPoint access works after entity transfers to new grid""" small = mcrfpy.Grid(grid_size=(10, 10)) large = mcrfpy.Grid(grid_size=(20, 20)) entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small) entity.update_visibility() - # Get state on small grid - state1 = entity.at(3, 3) - assert state1 is not None + # Wrappers obtained while on the small grid + gp_small = small.grid_data.at(3, 3) + pm_small = entity.perspective_map + assert pm_small.size == (10, 10) - # Transfer to large grid (gridstate resizes) + # Transfer to large grid: the entity's perspective map is reallocated. entity.grid = large entity.update_visibility() - # Access a cell that didn't exist on small grid - state2 = entity.at(15, 15) - assert state2 is not None - print(" PASS: gridpointstate_after_grid_transfer") + # entity.grid is the shared GridData, not the view (#313/#361) + assert entity.grid is large.grid_data + + # The new map covers the larger grid, and coordinate lookup reaches cells + # that did not exist on the small grid. + pm_large = entity.perspective_map + assert pm_large.size == (20, 20), f"Expected (20, 20), got {pm_large.size}" + assert pm_large.get((15, 15)) == mcrfpy.Perspective.UNKNOWN + assert pm_large.get((5, 5)) == mcrfpy.Perspective.VISIBLE + assert entity.at(5, 5) is not None + + # The pre-transfer wrappers must not dangle: they still describe the small + # grid / old map and remain safe to read and write. + assert pm_small.size == (10, 10) + assert pm_small.get((3, 3)) in (mcrfpy.Perspective.UNKNOWN, + mcrfpy.Perspective.DISCOVERED, + mcrfpy.Perspective.VISIBLE) + assert gp_small.grid_pos == (3, 3) + gp_small.walkable = True + assert small.grid_data.at(3, 3).walkable == True + print(" PASS: perspective_map_after_grid_transfer") def test_gridpoint_subscript(): """grid[x, y] returns working GridPoint""" @@ -72,26 +104,33 @@ def test_gridpoint_subscript(): assert grid.at(3, 4).walkable == True print(" PASS: gridpoint_subscript") -def test_gridstate_list(): - """entity.gridstate returns list with visible/discovered attrs""" +def test_perspective_map_all_cells(): + """entity.perspective_map covers every cell of the grid""" grid = mcrfpy.Grid(grid_size=(5, 5)) entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid) entity.update_visibility() - gs = entity.gridstate - assert len(gs) == 25, f"Expected 25, got {len(gs)}" - # Each element should have visible and discovered - for state in gs: - assert hasattr(state, 'visible') - assert hasattr(state, 'discovered') - print(" PASS: gridstate_list") + pm = entity.perspective_map + assert pm.size == (5, 5), f"Expected (5, 5), got {pm.size}" -print("Testing GridPoint/GridPointState coordinate-based access...") + # Every one of the 25 cells is addressable and holds a Perspective member + valid = (mcrfpy.Perspective.UNKNOWN, mcrfpy.Perspective.DISCOVERED, + mcrfpy.Perspective.VISIBLE) + count = 0 + for y in range(5): + for x in range(5): + state = pm.get((x, y)) + assert state in valid, f"({x}, {y}) has bad state {state}" + count += 1 + assert count == 25, f"Expected 25, got {count}" + print(" PASS: perspective_map_all_cells") + +print("Testing GridPoint/perspective_map coordinate-based access...") test_gridpoint_access() test_gridpoint_grid_pos() -test_gridpointstate_access() -test_gridpointstate_after_grid_transfer() +test_perspective_map_access() +test_perspective_map_after_grid_transfer() test_gridpoint_subscript() -test_gridstate_list() -print("PASS: all GridPoint/GridPointState tests passed") +test_perspective_map_all_cells() +print("PASS: all GridPoint/perspective_map tests passed") sys.exit(0) diff --git a/tests/regression/issue_341_metrics_counters_test.py b/tests/regression/issue_341_metrics_counters_test.py new file mode 100644 index 0000000..baa9624 --- /dev/null +++ b/tests/regression/issue_341_metrics_counters_test.py @@ -0,0 +1,133 @@ +""" +Regression test for issue #341. + +get_metrics() reported 0 for every render counter: + + * draw_calls / ui_elements / visible_elements WERE incremented -- but only inside + render(), which runs after every Python callback, while the counters were zeroed + at the top of the frame. Python was structurally unable to observe a nonzero + value. Render counters are now published at the end of the render pass. + + * grid_cells_rendered / entities_rendered / total_entities / grid_render_time / + entity_render_time were never incremented ANYWHERE -- dead code since the + GridView refactor. Re-instrumented in UIGridView::render(). + +Also locks two riders: frame_time is milliseconds (the docstring claimed seconds), +and fps is no longer inflated by the zero-filled history buffer on early frames. + +Model under test (#350): step() is the clock and never renders; rendering is +orthogonal and costs zero simulation time. So we drive time with step() and force a +render with a screenshot, then read the metrics that render published. +""" +import mcrfpy +from mcrfpy import automation +import sys +import os +import tempfile + +failures = [] + + +def check(label, condition, detail=""): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label} {detail}") + failures.append(label) + + +scene = mcrfpy.Scene("issue341") +ui = scene.children + +# A grid with a layer + entities, so the grid counters have something to count. +grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320)) +layer = mcrfpy.TileLayer("ground", 0) +grid.add_layer(layer) +for x in range(20): + for y in range(20): + layer.set((x, y), 0) + +for i in range(6): + grid.entities.append(mcrfpy.Entity(grid_pos=(i, i))) + +ui.append(grid) + +# Plain UI elements, so ui_elements / draw_calls have something to count. +for i in range(4): + ui.append(mcrfpy.Frame(pos=(400 + i * 20, 10), size=(15, 15))) +ui.append(mcrfpy.Caption(pos=(400, 200), text="metrics")) + +mcrfpy.current_scene = scene + +# Advance the clock a few times. step() must NOT render, so this alone must leave +# the render counters untouched. +for _ in range(5): + mcrfpy.step(0.016) + +print("1. step() advances the sim clock but renders nothing") +m = mcrfpy.get_metrics() +check("current_frame advanced under step()", m["current_frame"] >= 5, + f"got {m['current_frame']}") +check("frame_time is set by step()", m["frame_time"] > 0, f"got {m['frame_time']}") +check("draw_calls still 0 (step does not render)", m["draw_calls"] == 0, + f"got {m['draw_calls']}") + +# Force a render. This costs zero simulation time and publishes the render counters. +shot = os.path.join(tempfile.gettempdir(), "issue341_metrics.png") +frame_before = mcrfpy.get_metrics()["current_frame"] +automation.screenshot(shot) +m = mcrfpy.get_metrics() + +print("2. Rendering costs zero simulation time") +check("current_frame did not advance from rendering", + m["current_frame"] == frame_before, + f"{frame_before} -> {m['current_frame']}") + +print("3. Render counters are observable from Python (were always 0)") +check("draw_calls > 0", m["draw_calls"] > 0, f"got {m['draw_calls']}") +check("ui_elements > 0", m["ui_elements"] > 0, f"got {m['ui_elements']}") +check("visible_elements > 0", m["visible_elements"] > 0, f"got {m['visible_elements']}") + +print("4. Grid counters are re-instrumented (were dead code)") +check("grid_cells_rendered > 0", m["grid_cells_rendered"] > 0, + f"got {m['grid_cells_rendered']}") +check("entities_rendered > 0", m["entities_rendered"] > 0, + f"got {m['entities_rendered']}") +check("total_entities == 6", m["total_entities"] == 6, f"got {m['total_entities']}") +check("entities_rendered <= total_entities", + m["entities_rendered"] <= m["total_entities"]) +# A cell "render" is counted per cell per layer drawn: the 20x20 grid is fully on +# screen, so it is exactly 400 * (number of layers). +expected_cells = 400 * len(grid.layers) +check(f"grid_cells_rendered == 400 * {len(grid.layers)} layers", + m["grid_cells_rendered"] == expected_cells, + f"got {m['grid_cells_rendered']}, expected {expected_cells}") + +print("5. frame_time is in milliseconds, not seconds") +ft = m["frame_time"] +# step(0.016) -> 16ms. In seconds this would read 0.016 and fail the lower bound. +check("frame_time ~= 16ms for step(0.016)", 10.0 <= ft <= 25.0, f"got {ft}") + +print("6. fps is sane on early frames (was inflated by the zero-filled history)") +fps = m["fps"] +check("fps > 0", fps > 0, f"got {fps}") +# Pre-fix, dividing by a mostly-zero 60-slot buffer inflated this enormously. +# step(0.016) is 62.5 fps; allow slack but nothing absurd. +check("fps is not absurdly inflated", fps < 1000, f"got {fps}") + +print("7. Timing breakdowns are present") +check("grid_render_time >= 0", m["grid_render_time"] >= 0.0) +check("entity_render_time >= 0", m["entity_render_time"] >= 0.0) + +if os.path.exists(shot): + os.remove(shot) + +print() +if failures: + print(f"FAIL - {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/regression/issue_348_gridview_wrapper_identity_test.py b/tests/regression/issue_348_gridview_wrapper_identity_test.py index 1ced329..6304a37 100644 --- a/tests/regression/issue_348_gridview_wrapper_identity_test.py +++ b/tests/regression/issue_348_gridview_wrapper_identity_test.py @@ -26,9 +26,13 @@ def main(): c.walkable = (i % 2 == 0) assert g.at(0, 0).walkable is True - # entity.grid round-trips to the same Grid object + # entity.grid round-trips to the owning GridData wrapper. + # (#313/#361: entity.grid returns the shared GridData, NOT the Grid view. + # The #348 cache guarantee is what matters here: the same wrapper object + # comes back every time, and it is the *same* wrapper the view hands out.) e = mcrfpy.Entity((1, 1), grid=g) - assert e.grid is g, "entity.grid should be the owning Grid" + assert e.grid is g.grid_data, "entity.grid should be the owning GridData" + assert e.grid is e.grid, "entity.grid must have stable wrapper identity" # Reassigning grid_data invalidates the cache: new wrapper for new data g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40)) diff --git a/tests/regression/issue_350_step_frame_parity_test.py b/tests/regression/issue_350_step_frame_parity_test.py new file mode 100644 index 0000000..f588f10 --- /dev/null +++ b/tests/regression/issue_350_step_frame_parity_test.py @@ -0,0 +1,88 @@ +""" +Regression test for issue #350. + +Headless mcrfpy.step() advanced animations and timers only. It never called +McRFPy_API::updatePythonScenes(), which lives solely in doFrame() -- so under step(): + + * Scene.update(dt) overrides NEVER fired (the filed bug) + * the C++ scene update never ran + * scene transitions never progressed or completed + * current_frame never advanced + +Any game with per-frame logic in update() was untestable headless. + +step() is now a full SIMULATION frame: everything doFrame() does except render and +input. Rendering stays deliberately off the clock -- it costs zero simulation time +(see issue_341 test), so step() must still render nothing. +""" +import mcrfpy +import sys + +failures = [] + + +def check(label, condition, detail=""): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label} {detail}") + failures.append(label) + + +# ------------------------------------------------------- Scene.update under step() +print("1. Scene.update(dt) fires under step() (the filed bug)") + + +class CountingScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + self.update_count = 0 + self.dt_total = 0.0 + + def update(self, dt): + self.update_count += 1 + self.dt_total += dt + + +scene = CountingScene("issue350") +mcrfpy.current_scene = scene + +for _ in range(5): + mcrfpy.step(0.016) + +check("update() fired once per step()", scene.update_count == 5, + f"got {scene.update_count}") +check("update() received the dt", abs(scene.dt_total - 5 * 0.016) < 1e-4, + f"got {scene.dt_total}") + +# ------------------------------------------------------------ current_frame advances +print("2. current_frame advances under step()") +before = mcrfpy.get_metrics()["current_frame"] +for _ in range(3): + mcrfpy.step(0.016) +after = mcrfpy.get_metrics()["current_frame"] +check("current_frame advanced by 3", after - before == 3, f"{before} -> {after}") + +# ------------------------------------------------------------------- timers still fire +print("3. Timers still fire under step() (unchanged behavior)") +fired = [] +mcrfpy.Timer("t350", lambda timer, rt: fired.append(rt), 50) +for _ in range(4): + mcrfpy.step(0.02) +check("timer fired", len(fired) > 0, f"got {len(fired)} fires") + +# ----------------------------------------------------------- step() must not render +print("4. step() still renders nothing (render is off the clock)") +m = mcrfpy.get_metrics() +check("draw_calls == 0 after step()-only", m["draw_calls"] == 0, + f"got {m['draw_calls']}") + +print() +if failures: + print(f"FAIL - {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/regression/issue_356_module_attrs_discoverable_test.py b/tests/regression/issue_356_module_attrs_discoverable_test.py new file mode 100644 index 0000000..29293f7 --- /dev/null +++ b/tests/regression/issue_356_module_attrs_discoverable_test.py @@ -0,0 +1,107 @@ +""" +Regression test for issue #356. + +mcrfpy's dynamic module attributes (current_scene, scenes, timers, ...) used to be a +PEP-562 module __getattr__. getattr/hasattr worked, but they were absent from +dir(mcrfpy) -- and every doc/stub/manifest generator discovers module symbols via +dir(), so the single most common idiom in the engine appeared in no generated docs. + +They are now real getset descriptors on type(mcrfpy), plus a __dir__ override +(CPython's module.__dir__ reports only __dict__ keys, so descriptors alone are not +enough -- that is the trap this test exists to catch). +""" +import mcrfpy +import sys +import types + +DYNAMIC = [ + "current_scene", + "scenes", + "timers", + "animations", + "default_transition", + "default_transition_duration", + "save_dir", +] +READONLY = ["scenes", "timers", "animations", "save_dir"] + +failures = [] + + +def check(label, condition): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label}") + failures.append(label) + + +print("1. All dynamic attributes are in dir(mcrfpy)") +names = dir(mcrfpy) +for attr in DYNAMIC: + check(f"'{attr}' in dir(mcrfpy)", attr in names) + +print("2. dir() still contains normal module symbols") +check("classes still listed", "Frame" in names and "Scene" in names) +check("functions still listed", "find" in names and "step" in names) + +print("3. They are real descriptors carrying docstrings") +modtype = type(mcrfpy) +for attr in DYNAMIC: + descr = getattr(modtype, attr, None) + check(f"'{attr}' is a getset descriptor", + isinstance(descr, types.GetSetDescriptorType)) + check(f"'{attr}' has a docstring", bool(descr and descr.__doc__)) + +print("4. Access still works (behavior preserved)") +scene = mcrfpy.Scene("issue356") +mcrfpy.current_scene = scene +check("current_scene round-trips", mcrfpy.current_scene is not None) +check("scenes is a tuple", isinstance(mcrfpy.scenes, tuple)) +check("timers is a tuple", isinstance(mcrfpy.timers, tuple)) +check("animations is a tuple", isinstance(mcrfpy.animations, tuple)) +check("save_dir is a str", isinstance(mcrfpy.save_dir, str)) +check("default_transition_duration is a float", + isinstance(mcrfpy.default_transition_duration, float)) + +mcrfpy.default_transition_duration = 0.25 +check("default_transition_duration is writable", + abs(mcrfpy.default_transition_duration - 0.25) < 1e-6) + +print("5. Read-only attributes still reject assignment") +for attr in READONLY: + try: + setattr(mcrfpy, attr, ()) + check(f"assigning '{attr}' raises AttributeError", False) + except AttributeError: + check(f"assigning '{attr}' raises AttributeError", True) + +print("6. Validation preserved on the writable ones") +try: + mcrfpy.default_transition_duration = -1.0 + check("negative duration raises ValueError", False) +except ValueError: + check("negative duration raises ValueError", True) + +try: + mcrfpy.default_transition_duration = "not a number" + check("non-numeric duration raises TypeError", False) +except TypeError: + check("non-numeric duration raises TypeError", True) + +print("7. Unknown attributes still raise AttributeError") +try: + mcrfpy.definitely_not_an_attribute + check("unknown attribute raises AttributeError", False) +except AttributeError: + check("unknown attribute raises AttributeError", True) + +print() +if failures: + print(f"FAIL - {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/regression/issue_364_grid_children_on_view_test.py b/tests/regression/issue_364_grid_children_on_view_test.py index dbd9827..34a4fba 100644 --- a/tests/regression/issue_364_grid_children_on_view_test.py +++ b/tests/regression/issue_364_grid_children_on_view_test.py @@ -44,17 +44,14 @@ def shot(): def case_child_parent_is_the_view(): """The child's parent is the Grid (UIGridView), not the internal _GridData. - Identity (`child.parent is grid`) is NOT asserted: `.parent` allocates a fresh - wrapper on every read, so `is` fails for Frames too -- even `kid.parent is - kid.parent` is False. That is a pre-existing engine-wide gap, not this bug, and - it is filed separately. Here we assert the parent's TYPE and identify the view by - name, which is what #364 actually turns on. + Identity is asserted directly now that #369 routes .parent through the object + cache; this originally had to settle for comparing .name. """ grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="the_view") bubble = mcrfpy.Frame(pos=(32, 32), size=(20, 20)) grid.children.append(bubble) - check("1a: child.parent is the grid view", bubble.parent.name == "the_view") + check("1a: child.parent is the grid view", bubble.parent is grid) # #361: mcrfpy.Grid IS mcrfpy.GridView (one type object, two names), and the # canonical tp_name is GridView. check("1b: child.parent is a Grid, not a GridData", @@ -72,7 +69,7 @@ def case_children_are_per_view_entities_are_shared(): v1.children.append(marker) check("2b: child appended to v1 is in v1.children", len(v1.children) == 1) check("2c: child appended to v1 is NOT in v2.children", len(v2.children) == 0) - check("2d: the child's parent is v1, not v2", marker.parent.name == "v1") + check("2d: the child's parent is v1, not v2", marker.parent is v1) v2.children.append(mcrfpy.Frame(pos=(64, 64), size=(20, 20))) check("2e: v2 keeps its own children", len(v2.children) == 1 and len(v1.children) == 1) diff --git a/tests/regression/issue_369_parent_identity_test.py b/tests/regression/issue_369_parent_identity_test.py new file mode 100644 index 0000000..6cbdb1b --- /dev/null +++ b/tests/regression/issue_369_parent_identity_test.py @@ -0,0 +1,136 @@ +""" +Regression test for issue #369. + +Every path that hands a C++ drawable back to Python must return the *same* wrapper +object, or `child.parent is parent` silently answers False and Python subclasses +reached through those paths lose their identity. + +Broken paths this locks down: + - UIDrawable::get_parent (.parent) + - find_in_collection (mcrfpy.find / find_all) + - UIEntityCollection concat/slice (entities + [...], entities[a:b]) +""" +import mcrfpy +import sys + +failures = [] + + +def check(label, condition): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label}") + failures.append(label) + + +scene = mcrfpy.Scene("issue369") +ui = scene.children + +# ---------------------------------------------------------------- .parent identity +print("1. .parent returns a stable, identical wrapper") +parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100), name="parent_frame") +kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10), name="kid_frame") +ui.append(parent) +parent.children.append(kid) + +check("kid.parent is parent", kid.parent is parent) +check("repeated reads are identical", kid.parent is kid.parent) +check("id() is stable across reads", id(kid.parent) == id(kid.parent)) + +# .parent must be usable as a dict key / set member +seen = {kid.parent: "found"} +check(".parent works as a dict key", seen.get(parent) == "found") +check(".parent works in a set", len({kid.parent, kid.parent, parent}) == 1) + +# ------------------------------------------------------- subclass survives .parent +print("2. Python subclass identity survives .parent") + + +class MyFrame(mcrfpy.Frame): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.custom_state = "preserved" + + +sub_parent = MyFrame(pos=(0, 0), size=(200, 200), name="sub_parent") +sub_kid = mcrfpy.Caption(pos=(5, 5), text="inner", name="sub_kid") +ui.append(sub_parent) +sub_parent.children.append(sub_kid) + +check("sub_kid.parent is sub_parent", sub_kid.parent is sub_parent) +check("subclass type preserved", isinstance(sub_kid.parent, MyFrame)) +check("subclass attributes preserved", + getattr(sub_kid.parent, "custom_state", None) == "preserved") + +# ------------------------------------------------- .parent covers all drawable types +print("3. .parent resolves non-Frame/Caption/Sprite/Grid parents (was None)") +# Line/Circle/Arc/Viewport3D had no switch arm and fell through to Py_RETURN_NONE. +# They aren't containers, so verify the *child-of-grid* case instead: a GridView +# parent must come back identical, and every type must at least round-trip via find(). +grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100), name="the_grid") +ui.append(grid) +overlay = mcrfpy.Frame(pos=(0, 0), size=(10, 10), name="overlay_child") +grid.children.append(overlay) +check("overlay.parent is grid", overlay.parent is grid) + +# ------------------------------------------------------------- find() identity +print("4. find() / find_all() return existing wrappers") +found = mcrfpy.find("parent_frame", "issue369") +check("find() returns the same object", found is parent) + +found_sub = mcrfpy.find("sub_parent", "issue369") +check("find() preserves subclass", found_sub is sub_parent) +check("find() subclass attrs intact", + getattr(found_sub, "custom_state", None) == "preserved") + +all_found = mcrfpy.find_all("*_frame", "issue369") +by_id = {id(o) for o in all_found} +check("find_all() returns live wrappers", + id(parent) in by_id and id(kid) in by_id) + +# ---------------------------------------------------------- entity collection paths +print("5. EntityCollection slice / concat preserve entity identity") + + +class MyEntity(mcrfpy.Entity): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.tag = "mine" + + +e0 = MyEntity(grid_pos=(1, 1)) +e1 = mcrfpy.Entity(grid_pos=(2, 2)) +e2 = mcrfpy.Entity(grid_pos=(3, 3)) +for e in (e0, e1, e2): + grid.entities.append(e) + +check("entities[0] is e0", grid.entities[0] is e0) + +sliced = grid.entities[0:2] +check("slice returns the same wrappers", sliced[0] is e0 and sliced[1] is e1) +check("slice preserves subclass", isinstance(sliced[0], MyEntity)) +check("slice preserves subclass attrs", getattr(sliced[0], "tag", None) == "mine") + +concat = grid.entities + [] +check("concat returns the same wrappers", concat[0] is e0) +check("concat preserves subclass", isinstance(concat[0], MyEntity)) + +# The #266 identity ref must survive a duplicate-wrapper round trip: before the fix, +# the temporary wrapper's dealloc cleared UIEntity::pyobject on the shared C++ object. +del sliced, concat +import gc +gc.collect() +check("entity identity survives temp wrappers", grid.entities[0] is e0) +check("subclass attrs survive temp wrappers", + getattr(grid.entities[0], "tag", None) == "mine") + +print() +if failures: + print(f"FAIL - {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/regression/issue_373_subclass_identity_survives_gc_test.py b/tests/regression/issue_373_subclass_identity_survives_gc_test.py new file mode 100644 index 0000000..15dfba6 --- /dev/null +++ b/tests/regression/issue_373_subclass_identity_survives_gc_test.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Issue #373: a GC'd Python subclass wrapper must not resurrect as its base type. + +PythonObjectCache holds only weakrefs, so #369 (route every C++ -> Python conversion +through the cache) preserved object identity only while Python still held a reference +to the wrapper. If the user dropped their last reference while C++ still owned the +object, the wrapper was collected, the next lookup missed the cache, and a fresh +BASE-type wrapper was allocated -- silently downgrading the subclass and losing every +attribute set on it. + +The fix is an owner-held strong ref ("pin"): a subclassed drawable's wrapper is kept +alive for exactly as long as the drawable is a member of a children collection. That +boundary is the parent link, so the pin is taken in setParent()/setParentScene() and +released on every path out -- including an owner destroyed while it still holds +children, which is the leak this test also guards. +""" + +import gc +import sys +import weakref + +import mcrfpy + +failures = [] + + +def check(cond, msg): + if not cond: + failures.append(msg) + print(f"FAIL: {msg}") + else: + print(f" ok: {msg}") + + +class MyFrame(mcrfpy.Frame): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.hp = 100 + + +class MyCaption(mcrfpy.Caption): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.tag = "captioned" + + +def test_parent_survives_gc(): + """The scenario from the issue: reach a GC'd subclass parent through .parent.""" + scene = mcrfpy.Scene("gc_parent") + + parent = MyFrame(pos=(0, 0), size=(100, 100)) + scene.children.append(parent) + kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10)) + parent.children.append(kid) + + del parent # C++ still owns it via the scene's collection + gc.collect() + + recovered = kid.parent + check(type(recovered) is MyFrame, + f"kid.parent is still a MyFrame after gc (got {type(recovered).__name__})") + check(getattr(recovered, "hp", None) == 100, + "the subclass attribute set in __init__ survived") + check(kid.parent is kid.parent, + "repeated .parent reads return the same object") + check(kid.parent is recovered, + "the pinned wrapper is the one handed back, not a new one") + + +def test_collection_indexing_survives_gc(): + """Reaching the same object by indexing the collection, not via .parent.""" + scene = mcrfpy.Scene("gc_index") + sub = MyCaption(text="hello", pos=(5, 5)) + scene.children.append(sub) + + ref = weakref.ref(sub) + del sub + gc.collect() + + check(ref() is not None, + "a subclassed drawable in a scene keeps its wrapper alive") + got = scene.children[0] + check(type(got) is MyCaption, + f"scene.children[0] is still a MyCaption (got {type(got).__name__})") + check(got.tag == "captioned", "its subclass attribute survived") + check(got is ref(), "it is the original wrapper, not a copy") + + +def test_pin_released_on_removal(): + """Leaving the collection must drop the pin, or every subclass leaks forever.""" + scene = mcrfpy.Scene("gc_release") + sub = MyFrame(pos=(0, 0), size=(10, 10)) + scene.children.append(sub) + + ref = weakref.ref(sub) + scene.children.remove(sub) + del sub + gc.collect() + + check(ref() is None, + "the wrapper is freed once the drawable leaves the collection") + + +def test_pin_released_when_owner_dies(): + """An owner destroyed while still holding children must drop their pins. + + Nothing calls setParent(nullptr) on this path -- the children vector simply dies -- + so without an explicit release in ~UIFrame the wrapper <-> drawable cycle would + strand the whole subtree in memory. + """ + scene = mcrfpy.Scene("gc_owner") + holder = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + scene.children.append(holder) + + inner = MyFrame(pos=(1, 1), size=(5, 5)) + holder.children.append(inner) + ref = weakref.ref(inner) + del inner + gc.collect() + check(ref() is not None, "a nested subclass is pinned while its owner holds it") + + scene.children.remove(holder) + del holder + gc.collect() + check(ref() is None, + "the nested wrapper is freed when its owner is destroyed (no leak)") + + +def test_reparenting_preserves_identity(): + """Moving between collections must not drop the subclass on the floor. + + Reparenting unlinks then relinks; if the unlink released the last reference, the + re-pin would find nothing in the cache and the subclass would be gone. + """ + scene = mcrfpy.Scene("gc_reparent") + a = mcrfpy.Frame(pos=(0, 0), size=(80, 80)) + b = mcrfpy.Frame(pos=(0, 0), size=(80, 80)) + scene.children.append(a) + scene.children.append(b) + + sub = MyFrame(pos=(2, 2), size=(8, 8)) + sub.hp = 55 + a.children.append(sub) + ref = weakref.ref(sub) + del sub + gc.collect() + + moved = a.children[0] + b.children.append(moved) # reparent a -> b + del moved + gc.collect() + + check(len(a.children) == 0, "the drawable left its old parent") + check(len(b.children) == 1, "the drawable joined its new parent") + check(ref() is not None, "it is still pinned by its new owner") + got = b.children[0] + check(type(got) is MyFrame, + f"it is still a MyFrame after reparenting (got {type(got).__name__})") + check(got.hp == 55, "and kept the attribute that was set on it") + + +def test_base_type_is_not_pinned(): + """Only subclasses are pinned. A base wrapper carries no state, so re-creating it + is unobservable -- and pinning it would cost memory for nothing.""" + scene = mcrfpy.Scene("gc_base") + plain = mcrfpy.Frame(pos=(0, 0), size=(10, 10)) + scene.children.append(plain) + + ref = weakref.ref(plain) + del plain + gc.collect() + + check(ref() is None, "a base-type wrapper is not pinned") + got = scene.children[0] + check(type(got) is mcrfpy.Frame, "it still round-trips as a Frame") + check(got.w == 10, "and its C++ state is intact") + + +def main(): + test_parent_survives_gc() + test_collection_indexing_survives_gc() + test_pin_released_on_removal() + test_pin_released_when_owner_dies() + test_reparenting_preserves_identity() + test_base_type_is_not_pinned() + + if failures: + print(f"\nFAILED ({len(failures)} checks)") + for f in failures: + print(f" - {f}") + sys.exit(1) + print("\nPASS") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/regression/issue_377_collection_parent_link_test.py b/tests/regression/issue_377_collection_parent_link_test.py new file mode 100644 index 0000000..88aa9eb --- /dev/null +++ b/tests/regression/issue_377_collection_parent_link_test.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Issue #377: every UICollection mutator must maintain the parent link. + +`x in collection` and `x.parent is ` are supposed to be two views of +the same fact (#122/#183). Only append() maintained it. The rest got it wrong two ways: + + * insert/extend/setitem called setParent(owner.lock()) unconditionally. A SCENE + collection has no owner drawable, so that resolved to null -- and setParent(nullptr) + also clears parent_scene. The drawable landed in the scene's UI vector, rendered + fine, and reported .parent of None. + + * The slice arms never touched the link at all: a slice-delete left the removed child + pointing at a parent that no longer contained it, and a slice-assign never parented + the incoming items nor unparented the ones they displaced. + +Two adjacent traps in the same functions are also covered here: `collection[-1]` on an +EMPTY collection hung the engine forever (`while (index < 0) index += size()` adds zero +each pass), and getitem's `index > size() - 1` bounds check underflowed to SIZE_MAX when +size() was 0, so it went on to index an empty vector. + +This invariant is what #373's identity pin hangs off, so it has to hold for every path. +""" + +import sys + +import mcrfpy + +failures = [] + + +def check(cond, msg): + if not cond: + failures.append(msg) + print(f"FAIL: {msg}") + else: + print(f" ok: {msg}") + + +def frame(n=10): + return mcrfpy.Frame(pos=(0, 0), size=(n, n)) + + +def check_linked(child, owner, collection, label): + """The two views of membership must agree.""" + check(child in collection, f"{label}: child is in the collection") + check(child.parent is owner, f"{label}: child.parent is the owner (got {child.parent!r})") + + +def check_unlinked(child, collection, label): + check(child not in collection, f"{label}: child is NOT in the collection") + check(child.parent is None, f"{label}: child.parent is None (got {child.parent!r})") + + +def test_scene_collection_mutators(): + """A scene collection parents via parent_scene; only append used to do it.""" + scene = mcrfpy.Scene("link_scene") + + a, b, c, d = frame(), frame(), frame(), frame() + + scene.children.append(a) + check_linked(a, scene, scene.children, "scene append") + + scene.children.insert(0, b) + check_linked(b, scene, scene.children, "scene insert") + + scene.children.extend([c]) + check_linked(c, scene, scene.children, "scene extend") + + scene.children[0] = d + check_linked(d, scene, scene.children, "scene setitem") + + +def test_frame_collection_mutators(): + scene = mcrfpy.Scene("link_frame") + owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + scene.children.append(owner) + + a, b, c, d = frame(), frame(), frame(), frame() + + owner.children.append(a) + check_linked(a, owner, owner.children, "frame append") + + owner.children.insert(0, b) + check_linked(b, owner, owner.children, "frame insert") + + owner.children.extend([c]) + check_linked(c, owner, owner.children, "frame extend") + + replaced = owner.children[0] + owner.children[0] = d + check_linked(d, owner, owner.children, "frame setitem") + check_unlinked(replaced, owner.children, "frame setitem (displaced element)") + + +def test_removal_mutators_unlink(): + scene = mcrfpy.Scene("link_remove") + owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + scene.children.append(owner) + + a, b, c = frame(), frame(), frame() + owner.children.extend([a, b, c]) + + owner.children.remove(a) + check_unlinked(a, owner.children, "frame remove()") + + popped = owner.children.pop(0) + check_unlinked(popped, owner.children, "frame pop()") + + del owner.children[0] + check_unlinked(c, owner.children, "frame __delitem__") + check(len(owner.children) == 0, "the collection is empty after removing everything") + + +def test_slice_delete_unlinks(): + scene = mcrfpy.Scene("link_slicedel") + owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + scene.children.append(owner) + + a, b, c, d = frame(), frame(), frame(), frame() + owner.children.extend([a, b, c, d]) + + del owner.children[0:2] # contiguous + check(len(owner.children) == 2, "contiguous slice-delete removed two elements") + check_unlinked(a, owner.children, "contiguous slice-delete (a)") + check_unlinked(b, owner.children, "contiguous slice-delete (b)") + check_linked(c, owner, owner.children, "contiguous slice-delete (survivor c)") + + e, f = frame(), frame() + owner.children.extend([e, f]) # now: c, d, e, f + del owner.children[0::2] # extended slice: removes c and e + check_unlinked(c, owner.children, "extended slice-delete (c)") + check_unlinked(e, owner.children, "extended slice-delete (e)") + check_linked(d, owner, owner.children, "extended slice-delete (survivor d)") + check_linked(f, owner, owner.children, "extended slice-delete (survivor f)") + + +def test_slice_assign_links(): + scene = mcrfpy.Scene("link_sliceassign") + owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + scene.children.append(owner) + + a, b = frame(), frame() + owner.children.extend([a, b]) + + c, d = frame(), frame() + owner.children[0:1] = [c, d] # resizing contiguous assign + check_linked(c, owner, owner.children, "slice-assign (new element c)") + check_linked(d, owner, owner.children, "slice-assign (new element d)") + check_unlinked(a, owner.children, "slice-assign (displaced element a)") + check_linked(b, owner, owner.children, "slice-assign (untouched element b)") + + e = frame() + owner.children[0:1] = [e] # same-size contiguous assign + check_linked(e, owner, owner.children, "same-size slice-assign (new element)") + check_unlinked(c, owner.children, "same-size slice-assign (displaced element)") + + # Extended slice assign + g, h = frame(), frame() + owner.children[0::2] = [g, h] + check_linked(g, owner, owner.children, "extended slice-assign (g)") + check_linked(h, owner, owner.children, "extended slice-assign (h)") + + +def test_slice_assign_on_scene(): + """Slice-assign into a scene collection must parent to the SCENE.""" + scene = mcrfpy.Scene("link_slicescene") + a, b = frame(), frame() + scene.children.extend([a, b]) + + c = frame() + scene.children[0:1] = [c] + check_linked(c, scene, scene.children, "scene slice-assign") + check_unlinked(a, scene.children, "scene slice-assign (displaced)") + + +def test_reparent_between_owners(): + """Appending to a new owner must remove from the old one, not leave it in both.""" + scene = mcrfpy.Scene("link_reparent") + one = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + two = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + scene.children.extend([one, two]) + + x = frame() + one.children.append(x) + check_linked(x, one, one.children, "child starts in owner one") + + two.children.append(x) + check(x not in one.children, "reparenting removed the child from its old owner") + check_linked(x, two, two.children, "reparenting linked the child to its new owner") + + +def test_empty_collection_negative_index(): + """`collection[-1]` on an empty collection used to hang the engine forever.""" + scene = mcrfpy.Scene("link_empty") + + try: + scene.children[-1] + check(False, "getitem[-1] on an empty collection raises IndexError") + except IndexError: + check(True, "getitem[-1] on an empty collection raises IndexError") + + try: + scene.children[-1] = frame() + check(False, "setitem[-1] on an empty collection raises IndexError") + except IndexError: + check(True, "setitem[-1] on an empty collection raises IndexError") + + # Negative indexing still works when there IS something to index. + a, b = frame(), frame() + scene.children.extend([a, b]) + check(scene.children[-1] is b, "negative indexing resolves from the end") + check(scene.children[-2] is a, "negative indexing reaches the first element") + try: + scene.children[-3] + check(False, "an out-of-range negative index raises IndexError") + except IndexError: + check(True, "an out-of-range negative index raises IndexError") + + +def main(): + test_scene_collection_mutators() + test_frame_collection_mutators() + test_removal_mutators_unlink() + test_slice_delete_unlinks() + test_slice_assign_links() + test_slice_assign_on_scene() + test_reparent_between_owners() + test_empty_collection_negative_index() + + if failures: + print(f"\nFAILED ({len(failures)} checks)") + for f in failures: + print(f" - {f}") + sys.exit(1) + print("\nPASS") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/regression/issue_9_rendertexture_resize_test.py b/tests/regression/issue_9_rendertexture_resize_test.py index d4bc9f9..d24c4d8 100644 --- a/tests/regression/issue_9_rendertexture_resize_test.py +++ b/tests/regression/issue_9_rendertexture_resize_test.py @@ -1,220 +1,246 @@ #!/usr/bin/env python3 """ -Comprehensive test for Issue #9: Recreate RenderTexture when UIGrid is resized +Regression test for Issue #9: Recreate RenderTexture when a grid view is resized -This test demonstrates that UIGrid has a hardcoded RenderTexture size of 1920x1080, -which causes rendering issues when the grid is resized beyond these dimensions. +The bug: UIGrid::render() created its RenderTexture once, at a hardcoded size, and +never recreated it. Resizing the grid widget beyond that texture left the new area +unrendered (clipped), and shrinking it left stale content blitted outside the box. -The bug: UIGrid::render() creates a RenderTexture with fixed size (1920x1080) once, -but never recreates it when the grid is resized, causing clipping and rendering artifacts. +The fix (UIGridView::ensureRenderTextureSize()) sizes the RenderTexture from the game +resolution and recreates it whenever that changes; the blit is clipped to the widget +box. So today a grid must render *correctly across its whole box at every size*: + + * enlarging the widget renders content in the newly exposed area (was clipped), + * shrinking it leaves no stale pixels outside the new box, + * content never spills past the widget bounds, + * a widget larger than the window still renders its visible portion. + +This test asserts those four properties on real screenshot pixels. The old version of +this file only printed "check the screenshots by eye" and, worse, died in setup on +grid.at(x, y).color (removed -- per-cell color lives on a ColorLayer now), so none of +it ever ran. + +API notes for the update: GridPoint has no .color -> mcrfpy.ColorLayer; Grid takes +grid_size=/pos=/size= kwargs; mcrfpy.setScene/sceneUI -> mcrfpy.Scene + scene.children; +step() is the clock and never renders, automation.screenshot() forces the render. +Window.resolution cannot change in headless, so the resolution-driven recreation path +is exercised via widget resizes only. """ import mcrfpy from mcrfpy import automation +import struct import sys -import os +import zlib -def create_checkerboard_pattern(grid, grid_width, grid_height, cell_size=2): - """Create a checkerboard pattern on the grid for visibility""" +FAILURES = [] + +# Grid data is large enough (160 x 160 cells) that its content covers every widget +# size used below, so "no content here" always means a rendering failure. +GRID_CELLS = 160 + +WHITE = (255, 255, 255) +GRAY = (100, 100, 100) +RED = (255, 0, 0) +BACKGROUND = (0, 0, 0) # scene clear color, outside any grid +CONTENT_COLORS = (WHITE, GRAY, RED) + + +def check(label, condition, detail=""): + if condition: + print(" PASS: %s" % label) + else: + print(" FAIL: %s %s" % (label, detail)) + FAILURES.append(label) + + +# --------------------------------------------------------------------------- # +# Minimal PNG reader (stdlib only), same as tests/unit/validate_screenshot_test.py +# --------------------------------------------------------------------------- # +def read_png(path): + with open(path, "rb") as f: + data = f.read() + assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + pos = 8 + idat = b"" + width = height = depth = color = None + while pos < len(data): + (length,) = struct.unpack(">I", data[pos:pos + 4]) + ctype = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + length] + pos += 12 + length + if ctype == b"IHDR": + width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk) + assert depth == 8, "unexpected bit depth %d" % depth + assert color in (2, 6), "unexpected color type %d" % color + assert interlace == 0, "interlaced PNG not supported" + elif ctype == b"IDAT": + idat += chunk + elif ctype == b"IEND": + break + + raw = zlib.decompress(idat) + channels = 3 if color == 2 else 4 + stride = width * channels + out = bytearray(height * stride) + prev = bytearray(stride) + p = 0 + for y in range(height): + filt = raw[p] + p += 1 + line = bytearray(raw[p:p + stride]) + p += stride + if filt == 1: # Sub + for i in range(channels, stride): + line[i] = (line[i] + line[i - channels]) & 0xFF + elif filt == 2: # Up + for i in range(stride): + line[i] = (line[i] + prev[i]) & 0xFF + elif filt == 3: # Average + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF + elif filt == 4: # Paeth + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + b = prev[i] + c = prev[i - channels] if i >= channels else 0 + pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + elif filt != 0: + raise AssertionError("bad PNG filter %d" % filt) + out[y * stride:(y + 1) * stride] = line + prev = line + return width, height, channels, bytes(out) + + +class Image: + def __init__(self, path): + self.w, self.h, self.ch, self.px = read_png(path) + + def at(self, x, y): + i = (y * self.w + x) * self.ch + return tuple(self.px[i:i + 3]) + + +def create_checkerboard_pattern(layer, grid_width, grid_height, cell_size=2): + """Create a checkerboard pattern on the color layer for visibility""" for x in range(grid_width): for y in range(grid_height): if (x // cell_size + y // cell_size) % 2 == 0: - grid.at(x, y).color = mcrfpy.Color(255, 255, 255, 255) # White + layer.set((x, y), mcrfpy.Color(*WHITE)) else: - grid.at(x, y).color = mcrfpy.Color(100, 100, 100, 255) # Gray + layer.set((x, y), mcrfpy.Color(*GRAY)) -def add_border_markers(grid, grid_width, grid_height): + +def add_border_markers(layer, grid_width, grid_height): """Add colored markers at the borders to test rendering limits""" - # Red border on top for x in range(grid_width): - grid.at(x, 0).color = mcrfpy.Color(255, 0, 0, 255) - - # Green border on right + layer.set((x, 0), mcrfpy.Color(*RED)) + layer.set((x, grid_height - 1), mcrfpy.Color(*RED)) for y in range(grid_height): - grid.at(grid_width-1, y).color = mcrfpy.Color(0, 255, 0, 255) + layer.set((0, y), mcrfpy.Color(*RED)) + layer.set((grid_width - 1, y), mcrfpy.Color(*RED)) - # Blue border on bottom - for x in range(grid_width): - grid.at(x, grid_height-1).color = mcrfpy.Color(0, 0, 255, 255) - # Yellow border on left - for y in range(grid_height): - grid.at(0, y).color = mcrfpy.Color(255, 255, 0, 255) +def render(path): + """step() advances the sim; the screenshot is what forces a render.""" + mcrfpy.step(0.01) + automation.screenshot(path) + return Image(path) + + +def anchor_camera(grid): + """Keep tile (0,0) at the widget's top-left after a resize (#169 default).""" + grid.center = (grid.w / 2.0, grid.h / 2.0) + + +def is_content(px): + return px in CONTENT_COLORS + + +print("=== Testing grid RenderTexture resize (Issue #9) ===\n") -# Set up the test scene test = mcrfpy.Scene("test") mcrfpy.current_scene = test - -print("=== Testing UIGrid RenderTexture Resize (Issue #9) ===\n") - scene_ui = test.children -# Test 1: Small grid (should work fine) -print("--- Test 1: Small Grid (400x300) ---") -grid1 = mcrfpy.Grid(20, 15) # 20x15 tiles -grid1.x = 10 -grid1.y = 10 -grid1.w = 400 -grid1.h = 300 -scene_ui.append(grid1) +GRID_X, GRID_Y = 10, 10 +grid = mcrfpy.Grid(grid_size=(GRID_CELLS, GRID_CELLS), pos=(GRID_X, GRID_Y), size=(200, 150)) +colors = mcrfpy.ColorLayer(name="cells") +grid.add_layer(colors) +create_checkerboard_pattern(colors, GRID_CELLS, GRID_CELLS) +add_border_markers(colors, GRID_CELLS, GRID_CELLS) +scene_ui.append(grid) -create_checkerboard_pattern(grid1, 20, 15) -add_border_markers(grid1, 20, 15) +# --- Test 1: small grid renders its whole box, and nothing outside it --------- +print("--- Test 1: Small Grid (200x150) ---") +anchor_camera(grid) +img = render("/tmp/issue_9_small_grid.png") -mcrfpy.step(0.01) -automation.screenshot("/tmp/issue_9_small_grid.png") -print("PASS: Small grid created and rendered") +check("top-left tile of small grid is the red border marker", + img.at(GRID_X + 2, GRID_Y + 2) == RED, + "got %s" % (img.at(GRID_X + 2, GRID_Y + 2),)) +check("interior of small grid is rendered content", + is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),)) +check("far corner of small grid box is rendered content", + is_content(img.at(GRID_X + 195, GRID_Y + 145)), + "got %s" % (img.at(GRID_X + 195, GRID_Y + 145),)) +check("nothing rendered outside the small grid box", + img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),)) -# Test 2: Medium grid at 1920x1080 limit -print("\n--- Test 2: Medium Grid at 1920x1080 Limit ---") -grid2 = mcrfpy.Grid(64, 36) # 64x36 tiles at 30px each = 1920x1080 -grid2.x = 10 -grid2.y = 320 -grid2.w = 1920 -grid2.h = 1080 -scene_ui.append(grid2) +# --- Test 2: enlarging the widget must render the newly exposed area ---------- +# This is the heart of #9: (500,400) and (880,690) were outside the previous render +# area. With a stale RenderTexture they stay blank/clipped. +print("\n--- Test 2: Resize 200x150 -> 900x700 ---") +grid.w = 900 +grid.h = 700 +anchor_camera(grid) +img = render("/tmp/issue_9_resized.png") -create_checkerboard_pattern(grid2, 64, 36, 4) -add_border_markers(grid2, 64, 36) +check("area exposed by the resize is rendered (500,400)", + is_content(img.at(500, 400)), "got %s" % (img.at(500, 400),)) +check("far corner of the enlarged box is rendered (880,690)", + is_content(img.at(880, 690)), "got %s" % (img.at(880, 690),)) +check("enlarged grid does not spill past its right edge", + img.at(GRID_X + 900 + 5, 100) == BACKGROUND, + "got %s" % (img.at(GRID_X + 900 + 5, 100),)) +check("enlarged grid does not spill past its bottom edge", + img.at(100, GRID_Y + 700 + 5) == BACKGROUND, + "got %s" % (img.at(100, GRID_Y + 700 + 5),)) -mcrfpy.step(0.01) -automation.screenshot("/tmp/issue_9_limit_grid.png") -print("PASS: Grid at RenderTexture limit created") +# --- Test 3: widget larger than the window still renders its visible portion -- +print("\n--- Test 3: Grid larger than the window (2400x1400) ---") +grid.w = 2400 +grid.h = 1400 +anchor_camera(grid) +img = render("/tmp/issue_9_beyond_window.png") -# Test 3: Resize grid1 beyond limits -print("\n--- Test 3: Resizing Small Grid Beyond 1920x1080 ---") -print("Original size: 400x300") -grid1.w = 2400 -grid1.h = 1400 -print(f"Resized to: {grid1.w}x{grid1.h}") +check("oversized grid renders at the far edge of the window (1000,700)", + is_content(img.at(1000, 700)), "got %s" % (img.at(1000, 700),)) +check("oversized grid renders near the window origin (20,20)", + is_content(img.at(20, 20)), "got %s" % (img.at(20, 20),)) -# The content should still be visible but may be clipped -mcrfpy.step(0.01) -automation.screenshot("/tmp/issue_9_resized_beyond_limit.png") -print("EXPECTED ISSUE: Grid resized beyond RenderTexture limits") -print(" Content beyond 1920x1080 will be clipped!") +# --- Test 4: shrinking must not leave stale pixels outside the new box -------- +print("\n--- Test 4: Shrink back to 200x150 ---") +grid.w = 200 +grid.h = 150 +anchor_camera(grid) +img = render("/tmp/issue_9_shrunk.png") -# Test 4: Create large grid from start -print("\n--- Test 4: Large Grid from Start (2400x1400) ---") -# Clear previous grids -while len(scene_ui) > 0: - scene_ui.remove(0) +check("shrunken grid still renders inside its box", + is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),)) +check("no stale content left outside the shrunken box (500,400)", + img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),)) +check("no stale content left outside the shrunken box (880,690)", + img.at(880, 690) == BACKGROUND, "got %s" % (img.at(880, 690),)) -grid3 = mcrfpy.Grid(80, 50) # Large tile count -grid3.x = 10 -grid3.y = 10 -grid3.w = 2400 -grid3.h = 1400 -scene_ui.append(grid3) - -create_checkerboard_pattern(grid3, 80, 50, 5) -add_border_markers(grid3, 80, 50) - -# Add markers at specific positions to test rendering -# Mark the center -center_x, center_y = 40, 25 -for dx in range(-2, 3): - for dy in range(-2, 3): - grid3.at(center_x + dx, center_y + dy).color = mcrfpy.Color(255, 0, 255, 255) # Magenta - -# Mark position at 1920 pixel boundary (64 tiles * 30 pixels/tile = 1920) -if 64 < 80: # Only if within grid bounds - for y in range(min(50, 10)): - grid3.at(64, y).color = mcrfpy.Color(255, 128, 0, 255) # Orange - -mcrfpy.step(0.01) -automation.screenshot("/tmp/issue_9_large_grid.png") -print("EXPECTED ISSUE: Large grid created") -print(" Content beyond 1920x1080 will not render!") -print(" Look for missing orange line at x=1920 boundary") - -# Test 5: Dynamic resize test -print("\n--- Test 5: Dynamic Resize Test ---") -scene_ui.remove(0) - -grid4 = mcrfpy.Grid(100, 100) -grid4.x = 10 -grid4.y = 10 -scene_ui.append(grid4) - -sizes = [(500, 500), (1000, 1000), (1500, 1500), (2000, 2000), (2500, 2500)] - -for i, (w, h) in enumerate(sizes): - grid4.w = w - grid4.h = h - - # Add pattern at current size - visible_tiles_x = min(100, w // 30) - visible_tiles_y = min(100, h // 30) - - # Clear and create new pattern - for x in range(visible_tiles_x): - for y in range(visible_tiles_y): - if x == visible_tiles_x - 1 or y == visible_tiles_y - 1: - # Edge markers - grid4.at(x, y).color = mcrfpy.Color(255, 255, 0, 255) - elif (x + y) % 10 == 0: - # Diagonal lines - grid4.at(x, y).color = mcrfpy.Color(0, 255, 255, 255) - - mcrfpy.step(0.01) - automation.screenshot(f"/tmp/issue_9_resize_{w}x{h}.png") - - if w > 1920 or h > 1080: - print(f"FAIL: Size {w}x{h}: Content clipped at 1920x1080") - else: - print(f"PASS: Size {w}x{h}: Rendered correctly") - -# Test 6: Verify exact clipping boundary -print("\n--- Test 6: Exact Clipping Boundary Test ---") -scene_ui.remove(0) - -grid5 = mcrfpy.Grid(70, 40) -grid5.x = 0 -grid5.y = 0 -grid5.w = 2100 # 70 * 30 = 2100 pixels -grid5.h = 1200 # 40 * 30 = 1200 pixels -scene_ui.append(grid5) - -# Create a pattern that shows the boundary clearly -for x in range(70): - for y in range(40): - pixel_x = x * 30 - pixel_y = y * 30 - - if pixel_x == 1920 - 30: # Last tile before boundary - grid5.at(x, y).color = mcrfpy.Color(255, 0, 0, 255) # Red - elif pixel_x == 1920: # First tile after boundary - grid5.at(x, y).color = mcrfpy.Color(0, 255, 0, 255) # Green - elif pixel_y == 1080 - 30: # Last row before boundary - grid5.at(x, y).color = mcrfpy.Color(0, 0, 255, 255) # Blue - elif pixel_y == 1080: # First row after boundary - grid5.at(x, y).color = mcrfpy.Color(255, 255, 0, 255) # Yellow - else: - # Normal checkerboard - if (x + y) % 2 == 0: - grid5.at(x, y).color = mcrfpy.Color(200, 200, 200, 255) - -mcrfpy.step(0.01) -automation.screenshot("/tmp/issue_9_boundary_test.png") -print("Screenshot saved showing clipping boundary") -print("- Red tiles: Last visible column (x=1890-1919)") -print("- Green tiles: First clipped column (x=1920+)") -print("- Blue tiles: Last visible row (y=1050-1079)") -print("- Yellow tiles: First clipped row (y=1080+)") - -# Summary print("\n=== SUMMARY ===") -print("Issue #9: UIGrid uses a hardcoded RenderTexture size of 1920x1080") -print("Problems demonstrated:") -print("1. Grids larger than 1920x1080 are clipped") -print("2. Resizing grids doesn't recreate the RenderTexture") -print("3. Content beyond the boundary is not rendered") -print("\nThe fix should:") -print("1. Recreate RenderTexture when grid size changes") -print("2. Use the actual grid dimensions instead of hardcoded values") -print("3. Consider memory limits for very large grids") +if FAILURES: + print("FAIL: %d check(s) failed: %s" % (len(FAILURES), ", ".join(FAILURES))) + sys.exit(1) -print(f"\nScreenshots saved to /tmp/issue_9_*.png") -print("\nTest complete - check screenshots for visual verification") +print("Screenshots saved to /tmp/issue_9_*.png") +print("PASS") sys.exit(0) diff --git a/tests/regression/recursive_animation_callback_segfault.py b/tests/regression/recursive_animation_callback_segfault.py index a09c43f..9b51638 100644 --- a/tests/regression/recursive_animation_callback_segfault.py +++ b/tests/regression/recursive_animation_callback_segfault.py @@ -25,8 +25,7 @@ def recursive_callback(target, prop, value): callback_count += 1 if callback_count >= MAX_CALLBACKS: - print(f"PASS - {callback_count} recursive animation callbacks completed without segfault") - sys.exit(0) + return # Chain another animation - this used to cause segfault due to iterator invalidation target.animate("x", 100 + (callback_count * 20), 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback) @@ -34,13 +33,29 @@ def recursive_callback(target, prop, value): # Start the chain frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback) -def timeout_check(timer, runtime): - """Safety timeout""" - if callback_count >= MAX_CALLBACKS: - print(f"PASS - {callback_count} callbacks completed") - sys.exit(0) - else: - print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed") - sys.exit(1) +# In headless mode mcrfpy.step() is the only clock: drive the animation chain forward. +# Each link is 0.1s; MAX_CALLBACKS links need >= 1.0s of simulated time. Step generously +# and bail out as soon as the chain is complete. +MAX_STEPS = 400 +steps = 0 +while callback_count < MAX_CALLBACKS and steps < MAX_STEPS: + mcrfpy.step(0.02) + steps += 1 -safety_timer = mcrfpy.Timer("safety", timeout_check, 5000, once=True) +if callback_count < MAX_CALLBACKS: + print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed after {steps} steps") + sys.exit(1) + +# The chain completed without segfault (iterator invalidation in AnimationManager::update). +# The final animation of the chain targets x = 100 + (MAX_CALLBACKS-1)*20; run a few more +# steps so it settles, proving the manager is still healthy after the recursive churn. +for _ in range(20): + mcrfpy.step(0.02) + +expected_x = 100 + (MAX_CALLBACKS - 1) * 20 +if abs(frame.x - expected_x) > 0.5: + print(f"FAIL - final animation did not complete: frame.x={frame.x}, expected {expected_x}") + sys.exit(1) + +print(f"PASS - {callback_count} recursive animation callbacks completed without segfault; frame.x={frame.x}") +sys.exit(0) diff --git a/tests/run_tests.py b/tests/run_tests.py index 6a07d28..bdbea37 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -41,7 +41,18 @@ SANITIZER_PATTERNS = [ ] # Test directories to run (in order) -TEST_DIRS = ['unit', 'integration', 'regression'] +TEST_DIRS = ['unit', 'integration', 'regression', 'demo'] + +# #372: tests/demo/ was never run by anything, so the demo screens -- which CLAUDE.md +# points at as the canonical API-usage examples -- silently bitrotted until they could +# not even import. Smoke-gate the entry point that exercises screens/base.py and every +# wired DemoScreen, so "read the demos for correct usage" stays true. +# +# An allowlist, not a glob: the other scripts under tests/demo/ are standalone +# showcases that are not part of the demo_main path (see the follow-up issue for +# adopting them). Demos render, so they need a longer timeout than a unit test. +DEMO_SMOKE_TESTS = ['demo_main.py'] +DEMO_TIMEOUT = 60 # seconds # ANSI colors GREEN = '\033[92m' @@ -113,6 +124,13 @@ def run_test(test_path, verbose=False, timeout=DEFAULT_TIMEOUT, passed = result.returncode == 0 output = result.stdout + result.stderr + # An uncaught exception is never a pass. Exit code alone was not enough: + # a script that raised during setup registered no timers, was auto-exited 0 + # by the engine, and was scored PASS -- 59 tests sat rotted this way, their + # assertions never once executing. (#341/#350/#372) + if 'Traceback (most recent call last)' in output: + passed = False + # Check for PASS/FAIL in output if 'FAIL' in output and 'PASS' not in output.split('FAIL')[-1]: passed = False @@ -150,6 +168,9 @@ def find_tests(directory): test_dir = TESTS_DIR / directory if not test_dir.exists(): return [] + if directory == 'demo': + # #372: allowlisted entry points only -- see DEMO_SMOKE_TESTS. + return [test_dir / name for name in DEMO_SMOKE_TESTS if (test_dir / name).exists()] return sorted(test_dir.glob("*.py")) def main(): @@ -203,8 +224,11 @@ def main(): for test_path in tests: test_name = test_path.name + # Demos render several scenes; they need more headroom than a unit test. + test_timeout = (DEMO_TIMEOUT * timeout_multiplier + if test_dir == 'demo' else effective_timeout) passed, duration, output, san_errors = run_test( - test_path, verbose, effective_timeout, + test_path, verbose, test_timeout, sanitizer_mode=sanitizer_mode, valgrind_mode=valgrind_mode ) diff --git a/tests/snapshots/api_surface.golden.txt b/tests/snapshots/api_surface.golden.txt index a423c52..166e06b 100644 --- a/tests/snapshots/api_surface.golden.txt +++ b/tests/snapshots/api_surface.golden.txt @@ -24,6 +24,15 @@ const keyboard: Keyboard const mouse: Mouse const window: Window +=== MODULE DYNAMIC ATTRIBUTES (7) === +attr animations (ro) :: Tuple of all currently running Animation objects (tuple, read-only). +attr current_scene (rw) :: The active scene (Scene). Assign a Scene object, or a scene name as a string, to switch scenes. +attr default_transition (rw) :: Default transition (Transition) applied when switching scenes without an explicit transition. +attr default_transition_duration (rw) :: Default scene-transition duration in seconds (float). Must be non-negative. +attr save_dir (ro) :: Directory used for persistent save data (str, read-only). '/save' under Emscripten. +attr scenes (ro) :: Tuple of all registered Scene objects (tuple, read-only). +attr timers (ro) :: Tuple of all active Timer objects (tuple, read-only). + === SUBMODULES (1) === submodule automation diff --git a/tests/unit/WORKING_automation_test_example.py b/tests/unit/WORKING_automation_test_example.py index e7307d2..8ec73ce 100644 --- a/tests/unit/WORKING_automation_test_example.py +++ b/tests/unit/WORKING_automation_test_example.py @@ -1,12 +1,25 @@ #!/usr/bin/env python3 """Example of CORRECT test pattern using mcrfpy.step() for automation Refactored from timer-based approach to synchronous step() pattern. + +Note (#350/#341): step() is the simulation clock and never renders; +automation.screenshot() is what forces a render of the current scene. """ import mcrfpy from mcrfpy import automation from datetime import datetime +import os import sys +failures = [] + +def check(condition, message): + if condition: + print(f" ok: {message}") + else: + print(f" FAIL: {message}") + failures.append(message) + # This code runs during --exec script execution print("=== Setting Up Test Scene ===") @@ -32,11 +45,11 @@ caption.font_size = 24 frame.children.append(caption) # Add click handler to demonstrate interaction -click_received = False -def frame_clicked(x, y, button): - global click_received - click_received = True - print(f"Frame clicked at ({x}, {y}) with button {button}") +# (#230) on_click receives (pos: Vector, button: MouseButton, action: InputState) +clicks_received = [] +def frame_clicked(pos, button, action): + clicks_received.append((pos, button, action)) + print(f"Frame clicked at ({pos.x}, {pos.y}) with button {button} ({action})") frame.on_click = frame_clicked @@ -54,13 +67,25 @@ filename = f"WORKING_screenshot_{timestamp}.png" # Take screenshot - this should now show our red frame result = automation.screenshot(filename) print(f"Screenshot taken: {filename} - Result: {result}") +check(result is True, "screenshot() reported success") +check(os.path.exists(filename), f"{filename} exists on disk") +# A blank 1024x768 PNG compresses to a few hundred bytes; real content is far bigger +check(os.path.getsize(filename) > 2000, + f"{filename} contains rendered content (size {os.path.getsize(filename)} > 2000)") # Test clicking on the frame -automation.click(200, 200) # Click in center of red frame +automation.click((200, 200)) # Click in center of red frame # Step to process the click mcrfpy.step(0.1) +check(len(clicks_received) > 0, "frame.on_click fired for click inside the frame") +if clicks_received: + pos, button, action = clicks_received[0] + check((pos.x, pos.y) == (200.0, 200.0), "click reported the position it was sent to") + check(button == mcrfpy.MouseButton.LEFT, "click reported the LEFT button") + check(action == mcrfpy.InputState.PRESSED, "click reported the PRESSED state") + # Test keyboard input automation.typewrite("Hello from step-based test!") @@ -69,14 +94,26 @@ mcrfpy.step(0.1) # Take another screenshot to show any changes filename2 = f"WORKING_screenshot_after_click_{timestamp}.png" -automation.screenshot(filename2) +result2 = automation.screenshot(filename2) print(f"Second screenshot: {filename2}") +check(result2 is True, "second screenshot() reported success") +check(os.path.exists(filename2), f"{filename2} exists on disk") + +# Clean up the artifacts this test produced +for f in (filename, filename2): + if os.path.exists(f): + os.remove(f) -print("Test completed successfully!") print("\nThis works because:") print("1. mcrfpy.step() advances simulation synchronously") -print("2. The scene renders during step() calls") +print("2. automation.screenshot() forces a render of the active scene") print("3. The RenderTexture contains actual rendered content") -print("PASS") +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("\nPASS") sys.exit(0) diff --git a/tests/unit/animation_loop_test.py b/tests/unit/animation_loop_test.py index 12d777b..e04cd34 100644 --- a/tests/unit/animation_loop_test.py +++ b/tests/unit/animation_loop_test.py @@ -90,12 +90,21 @@ for _ in range(10): check("replacement anim completes", anim5b.is_complete == True) -# --- Test 6: Animation object created with loop=True via constructor --- -anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True) -check("Animation constructor loop=True", anim6.is_looping == True) +# --- Test 6: loop flag set at Animation creation time --- +# mcrfpy.Animation is no longer exported; the successor factory is target.animate(), +# which returns the Animation object. Same intent: the loop keyword is honored at +# creation, and defaults to False. +sprite6 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite6) -anim7 = mcrfpy.Animation("x", 100.0, 1.0) -check("Animation constructor default loop=False", anim7.is_looping == False) +anim6 = sprite6.animate("x", 100.0, 1.0, loop=True) +check("animate(loop=True) sets is_looping", anim6.is_looping == True) + +anim7 = sprite6.animate("y", 100.0, 1.0) +check("animate() default loop=False", anim7.is_looping == False) + +anim8 = sprite6.animate("z_index", 5, 1.0, loop=False) +check("animate(loop=False) sets is_looping False", anim8.is_looping == False) # --- Summary --- diff --git a/tests/unit/api_createScene_test.py b/tests/unit/api_createScene_test.py index 8cb17b1..4574505 100644 --- a/tests/unit/api_createScene_test.py +++ b/tests/unit/api_createScene_test.py @@ -1,34 +1,65 @@ #!/usr/bin/env python3 -"""Test for mcrfpy.createScene() method""" +"""Test for mcrfpy.Scene() creation and activation (formerly mcrfpy.createScene())""" import mcrfpy +import sys def test_createScene(): """Test creating a new scene""" + failures = [] + scenes = {} + # Test creating scenes test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"] - + for scene_name in test_scenes: try: - _scene = mcrfpy.Scene(scene_name) - print(f"✓ Created scene: {scene_name}") + scenes[scene_name] = mcrfpy.Scene(scene_name) + print(f"PASS: Created scene: {scene_name}") except Exception as e: - print(f"✗ Failed to create scene {scene_name}: {e}") - return - - # Try to set scene to verify it was created - try: - test_scene1.activate() # Note: ensure scene was created - current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) - if current == "test_scene1": - print("✓ Scene switching works correctly") - else: - print(f"✗ Scene switch failed: expected 'test_scene1', got '{current}'") - except Exception as e: - print(f"✗ Scene switching error: {e}") - - print("PASS") + print(f"FAIL: Failed to create scene {scene_name}: {e}") + failures.append(f"create {scene_name}") + continue + + if scenes[scene_name].name != scene_name: + print(f"FAIL: scene.name mismatch: expected '{scene_name}', got '{scenes[scene_name].name}'") + failures.append(f"name {scene_name}") + + # Activate a created scene to verify it exists and is switchable + if "test_scene1" in scenes: + try: + scenes["test_scene1"].activate() + current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) + if current == "test_scene1": + print("PASS: Scene switching works correctly") + else: + print(f"FAIL: Scene switch failed: expected 'test_scene1', got '{current}'") + failures.append("activate()") + except Exception as e: + print(f"FAIL: Scene switching error: {e}") + failures.append("activate()") + + # current_scene is also writable directly + try: + mcrfpy.current_scene = scenes["test_scene2"] + current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) + if current == "test_scene2": + print("PASS: mcrfpy.current_scene assignment works correctly") + else: + print(f"FAIL: current_scene assignment failed: expected 'test_scene2', got '{current}'") + failures.append("current_scene=") + except Exception as e: + print(f"FAIL: current_scene assignment error: {e}") + failures.append("current_scene=") + + return failures # Run test immediately print("Running createScene test...") -test_createScene() -print("Test completed.") \ No newline at end of file +failures = test_createScene() +print("Test completed.") + +if failures: + print("FAIL: " + ", ".join(failures)) + sys.exit(1) +print("PASS") +sys.exit(0) diff --git a/tests/unit/api_setScene_currentScene_test.py b/tests/unit/api_setScene_currentScene_test.py index 299af65..bdf44e0 100644 --- a/tests/unit/api_setScene_currentScene_test.py +++ b/tests/unit/api_setScene_currentScene_test.py @@ -1,44 +1,78 @@ #!/usr/bin/env python3 -"""Test for mcrfpy.setScene() and currentScene() methods""" +"""Test for mcrfpy.current_scene (successor to setScene()/currentScene())""" import mcrfpy +import sys -print("Starting setScene/currentScene test...") +print("Starting current_scene test...") # Create test scenes first scenes = ["scene_A", "scene_B", "scene_C"] +scene_objs = {} for scene in scenes: - _scene = mcrfpy.Scene(scene) + scene_objs[scene] = mcrfpy.Scene(scene) print(f"Created scene: {scene}") results = [] -# Test switching between scenes +# Test switching between scenes by name (mcrfpy.current_scene is read/write) for scene in scenes: try: mcrfpy.current_scene = scene current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) if current == scene: - results.append(f"✓ setScene/currentScene works for '{scene}'") + results.append(f"[ok] current_scene set/get by name works for '{scene}'") else: - results.append(f"✗ Scene mismatch: set '{scene}', got '{current}'") + results.append(f"[FAIL] Scene mismatch: set '{scene}', got '{current}'") except Exception as e: - results.append(f"✗ Error with scene '{scene}': {e}") + results.append(f"[FAIL] Error with scene '{scene}': {e}") -# Test invalid scene - it should not change the current scene +# Test switching by Scene object, and via Scene.activate() +for scene in scenes: + try: + mcrfpy.current_scene = scene_objs[scene] + current = mcrfpy.current_scene + if current.name == scene and current.active: + results.append(f"[ok] current_scene set by Scene object works for '{scene}'") + else: + results.append(f"[FAIL] Object assign mismatch: set '{scene}', got '{current.name}'") + except Exception as e: + results.append(f"[FAIL] Error assigning Scene object '{scene}': {e}") + +try: + scene_objs["scene_B"].activate() + if mcrfpy.current_scene.name == "scene_B": + results.append("[ok] Scene.activate() switches the current scene") + else: + results.append(f"[FAIL] activate() left current scene at '{mcrfpy.current_scene.name}'") +except Exception as e: + results.append(f"[FAIL] Error in Scene.activate(): {e}") + +# Test invalid scene - it must not change the current scene. +# Current contract: assigning an unknown scene name raises KeyError and the +# current scene is left untouched (the old setScene() silently ignored it). current_before = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) -nonexistent_scene.activate() # Note: ensure scene was created +try: + mcrfpy.current_scene = "nonexistent_scene" + results.append("[FAIL] Assigning a nonexistent scene name did not raise") +except KeyError: + results.append("[ok] Assigning a nonexistent scene name raises KeyError") +except Exception as e: + results.append(f"[FAIL] Expected KeyError for nonexistent scene, got {type(e).__name__}: {e}") + current_after = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) if current_before == current_after: - results.append(f"✓ setScene correctly ignores nonexistent scene (stayed on '{current_after}')") + results.append(f"[ok] Failed switch leaves current scene alone (stayed on '{current_after}')") else: - results.append(f"✗ Scene changed unexpectedly from '{current_before}' to '{current_after}'") + results.append(f"[FAIL] Scene changed unexpectedly from '{current_before}' to '{current_after}'") # Print results for result in results: print(result) # Determine pass/fail -if all("✓" in r for r in results): +if all("[ok]" in r for r in results): print("PASS") + sys.exit(0) else: - print("FAIL") \ No newline at end of file + print("FAIL") + sys.exit(1) diff --git a/tests/unit/api_surface_snapshot_test.py b/tests/unit/api_surface_snapshot_test.py index 77b692e..4792aae 100644 --- a/tests/unit/api_surface_snapshot_test.py +++ b/tests/unit/api_surface_snapshot_test.py @@ -270,7 +270,16 @@ def build_snapshot(): out.append("# Regenerate intentionally: MCRF_UPDATE_API_SNAPSHOT=1") out.append("# Singleton/constant VALUES are intentionally NOT captured.") - names = [n for n in dir(mcrfpy) if not n.startswith("_")] + # #356: the dynamic module attributes are getset descriptors on type(mcrfpy). + # Capture them from the DESCRIPTOR, not from getattr(): their value is runtime + # state (current_scene is None here, a Scene in a running game), so snapshotting + # type(value) would make the golden depend on engine state. + dynamic_attrs = sorted( + n for n, d in vars(type(mcrfpy)).items() + if not n.startswith("_") and isinstance(d, types.GetSetDescriptorType) + ) + + names = [n for n in dir(mcrfpy) if not n.startswith("_") and n not in dynamic_attrs] enums, classes, funcs, singletons, submodules = [], [], [], [], [] for n in names: v = getattr(mcrfpy, n) @@ -298,6 +307,16 @@ def build_snapshot(): for n, tn in sorted(singletons): out.append("const %s: %s" % (n, tn)) + out.append("") + out.append("=== MODULE DYNAMIC ATTRIBUTES (%d) ===" % len(dynamic_attrs)) + for n in dynamic_attrs: + descr = getattr(type(mcrfpy), n) + doc = descr.__doc__ or "" + # A getset_descriptor is always a data descriptor (it exposes __set__ even + # with a NULL setter), so read-only-ness can only be read off the docstring. + rw = "ro" if "read-only" in doc.lower() else "rw" + out.append("attr %s (%s) :: %s" % (n, rw, first_doc_line(doc) or "")) + out.append("") out.append("=== SUBMODULES (%d) ===" % len(submodules)) for n in sorted(submodules): diff --git a/tests/unit/debug_empty_paths.py b/tests/unit/debug_empty_paths.py index a231bff..10fb45f 100644 --- a/tests/unit/debug_empty_paths.py +++ b/tests/unit/debug_empty_paths.py @@ -1,80 +1,125 @@ #!/usr/bin/env python3 -"""Debug empty paths issue""" +"""Debug empty paths issue + +Original intent: pathfinding was returning EMPTY paths on a fully-walkable grid. +This test verifies that A* and Dijkstra both return non-empty paths, that walls +are respected (detour), that an unreachable/blocked destination yields no path, +and that the TCOD map stays in sync with walkability changes. + +API notes (2026-07): compute_astar_path/compute_dijkstra/get_dijkstra_path are gone. +Pathfinding lives on GridData: find_path() -> AStarPath | None, +get_dijkstra_map(root=...) -> DijkstraMap (cached; clear_dijkstra_maps() after +walkability changes). TCOD sync is automatic -- there is no sync_tcod_map(). +""" import mcrfpy import sys print("Debugging empty paths...") +failures = [] + +def check(cond, msg): + if cond: + print(f" OK: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + # Create scene and grid debug = mcrfpy.Scene("debug") -grid = mcrfpy.Grid(grid_w=10, grid_h=10) +grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(320, 320)) +data = grid.grid_data # Initialize grid - all walkable print("\nInitializing grid...") for y in range(10): for x in range(10): - grid.at(x, y).walkable = True + data.at(x, y).walkable = True # Test simple path print("\nTest 1: Simple path from (0,0) to (5,5)") -path = grid.compute_astar_path(0, 0, 5, 5) -print(f" A* path: {path}") -print(f" Path length: {len(path)}") +path = data.find_path((0, 0), (5, 5)) +check(path is not None, "A* found a path on an all-walkable grid") +steps = list(path) if path else [] +print(f" A* path: {steps}") +print(f" Path length: {len(steps)}") +check(len(steps) > 0, "A* path is not empty") +check(path is not None and tuple(path.destination) == (5, 5), "A* path ends at (5,5)") # Test with Dijkstra print("\nTest 2: Same path with Dijkstra") -grid.compute_dijkstra(0, 0) -dpath = grid.get_dijkstra_path(5, 5) -print(f" Dijkstra path: {dpath}") -print(f" Path length: {len(dpath)}") +dmap = data.get_dijkstra_map(root=(0, 0)) +dpath = dmap.path_from((5, 5)) +dsteps = list(dpath) if dpath else [] +print(f" Dijkstra path: {dsteps}") +print(f" Path length: {len(dsteps)}") +check(len(dsteps) > 0, "Dijkstra path is not empty") +check(dmap.distance((5, 5)) > 0, "Dijkstra distance to (5,5) is finite and positive") # Check if grid is properly initialized print("\nTest 3: Checking grid cells") for y in range(3): for x in range(3): - cell = grid.at(x, y) + cell = data.at(x, y) print(f" Cell ({x},{y}): walkable={cell.walkable}") + check(cell.walkable, f"cell ({x},{y}) reports walkable=True") # Test with walls print("\nTest 4: Path with wall") -grid.at(2, 2).walkable = False -grid.at(3, 2).walkable = False -grid.at(4, 2).walkable = False +for wx in (2, 3, 4): + data.at(wx, 2).walkable = False +data.clear_dijkstra_maps() # walkability changed: invalidate cached maps print(" Added wall at y=2, x=2,3,4") -path2 = grid.compute_astar_path(0, 0, 5, 5) -print(f" A* path with wall: {path2}") -print(f" Path length: {len(path2)}") +path2 = data.find_path((0, 0), (5, 5)) +check(path2 is not None, "A* still finds a path around the wall") +steps2 = list(path2) if path2 else [] +print(f" A* path with wall: {steps2}") +print(f" Path length: {len(steps2)}") +check(len(steps2) > 0, "walled A* path is not empty") +blocked = {(2, 2), (3, 2), (4, 2)} +check(not any(tuple(p) in blocked for p in steps2), + "A* path does not cross the wall cells (TCOD map synced with walkability)") # Test invalid paths print("\nTest 5: Path to blocked cell") -grid.at(9, 9).walkable = False -path3 = grid.compute_astar_path(0, 0, 9, 9) +data.at(9, 9).walkable = False +data.clear_dijkstra_maps() +path3 = data.find_path((0, 0), (9, 9)) print(f" Path to blocked cell: {path3}") +check(path3 is None or len(list(path3)) == 0, + "no path is produced to a non-walkable destination") # Check TCOD map sync print("\nTest 6: Verify TCOD map is synced") -# Try to force a sync -print(" Checking if syncTCODMap exists...") -if hasattr(grid, 'sync_tcod_map'): - print(" Calling sync_tcod_map()") - grid.sync_tcod_map() -else: - print(" No sync_tcod_map method found") +# Fully wall off (9,8) and (8,9) too -- (9,9) neighbors -- then re-open (9,9): +# a stale TCOD map would still refuse the destination. +data.at(9, 9).walkable = True +data.clear_dijkstra_maps() +path_reopened = data.find_path((0, 0), (9, 9)) +check(path_reopened is not None and len(list(path_reopened)) > 0, + "re-opening a blocked cell makes it reachable again (automatic TCOD sync)") # Try path again -print("\nTest 7: Path after potential sync") -path4 = grid.compute_astar_path(0, 0, 5, 5) -print(f" A* path: {path4}") +print("\nTest 7: Path after mutation round-trip") +path4 = data.find_path((0, 0), (5, 5)) +steps4 = list(path4) if path4 else [] +print(f" A* path: {steps4}") +check(len(steps4) > 0, "A* path (0,0)->(5,5) still non-empty after mutations") -def timer_cb(timer, runtime): - sys.exit(0) - -# Quick UI setup +# Quick UI setup: the grid must survive being attached to a live scene. ui = debug.children ui.append(grid) debug.activate() -exit_timer = mcrfpy.Timer("exit", timer_cb, 100, once=True) +mcrfpy.step(0.1) +check(mcrfpy.current_scene is debug, "grid attached to the active scene") -print("\nStarting timer...") \ No newline at end of file +if failures: + print(f"\nFAIL ({len(failures)} check(s) failed):") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/unit/empty_script.py b/tests/unit/empty_script.py index b34ee08..ed1c7af 100644 --- a/tests/unit/empty_script.py +++ b/tests/unit/empty_script.py @@ -1,2 +1,34 @@ -# This script is intentionally empty -pass \ No newline at end of file +# This script is intentionally (almost) empty. +# +# Intent: the engine must survive a script that sets nothing up -- no scene, no UI, +# no timers -- and must not crash when the headless clock is driven in that state. +# +# Under the #350 exit contract a bare `pass` is no longer a valid --exec script: +# falling off the end exits nonzero with a diagnostic. So the "do nothing" case is +# now spelled as "do nothing, then exit 0", plus the checks that make it a real test. +import sys + +import mcrfpy + +failures = [] + +# A script that never creates a scene must still find the engine in a sane state. +if not hasattr(mcrfpy, "step"): + failures.append("mcrfpy.step is missing") +if not hasattr(mcrfpy, "current_scene"): + failures.append("mcrfpy.current_scene is missing") + +# Driving the clock with no scene / no timers / no animations must be a harmless no-op. +try: + for _ in range(3): + mcrfpy.step(0.05) +except Exception as e: + failures.append("mcrfpy.step() raised with no scene set up: %r" % (e,)) + +if failures: + for f in failures: + print("FAIL:", f) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/exit_immediately_test.py b/tests/unit/exit_immediately_test.py index 8df6089..c04a4ec 100644 --- a/tests/unit/exit_immediately_test.py +++ b/tests/unit/exit_immediately_test.py @@ -1,7 +1,78 @@ #!/usr/bin/env python3 -"""Test if calling mcrfpy.exit() prevents the >>> prompt""" -import mcrfpy +"""Test that mcrfpy.exit() shuts the engine down instead of leaving it running +Original intent: "does calling mcrfpy.exit() prevent the >>> prompt?" -- i.e. does +exit() actually terminate the application rather than falling through into an +interactive/never-ending loop? + +Current contract (#350): mcrfpy.exit() calls GameEngine::quit(); it stops the run +loop. It is NOT a SystemExit -- the Python script keeps executing after it, and a +headless --exec script still states its own outcome with sys.exit(). So the real +"did exit() work?" question is answered in a child process that DOES enter the run +loop (--run-forever): with exit() it must terminate; without it, it must not. +""" +import mcrfpy +import os +import subprocess +import sys + +failures = [] +scratch = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build") +scratch = os.path.abspath(scratch) + +# --- 1. exit() is a no-arg call returning None; it does not abort the interpreter --- print("Calling mcrfpy.exit() immediately...") -mcrfpy.exit() -print("This should not print if exit worked") \ No newline at end of file +result = mcrfpy.exit() +print("This still prints: exit() quits the engine, it does not raise SystemExit") + +if result is not None: + failures.append("mcrfpy.exit() should return None, got %r" % (result,)) + +try: + mcrfpy.exit(1) +except TypeError: + pass +else: + failures.append("mcrfpy.exit() takes no arguments; exit(1) should raise TypeError") + +# --- 2. exit() really stops the run loop (the ">>> prompt"/hang the test was about) --- +child_exit = os.path.join(scratch, "_exit_immediately_child_exit.py") +child_stay = os.path.join(scratch, "_exit_immediately_child_stay.py") +with open(child_exit, "w") as f: + f.write("import mcrfpy\nmcrfpy.exit()\n") +with open(child_stay, "w") as f: + f.write("import mcrfpy\n") + +try: + # sys.executable is the mcrogueface binary itself. + proc = subprocess.run( + [sys.executable, "--headless", "--run-forever", "--exec", child_exit], + cwd=scratch, capture_output=True, text=True, timeout=20) + if proc.returncode != 0: + failures.append("exit() child should terminate cleanly, got returncode %d" + % proc.returncode) +except subprocess.TimeoutExpired: + failures.append("exit() did not stop the run loop: --run-forever child hung") + +# Control: without exit(), --run-forever must keep the process alive. This proves the +# check above is actually observing exit(), not just a run loop that always ends. +try: + subprocess.run( + [sys.executable, "--headless", "--run-forever", "--exec", child_stay], + cwd=scratch, capture_output=True, text=True, timeout=5) + failures.append("control child (no exit()) terminated on its own; " + "the exit() check above proves nothing") +except subprocess.TimeoutExpired: + pass # expected: still running + +for path in (child_exit, child_stay): + if os.path.exists(path): + os.remove(path) + +if failures: + for f in failures: + print("FAIL: %s" % f) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/generate_sprite_screenshot.py b/tests/unit/generate_sprite_screenshot.py index 5191b7d..1fb524d 100644 --- a/tests/unit/generate_sprite_screenshot.py +++ b/tests/unit/generate_sprite_screenshot.py @@ -3,17 +3,26 @@ import mcrfpy from mcrfpy import automation +import os import sys -def capture_sprites(timer, runtime): - """Capture sprite examples after render loop starts""" +SCREENSHOT = "ui_sprite_example.png" - # Take screenshot - automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png") +results = [] + +def check(label, condition): + results.append((label, bool(condition))) + print(f"{'PASS' if condition else 'FAIL'}: {label}") + +def capture_sprites(timer, runtime): + """Capture sprite examples once the timer fires""" + + # Take screenshot (rendering is on-demand in headless: screenshot forces a render) + automation.screenshot(SCREENSHOT) print("Sprite screenshot saved!") - # Exit after capturing - sys.exit(0) + check("screenshot file created", os.path.exists(SCREENSHOT)) + check("screenshot is non-empty", os.path.exists(SCREENSHOT) and os.path.getsize(SCREENSHOT) > 0) # Create scene sprites = mcrfpy.Scene("sprites") @@ -21,9 +30,8 @@ sprites = mcrfpy.Scene("sprites") # Load texture texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16) -# Title -title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples") -title.font = mcrfpy.default_font +# Title (Caption.font is read-only: pass it to the constructor) +title = mcrfpy.Caption(pos=(400, 30), font=mcrfpy.default_font, text="Sprite Examples") title.font_size = 24 title.fill_color = mcrfpy.Color(255, 255, 255) @@ -33,102 +41,71 @@ frame.fill_color = mcrfpy.Color(64, 64, 128) frame.outline = 2 # Player sprite -player_label = mcrfpy.Caption(pos=(100, 120), text="Player") -player_label.font = mcrfpy.default_font +player_label = mcrfpy.Caption(pos=(100, 120), font=mcrfpy.default_font, text="Player") player_label.fill_color = mcrfpy.Color(255, 255, 255) -player = mcrfpy.Sprite(120, 150) -player.texture = texture -player.sprite_index = 84 # Player sprite -player.scale = (3.0, 3.0) +player = mcrfpy.Sprite(pos=(120, 150), texture=texture, sprite_index=84) # Player sprite +player.scale = 3.0 # Enemy sprites -enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies") -enemy_label.font = mcrfpy.default_font +enemy_label = mcrfpy.Caption(pos=(250, 120), font=mcrfpy.default_font, text="Enemies") enemy_label.fill_color = mcrfpy.Color(255, 255, 255) -rat = mcrfpy.Sprite(250, 150) -rat.texture = texture -rat.sprite_index = 123 # Rat -rat.scale = (3.0, 3.0) +rat = mcrfpy.Sprite(pos=(250, 150), texture=texture, sprite_index=123) # Rat +rat.scale = 3.0 -big_rat = mcrfpy.Sprite(320, 150) -big_rat.texture = texture -big_rat.sprite_index = 130 # Big rat -big_rat.scale = (3.0, 3.0) +big_rat = mcrfpy.Sprite(pos=(320, 150), texture=texture, sprite_index=130) # Big rat +big_rat.scale = 3.0 -cyclops = mcrfpy.Sprite(390, 150) -cyclops.texture = texture -cyclops.sprite_index = 109 # Cyclops -cyclops.scale = (3.0, 3.0) +cyclops = mcrfpy.Sprite(pos=(390, 150), texture=texture, sprite_index=109) # Cyclops +cyclops.scale = 3.0 # Items row -items_label = mcrfpy.Caption(pos=(100, 250), text="Items") -items_label.font = mcrfpy.default_font +items_label = mcrfpy.Caption(pos=(100, 250), font=mcrfpy.default_font, text="Items") items_label.fill_color = mcrfpy.Color(255, 255, 255) # Boulder -boulder = mcrfpy.Sprite(100, 280) -boulder.texture = texture -boulder.sprite_index = 66 # Boulder -boulder.scale = (3.0, 3.0) +boulder = mcrfpy.Sprite(pos=(100, 280), texture=texture, sprite_index=66) # Boulder +boulder.scale = 3.0 # Chest -chest = mcrfpy.Sprite(170, 280) -chest.texture = texture -chest.sprite_index = 89 # Closed chest -chest.scale = (3.0, 3.0) +chest = mcrfpy.Sprite(pos=(170, 280), texture=texture, sprite_index=89) # Closed chest +chest.scale = 3.0 # Key -key = mcrfpy.Sprite(240, 280) -key.texture = texture -key.sprite_index = 384 # Key -key.scale = (3.0, 3.0) +key = mcrfpy.Sprite(pos=(240, 280), texture=texture, sprite_index=384) # Key +key.scale = 3.0 # Button -button = mcrfpy.Sprite(310, 280) -button.texture = texture -button.sprite_index = 250 # Button -button.scale = (3.0, 3.0) +button = mcrfpy.Sprite(pos=(310, 280), texture=texture, sprite_index=250) # Button +button.scale = 3.0 # UI elements row -ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements") -ui_label.font = mcrfpy.default_font +ui_label = mcrfpy.Caption(pos=(100, 380), font=mcrfpy.default_font, text="UI Elements") ui_label.fill_color = mcrfpy.Color(255, 255, 255) # Hearts -heart_full = mcrfpy.Sprite(100, 410) -heart_full.texture = texture -heart_full.sprite_index = 210 # Full heart -heart_full.scale = (3.0, 3.0) +heart_full = mcrfpy.Sprite(pos=(100, 410), texture=texture, sprite_index=210) # Full heart +heart_full.scale = 3.0 -heart_half = mcrfpy.Sprite(170, 410) -heart_half.texture = texture -heart_half.sprite_index = 209 # Half heart -heart_half.scale = (3.0, 3.0) +heart_half = mcrfpy.Sprite(pos=(170, 410), texture=texture, sprite_index=209) # Half heart +heart_half.scale = 3.0 -heart_empty = mcrfpy.Sprite(240, 410) -heart_empty.texture = texture -heart_empty.sprite_index = 208 # Empty heart -heart_empty.scale = (3.0, 3.0) +heart_empty = mcrfpy.Sprite(pos=(240, 410), texture=texture, sprite_index=208) # Empty heart +heart_empty.scale = 3.0 # Armor -armor = mcrfpy.Sprite(340, 410) -armor.texture = texture -armor.sprite_index = 211 # Armor -armor.scale = (3.0, 3.0) +armor = mcrfpy.Sprite(pos=(340, 410), texture=texture, sprite_index=211) # Armor +armor.scale = 3.0 # Scale demonstration -scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo") -scale_label.font = mcrfpy.default_font +scale_label = mcrfpy.Caption(pos=(500, 120), font=mcrfpy.default_font, text="Scale Demo") scale_label.fill_color = mcrfpy.Color(255, 255, 255) # Same sprite at different scales for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]): - s = mcrfpy.Sprite(500 + i * 60, 150) - s.texture = texture - s.sprite_index = 84 # Player - s.scale = (scale, scale) + s = mcrfpy.Sprite(pos=(500 + i * 60, 150), texture=texture, sprite_index=84) # Player + s.scale = scale sprites.children.append(s) # Add all elements to scene @@ -156,5 +133,22 @@ ui.append(scale_label) # Switch to scene sprites.activate() -# Set timer to capture after rendering starts -capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True) \ No newline at end of file +# Everything got built and attached +check("scene populated with sprite showcase", len(sprites.children) == 23) +check("sprite scale applied", player.scale == 3.0) +check("sprite index applied", player.sprite_index == 84) + +# Set timer to capture; headless has no clock of its own, so drive it with step() +capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True) +for _ in range(4): + mcrfpy.step(0.05) + +check("capture timer fired", any(label.startswith("screenshot") for label, _ in results)) + +failures = [label for label, ok in results if not ok] +if failures: + print(f"FAIL - {len(failures)} check(s) failed: {failures}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/grid_camera_rotation_test.py b/tests/unit/grid_camera_rotation_test.py index 9533f4c..6793d9a 100644 --- a/tests/unit/grid_camera_rotation_test.py +++ b/tests/unit/grid_camera_rotation_test.py @@ -28,6 +28,7 @@ for i in range(1, 8): # Apply camera rotation grid.camera_rotation = 30.0 # 30 degree rotation grid.center_camera((4, 4)) # Center on middle of grid +assert grid.camera_rotation == 30.0, "camera_rotation should round-trip" ui.append(grid) @@ -46,6 +47,10 @@ for i in range(1, 8): grid2.camera_rotation = 0.0 # No rotation grid2.center_camera((4, 4)) +assert grid2.camera_rotation == 0.0, "camera_rotation=0 should round-trip" + +# camera_rotation is per-view state: rotating grid must not disturb grid2 +assert grid.camera_rotation == 30.0, "camera_rotation must be per-grid, not shared" ui.append(grid2) @@ -69,6 +74,9 @@ for i in range(6): grid3.rotation = 15.0 grid3.origin = (100, 75) # Center origin for rotation grid3.center_camera((3, 3)) +assert grid3.rotation == 15.0, "viewport rotation should round-trip" +# viewport rotation is distinct from camera rotation +assert grid3.camera_rotation == 0.0, "setting .rotation must not set .camera_rotation" ui.append(grid3) @@ -76,9 +84,9 @@ label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire wid ui.append(label3) # Test center_camera computes correct pixel center +# (GridView has no .cell_size accessor; the raw cell metrics aren't needed here -- +# center_camera is verified by the relationships between the centers it produces.) test_grid = mcrfpy.Grid(grid_size=(20, 15), pos=(0, 0), size=(320, 240)) -cell_w = test_grid.cell_size[0] -cell_h = test_grid.cell_size[1] # center_camera((0, 0)) should put tile (0,0) at view center test_grid.center_camera((0, 0)) @@ -90,6 +98,8 @@ c0 = test_grid.center test_grid.center_camera((10, 7)) c1 = test_grid.center assert c0.x != c1.x or c0.y != c1.y, "center_camera at different positions should give different centers" +# ...and it should move monotonically with the tile coordinate +assert c1.x > c0.x and c1.y > c0.y, "center_camera to a larger tile should increase the center" # center_camera at same position twice should be idempotent test_grid.center_camera((5, 5)) diff --git a/tests/unit/gridview_test.py b/tests/unit/gridview_test.py index 7bd2b82..fb89f4b 100644 --- a/tests/unit/gridview_test.py +++ b/tests/unit/gridview_test.py @@ -73,24 +73,31 @@ def test_gridview_repr(): print("PASS: GridView repr") def test_gridview_grid_property(): - """GridView.grid returns the correct Grid with identity preservation.""" + """GridView.grid_data returns the shared GridData with identity preservation. + + Contract change (#313/#361): a view no longer exposes `.grid` (the source view); + Grid and GridView are the same type, and the map they share is `.grid_data`. + """ tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160)) view = mcrfpy.GridView(grid=grid, pos=(250, 0), size=(240, 160)) - assert view.grid is grid, "view.grid should be the same Grid object" - assert view.grid.grid_w == 15 + assert view.grid_data is grid.grid_data, "view must share the source Grid's GridData" + assert view.grid_data.grid_w == 15 # Identity preserved on repeated access - assert view.grid is view.grid - print("PASS: GridView.grid property with identity") + assert view.grid_data is view.grid_data + print("PASS: GridView.grid_data property with identity") -def test_gridview_no_grid(): - """GridView without a grid doesn't crash.""" +def test_gridview_default_grid(): + """GridView with no source grid doesn't crash; it gets its own default GridData.""" view = mcrfpy.GridView() - assert view.grid is None + gd = view.grid_data + assert gd is not None, "a bare GridView still owns a default GridData" + assert gd.grid_w > 0 and gd.grid_h > 0 + assert gd is view.grid_data # identity preserved r = repr(view) - assert "None" in r - print("PASS: GridView without grid") + assert "GridView" in r + print("PASS: GridView without a source grid") if __name__ == "__main__": test_gridview_creation() @@ -99,6 +106,6 @@ if __name__ == "__main__": test_gridview_multi_view() test_gridview_repr() test_gridview_grid_property() - test_gridview_no_grid() + test_gridview_default_grid() print("All GridView tests passed") sys.exit(0) diff --git a/tests/unit/minimal_reparent.py b/tests/unit/minimal_reparent.py index 1c3b385..713b5e2 100644 --- a/tests/unit/minimal_reparent.py +++ b/tests/unit/minimal_reparent.py @@ -1,4 +1,6 @@ import mcrfpy +import sys + scene = mcrfpy.Scene("test") scene.activate() f1 = mcrfpy.Frame((10,10), (100,100), fill_color = (255, 0, 0, 64)) @@ -8,7 +10,37 @@ f_child = mcrfpy.Frame((25,25), (50,50), fill_color = (0, 0, 255, 64)) scene.children.append(f1) scene.children.append(f2) f1.children.append(f_child) + +failures = [] + +# Before reparent: child belongs to f1 +if len(f1.children) != 1: + failures.append("f1 should have 1 child before reparent, has %d" % len(f1.children)) +if len(f2.children) != 0: + failures.append("f2 should have 0 children before reparent, has %d" % len(f2.children)) +if f_child.parent is not f1: + failures.append("f_child.parent should be f1 before reparent, got %r" % (f_child.parent,)) + +# Reparent by assigning .parent f_child.parent = f2 print(f1.children) print(f2.children) + +# After reparent: child moved out of f1 and into f2 (no duplication, no orphaning) +if len(f1.children) != 0: + failures.append("f1 should have 0 children after reparent, has %d" % len(f1.children)) +if len(f2.children) != 1: + failures.append("f2 should have 1 child after reparent, has %d" % len(f2.children)) +elif f2.children[0] is not f_child: + failures.append("f2's child is not f_child") +if f_child.parent is not f2: + failures.append("f_child.parent should be f2 after reparent, got %r" % (f_child.parent,)) + +if failures: + for f in failures: + print("FAIL: " + f) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/ping_pong_easing_test.py b/tests/unit/ping_pong_easing_test.py index d46e654..77d3824 100644 --- a/tests/unit/ping_pong_easing_test.py +++ b/tests/unit/ping_pong_easing_test.py @@ -188,10 +188,15 @@ check(f"standard easing complete() goes to target ({sprite9.x:.1f})", abs(sprite9.x - 500.0) < 5.0) -# --- Test 11: Animation constructor with ping-pong easing --- -anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True) -check("Animation constructor with PING_PONG", anim10 is not None) -check("Animation constructor loop=True", anim10.is_looping == True) +# --- Test 11: Animation construction with ping-pong easing --- +# mcrfpy.Animation is no longer exported; animations are constructed via the +# target's .animate() method, which returns the Animation object. +sprite10 = mcrfpy.Sprite(pos=(0, 0)) +scene.children.append(sprite10) +anim10 = sprite10.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True) +check("animate() with PING_PONG", anim10 is not None) +check("animate() loop=True", anim10.is_looping == True) +check("animate() ping-pong easing retained", anim10.is_complete == False) # --- Summary --- diff --git a/tests/unit/simple_screenshot_test.py b/tests/unit/simple_screenshot_test.py index 74c4fa1..523f381 100644 --- a/tests/unit/simple_screenshot_test.py +++ b/tests/unit/simple_screenshot_test.py @@ -3,43 +3,80 @@ import mcrfpy from mcrfpy import automation +import os import sys -import time + +failures = [] +fired = [] + +OUT_DIR = "test_screenshots" +GOOD_PATH = os.path.join(OUT_DIR, "test_screenshot.png") +BAD_PATH = os.path.join("no_such_dir_xyz", "test_screenshot.png") + def take_screenshot(timer, runtime): - """Take screenshot after render starts""" + """Take screenshot after the timer fires""" print(f"Timer callback fired at runtime: {runtime}") + fired.append(runtime) - # Try different paths - paths = [ - "test_screenshot.png", - "./test_screenshot.png", - "mcrogueface.github.io/images/test_screenshot.png" - ] + # A writable path must succeed and produce a real PNG file + print(f"Trying to save to: {GOOD_PATH}") + if automation.screenshot(GOOD_PATH) is not True: + failures.append(f"screenshot({GOOD_PATH}) did not return True") + return + if not os.path.exists(GOOD_PATH): + failures.append(f"screenshot reported success but {GOOD_PATH} does not exist") + return + size = os.path.getsize(GOOD_PATH) + if size == 0: + failures.append(f"{GOOD_PATH} is empty") + return + with open(GOOD_PATH, "rb") as f: + magic = f.read(8) + if magic != b"\x89PNG\r\n\x1a\n": + failures.append(f"{GOOD_PATH} is not a PNG (magic={magic!r})") + return + print(f"Success: {GOOD_PATH} ({size} bytes)") - for path in paths: - try: - print(f"Trying to save to: {path}") - automation.screenshot(path) - print(f"Success: {path}") - except Exception as e: - print(f"Failed {path}: {e}") + # An unwritable path must fail cleanly (return False, no exception) + print(f"Trying to save to: {BAD_PATH}") + if automation.screenshot(BAD_PATH) is not False: + failures.append(f"screenshot({BAD_PATH}) should have returned False") + return + print(f"Failed as expected: {BAD_PATH}") - sys.exit(0) # Create minimal scene test = mcrfpy.Scene("test") -# Add a visible element -caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test") -caption.font = mcrfpy.default_font +# Add a visible element (Caption.font is read-only as of #320 -- set it in the ctor) +caption = mcrfpy.Caption(pos=(100, 100), font=mcrfpy.default_font, text="Screenshot Test") caption.fill_color = mcrfpy.Color(255, 255, 255) caption.font_size = 24 test.children.append(caption) test.activate() -# Use timer to ensure rendering has started +os.makedirs(OUT_DIR, exist_ok=True) +if os.path.exists(GOOD_PATH): + os.remove(GOOD_PATH) + +# Timer fires at 500ms; headless has no clock of its own, so step() drives it (#350) print("Setting timer...") -mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds -print("Timer set, entering game loop...") +mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) +print("Timer set, advancing the clock...") +for _ in range(20): # 20 * 50ms = 1000ms > 500ms + mcrfpy.step(0.05) + if fired: + break + +if not fired: + failures.append("timer never fired after 1000ms of stepping") + +if failures: + for f in failures: + print(f"FAIL: {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_animation_callback_simple.py b/tests/unit/test_animation_callback_simple.py index 26fdcf0..43e68b4 100644 --- a/tests/unit/test_animation_callback_simple.py +++ b/tests/unit/test_animation_callback_simple.py @@ -9,12 +9,14 @@ print("=" * 30) # Global state to track callback callback_count = 0 +callback_args = [] # #229 - Animation callbacks now receive (target, property, value) instead of (anim, target) def my_callback(target, prop, value): """Simple callback that prints when animation completes""" global callback_count callback_count += 1 + callback_args.append((target, prop, value)) print(f"Animation completed! Callback #{callback_count}") print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}") @@ -23,14 +25,15 @@ callback_demo = mcrfpy.Scene("callback_demo") callback_demo.activate() # Create a frame to animate -frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0)) +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), fill_color=(255, 0, 0)) ui = callback_demo.children ui.append(frame) # Test 1: Animation with callback +# mcrfpy.Animation is no longer exported; animations are constructed via the +# target drawable's .animate() method, which starts them immediately. print("Starting animation with callback (1.0s duration)...") -anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback) -anim.start(frame) +frame.animate("x", 400.0, 1.0, mcrfpy.Easing.EASE_IN_OUT_QUAD, callback=my_callback) # Use mcrfpy.step() to advance past animation completion mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s @@ -38,12 +41,21 @@ mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s if callback_count != 1: print(f"FAIL: Expected 1 callback, got {callback_count}") sys.exit(1) + +# The callback receives (target, property, final_value) -- and the animation must +# have actually driven the property to its target. +cb_target, cb_prop, cb_value = callback_args[0] +if cb_target is not frame or cb_prop != "x" or cb_value != 400.0: + print(f"FAIL: Bad callback args: {callback_args[0]}") + sys.exit(1) +if frame.x != 400.0: + print(f"FAIL: Expected frame.x == 400.0, got {frame.x}") + sys.exit(1) print("SUCCESS: Callback fired exactly once!") # Test 2: Animation without callback print("\nTesting animation without callback (0.5s duration)...") -anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear") -anim2.start(frame) +frame.animate("y", 300.0, 0.5, mcrfpy.Easing.LINEAR) # Advance past second animation mcrfpy.step(0.7) @@ -51,7 +63,11 @@ mcrfpy.step(0.7) if callback_count != 1: print(f"FAIL: Callback count changed to {callback_count}") sys.exit(1) +if frame.y != 300.0: + print(f"FAIL: Expected frame.y == 300.0, got {frame.y}") + sys.exit(1) print("SUCCESS: No unexpected callbacks fired!") print("\nAnimation callback feature working correctly!") +print("PASS") sys.exit(0) diff --git a/tests/unit/test_animation_chaining.py b/tests/unit/test_animation_chaining.py index 7d12a8c..ac35d13 100644 --- a/tests/unit/test_animation_chaining.py +++ b/tests/unit/test_animation_chaining.py @@ -3,15 +3,30 @@ Test Animation Chaining ======================= -Demonstrates proper animation chaining to avoid glitches. +Demonstrates proper animation chaining to avoid glitches: an entity walks a path +one tile at a time, and each step's animation only starts after the previous +step's animation has completed (never two overlapping position animations). + +Headless: mcrfpy.step(dt) is the clock -- it drives both timers and animations. +Animations are created with target.animate(...) (mcrfpy.Animation is no longer +constructible); chaining uses the completion callback instead of polling. """ import mcrfpy import sys +failures = [] + +def check(name, condition, detail=""): + if condition: + print(f" PASS: {name}") + else: + print(f" FAIL: {name} {detail}") + failures.append(name) + class PathAnimator: """Handles step-by-step path animation with proper chaining""" - + def __init__(self, entity, path, step_duration=0.3, on_complete=None): self.entity = entity self.path = path @@ -19,63 +34,67 @@ class PathAnimator: self.step_duration = step_duration self.on_complete = on_complete self.animating = False - self.check_timer_name = f"path_check_{id(self)}" - + self.completed_steps = [] # (index, draw_pos) recorded as each step lands + self.overlaps = 0 # times a new step started while one was in flight + self.anim_x = None + self.anim_y = None + def start(self): """Start animating along the path""" if not self.path or self.animating: return - + self.current_index = 0 self.animating = True self._animate_next_step() - + def _animate_next_step(self): """Animate to the next position in the path""" if self.current_index >= len(self.path): # Path complete self.animating = False - if hasattr(self, '_check_timer'): - self._check_timer.stop() if self.on_complete: self.on_complete() return + # Detect a chaining violation: the previous step must be finished + if self.anim_x is not None and not self.anim_x.is_complete: + self.overlaps += 1 + if self.anim_y is not None and not self.anim_y.is_complete: + self.overlaps += 1 + # Get target position target_x, target_y = self.path[self.current_index] - # Create animations - self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut") - self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut") - - # Start animations - self.anim_x.start(self.entity) - self.anim_y.start(self.entity) + # Create + start animations ('x'/'y' animate the entity's draw position, + # in tile coordinates). The x animation's callback chains the next step. + self.anim_y = self.entity.animate("y", float(target_y), self.step_duration, + mcrfpy.Easing.EASE_IN_OUT) + self.anim_x = self.entity.animate("x", float(target_x), self.step_duration, + mcrfpy.Easing.EASE_IN_OUT, + callback=self._on_step_complete) # Update visibility if entity has this method if hasattr(self.entity, 'update_visibility'): self.entity.update_visibility() - # Set timer to check completion - self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50) - - def _check_completion(self, timer, runtime): - """Check if current animation is complete""" - if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete: - # Move to next step - self.current_index += 1 - timer.stop() - self._animate_next_step() + def _on_step_complete(self, target, prop, value): + """Animation completion callback -- advance to the next path node""" + self.completed_steps.append((self.current_index, + (self.entity.draw_pos.x, self.entity.draw_pos.y))) + self.current_index += 1 + self._animate_next_step() # Create test scene chain_test = mcrfpy.Scene("chain_test") # Create grid -grid = mcrfpy.Grid(grid_w=20, grid_h=15) +grid = mcrfpy.Grid(grid_size=(20, 15), pos=(100, 100), size=(600, 450)) grid.fill_color = mcrfpy.Color(20, 20, 30) -# Add a color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# Add a color layer for cell coloring (GridPoint has no .color any more) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.add_layer(color_layer) # Simple map for y in range(15): @@ -84,33 +103,29 @@ for y in range(15): if x == 0 or x == 19 or y == 0 or y == 14: cell.walkable = False cell.transparent = False - color_layer.set(x, y, mcrfpy.Color(60, 40, 40)) + color_layer.set((x, y), mcrfpy.Color(60, 40, 40)) else: cell.walkable = True cell.transparent = True - color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) + color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Create entities -player = mcrfpy.Entity((2, 2), grid=grid) +player = mcrfpy.Entity(grid_pos=(2, 2)) player.sprite_index = 64 # @ +grid.entities.append(player) -enemy = mcrfpy.Entity((17, 12), grid=grid) +enemy = mcrfpy.Entity(grid_pos=(17, 12)) enemy.sprite_index = 69 # E +grid.entities.append(enemy) # UI setup ui = chain_test.children ui.append(grid) -grid.pos = (100, 100) -grid.size = (600, 450) title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit") -status.fill_color = mcrfpy.Color(200, 200, 200) -ui.append(status) - info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready") info.fill_color = mcrfpy.Color(100, 255, 100) ui.append(info) @@ -118,42 +133,48 @@ ui.append(info) # Path animators player_animator = None enemy_animator = None +player_done = False +enemy_done = False + +PLAYER_PATH = [ + (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right + (6, 3), (6, 4), (6, 5), (6, 6), # Down + (7, 6), (8, 6), (9, 6), (10, 6), # Right + (10, 7), (10, 8), (10, 9), # Down +] + +ENEMY_PATH = [ + (17, 12), (16, 12), (15, 12), (14, 12), # Left + (14, 11), (14, 10), (14, 9), # Up + (13, 9), (12, 9), (11, 9), (10, 9), # Left + (10, 8), (10, 7), (10, 6), # Up +] def animate_player(): """Animate player along a path""" global player_animator - - # Define path - path = [ - (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right - (6, 3), (6, 4), (6, 5), (6, 6), # Down - (7, 6), (8, 6), (9, 6), (10, 6), # Right - (10, 7), (10, 8), (10, 9), # Down - ] - + def on_complete(): + global player_done + player_done = True info.text = "Player animation complete!" - - player_animator = PathAnimator(player, path, step_duration=0.2, on_complete=on_complete) + + player_animator = PathAnimator(player, PLAYER_PATH, step_duration=0.2, + on_complete=on_complete) player_animator.start() info.text = "Animating player..." def animate_enemy(): """Animate enemy along a path""" global enemy_animator - - # Define path - path = [ - (17, 12), (16, 12), (15, 12), (14, 12), # Left - (14, 11), (14, 10), (14, 9), # Up - (13, 9), (12, 9), (11, 9), (10, 9), # Left - (10, 8), (10, 7), (10, 6), # Up - ] - + def on_complete(): + global enemy_done + enemy_done = True info.text = "Enemy animation complete!" - - enemy_animator = PathAnimator(enemy, path, step_duration=0.25, on_complete=on_complete) + + enemy_animator = PathAnimator(enemy, ENEMY_PATH, step_duration=0.25, + on_complete=on_complete) enemy_animator.start() info.text = "Animating enemy..." @@ -164,60 +185,101 @@ def animate_both(): animate_enemy() # Camera follow test -camera_follow = False +camera_follow = True +camera_updates = 0 def update_camera(timer, runtime): """Update camera to follow player if enabled""" + global camera_updates if camera_follow and player_animator and player_animator.animating: - # Smooth camera follow - center_x = player.x * 30 # Assuming ~30 pixels per cell - center_y = player.y * 30 - cam_anim = mcrfpy.Animation("center", (center_x, center_y), 0.25, "linear") - cam_anim.start(grid) - -# Input handler -def handle_input(key, state): - global camera_follow - - if state != mcrfpy.InputState.PRESSED: - return - - if key == mcrfpy.Key.Q: - sys.exit(0) - elif key == mcrfpy.Key.NUM_1: - animate_player() - elif key == mcrfpy.Key.NUM_2: - animate_enemy() - elif key == mcrfpy.Key.NUM_3: - animate_both() - elif key == mcrfpy.Key.C: - camera_follow = not camera_follow - info.text = f"Camera follow: {'ON' if camera_follow else 'OFF'}" - elif key == mcrfpy.Key.R: - # Reset positions - player.x, player.y = 2, 2 - enemy.x, enemy.y = 17, 12 - info.text = "Positions reset" + # Smooth camera follow. grid.center is in pixels; entity.x/.y are the + # entity's draw position in pixels (draw_pos is the same in tile coords). + grid.animate("center", (player.x, player.y), 0.25, mcrfpy.Easing.LINEAR) + camera_updates += 1 # Setup chain_test.activate() -chain_test.on_key = handle_input - -# Camera update timer cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100) print("Animation Chaining Test") print("=======================") -print("This test demonstrates proper animation chaining") -print("to avoid simultaneous position updates.") -print() -print("Controls:") -print(" 1 - Animate player step by step") -print(" 2 - Animate enemy step by step") -print(" 3 - Animate both (simultaneous)") -print(" C - Toggle camera follow") -print(" R - Reset positions") -print(" Q - Quit") -print() -print("Notice how each entity moves one tile at a time,") -print("waiting for each step to complete before the next.") + +# --- Drive the chained animations headlessly ------------------------------- +animate_both() + +check("player animator started", player_animator.animating) +check("enemy animator started", enemy_animator.animating) + +# Path node 0 is the entity's starting cell, so the first 0.2s step is a no-op +# move. Halfway through the *second* step the entity must be strictly between +# nodes 0 and 1 -- i.e. the animation is interpolating, one step at a time. +for _ in range(2): + mcrfpy.step(0.1) # completes step 0, chains step 1 +check("first step completed before the next began", + player_animator.current_index == 1 and len(player_animator.completed_steps) == 1, + f"(index={player_animator.current_index})") + +mcrfpy.step(0.1) # halfway through step 1: (2,2) -> (3,2) +mid_x = player.draw_pos.x +check("player interpolates between tiles", 2.0 < mid_x < 3.0, + f"(draw_x={mid_x})") +check("only one step in flight at a time (mid-step)", + player_animator.current_index == 1, + f"(index={player_animator.current_index})") + +# Player: 16 nodes * 0.2s; enemy: 14 nodes * 0.25s -> 3.5s worst case. +elapsed = 0.3 +while elapsed < 6.0 and not (player_done and enemy_done): + mcrfpy.step(0.05) + elapsed += 0.05 + +# --- Assertions ------------------------------------------------------------ +check("player path completed", player_done) +check("enemy path completed", enemy_done) +check("player animator stopped", not player_animator.animating) +check("enemy animator stopped", not enemy_animator.animating) + +check("no overlapping player animations", player_animator.overlaps == 0, + f"(overlaps={player_animator.overlaps})") +check("no overlapping enemy animations", enemy_animator.overlaps == 0, + f"(overlaps={enemy_animator.overlaps})") + +check("player visited every path node", + [i for i, _ in player_animator.completed_steps] == list(range(len(PLAYER_PATH))), + f"({player_animator.completed_steps})") +check("enemy visited every path node", + [i for i, _ in enemy_animator.completed_steps] == list(range(len(ENEMY_PATH))), + f"({enemy_animator.completed_steps})") + +# Each step landed exactly on its path node (chaining kept positions in sync) +player_landings_ok = all(pos == (float(px), float(py)) + for (i, pos), (px, py) + in zip(player_animator.completed_steps, PLAYER_PATH)) +check("each player step landed on its path node", player_landings_ok, + f"({player_animator.completed_steps})") + +enemy_landings_ok = all(pos == (float(px), float(py)) + for (i, pos), (px, py) + in zip(enemy_animator.completed_steps, ENEMY_PATH)) +check("each enemy step landed on its path node", enemy_landings_ok, + f"({enemy_animator.completed_steps})") + +check("player ended at final path node", + (player.draw_pos.x, player.draw_pos.y) == (float(PLAYER_PATH[-1][0]), + float(PLAYER_PATH[-1][1])), + f"({player.draw_pos})") +check("enemy ended at final path node", + (enemy.draw_pos.x, enemy.draw_pos.y) == (float(ENEMY_PATH[-1][0]), + float(ENEMY_PATH[-1][1])), + f"({enemy.draw_pos})") + +# Camera-follow timer ran while the player was animating +check("camera follow timer fired during animation", camera_updates > 0, + f"(updates={camera_updates})") + +if failures: + print(f"FAIL: {len(failures)} check(s) failed: {failures}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_animation_removal.py b/tests/unit/test_animation_removal.py index 126dbdb..70373bf 100644 --- a/tests/unit/test_animation_removal.py +++ b/tests/unit/test_animation_removal.py @@ -33,9 +33,8 @@ for i in range(10): ui.append(f) initial_frames.append(f) - # Animate them - anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce") - anim.start(f) + # Animate them (mcrfpy.Animation is gone; animations are built by the target) + f.animate("y", 300.0, 2.0, mcrfpy.Easing.EASE_OUT_BOUNCE) print(f"Initial scene has {len(ui)} elements") @@ -55,6 +54,7 @@ print(f"Scene has {len(ui)} elements after clearing") # Create new animated objects print("Creating new animated objects...") +new_frames = [] for i in range(5): f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40)) f.fill_color = mcrfpy.Color(100 + i*30, 50, 200) @@ -62,20 +62,34 @@ for i in range(5): # Start animation on the new frame target_x = 300 + i * 50 - anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut") - anim.start(f) + f.animate("x", float(target_x), 1.0, mcrfpy.Easing.EASE_IN_OUT) + new_frames.append((f, float(target_x))) print("New objects created and animated") print(f"Scene now has {len(ui)} elements") -# Let new animations run -mcrfpy.step(1.5) +# Let new animations run to completion (duration 1.0s) +for _ in range(20): + mcrfpy.step(0.1) -# Final check +failures = [] + +# Final check: element count survived the clear/recreate cycle print(f"\nFinal scene has {len(ui)} elements") -if len(ui) == 7: # 2 captions + 5 new frames - print("SUCCESS: Animation removal test passed!") - sys.exit(0) -else: - print(f"FAIL: Expected 7 elements, got {len(ui)}") +if len(ui) != 7: # 2 captions + 5 new frames + failures.append(f"Expected 7 elements, got {len(ui)}") + +# The removed frames' animations must not have kept running (or crashed); +# the new frames' animations must have actually completed. +for i, (f, target_x) in enumerate(new_frames): + if abs(f.x - target_x) > 0.5: + failures.append(f"new frame {i}: x={f.x}, expected {target_x}") + +if failures: + for msg in failures: + print(f"FAIL: {msg}") sys.exit(1) + +print("SUCCESS: Animation removal test passed!") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_api_docs.py b/tests/unit/test_api_docs.py index c5f75ca..8cebdf8 100644 --- a/tests/unit/test_api_docs.py +++ b/tests/unit/test_api_docs.py @@ -1,164 +1,164 @@ #!/usr/bin/env python3 -"""Test that API documentation generator works correctly.""" +"""Test that API documentation generator works correctly. + +The hand-written docs/API_REFERENCE.md is gone; the canonical API reference is now +docs/API_REFERENCE_DYNAMIC.md, generated from the compiled module by +tools/generate_dynamic_docs.py (MCRF_* docstring macros -> introspection -> markdown). +This test verifies that generated artifact exists, is well-formed, and stays in sync +with the live mcrfpy module. +""" import os import sys from pathlib import Path +# tests/unit/test_api_docs.py -> repo root -> docs/ +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DOCS_PATH = REPO_ROOT / "docs" / "API_REFERENCE_DYNAMIC.md" + + def test_api_docs_exist(): """Test that API documentation was generated.""" - docs_path = Path("docs/API_REFERENCE.md") - - if not docs_path.exists(): - print("ERROR: API documentation not found at docs/API_REFERENCE.md") + if not DOCS_PATH.exists(): + print(f"ERROR: API documentation not found at {DOCS_PATH}") return False - - print("✓ API documentation file exists") - + + print("+ API documentation file exists") + # Check file size - size = docs_path.stat().st_size + size = DOCS_PATH.stat().st_size if size < 1000: print(f"ERROR: API documentation seems too small ({size} bytes)") return False - - print(f"✓ API documentation has reasonable size ({size} bytes)") - + + print(f"+ API documentation has reasonable size ({size} bytes)") + # Read content - with open(docs_path, 'r') as f: + with open(DOCS_PATH, 'r') as f: content = f.read() - - # Check for expected sections + + # Check for expected sections (current generated layout) expected_sections = [ "# McRogueFace API Reference", - "## Overview", - "## Classes", + "## Table of Contents", + "## Module Attributes", "## Functions", - "## Automation Module" + "## Classes", ] - - missing = [] - for section in expected_sections: - if section not in content: - missing.append(section) - + + missing = [s for s in expected_sections if s not in content] if missing: print(f"ERROR: Missing sections: {missing}") return False - - print("✓ All expected sections present") - - # Check for key classes + + print("+ All expected sections present") + + # Check for key classes ("### ClassName" headings) key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"] - missing_classes = [] - for cls in key_classes: - if f"### class {cls}" not in content: - missing_classes.append(cls) - + missing_classes = [c for c in key_classes if f"\n### {c}\n" not in content] if missing_classes: print(f"ERROR: Missing classes: {missing_classes}") return False - - print("✓ All key classes documented") - - # Check for key functions - key_functions = ["createScene", "setScene", "currentScene", "find", "setTimer"] - missing_funcs = [] - for func in key_functions: - if f"### {func}" not in content: - missing_funcs.append(func) - + + print("+ All key classes documented") + + # Check for key module-level functions ("### `name(...)`" headings) + key_functions = ["find", "find_all", "step", "get_metrics", "set_scale", "exit"] + missing_funcs = [f for f in key_functions if f"\n### `{f}(" not in content] if missing_funcs: print(f"ERROR: Missing functions: {missing_funcs}") return False - - print("✓ All key functions documented") - - # Check automation module - if "automation.screenshot" in content: - print("✓ Automation module documented") - else: - print("ERROR: Automation module not properly documented") + + print("+ All key functions documented") + + # Scene management moved from functions to the mcrfpy.current_scene attribute + if "### `mcrfpy.current_scene`" not in content: + print("ERROR: mcrfpy.current_scene not documented under Module Attributes") return False - + + print("+ Module attributes documented") + # Count documentation entries - class_count = content.count("### class ") - func_count = content.count("### ") - class_count - content.count("### automation.") - auto_count = content.count("### automation.") - + class_count = sum(1 for line in content.splitlines() + if line.startswith("### ") and not line.startswith("### `")) + func_count = sum(1 for line in content.splitlines() if line.startswith("### `")) + member_count = sum(1 for line in content.splitlines() if line.startswith("#### ")) + print(f"\nDocumentation Coverage:") print(f"- Classes: {class_count}") - print(f"- Functions: {func_count}") - print(f"- Automation methods: {auto_count}") - + print(f"- Functions/attributes: {func_count}") + print(f"- Class members: {member_count}") + + if class_count == 0 or func_count == 0 or member_count == 0: + print("ERROR: documentation contains no entries") + return False + return True + def test_doc_accuracy(): """Test that documentation matches actual API.""" - # Import mcrfpy to check import mcrfpy - + print("\nVerifying documentation accuracy...") - - # Read documentation - with open("docs/API_REFERENCE.md", 'r') as f: + + with open(DOCS_PATH, 'r') as f: content = f.read() - + + passed = True + # Check that all public classes are documented - actual_classes = [name for name in dir(mcrfpy) + actual_classes = [name for name in dir(mcrfpy) if isinstance(getattr(mcrfpy, name), type) and not name.startswith('_')] - - undocumented = [] - for cls in actual_classes: - if f"### class {cls}" not in content: - undocumented.append(cls) - + + undocumented = [c for c in actual_classes if f"\n### {c}\n" not in content] if undocumented: - print(f"WARNING: Undocumented classes: {undocumented}") + print(f"ERROR: Undocumented classes: {undocumented}") + passed = False else: - print("✓ All public classes are documented") - + print(f"+ All {len(actual_classes)} public classes are documented") + # Check functions - actual_funcs = [name for name in dir(mcrfpy) - if callable(getattr(mcrfpy, name)) and not name.startswith('_') + actual_funcs = [name for name in dir(mcrfpy) + if callable(getattr(mcrfpy, name)) and not name.startswith('_') and not isinstance(getattr(mcrfpy, name), type)] - - undoc_funcs = [] - for func in actual_funcs: - if f"### {func}" not in content: - undoc_funcs.append(func) - + + undoc_funcs = [f for f in actual_funcs if f"\n### `{f}(" not in content] if undoc_funcs: - print(f"WARNING: Undocumented functions: {undoc_funcs}") + print(f"ERROR: Undocumented functions: {undoc_funcs}") + passed = False else: - print("✓ All public functions are documented") - - return True + print(f"+ All {len(actual_funcs)} public functions are documented") + + return passed + def main(): """Run all API documentation tests.""" print("API Documentation Tests") print("======================\n") - + all_passed = True - + # Test 1: Documentation exists and is complete print("Test 1: Documentation Generation") if not test_api_docs_exist(): all_passed = False print() - + # Test 2: Documentation accuracy print("Test 2: Documentation Accuracy") if not test_doc_accuracy(): all_passed = False print() - + if all_passed: - print("✅ All API documentation tests passed!") + print("PASS: All API documentation tests passed!") sys.exit(0) else: - print("❌ Some tests failed.") + print("FAIL: Some tests failed.") sys.exit(1) + if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/tests/unit/test_astar.py b/tests/unit/test_astar.py index 28d5c9b..360d9a9 100644 --- a/tests/unit/test_astar.py +++ b/tests/unit/test_astar.py @@ -3,7 +3,15 @@ Test A* Pathfinding Implementation ================================== -Compares A* with Dijkstra and the existing find_path method. +Compares A* (GridData.find_path) with Dijkstra (GridData.get_dijkstra_map) +and verifies path validity around obstacles. + +API notes (current contract): + - The old grid.compute_astar_path/compute_dijkstra/get_dijkstra_path methods are gone. + A* is now GridData.find_path(start, end, ...) -> AStarPath | None + Dijkstra is now GridData.get_dijkstra_map(root=...) -> DijkstraMap (.path_from/.distance) + - Pathfinding lives on GridData, not on the Grid view (mcrfpy.Grid is a GridView). + - Headless has no automatic clock: timers only fire from mcrfpy.step(). """ import mcrfpy @@ -13,24 +21,42 @@ import time print("A* Pathfinding Test") print("==================") +failures = [] + +def check(cond, msg): + if cond: + print(f" PASS: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + # Create scene and grid astar_test = mcrfpy.Scene("astar_test") -grid = mcrfpy.Grid(grid_w=20, grid_h=20) +grid = mcrfpy.Grid(grid_size=(20, 20), pos=(50, 50), size=(400, 400)) +data = grid.grid_data # pathfinding lives on GridData # Initialize grid - all walkable for y in range(20): for x in range(20): - grid.at(x, y).walkable = True + data.at(x, y).walkable = True -# Create a wall barrier with a narrow passage +# Create a wall barrier with a narrow passage. +# The barrier is 4 cells thick (x 8..11) and spans the full grid height, with a +# 1-cell-tall corridor carved through it at y == 10. Two corrections vs. the +# original test: (a) a partial-height wall could simply be rounded diagonally for +# the same cost, and (b) a single free cell inside a 4-thick wall is not a passage +# at all -- every neighbour is wall, so it can never be entered. print("\nCreating wall with narrow passage...") -for y in range(5, 15): +walls = set() +for y in range(20): for x in range(8, 12): - if not (x == 10 and y == 10): # Leave a gap at (10, 10) - grid.at(x, y).walkable = False - print(f" Wall at ({x}, {y})") + if y == 10: # corridor through the barrier, including (10, 10) + continue + data.at(x, y).walkable = False + walls.add((x, y)) -print(f"\nPassage at (10, 10)") +print(f"Wall cells: {len(walls)}, passage at (10, 10)") +data.clear_dijkstra_maps() # Test points start = (2, 10) @@ -38,93 +64,125 @@ end = (18, 10) print(f"\nFinding path from {start} to {end}") -# Test 1: A* pathfinding -print("\n1. Testing A* pathfinding (compute_astar_path):") -start_time = time.time() -astar_path = grid.compute_astar_path(start[0], start[1], end[0], end[1]) -astar_time = time.time() - start_time -print(f" A* path length: {len(astar_path)}") -print(f" A* time: {astar_time*1000:.3f} ms") -if astar_path: - print(f" First 5 steps: {astar_path[:5]}") -# Test 2: find_path method (which should also use A*) -print("\n2. Testing find_path method:") +def as_cells(path): + return [(int(v.x), int(v.y)) for v in path] + + +def is_contiguous(cells): + for a, b in zip(cells, cells[1:]): + if max(abs(a[0] - b[0]), abs(a[1] - b[1])) != 1: + return False + return True + + +# Test 1: A* pathfinding +print("\n1. Testing A* pathfinding (find_path):") start_time = time.time() -find_path_result = grid.find_path(start[0], start[1], end[0], end[1]) -find_path_time = time.time() - start_time -print(f" find_path length: {len(find_path_result)}") -print(f" find_path time: {find_path_time*1000:.3f} ms") -if find_path_result: - print(f" First 5 steps: {find_path_result[:5]}") +astar_path = data.find_path(start, end) +astar_time = time.time() - start_time +check(astar_path is not None, "A* found a path through the barrier") +# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells(). +astar_remaining = astar_path.remaining if astar_path else 0 +astar_cells = as_cells(astar_path) if astar_path else [] +print(f" A* path length: {len(astar_cells)}") +print(f" A* time: {astar_time*1000:.3f} ms") +print(f" First 5 steps: {astar_cells[:5]}") +check(bool(astar_cells) and astar_cells[-1] == end, "A* path terminates at the destination") +check(not (walls & set(astar_cells)), "A* path never enters a wall cell") +check(is_contiguous([start] + astar_cells), "A* path is contiguous from the start cell") +check((10, 10) in astar_cells, "A* path uses the narrow passage at (10, 10)") + +# Test 2: A* path costs/endpoints via the AStarPath object +print("\n2. Testing AStarPath object:") +check(as_cells([astar_path.origin])[0] == start, "AStarPath.origin is the start cell") +check(as_cells([astar_path.destination])[0] == end, "AStarPath.destination is the end cell") +check(astar_remaining == len(astar_cells), "AStarPath.remaining matches path length") +check(astar_path.remaining == 0, "AStarPath is consumed once fully walked/iterated") # Test 3: Dijkstra pathfinding for comparison print("\n3. Testing Dijkstra pathfinding:") start_time = time.time() -grid.compute_dijkstra(start[0], start[1]) -dijkstra_path = grid.get_dijkstra_path(end[0], end[1]) +dmap = data.get_dijkstra_map(root=start) +dijkstra_path = dmap.path_from(end) dijkstra_time = time.time() - start_time -print(f" Dijkstra path length: {len(dijkstra_path)}") +dijkstra_cells = as_cells(dijkstra_path) if dijkstra_path else [] +print(f" Dijkstra path length: {len(dijkstra_cells)}") print(f" Dijkstra time: {dijkstra_time*1000:.3f} ms") -if dijkstra_path: - print(f" First 5 steps: {dijkstra_path[:5]}") +print(f" First 5 steps: {dijkstra_cells[:5]}") +check(bool(dijkstra_cells), "Dijkstra found a path back to the root") +check(not (walls & set(dijkstra_cells)), "Dijkstra path never enters a wall cell") +check((10, 10) in dijkstra_cells, "Dijkstra path uses the narrow passage at (10, 10)") +check(dmap.distance(end) is not None, "Dijkstra distance to the destination is defined") -# Compare results +# Compare results - both are optimal, so step counts must agree print("\nComparison:") -print(f" A* vs find_path: {'SAME' if astar_path == find_path_result else 'DIFFERENT'}") -print(f" A* vs Dijkstra: {'SAME' if astar_path == dijkstra_path else 'DIFFERENT'}") +print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}") +check(len(astar_cells) == len(dijkstra_cells), + "A* and Dijkstra agree on optimal step count") -# Test with no path (blocked endpoints) +# Test 4: no path (blocked destination inside the wall) print("\n4. Testing with blocked destination:") -blocked_end = (10, 8) # Inside the wall -grid.at(blocked_end[0], blocked_end[1]).walkable = False -no_path = grid.compute_astar_path(start[0], start[1], blocked_end[0], blocked_end[1]) -print(f" Path to blocked cell: {no_path} (should be empty)") +blocked_end = (10, 8) # inside the wall +check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable") +no_path = data.find_path(start, blocked_end) +print(f" Path to blocked cell: {no_path}") +check(no_path is None or len(no_path) == 0, "no path is returned for a blocked destination") -# Test diagonal movement +# Test 5: diagonal movement print("\n5. Testing diagonal paths:") diag_start = (0, 0) diag_end = (5, 5) -diag_path = grid.compute_astar_path(diag_start[0], diag_start[1], diag_end[0], diag_end[1]) -print(f" Diagonal path from {diag_start} to {diag_end}:") -print(f" Length: {len(diag_path)}") -print(f" Path: {diag_path}") +diag_path = data.find_path(diag_start, diag_end) +diag_cells = as_cells(diag_path) if diag_path else [] +print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}") +# Optimal diagonal path is 5 moves (one diagonal step per cell) +check(len(diag_cells) == 5, "diagonal path from (0,0) to (5,5) takes 5 steps") +check(diag_cells and diag_cells[-1] == diag_end, "diagonal path reaches the destination") -# Expected optimal diagonal path length is 5 moves (moving diagonally each step) - -# Performance test with larger path +# Test 6: performance / corner-to-corner agreement print("\n6. Performance test (corner to corner):") -corner_paths = [] -methods = [ - ("A*", lambda: grid.compute_astar_path(0, 0, 19, 19)), - ("Dijkstra", lambda: (grid.compute_dijkstra(0, 0), grid.get_dijkstra_path(19, 19))[1]) -] +results = {} +start_time = time.time() +corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or []) +results["A*"] = (len(corner_astar), time.time() - start_time) +start_time = time.time() +corner_dmap = data.get_dijkstra_map(root=(0, 0)) +corner_dijkstra = as_cells(corner_dmap.path_from((19, 19)) or []) +results["Dijkstra"] = (len(corner_dijkstra), time.time() - start_time) +for name, (steps, elapsed) in results.items(): + print(f" {name}: {steps} steps in {elapsed*1000:.3f} ms") +check(len(corner_astar) > 0 and len(corner_dijkstra) > 0, + "corner-to-corner path found by both algorithms") +check(len(corner_astar) == len(corner_dijkstra), + "corner-to-corner step counts agree between A* and Dijkstra") -for name, method in methods: - start_time = time.time() - path = method() - elapsed = time.time() - start_time - print(f" {name}: {len(path)} steps in {elapsed*1000:.3f} ms") +# Quick smoke test that the grid renders in a scene and the clock advances. +# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own. +timer_fired = [] -print("\nA* pathfinding tests completed!") -print("Summary:") -print(" - A* pathfinding is working correctly") -print(" - Paths match between A* and Dijkstra") -print(" - Empty paths returned for blocked destinations") -print(" - Diagonal movement supported") -# Quick visual test def visual_test(timer, runtime): print("\nVisual test timer fired") - sys.exit(0) + timer_fired.append(runtime) + -# Set up minimal UI for visual test ui = astar_test.children ui.append(grid) -grid.pos = (50, 50) -grid.size = (400, 400) - astar_test.activate() visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True) -print("\nStarting visual test...") \ No newline at end of file +print("\nStarting visual test...") +for _ in range(10): + mcrfpy.step(0.05) +check(bool(timer_fired), "scene timer fired after stepping the headless clock") + +print("\nA* pathfinding tests completed!") +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_builtin_context.py b/tests/unit/test_builtin_context.py index 63877b2..5f5d3dd 100644 --- a/tests/unit/test_builtin_context.py +++ b/tests/unit/test_builtin_context.py @@ -1,19 +1,40 @@ #!/usr/bin/env python3 -"""Test Python builtins in function context like the failing demos""" +"""Test Python builtins in function context like the failing demos + +Regression guard: builtins (range, list.append, f-strings) must keep working at +module level, inside functions, and -- critically -- after mcrfpy objects have +been constructed. A broken embedded-interpreter builtins dict used to make +`range()` blow up in exactly those spots. + +Failures are RECORDED, not just printed: the script exits 1 if any check fails. +""" import mcrfpy +import sys +import traceback + +failures = [] + +def fail(label, msg): + failures.append(f"{label}: {msg}") + print(f" x FAIL {label}: {msg}") print("Testing builtins in different contexts...") print("=" * 50) -# Test 1: At module level (working in our test) +# Test 1: At module level print("Test 1: Module level") try: + xs = [] for x in range(3): - print(f" x={x}") - print(" ✓ Module level works") + xs.append(x) + if xs != [0, 1, 2]: + fail("module level range", f"expected [0, 1, 2], got {xs}") + else: + print(" ok Module level works") except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + traceback.print_exc() + fail("module level range", f"{type(e).__name__}: {e}") print() @@ -21,13 +42,17 @@ print() print("Test 2: Inside function") def test_function(): try: + xs = [x for x in range(3)] + xs2 = [] for x in range(3): - print(f" x={x}") - print(" ✓ Function level works") + xs2.append(f"x={x}") + if xs != [0, 1, 2] or xs2 != ["x=0", "x=1", "x=2"]: + fail("function level builtins", f"got {xs}, {xs2}") + return + print(" ok Function level works") except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() + fail("function level builtins", f"{type(e).__name__}: {e}") test_function() @@ -38,30 +63,35 @@ print("Test 3: Function creating mcrfpy objects") def create_scene(): try: test = mcrfpy.Scene("test") - print(" ✓ Created scene") - - # Now try range - for x in range(3): - print(f" x={x}") - print(" ✓ Range after createScene works") - - # Create grid - grid = mcrfpy.Grid(grid_w=10, grid_h=10) - print(" ✓ Created grid") - - # Try range again - for x in range(3): - print(f" x={x}") - print(" ✓ Range after Grid creation works") - + print(" ok Created scene") + + # Now try range -- must still work after a Scene exists + xs = [x for x in range(3)] + if xs != [0, 1, 2]: + fail("range after Scene()", f"expected [0, 1, 2], got {xs}") + return None + print(" ok Range after Scene creation works") + + # Create grid (current API: grid_size tuple) + grid = mcrfpy.Grid(grid_size=(10, 10)) + print(" ok Created grid") + + # Try range again -- must still work after a Grid exists + xs = [x for x in range(3)] + if xs != [0, 1, 2]: + fail("range after Grid()", f"expected [0, 1, 2], got {xs}") + return None + print(" ok Range after Grid creation works") + return grid except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() + fail("mcrfpy object construction context", f"{type(e).__name__}: {e}") return None grid = create_scene() +if grid is None: + fail("create_scene", "returned None") print() @@ -70,19 +100,22 @@ print("Test 4: Exact failing pattern") def failing_pattern(): try: failing_test = mcrfpy.Scene("failing_test") - grid = mcrfpy.Grid(grid_w=14, grid_h=10) - - # This is where it fails in the demos + grid = mcrfpy.Grid(grid_size=(14, 10)) + + # This is where it used to fail in the demos walls = [] print(" About to enter range loop...") for x in range(1, 8): walls.append((x, 1)) - print(f" ✓ Created walls: {walls}") - + expected = [(x, 1) for x in range(1, 8)] + if walls != expected: + fail("demo wall pattern", f"expected {expected}, got {walls}") + return + print(f" ok Created walls: {walls}") + except Exception as e: - print(f" ✗ Error at line: {type(e).__name__}: {e}") - import traceback traceback.print_exc() + fail("demo wall pattern", f"{type(e).__name__}: {e}") failing_pattern() @@ -95,34 +128,53 @@ def test_append(): walls = [] # Test 1: Simple append walls.append((1, 1)) - print(" ✓ Single append works") - + if walls != [(1, 1)]: + fail("single append", f"got {walls}") + return + print(" ok Single append works") + # Test 2: Manual loop i = 0 while i < 3: walls.append((i, 1)) i += 1 - print(f" ✓ While loop append works: {walls}") - + if walls != [(1, 1), (0, 1), (1, 1), (2, 1)]: + fail("while loop append", f"got {walls}") + return + print(f" ok While loop append works: {walls}") + # Test 3: Range with different operations walls2 = [] for x in range(3): tup = (x, 2) walls2.append(tup) - print(f" ✓ Range with temp variable works: {walls2}") - + if walls2 != [(0, 2), (1, 2), (2, 2)]: + fail("range with temp variable", f"got {walls2}") + return + print(f" ok Range with temp variable works: {walls2}") + # Test 4: Direct tuple creation in append walls3 = [] for x in range(3): walls3.append((x, 3)) - print(f" ✓ Direct tuple append works: {walls3}") - + if walls3 != [(0, 3), (1, 3), (2, 3)]: + fail("direct tuple append", f"got {walls3}") + return + print(f" ok Direct tuple append works: {walls3}") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() + fail("append in loop", f"{type(e).__name__}: {e}") test_append() print() -print("All tests complete.") \ No newline at end of file +if failures: + print(f"FAIL ({len(failures)} check(s) failed)") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("All tests complete.") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_color_fix.py b/tests/unit/test_color_fix.py index 84f3907..dab1bfa 100644 --- a/tests/unit/test_color_fix.py +++ b/tests/unit/test_color_fix.py @@ -1,31 +1,76 @@ #!/usr/bin/env python3 -"""Simple test for Color setter fix""" +"""Simple test for Color setter fix + +Original intent: assigning a cell color from a plain (r, g, b) tuple must work +just as well as assigning a mcrfpy.Color object. + +API update: per-cell color no longer lives on GridPoint (grid.at(x, y).color is +gone). The successor API is a ColorLayer attached to the grid: + cl = mcrfpy.ColorLayer(name="bg"); grid.add_layer(cl) + cl.set((x, y), color) +So the same tuple-vs-Color coercion is exercised through ColorLayer.set. +""" import mcrfpy +import sys print("Testing Color fix...") -# Test 1: Create grid +failures = 0 + +# Test 1: Create grid + color layer try: test = mcrfpy.Scene("test") - grid = mcrfpy.Grid(grid_w=5, grid_h=5) - print("✓ Grid created") + grid = mcrfpy.Grid(grid_size=(5, 5)) + color_layer = mcrfpy.ColorLayer(name="bg") + grid.add_layer(color_layer) + print("+ Grid created") except Exception as e: - print(f"✗ Grid creation failed: {e}") - exit(1) + print(f"x Grid creation failed: {e}") + sys.exit(1) # Test 2: Set color with tuple try: - grid.at(0, 0).color = (100, 100, 100) - print("✓ Tuple color assignment works") + color_layer.set((0, 0), (100, 100, 100)) + c = color_layer.at((0, 0)) + assert (c.r, c.g, c.b) == (100, 100, 100), f"expected (100,100,100), got {(c.r, c.g, c.b)}" + print("+ Tuple color assignment works") except Exception as e: - print(f"✗ Tuple assignment failed: {e}") + print(f"x Tuple assignment failed: {e}") + failures += 1 # Test 3: Set color with Color object try: - grid.at(0, 0).color = mcrfpy.Color(200, 200, 200) - print("✓ Color object assignment works!") + color_layer.set((0, 0), mcrfpy.Color(200, 200, 200)) + c = color_layer.at((0, 0)) + assert (c.r, c.g, c.b) == (200, 200, 200), f"expected (200,200,200), got {(c.r, c.g, c.b)}" + print("+ Color object assignment works!") except Exception as e: - print(f"✗ Color assignment failed: {e}") + print(f"x Color assignment failed: {e}") + failures += 1 -print("Done.") \ No newline at end of file +# Test 4: RGBA tuple (4-component) also coerces +try: + color_layer.set((1, 1), (10, 20, 30, 40)) + c = color_layer.at((1, 1)) + assert (c.r, c.g, c.b, c.a) == (10, 20, 30, 40), f"expected (10,20,30,40), got {(c.r, c.g, c.b, c.a)}" + print("+ RGBA tuple assignment works") +except Exception as e: + print(f"x RGBA tuple assignment failed: {e}") + failures += 1 + +# Test 5: bad input must raise, not silently succeed +try: + color_layer.set((2, 2), "not a color") + print("x Bad color value was accepted") + failures += 1 +except (TypeError, ValueError): + print("+ Invalid color rejected") + +if failures: + print(f"FAIL ({failures} check(s) failed)") + sys.exit(1) + +print("Done.") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_color_operations.py b/tests/unit/test_color_operations.py index e78da94..cd0cdb3 100644 --- a/tests/unit/test_color_operations.py +++ b/tests/unit/test_color_operations.py @@ -1,7 +1,19 @@ #!/usr/bin/env python3 -"""Test if Color assignment is the trigger""" +"""Test if Color assignment is the trigger + +Originally a repro script for a crash/corruption suspected in per-cell Color +assignment (grid.at(x, y).color = Color(...)) followed by range() iteration. + +API update: GridPoint no longer carries a .color -- per-cell color now lives on a +ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), Color)). +The original intent (bulk color writes must not corrupt the interpreter, and the +colors must actually stick) is preserved against the new API. +""" import mcrfpy +import sys + +failures = [] print("Testing Color operations with range()...") print("=" * 50) @@ -10,44 +22,61 @@ print("=" * 50) print("Test 1: Color assignment in grid") try: test1 = mcrfpy.Scene("test1") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - + grid = mcrfpy.Grid(grid_size=(25, 15)) + colors = mcrfpy.ColorLayer(name="cell_color") + grid.add_layer(colors) + # Assign color to a cell - grid.at(0, 0).color = mcrfpy.Color(200, 200, 220) + colors.set((0, 0), mcrfpy.Color(200, 200, 220)) + c = colors.at((0, 0)) + assert (c.r, c.g, c.b) == (200, 200, 220), f"color readback wrong: {c}" print(" ✓ Single color assignment works") - + # Test range for i in range(25): pass print(" ✓ range(25) works after single color assignment") - + except Exception as e: print(f" ✗ Error: {type(e).__name__}: {e}") + failures.append(f"Test 1: {type(e).__name__}: {e}") # Test 2: Multiple color assignments print("\nTest 2: Multiple color assignments") try: test2 = mcrfpy.Scene("test2") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - + grid = mcrfpy.Grid(grid_size=(25, 15)) + colors = mcrfpy.ColorLayer(name="cell_color") + grid.add_layer(colors) + # Multiple properties including color for y in range(15): for x in range(25): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - + colors.set((x, y), mcrfpy.Color(200, 200, 220)) + print(" ✓ Completed all property assignments") - + + # Every cell must have kept its value + for y in range(15): + for x in range(25): + c = colors.at((x, y)) + assert (c.r, c.g, c.b) == (200, 200, 220), f"cell ({x},{y}) = {c}" + assert grid.at(x, y).walkable is True, f"cell ({x},{y}) not walkable" + assert grid.at(x, y).transparent is True, f"cell ({x},{y}) not transparent" + print(" ✓ All 375 cells read back correctly") + # This is where it would fail for i in range(25): pass print(" ✓ range(25) still works!") - + except Exception as e: print(f" ✗ Error: {type(e).__name__}: {e}") import traceback traceback.print_exc() + failures.append(f"Test 2: {type(e).__name__}: {e}") # Test 3: Exact reproduction of failing pattern print("\nTest 3: Exact pattern from dijkstra_demo_final.py") @@ -55,37 +84,46 @@ try: # Recreate the exact function def create_demo(): dijkstra_demo = mcrfpy.Scene("dijkstra_demo") - + # Create grid - grid = mcrfpy.Grid(grid_w=25, grid_h=15) + grid = mcrfpy.Grid(grid_size=(25, 15)) grid.fill_color = mcrfpy.Color(0, 0, 0) - + colors = mcrfpy.ColorLayer(name="cell_color") + grid.add_layer(colors) + # Initialize all as floor for y in range(15): for x in range(25): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - + colors.set((x, y), mcrfpy.Color(200, 200, 220)) + # Create an interesting dungeon layout walls = [] - + # Room walls # Top-left room for x in range(1, 8): walls.append((x, 1)) - + return grid, walls - + grid, walls = create_demo() + assert walls == [(x, 1) for x in range(1, 8)], f"walls corrupted: {walls}" + fc = grid.fill_color + assert (fc.r, fc.g, fc.b) == (0, 0, 0), f"fill_color corrupted: {fc}" print(f" ✓ Function completed successfully, walls: {walls}") - + except Exception as e: print(f" ✗ Error: {type(e).__name__}: {e}") import traceback traceback.print_exc() + failures.append(f"Test 3: {type(e).__name__}: {e}") -print("\nConclusion: The bug is inconsistent and may be related to:") -print("- Memory layout at the time of execution") -print("- Specific bytecode patterns in the Python code") -print("- C++ reference counting issues with Color objects") -print("- Stack/heap corruption in the grid.at() implementation") \ No newline at end of file +print() +if failures: + for f in failures: + print(f"FAILED: {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_color_setter_bug.py b/tests/unit/test_color_setter_bug.py index 3fa102e..8e966b8 100644 --- a/tests/unit/test_color_setter_bug.py +++ b/tests/unit/test_color_setter_bug.py @@ -1,70 +1,138 @@ #!/usr/bin/env python3 -"""Test that confirms the Color setter bug""" +"""Test that the grid cell Color setter does not leave a pending exception. + +Original bug: PyObject_to_sfColor in UIGridPoint.cpp called PyArg_ParseTuple to +parse the assigned color. When handed an mcrfpy.Color object (rather than a +tuple) the parse failed, the setter swallowed the failure without clearing the +error indicator, and the *pending* exception then erupted out of the next, +totally unrelated Python operation (e.g. `range(25)`). + +GridPoint.color no longer exists; per-cell color now lives on a ColorLayer +(grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)). This test is +retargeted onto that successor API, preserving the original intent: + - a tuple is accepted + - an mcrfpy.Color object is accepted (the case that used to fail) + - neither leaves a stray pending exception that surfaces later + - the assigned color actually round-trips +""" import mcrfpy +import sys -print("Testing GridPoint color setter bug...") +failures = [] + + +def check(label, condition, detail=""): + if condition: + print(f" PASS: {label}") + else: + print(f" FAIL: {label} {detail}") + failures.append(label) + + +def make_color_layer(w, h): + scene = mcrfpy.Scene(f"colorsetter_{w}x{h}") + grid = mcrfpy.Grid(grid_size=(w, h)) + layer = mcrfpy.ColorLayer(name="bg") + grid.add_layer(layer) + return grid, layer + + +print("Testing grid cell color setter (pending-exception bug)...") print("=" * 50) -# Test 1: Setting color with tuple (old way) +# Test 1: Setting color with a tuple (the path that always worked) print("Test 1: Setting color with tuple") try: - test1 = mcrfpy.Scene("test1") - grid = mcrfpy.Grid(grid_w=5, grid_h=5) - - # This should work (PyArg_ParseTuple expects tuple) - grid.at(0, 0).color = (200, 200, 220) - - # Check if exception is pending + grid, layer = make_color_layer(5, 5) + layer.set((0, 0), (200, 200, 220)) + + # The original bug surfaced here: an unrelated op raising the pending error. _ = list(range(1)) - print(" ✓ Tuple assignment works") + + c = layer.at((0, 0)) + check("tuple assignment raises nothing", True) + check( + "tuple color round-trips", + (c.r, c.g, c.b) == (200, 200, 220), + f"got ({c.r}, {c.g}, {c.b})", + ) except Exception as e: - print(f" ✗ Tuple assignment failed: {type(e).__name__}: {e}") + check("tuple assignment", False, f"{type(e).__name__}: {e}") print() -# Test 2: Setting color with Color object (the bug) +# Test 2: Setting color with a Color object (this is what used to set the +# pending exception). It must now work outright -- no exception, pending or not. print("Test 2: Setting color with Color object") try: - test2 = mcrfpy.Scene("test2") - grid = mcrfpy.Grid(grid_w=5, grid_h=5) - - # This will fail in PyArg_ParseTuple but not report it - grid.at(0, 0).color = mcrfpy.Color(200, 200, 220) - print(" ⚠️ Color assignment appeared to work...") - - # But exception is pending! + grid, layer = make_color_layer(5, 5) + layer.set((0, 0), mcrfpy.Color(200, 200, 220)) + + # If a stale error indicator were left behind, this innocent call would + # raise it instead. _ = list(range(1)) - print(" ✓ No exception detected (unexpected!)") + + c = layer.at((0, 0)) + check("Color object assignment raises nothing", True) + check( + "Color object round-trips", + (c.r, c.g, c.b) == (200, 200, 220), + f"got ({c.r}, {c.g}, {c.b})", + ) except Exception as e: - print(f" ✗ Exception detected: {type(e).__name__}: {e}") - print(" This confirms the bug - exception was set but not raised") + check("Color object assignment", False, f"{type(e).__name__}: {e}") print() -# Test 3: Multiple color assignments +# Test 3: Many Color assignments in a row (reproduces the original failure +# shape: the error only became visible on a later, unrelated operation). print("Test 3: Multiple Color assignments (reproducing original bug)") try: - test3 = mcrfpy.Scene("test3") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - - # Do multiple color assignments - for y in range(2): # Just 2 rows to be quick + grid, layer = make_color_layer(25, 15) + + for y in range(2): for x in range(25): - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - - print(" All color assignments completed...") - - # This should fail + layer.set((x, y), mcrfpy.Color(200, 200, 220)) + + # The canary from the original bug report. for i in range(25): pass - print(" ✓ range(25) worked (unexpected!)") + + c = layer.at((24, 1)) + check("50 Color assignments leave no pending exception", True) + check( + "last assigned cell round-trips", + (c.r, c.g, c.b) == (200, 200, 220), + f"got ({c.r}, {c.g}, {c.b})", + ) except Exception as e: - print(f" ✗ range(25) failed as expected: {type(e).__name__}") - print(" The exception was set during color assignment") + check("multiple Color assignments", False, f"{type(e).__name__}: {e}") print() -print("Bug confirmed: PyObject_to_sfColor in UIGridPoint.cpp") -print("doesn't clear the exception when PyArg_ParseTuple fails.") -print("The fix: Either check PyErr_Occurred() after ParseTuple,") -print("or support mcrfpy.Color objects directly.") \ No newline at end of file + +# Test 4: A genuinely bad color must raise a REAL exception, immediately -- the +# other half of the bug was errors being set but never raised. +print("Test 4: Invalid color raises immediately") +try: + grid, layer = make_color_layer(5, 5) + try: + layer.set((0, 0), "not a color") + check("invalid color raises", False, "no exception raised") + except TypeError: + check("invalid color raises TypeError at the call site", True) + + # And the failed call must not have left an error indicator behind. + _ = list(range(1)) + check("failed set leaves no pending exception", True) +except Exception as e: + check("invalid color handling", False, f"{type(e).__name__}: {e}") + +print() +print("=" * 50) +if failures: + print(f"FAILED ({len(failures)}): {', '.join(failures)}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_colorlayer_heightmap.py b/tests/unit/test_colorlayer_heightmap.py index 1ff2ffe..1007d89 100644 --- a/tests/unit/test_colorlayer_heightmap.py +++ b/tests/unit/test_colorlayer_heightmap.py @@ -8,12 +8,23 @@ import sys import mcrfpy +def make_color_layer(size=(10, 10)): + """Create a Grid with an attached ColorLayer, returning (grid, layer). + + add_layer() takes a layer object and no keyword arguments; the ColorLayer + constructor is what accepts name/z_index. + """ + grid = mcrfpy.Grid(grid_size=size) + layer = mcrfpy.ColorLayer(name='color', z_index=0) + grid.add_layer(layer) + return grid, layer + + def test_apply_threshold_basic(): """apply_threshold sets colors in range""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((0, 0, 0, 0)) # Clear all # Apply threshold - all cells should get blue @@ -32,8 +43,7 @@ def test_apply_threshold_with_alpha(): """apply_threshold handles RGBA colors""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128)) @@ -47,8 +57,7 @@ def test_apply_threshold_preserves_outside(): """apply_threshold doesn't modify cells outside range""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((255, 0, 0)) # Fill with red # Apply threshold for range that doesn't include 0.5 @@ -65,8 +74,7 @@ def test_apply_threshold_with_color_object(): """apply_threshold accepts Color objects""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() color = mcrfpy.Color(50, 100, 150) layer.apply_threshold(hmap, (0.0, 1.0), color) @@ -80,8 +88,7 @@ def test_apply_threshold_size_mismatch(): """apply_threshold rejects mismatched HeightMap size""" hmap = mcrfpy.HeightMap((5, 5)) # Different size - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() try: layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0)) @@ -97,8 +104,7 @@ def test_apply_gradient_basic(): """apply_gradient interpolates colors""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() # Apply gradient from black to white result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255)) @@ -118,8 +124,7 @@ def test_apply_gradient_full_range(): # Test at minimum of range hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0)) @@ -144,8 +149,7 @@ def test_apply_gradient_preserves_outside(): """apply_gradient doesn't modify cells outside range""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((255, 0, 0)) # Fill with red # Apply gradient for range that doesn't include 0.5 @@ -161,8 +165,7 @@ def test_apply_ranges_fixed_colors(): """apply_ranges with fixed colors""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((0, 0, 0)) result = layer.apply_ranges(hmap, [ @@ -183,8 +186,7 @@ def test_apply_ranges_gradient(): """apply_ranges with gradient specification""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() # Gradient from (0,0,0) to (255,255,255) over range [0,1] # At value 0.5, should be ~(127,127,127) @@ -201,8 +203,7 @@ def test_apply_ranges_mixed(): """apply_ranges with mixed fixed and gradient entries""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((0, 0, 0)) # Test mixed: gradient that includes 0.5 @@ -222,8 +223,7 @@ def test_apply_ranges_later_wins(): """apply_ranges: later ranges override earlier ones""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.apply_ranges(hmap, [ ((0.0, 1.0), (255, 0, 0)), # Red, matches everything @@ -240,8 +240,7 @@ def test_apply_ranges_no_match_unchanged(): """apply_ranges leaves unmatched cells unchanged""" hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() layer.fill((128, 128, 128)) # Gray marker layer.apply_ranges(hmap, [ @@ -259,8 +258,7 @@ def test_apply_threshold_invalid_range(): """apply_threshold rejects min > max""" hmap = mcrfpy.HeightMap((10, 10)) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() try: layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0)) @@ -277,8 +275,7 @@ def test_apply_gradient_narrow_range(): # Use a value exactly at the range hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('color', z_index=0) + grid, layer = make_color_layer() # Apply gradient over exact value (min == max) layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255)) diff --git a/tests/unit/test_dijkstra_pathfinding.py b/tests/unit/test_dijkstra_pathfinding.py index 4da78ab..55df38a 100644 --- a/tests/unit/test_dijkstra_pathfinding.py +++ b/tests/unit/test_dijkstra_pathfinding.py @@ -8,32 +8,49 @@ Demonstrates: 2. Getting distances to any position 3. Finding paths from any position back to the root 4. Multi-target pathfinding (flee/approach scenarios) + +API notes (updated for current mcrfpy): +- The `mcrfpy.libtcod` module is gone. Its successor is the GridData Dijkstra API: + grid.grid_data.get_dijkstra_map(root=(x, y)) -> DijkstraMap + dmap.root / dmap.distance((x, y)) / dmap.path_from((x, y)) + grid.grid_data.clear_dijkstra_maps() (invalidate after walkability changes) +- GridPoint has no .tilesprite/.color; tiles go on a TileLayer, colors on a ColorLayer. """ import mcrfpy -from mcrfpy import libtcod import sys +failures = [] + +def check(cond, message): + """Record a failed check instead of aborting, so all checks run.""" + if not cond: + failures.append(message) + print(f" FAIL: {message}") + return cond + +def adjacent(a, b): + """True if a and b are the same cell or 8-way neighbors.""" + return abs(int(a[0]) - int(b[0])) <= 1 and abs(int(a[1]) - int(b[1])) <= 1 + def create_test_grid(): """Create a test grid with obstacles""" - dijkstra_test = mcrfpy.Scene("dijkstra_test") - - # Create grid - grid = mcrfpy.Grid(grid_w=20, grid_h=20) + # Create grid (already has a default tile layer) + grid = mcrfpy.Grid(grid_size=(20, 20)) # Add color layer for cell coloring - color_layer = grid.add_layer("color", z_index=-1) - # Store color_layer on grid for access elsewhere - grid._color_layer = color_layer + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid.add_layer(color_layer) + + data = grid.grid_data # Initialize all cells as walkable - for y in range(grid.grid_h): - for x in range(grid.grid_w): + for y in range(data.grid_h): + for x in range(data.grid_w): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.tilesprite = 46 # . period - color_layer.set(x, y, mcrfpy.Color(50, 50, 50)) + color_layer.set((x, y), mcrfpy.Color(50, 50, 50)) # Create some walls to make pathfinding interesting # Vertical wall @@ -41,187 +58,261 @@ def create_test_grid(): cell = grid.at(10, y) cell.walkable = False cell.transparent = False - cell.tilesprite = 219 # Block - color_layer.set(10, y, mcrfpy.Color(100, 100, 100)) + color_layer.set((10, y), mcrfpy.Color(100, 100, 100)) # Horizontal wall for x in range(5, 15): - if x != 10: # Leave a gap + if x != 10: # (10, 10) is already part of the vertical wall cell = grid.at(x, 10) cell.walkable = False cell.transparent = False - cell.tilesprite = 219 - color_layer.set(x, 10, mcrfpy.Color(100, 100, 100)) + color_layer.set((x, 10), mcrfpy.Color(100, 100, 100)) - return grid + # Walkability changed after construction; drop any cached maps + data.clear_dijkstra_maps() + + return grid, color_layer def test_basic_dijkstra(): """Test basic Dijkstra functionality""" print("\n=== Testing Basic Dijkstra ===") - - grid = create_test_grid() - + + grid, _color_layer = create_test_grid() + data = grid.grid_data + # Compute Dijkstra map from position (5, 5) root_x, root_y = 5, 5 print(f"Computing Dijkstra map from root ({root_x}, {root_y})") - grid.compute_dijkstra(root_x, root_y) - + dmap = data.get_dijkstra_map(root=(root_x, root_y)) + check(dmap is not None, "get_dijkstra_map returned None") + check((dmap.root.x, dmap.root.y) == (root_x, root_y), + f"dmap.root {dmap.root} != root ({root_x}, {root_y})") + # Test getting distances to various points + # (x, y, expected) -- expected None means "unreachable" test_points = [ - (5, 5), # Root position (should be 0) - (6, 5), # Adjacent (should be 1) - (7, 5), # Two steps away - (15, 15), # Far corner - (10, 10), # On a wall (should be unreachable) + (5, 5, 0.0), # Root position + (6, 5, 1.0), # Adjacent + (7, 5, 2.0), # Two steps away + (15, 15, None), # Far corner: reachable, distance checked below + (10, 10, None), # On a wall: unreachable ] - + print("\nDistances from root:") - for x, y in test_points: - distance = grid.get_dijkstra_distance(x, y) + for x, y, expected in test_points: + distance = dmap.distance((x, y)) if distance is None: print(f" ({x:2}, {y:2}): UNREACHABLE") else: print(f" ({x:2}, {y:2}): {distance:.1f}") - + + if (x, y) == (10, 10): + check(distance is None, "wall cell (10, 10) should be unreachable") + elif (x, y) == (15, 15): + # Reachable by going around the walls; exact cost depends on + # diagonal_cost, but it must be finite and clearly non-trivial. + check(distance is not None, "(15, 15) should be reachable around the walls") + if distance is not None: + check(distance > 10.0, f"(15, 15) distance {distance} implausibly short") + else: + check(distance is not None, f"({x}, {y}) should be reachable") + if distance is not None: + check(abs(distance - expected) < 0.001, + f"({x}, {y}) distance {distance} != expected {expected}") + # Test getting paths print("\nPaths to root:") for x, y in [(15, 5), (15, 15), (5, 15)]: - path = grid.get_dijkstra_path(x, y) - if path: - print(f" From ({x}, {y}): {len(path)} steps") - # Show first few steps - for i, (px, py) in enumerate(path[:3]): - print(f" Step {i+1}: ({px}, {py})") - if len(path) > 3: - print(f" ... {len(path)-3} more steps") + path = dmap.path_from((x, y)) + steps = list(path) if path else [] + if steps: + print(f" From ({x}, {y}): {len(steps)} steps") + for i, step in enumerate(steps[:3]): + print(f" Step {i+1}: ({step.x}, {step.y})") + if len(steps) > 3: + print(f" ... {len(steps)-3} more steps") else: print(f" From ({x}, {y}): No path found") -def test_libtcod_interface(): - """Test the libtcod module interface""" - print("\n=== Testing libtcod Interface ===") - - grid = create_test_grid() - - # Use libtcod functions - print("Using libtcod.dijkstra_* functions:") - - # Create dijkstra context (returns grid) - dijkstra = libtcod.dijkstra_new(grid) - print(f"Created Dijkstra context: {type(dijkstra)}") - - # Compute from a position - libtcod.dijkstra_compute(grid, 10, 2) - print("Computed Dijkstra map from (10, 2)") - - # Get distance using libtcod - distance = libtcod.dijkstra_get_distance(grid, 10, 17) + check(len(steps) > 0, f"no path from ({x}, {y}) back to root") + if steps: + # An AStarPath walks origin -> destination: successive steps must be + # adjacent, the first step must neighbor the query position, and the + # last step must be the root. (This is what find_path() returns.) + cells = [(int(s.x), int(s.y)) for s in steps] + check(adjacent(cells[0], (x, y)), + f"path from ({x}, {y}): first step {cells[0]} is not adjacent to the start") + check(cells[-1] == (root_x, root_y), + f"path from ({x}, {y}) ends at {cells[-1]}, not the root ({root_x}, {root_y})") + for a, b in zip(cells, cells[1:]): + check(adjacent(a, b), + f"path from ({x}, {y}) jumps from {a} to {b} (not adjacent)") + for cell in cells: + check(grid.at(cell[0], cell[1]).walkable, + f"path from ({x}, {y}) crosses wall at {cell}") + +def test_dijkstra_map_interface(): + """Test the DijkstraMap object interface (successor to the old libtcod module)""" + print("\n=== Testing DijkstraMap Interface ===") + + grid, _color_layer = create_test_grid() + data = grid.grid_data + + # Create dijkstra map (successor of libtcod.dijkstra_new + dijkstra_compute) + dijkstra = data.get_dijkstra_map(root=(10, 2)) + print(f"Created Dijkstra map: {type(dijkstra).__name__}, root={dijkstra.root}") + check(isinstance(dijkstra, mcrfpy.DijkstraMap), + "get_dijkstra_map did not return a DijkstraMap") + + # Get distance (successor of libtcod.dijkstra_get_distance) + distance = dijkstra.distance((10, 17)) print(f"Distance to (10, 17): {distance}") - - # Get path using libtcod - path = libtcod.dijkstra_path_to(grid, 10, 17) - print(f"Path from (10, 17) to root: {len(path) if path else 0} steps") + check(distance is not None, "(10, 17) should be reachable from (10, 2)") + + # Get path (successor of libtcod.dijkstra_path_to) + path = dijkstra.path_from((10, 17)) + steps = [(int(s.x), int(s.y)) for s in path] if path else [] + print(f"Path from (10, 17) to root: {len(steps)} steps -> {steps[:4]}...") + check(len(steps) > 0, "no path from (10, 17) to root (10, 2)") + if steps: + check((path.origin.x, path.origin.y) == (10, 17), "AStarPath.origin is not the query pos") + check((path.destination.x, path.destination.y) == (10, 2), + "AStarPath.destination is not the root") + check(adjacent(steps[0], (10, 17)), + f"first step {steps[0]} is not adjacent to the start (10, 17)") + check(steps[-1] == (10, 2), f"path ends at {steps[-1]}, not root (10, 2)") + + # step_from(pos) is documented as "single step from position toward root": + # it must be the same as the first element of path_from(pos), and adjacent. + step = dijkstra.step_from((10, 17)) + print(f"step_from((10, 17)) -> {step}") + check(step is not None, "step_from returned None for a reachable cell") + if step is not None: + check(adjacent((int(step.x), int(step.y)), (10, 17)), + f"step_from((10, 17)) returned ({int(step.x)}, {int(step.y)}), which is not adjacent") + + # Maps are cached per-root; asking again yields an equivalent map + again = data.get_dijkstra_map(root=(10, 2)) + check((again.root.x, again.root.y) == (10, 2), "cached map lost its root") + check(again.distance((10, 17)) == distance, "cached map distance changed") def test_multi_target_scenario(): """Test fleeing/approaching multiple targets""" print("\n=== Testing Multi-Target Scenario ===") - - grid = create_test_grid() - + + grid, color_layer = create_test_grid() + data = grid.grid_data + # Place three "threats" and compute their Dijkstra maps threats = [(3, 3), (17, 3), (10, 17)] - + print("Computing threat distances...") threat_distances = [] - + for i, (tx, ty) in enumerate(threats): # Mark threat position - cell = grid.at(tx, ty) - cell.tilesprite = 84 # T for threat - grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0)) - + color_layer.set((tx, ty), mcrfpy.Color(255, 0, 0)) + # Compute Dijkstra from this threat - grid.compute_dijkstra(tx, ty) - + dmap = data.get_dijkstra_map(root=(tx, ty)) + # Store distances for all cells distances = {} - for y in range(grid.grid_h): - for x in range(grid.grid_w): - d = grid.get_dijkstra_distance(x, y) + for y in range(data.grid_h): + for x in range(data.grid_w): + d = dmap.distance((x, y)) if d is not None: distances[(x, y)] = d - + threat_distances.append(distances) print(f" Threat {i+1} at ({tx}, {ty}): {len(distances)} reachable cells") - + + check(distances.get((tx, ty)) == 0.0, + f"threat {i+1} root ({tx}, {ty}) should have distance 0") + check(len(distances) > 100, + f"threat {i+1} only reached {len(distances)} cells; map looks broken") + check((10, 10) not in distances, + f"threat {i+1} reached wall cell (10, 10)") + # Find safest position (farthest from all threats) print("\nFinding safest position...") best_pos = None best_min_dist = 0 - - for y in range(grid.grid_h): - for x in range(grid.grid_w): + + for y in range(data.grid_h): + for x in range(data.grid_w): # Skip if not walkable if not grid.at(x, y).walkable: continue - + # Get minimum distance to any threat min_dist = float('inf') for threat_dist in threat_distances: if (x, y) in threat_dist: min_dist = min(min_dist, threat_dist[(x, y)]) - + # Track best position if min_dist > best_min_dist and min_dist != float('inf'): best_min_dist = min_dist best_pos = (x, y) - + + check(best_pos is not None, "no safest position found (all cells unreachable?)") if best_pos: print(f"Safest position: {best_pos} (min distance to threats: {best_min_dist:.1f})") + check(grid.at(best_pos[0], best_pos[1]).walkable, "safest position is a wall") + check(best_min_dist > 0, "safest position sits on top of a threat") # Mark safe position - cell = grid.at(best_pos[0], best_pos[1]) - cell.tilesprite = 83 # S for safe - grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0)) + color_layer.set((best_pos[0], best_pos[1]), mcrfpy.Color(0, 255, 0)) + + # Multi-source Dijkstra: one map rooted at ALL threats at once. Its distance + # to any cell must equal the min over the individual per-threat maps. + multi = data.get_dijkstra_map(roots=threats) + mismatches = 0 + for (x, y), _ in list(threat_distances[0].items())[:200]: + expected = min(td[(x, y)] for td in threat_distances if (x, y) in td) + got = multi.distance((x, y)) + if got is None or abs(got - expected) > 0.001: + mismatches += 1 + check(mismatches == 0, + f"multi-source Dijkstra disagrees with min-of-single-source on {mismatches} cells") + +def main(): + print("McRogueFace Dijkstra Pathfinding Test") + print("=====================================") + + # Set up scene so the grid is actually attached to a live UI tree + dijkstra_test = mcrfpy.Scene("dijkstra_test") + grid, _color_layer = create_test_grid() + grid.pos = (0, 0) + grid.size = (400, 400) + ui = dijkstra_test.children + ui.append(grid) + + title = mcrfpy.Caption(text="Dijkstra Pathfinding Test", pos=(10, 10)) + title.fill_color = mcrfpy.Color(255, 255, 255) + ui.append(title) + + dijkstra_test.activate() -def run_test(timer, runtime): - """Timer callback to run tests after scene loads""" test_basic_dijkstra() - test_libtcod_interface() + test_dijkstra_map_interface() test_multi_target_scenario() - + print("\n=== Dijkstra Implementation Test Complete ===") - print("✓ Basic Dijkstra computation works") - print("✓ Distance queries work") - print("✓ Path finding works") - print("✓ libtcod interface works") - print("✓ Multi-target scenarios work") - - # Take screenshot - try: - from mcrfpy import automation - automation.screenshot("dijkstra_test.png") - print("\nScreenshot saved: dijkstra_test.png") - except: - pass - + if failures: + print(f"\n{len(failures)} check(s) FAILED:") + for f in failures: + print(f" - {f}") + print("FAIL") + sys.exit(1) + + print("Basic Dijkstra computation works") + print("Distance queries work") + print("Path finding works") + print("DijkstraMap interface works") + print("Multi-target scenarios work") + print("PASS") sys.exit(0) -# Main execution -print("McRogueFace Dijkstra Pathfinding Test") -print("=====================================") - -# Set up scene -grid = create_test_grid() -ui = dijkstra_test.children -ui.append(grid) - -# Add title -title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test") -title.fill_color = mcrfpy.Color(255, 255, 255) -ui.append(title) - -# Set timer to run tests -test_timer = mcrfpy.Timer("test", run_test, 100, once=True) - -# Show scene -dijkstra_test.activate() \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/tests/unit/test_documentation.py b/tests/unit/test_documentation.py index 384a338..3ef266a 100644 --- a/tests/unit/test_documentation.py +++ b/tests/unit/test_documentation.py @@ -1,133 +1,164 @@ #!/usr/bin/env python3 -"""Test that method documentation is properly accessible in Python.""" +"""Test that method documentation is properly accessible in Python. + +Rot repair: this test used to only *print* what it found (so a missing +docstring silently "passed"), and it probed the pre-#350 API surface +(setScene/createScene/sceneUI/currentScene, the module-level audio +functions). It now asserts against the current mcrfpy API and fails loudly +on any missing docstring. +""" import mcrfpy import sys +import types +import io +import contextlib + +failures = [] + +def check(condition, message): + if condition: + print(f" PASS: {message}") + else: + print(f" FAIL: {message}") + failures.append(message) def test_module_doc(): """Test module-level documentation.""" print("=== Module Documentation ===") - print(f"Module: {mcrfpy.__name__}") - print(f"Doc: {mcrfpy.__doc__[:100]}..." if mcrfpy.__doc__ else "No module doc") + doc = mcrfpy.__doc__ + check(bool(doc and doc.strip()), "mcrfpy has a module docstring") + check(bool(doc and 'McRogueFace' in doc), "module docstring names McRogueFace") print() - + def test_method_docs(): - """Test method documentation.""" + """Every public module-level function must be documented.""" print("=== Method Documentation ===") - - # Test main API methods + + # Current module-level API (audio moved to Sound/Music/SoundBuffer classes; + # setScene/createScene/currentScene/sceneUI replaced by mcrfpy.Scene). methods = [ - 'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume', - 'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI', - 'currentScene', 'setScene', 'createScene', 'keypressScene', - 'exit', 'setScale', 'find', 'findAll', - 'getMetrics' + 'bresenham', 'end_benchmark', 'exit', 'find', 'find_all', 'get_metrics', + 'lock', 'log_benchmark', 'set_dev_console', 'set_scale', + 'start_benchmark', 'step', ] - + for method_name in methods: - if hasattr(mcrfpy, method_name): - method = getattr(mcrfpy, method_name) - doc = method.__doc__ - if doc: - # Extract first line of docstring - first_line = doc.strip().split('\n')[0] - print(f"{method_name}: {first_line}") - else: - print(f"{method_name}: NO DOCUMENTATION") + check(hasattr(mcrfpy, method_name), f"mcrfpy.{method_name} exists") + method = getattr(mcrfpy, method_name, None) + doc = getattr(method, '__doc__', None) + check(bool(doc and doc.strip()), f"mcrfpy.{method_name} has documentation") + + # Nothing public may be undocumented, even if not listed above. + for name in dir(mcrfpy): + if name.startswith('_'): + continue + obj = getattr(mcrfpy, name) + if isinstance(obj, types.BuiltinFunctionType): + check(bool(obj.__doc__), f"module function {name} has documentation") print() def test_class_docs(): """Test class documentation.""" print("=== Class Documentation ===") - - classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer'] - + + classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData', + 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer', 'Scene'] + for class_name in classes: - if hasattr(mcrfpy, class_name): - cls = getattr(mcrfpy, class_name) - doc = cls.__doc__ - if doc: - # Extract first line - first_line = doc.strip().split('\n')[0] - print(f"{class_name}: {first_line[:80]}...") - else: - print(f"{class_name}: NO DOCUMENTATION") + check(hasattr(mcrfpy, class_name), f"mcrfpy.{class_name} exists") + cls = getattr(mcrfpy, class_name, None) + doc = getattr(cls, '__doc__', None) + check(bool(doc and doc.strip()), f"class {class_name} has documentation") + if doc: + print(f" {class_name}: {doc.strip().splitlines()[0][:70]}") print() def test_property_docs(): - """Test property documentation.""" + """Every getset property on the core drawables must be documented.""" print("=== Property Documentation ===") - - # Test Frame properties - if hasattr(mcrfpy, 'Frame'): - frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', 'children', 'visible', 'z_index'] - print("Frame properties:") - for prop_name in frame_props: - prop = getattr(mcrfpy.Frame, prop_name, None) - if prop and hasattr(prop, '__doc__'): - print(f" {prop_name}: {prop.__doc__}") + + frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', + 'children', 'visible', 'z_index'] + for prop_name in frame_props: + prop = getattr(mcrfpy.Frame, prop_name, None) + check(prop is not None, f"Frame.{prop_name} exists") + check(bool(prop is not None and prop.__doc__), + f"Frame.{prop_name} has documentation") + + # No getset descriptor on the core types may be undocumented. + for class_name in ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', + 'Color', 'Vector', 'Timer']: + cls = getattr(mcrfpy, class_name) + for prop_name, prop in vars(cls).items(): + if isinstance(prop, types.GetSetDescriptorType): + check(bool(prop.__doc__), + f"{class_name}.{prop_name} has documentation") print() def test_method_signatures(): - """Test that methods have correct signatures in docs.""" + """Test that docstrings carry a usable signature line.""" print("=== Method Signatures ===") - - # Check a few key methods - if hasattr(mcrfpy, 'setScene'): - doc = mcrfpy.setScene.__doc__ - if doc and 'setScene(scene: str, transition: str = None, duration: float = 0.0)' in doc: - print("✓ setScene signature correct") - else: - print("✗ setScene signature incorrect or missing") - - if hasattr(mcrfpy, 'Timer'): - doc = mcrfpy.Timer.__doc__ - if doc and 'Timer' in doc: - print("+ Timer class documentation present") - else: - print("x Timer class documentation missing") - - if hasattr(mcrfpy, 'find'): - doc = mcrfpy.find.__doc__ - if doc and 'find(name: str, scene: str = None)' in doc: - print("✓ find signature correct") - else: - print("✗ find signature incorrect or missing") + + doc = mcrfpy.find.__doc__ + check(bool(doc and 'find(name: str, scene: str = None)' in doc), + "find() signature present in docstring") + + doc = mcrfpy.step.__doc__ + check(bool(doc and 'step(' in doc), "step() signature present in docstring") + + doc = mcrfpy.Timer.__doc__ + check(bool(doc and 'Timer(' in doc), "Timer signature present in class doc") + + doc = mcrfpy.Scene.__doc__ + check(bool(doc and 'Scene(' in doc), "Scene signature present in class doc") + + # Bound method documentation (animation is now an object method, #229). + doc = mcrfpy.Frame.animate.__doc__ + check(bool(doc and 'animate(' in doc), + "Frame.animate() signature present in docstring") print() def test_help_output(): """Test Python help() function output.""" print("=== Help Function Test ===") - print("Testing help(mcrfpy.setScene):") - import io - import contextlib - - # Capture help output + buffer = io.StringIO() with contextlib.redirect_stdout(buffer): - help(mcrfpy.setScene) - + help(mcrfpy.find) help_text = buffer.getvalue() - if 'transition to a different scene' in help_text: - print("✓ Help text contains expected documentation") - else: - print("✗ Help text missing expected documentation") + check('Find the first UI element' in help_text, + "help(mcrfpy.find) contains its documentation") + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + help(mcrfpy.Frame) + help_text = buffer.getvalue() + check('Frame' in help_text and 'animate' in help_text, + "help(mcrfpy.Frame) lists the class and its methods") print() def main(): """Run all documentation tests.""" print("McRogueFace Documentation Tests") print("===============================\n") - + test_module_doc() test_method_docs() test_class_docs() test_property_docs() test_method_signatures() test_help_output() - + + if failures: + print(f"\nFAIL: {len(failures)} documentation check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + print("\nDocumentation tests complete!") + print("PASS") sys.exit(0) if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/tests/unit/test_entity_animation.py b/tests/unit/test_entity_animation.py index dfd28d0..2d10ce5 100644 --- a/tests/unit/test_entity_animation.py +++ b/tests/unit/test_entity_animation.py @@ -5,20 +5,32 @@ Test Entity Animation Isolated test for entity position animation. No perspective, just basic movement in a square pattern. + +Headless: mcrfpy.step(dt) is the only clock, so the waypoint sequence is driven +explicitly instead of by keyboard input + a free-running game loop (#350/#341). """ import mcrfpy import sys +failures = [] + +def check(condition, message): + if not condition: + print(f"FAIL: {message}") + failures.append(message) + return condition + # Create scene test_anim = mcrfpy.Scene("test_anim") # Create simple grid -grid = mcrfpy.Grid(grid_w=15, grid_h=15) +grid = mcrfpy.Grid(grid_size=(15, 15), pos=(100, 100), size=(450, 450)) grid.fill_color = mcrfpy.Color(20, 20, 30) -# Add a color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# Add a color layer for cell coloring (GridPoint.color is gone; use a ColorLayer) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.grid_data.add_layer(color_layer) # Initialize all cells as walkable floors for y in range(15): @@ -26,7 +38,7 @@ for y in range(15): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) + color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Mark the path we'll follow with different color path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5), @@ -35,17 +47,16 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5), (5,9), (5,8), (5,7), (5,6)] for x, y in path_cells: - color_layer.set(x, y, mcrfpy.Color(120, 120, 150)) + color_layer.set((x, y), mcrfpy.Color(120, 120, 150)) # Create entity at start position -entity = mcrfpy.Entity((5, 5), grid=grid) +entity = mcrfpy.Entity(grid_pos=(5, 5)) entity.sprite_index = 64 # @ +grid.entities.append(entity) # UI setup ui = test_anim.children ui.append(grid) -grid.pos = (100, 100) -grid.size = (450, 450) # 15 * 30 pixels per cell # Title title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path") @@ -53,12 +64,13 @@ title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Status display -status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit") +status = mcrfpy.Caption(pos=(100, 50), text="Animating square path") status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) # Position display -pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})") +pos_display = mcrfpy.Caption(pos=(100, 70), + text=f"Entity Position: ({entity.draw_pos.x:.2f}, {entity.draw_pos.y:.2f})") pos_display.fill_color = mcrfpy.Color(255, 255, 100) ui.append(pos_display) @@ -67,139 +79,118 @@ anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started") anim_info.fill_color = mcrfpy.Color(100, 255, 255) ui.append(anim_info) -# Debug info -debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...") -debug_info.fill_color = mcrfpy.Color(150, 150, 150) -ui.append(debug_info) - # Animation state -current_waypoint = 0 -animating = False waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)] +completed = [] # (property, final_value) tuples from animation callbacks -def update_position_display(timer, runtime): - """Update position display every 200ms""" - pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})" +def on_anim_done(target, prop, value): + """Animation completion callback: (target, property, final_value)""" + completed.append((prop, value)) - # Check if entity is at expected position - if animating and current_waypoint > 0: - target = waypoints[current_waypoint - 1] - distance = ((entity.x - target[0])**2 + (entity.y - target[1])**2)**0.5 - debug_info.text = f"Debug: Distance to target {target}: {distance:.3f}" +def animate_to_waypoint(index): + """Animate the entity's draw position to waypoints[index].""" + target_x, target_y = waypoints[index] + duration = 2.0 -def animate_to_next_waypoint(): - """Animate to the next waypoint""" - global current_waypoint, animating - - if current_waypoint >= len(waypoints): - status.text = "Animation complete! Press SPACE to restart" - anim_info.text = "Animation: Complete" - animating = False - current_waypoint = 0 - return - - target_x, target_y = waypoints[current_waypoint] - - # Log what we're doing - print(f"Animating from ({entity.x}, {entity.y}) to ({target_x}, {target_y})") - - # Update status - status.text = f"Moving to waypoint {current_waypoint + 1}/{len(waypoints)}: ({target_x}, {target_y})" + print(f"Animating from ({entity.draw_pos.x}, {entity.draw_pos.y}) to ({target_x}, {target_y})") + status.text = f"Moving to waypoint {index + 1}/{len(waypoints)}: ({target_x}, {target_y})" anim_info.text = f"Animation: Active (target: {target_x}, {target_y})" - - # Create animations - ensure we're using floats - duration = 2.0 # 2 seconds per segment - - # Try different approaches to see what works - - # Approach 1: Direct property animation - anim_x = mcrfpy.Animation("x", float(target_x), duration, "linear") - anim_y = mcrfpy.Animation("y", float(target_y), duration, "linear") - - # Start animations - anim_x.start(entity) - anim_y.start(entity) - - # Log animation details - print(f"Started animations: x to {float(target_x)}, y to {float(target_y)}, duration: {duration}s") - - current_waypoint += 1 - # Schedule next waypoint - global next_waypoint_timer - next_waypoint_timer = mcrfpy.Timer("next_waypoint", lambda t, r: animate_to_next_waypoint(), int(duration * 1000 + 100), once=True) + # 'draw_x'/'draw_y' are the tile-space draw coordinates ('x'/'y' are aliases). + # mcrfpy.Animation is no longer exported; animations are created via .animate(). + entity.animate("draw_x", float(target_x), duration, mcrfpy.Easing.LINEAR, + callback=on_anim_done) + entity.animate("draw_y", float(target_y), duration, mcrfpy.Easing.LINEAR, + callback=on_anim_done) + return duration -def start_animation(): - """Start or restart the animation sequence""" - global current_waypoint, animating - - # Reset entity position - entity.x = 5 - entity.y = 5 - - # Reset state - current_waypoint = 0 - animating = True - - print("Starting animation sequence...") - - # Start first animation - animate_to_next_waypoint() +def step_seconds(seconds, dt=0.1): + """Advance the headless clock; the engine never advances time on its own.""" + for _ in range(int(round(seconds / dt))): + mcrfpy.step(dt) def test_immediate_position(): - """Test setting position directly""" - print(f"Before: entity at ({entity.x}, {entity.y})") - entity.x = 7 - entity.y = 7 - print(f"After direct set: entity at ({entity.x}, {entity.y})") - - # Try with animation to same position - anim_x = mcrfpy.Animation("x", 9.0, 1.0, "linear") - anim_x.start(entity) - print("Started animation to x=9.0") + """Test setting position directly (no animation).""" + # #295: grid_pos (logical cell) is DECOUPLED from draw_pos (visual position). + # Setting one does not move the other; they are assigned independently. + entity.grid_pos = (7, 7) + check(tuple(entity.cell_pos) == (7, 7), + f"direct grid_pos set: expected cell (7,7), got {tuple(entity.cell_pos)}") + check((entity.draw_pos.x, entity.draw_pos.y) == (5.0, 5.0), + f"grid_pos is decoupled from draw_pos; draw_pos should be unchanged at " + f"(5,5), got {entity.draw_pos}") -# Input handler -def handle_input(key, state): - if state != mcrfpy.InputState.PRESSED: - return + entity.draw_pos = (7.0, 7.0) + check((entity.draw_pos.x, entity.draw_pos.y) == (7.0, 7.0), + f"direct draw_pos set: expected (7.0, 7.0), got {entity.draw_pos}") - if key == mcrfpy.Key.Q: - print("Exiting test...") - sys.exit(0) - elif key == mcrfpy.Key.SPACE: - if not animating: - start_animation() - else: - print("Animation already in progress!") - elif key == mcrfpy.Key.T: - # Test immediate position change - test_immediate_position() - elif key == mcrfpy.Key.R: - # Reset position - entity.x = 5 - entity.y = 5 - print(f"Reset entity to ({entity.x}, {entity.y})") - -# Set scene -test_anim.activate() -test_anim.on_key = handle_input - -# Start position update timer -update_pos_timer = mcrfpy.Timer("update_pos", update_position_display, 200) - -# No perspective (omniscient view) -grid.perspective = -1 + # Animation to a new x, driven by explicit steps + entity.animate("draw_x", 9.0, 1.0, mcrfpy.Easing.LINEAR) + step_seconds(0.5) + mid_x = entity.draw_pos.x + check(7.0 < mid_x < 9.0, + f"linear animation should be mid-flight at t=0.5s, draw_x={mid_x}") + step_seconds(0.6) + check(abs(entity.draw_pos.x - 9.0) < 0.01, + f"animation should land on draw_x=9.0, got {entity.draw_pos.x}") print("Entity Animation Test") print("====================") print("This test animates an entity in a square pattern:") -print("(5,5) → (10,5) → (10,10) → (5,10) → (5,5)") +print("(5,5) -> (10,5) -> (10,10) -> (5,10) -> (5,5)") print() -print("Controls:") -print(" SPACE - Start animation") -print(" T - Test immediate position change") -print(" R - Reset position to (5,5)") -print(" Q - Quit") -print() -print("The position display updates every 200ms") -print("Watch the console for animation logs") \ No newline at end of file + +test_anim.activate() +grid.perspective = None # omniscient view (perspective takes an Entity or None now) + +# --- Direct position assignment --------------------------------------------- +test_immediate_position() + +# --- Square-path waypoint animation ----------------------------------------- +entity.grid_pos = (5, 5) +entity.draw_pos = (5.0, 5.0) # decoupled from grid_pos; reset both +completed.clear() + +for i, (wx, wy) in enumerate(waypoints): + start = (entity.draw_pos.x, entity.draw_pos.y) + duration = animate_to_waypoint(i) + + # Halfway through: entity must be strictly between start and target on any + # axis that actually changes (proves interpolation, not a snap). + step_seconds(duration / 2) + half = (entity.draw_pos.x, entity.draw_pos.y) + for axis, s, t, h in (("x", start[0], wx, half[0]), ("y", start[1], wy, half[1])): + if s != t: + check(min(s, t) < h < max(s, t), + f"waypoint {i}: draw_{axis} should be interpolating between " + f"{s} and {t} at half-time, got {h}") + + # Finish the segment (plus a little slack for the final step) + step_seconds(duration / 2 + 0.2) + end = entity.draw_pos + check(abs(end.x - wx) < 0.01 and abs(end.y - wy) < 0.01, + f"waypoint {i}: expected draw_pos ({wx}, {wy}), got ({end.x}, {end.y})") + + pos_display.text = f"Entity Position: ({end.x:.2f}, {end.y:.2f})" + +anim_info.text = "Animation: Complete" + +# Each waypoint fires two completion callbacks (draw_x and draw_y) +check(len(completed) == 2 * len(waypoints), + f"expected {2 * len(waypoints)} animation callbacks, got {len(completed)}") + +# The entity ends where it started -- a closed square +check(abs(entity.draw_pos.x - 5.0) < 0.01 and abs(entity.draw_pos.y - 5.0) < 0.01, + f"square path should end at (5, 5), got {entity.draw_pos}") + +# Animating draw_pos does not move the entity's logical cell (#313 contract: +# draw_pos is the visual position, cell_pos/grid_pos is the authoritative cell). +check(tuple(entity.cell_pos) == (5, 5), + f"logical cell should still be (5,5), got {tuple(entity.cell_pos)}") + +if failures: + print(f"\n{len(failures)} check(s) failed") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/unit/test_entity_path_to.py b/tests/unit/test_entity_path_to.py index 4cc8e12..84a70bb 100644 --- a/tests/unit/test_entity_path_to.py +++ b/tests/unit/test_entity_path_to.py @@ -1,14 +1,24 @@ #!/usr/bin/env python3 -"""Test the new Entity.path_to() method""" +"""Test the Entity.path_to() method""" import mcrfpy +import sys print("Testing Entity.path_to() method...") print("=" * 50) +failures = [] + +def check(label, condition, detail=""): + if condition: + print(f" PASS {label}") + else: + print(f" FAIL {label}: {detail}") + failures.append(label) + # Create scene and grid path_test = mcrfpy.Scene("path_test") -grid = mcrfpy.Grid(grid_w=10, grid_h=10) +grid = mcrfpy.Grid(grid_size=(10, 10)) # Set up a simple map with some walls for y in range(10): @@ -24,48 +34,76 @@ for x, y in walls: # Create entity entity = mcrfpy.Entity((2, 2), grid=grid) -print(f"Entity at: ({entity.x}, {entity.y})") +# NOTE: entity.x/.y are pixel draw coords now; the logical cell is entity.cell_pos. +start = (int(entity.cell_pos.x), int(entity.cell_pos.y)) +print(f"Entity at cell: {start}") +check("entity starts at (2, 2)", start == (2, 2), f"got {start}") -# Test 1: Simple path + +def validate_path(path, target): + """A path must be a contiguous, wall-free walk ending on the target.""" + if not path: + return "path is empty" + if tuple(path[-1]) != target: + return f"path does not end at {target}: {path[-1]}" + prev = start + for step in path: + step = tuple(step) + if step in walls: + return f"path walks through wall {step}" + dx, dy = abs(step[0] - prev[0]), abs(step[1] - prev[1]) + if max(dx, dy) != 1: + return f"non-adjacent step {prev} -> {step}" + prev = step + return None + + +# Test 1: Simple path (positional x, y) print("\nTest 1: Path to (6, 6)") -try: - path = entity.path_to(6, 6) - print(f" Path: {path}") - print(f" Length: {len(path)} steps") - print(" ✓ SUCCESS") -except Exception as e: - print(f" ✗ FAILED: {e}") +path = entity.path_to(6, 6) +print(f" Path: {path}") +err = validate_path(path, (6, 6)) +check("path_to(6, 6) returns a valid walk to (6, 6)", err is None, err or "") -# Test 2: Path with target_x/target_y keywords -print("\nTest 2: Path using keyword arguments") -try: - path = entity.path_to(target_x=7, target_y=7) - print(f" Path: {path}") - print(f" Length: {len(path)} steps") - print(" ✓ SUCCESS") -except Exception as e: - print(f" ✗ FAILED: {e}") +# Test 2: Path using keyword arguments. +# API CHANGE: the old target_x=/target_y= keywords are gone; the position-spec +# parser now accepts pos=(x, y) (or a bare tuple/Vector). +print("\nTest 2: Path using keyword argument pos=(7, 7)") +path = entity.path_to(pos=(7, 7)) +print(f" Path: {path}") +err = validate_path(path, (7, 7)) +check("path_to(pos=(7, 7)) returns a valid walk to (7, 7)", err is None, err or "") -# Test 3: Path to unreachable location +# Test 3: Path to current position is empty (already there) print("\nTest 3: Path to current position") -try: - path = entity.path_to(2, 2) - print(f" Path: {path}") - print(f" Length: {len(path)} steps") - print(" ✓ SUCCESS") -except Exception as e: - print(f" ✗ FAILED: {e}") +path = entity.path_to(2, 2) +print(f" Path: {path}") +check("path_to(own position) is empty", path == [], f"got {path}") # Test 4: Error cases print("\nTest 4: Error handling") try: - # Out of bounds - path = entity.path_to(15, 15) - print(" ✗ Should have failed for out of bounds") + entity.path_to(15, 15) + check("out-of-bounds target raises ValueError", False, "no exception raised") except ValueError as e: - print(f" ✓ Correctly caught out of bounds: {e}") + check("out-of-bounds target raises ValueError", True) + print(f" ({e})") except Exception as e: - print(f" ✗ Wrong exception type: {e}") + check("out-of-bounds target raises ValueError", False, + f"wrong exception type {type(e).__name__}: {e}") + +# Test 5: Unreachable target -> empty path (wall off a cell completely) +print("\nTest 5: Unreachable target") +for x, y in [(8, 0), (8, 1), (9, 1)]: + grid.at(x, y).walkable = False +path = entity.path_to(9, 0) +print(f" Path: {path}") +check("walled-off target yields empty path", path == [], f"got {path}") print("\n" + "=" * 50) -print("Entity.path_to() testing complete!") \ No newline at end of file +if failures: + print(f"FAILED: {len(failures)} check(s): {failures}") + sys.exit(1) +print("Entity.path_to() testing complete!") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_entity_path_to_edge_cases.py b/tests/unit/test_entity_path_to_edge_cases.py index 187dcf5..74314fc 100644 --- a/tests/unit/test_entity_path_to_edge_cases.py +++ b/tests/unit/test_entity_path_to_edge_cases.py @@ -2,25 +2,36 @@ """Test edge cases for Entity.path_to() method""" import mcrfpy +import sys + +failures = [] + +def check(condition, message): + """Record a pass/fail instead of just printing it.""" + if condition: + print(" [ok] %s" % message) + else: + print(" [FAIL] %s" % message) + failures.append(message) print("Testing Entity.path_to() edge cases...") print("=" * 50) # Test 1: Entity without grid print("Test 1: Entity not in grid") +entity = mcrfpy.Entity(grid_pos=(5, 5)) try: - entity = mcrfpy.Entity((5, 5)) path = entity.path_to(8, 8) - print(" ✗ Should have failed for entity not in grid") + check(False, "path_to() on a grid-less entity should raise ValueError, got %r" % (path,)) except ValueError as e: - print(f" ✓ Correctly caught no grid error: {e}") + check(True, "correctly raised ValueError with no grid: %s" % e) except Exception as e: - print(f" ✗ Wrong exception type: {e}") + check(False, "wrong exception type: %s: %s" % (type(e).__name__, e)) # Test 2: Entity in grid with walls blocking path print("\nTest 2: Completely blocked path") blocked_test = mcrfpy.Scene("blocked_test") -grid = mcrfpy.Grid(grid_w=5, grid_h=5) +grid = mcrfpy.Grid(grid_size=(5, 5)) # Make all tiles walkable first for y in range(5): @@ -31,25 +42,28 @@ for y in range(5): for x in range(5): grid.at(x, 2).walkable = False -entity = mcrfpy.Entity((1, 1), grid=grid) +entity = mcrfpy.Entity(grid_pos=(1, 1), grid=grid) -try: - path = entity.path_to(1, 4) - if path: - print(f" Path found: {path}") - else: - print(" ✓ No path found (empty list returned)") -except Exception as e: - print(f" ✗ Unexpected error: {e}") +path = entity.path_to(1, 4) +check(path == [], "no path across the full-width wall, got %r" % (path,)) -# Test 3: Alternative parameter parsing +# Test 3: Alternative parameter parsing (keyword x/y) print("\nTest 3: Alternative parameter names") -try: - path = entity.path_to(x=3, y=1) - print(f" Path with x/y params: {path}") - print(" ✓ SUCCESS") -except Exception as e: - print(f" ✗ FAILED: {e}") +path = entity.path_to(x=3, y=1) +check(path == [(2, 1), (3, 1)], + "path_to(x=, y=) walked the open row: %r" % (path,)) + +# The same target via positional args must agree with the keyword form. +check(entity.path_to(3, 1) == path, + "positional path_to(3, 1) matches the keyword form") print("\n" + "=" * 50) -print("Edge case testing complete!") \ No newline at end of file +if failures: + print("Edge case testing FAILED (%d):" % len(failures)) + for f in failures: + print(" - %s" % f) + sys.exit(1) + +print("Edge case testing complete!") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_exact_failure.py b/tests/unit/test_exact_failure.py index c067bfd..ee3a879 100644 --- a/tests/unit/test_exact_failure.py +++ b/tests/unit/test_exact_failure.py @@ -1,7 +1,19 @@ #!/usr/bin/env python3 -"""Reproduce the exact failure from dijkstra_demo_final.py""" +"""Reproduce the exact failure from dijkstra_demo_final.py + +Original intent: a bulk per-cell mutation loop (walkable/transparent/color over +every cell of a Grid) appeared to set a Python exception that was NOT raised at +its source, but instead surfaced later on an unrelated line (the `walls.append` +loop). This test drives that exact pattern and asserts that: + 1. the pattern completes without raising, and + 2. no *stale/pending* exception leaks out onto a later, unrelated statement. + +API note: GridPoint no longer has `.color` -- per-cell color now lives on a +ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)). +""" import mcrfpy +import sys print("Reproducing exact failure pattern...") print("=" * 50) @@ -10,87 +22,123 @@ print("=" * 50) WALL_COLOR = mcrfpy.Color(60, 30, 30) FLOOR_COLOR = mcrfpy.Color(200, 200, 220) +GRID_W, GRID_H = 25, 15 + +failures = [] + def test_exact_pattern(): - """Exact code from dijkstra_demo_final.py""" + """Exact code from dijkstra_demo_final.py (ported to the current API)""" dijkstra_demo = mcrfpy.Scene("dijkstra_demo") - + # Create grid - grid = mcrfpy.Grid(grid_w=25, grid_h=15) + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H)) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Per-cell color now lives on a ColorLayer, not on GridPoint + color_layer = mcrfpy.ColorLayer(name="floor") + grid.add_layer(color_layer) + # Initialize all as floor - for y in range(15): - for x in range(25): + for y in range(GRID_H): + for x in range(GRID_W): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = FLOOR_COLOR - + color_layer.set((x, y), FLOOR_COLOR) + # Create an interesting dungeon layout walls = [] - + # Room walls # Top-left room for x in range(1, 8): walls.append((x, 1)) - - return grid, walls + + return grid, color_layer, walls print("Test 1: Running exact pattern...") try: - grid, walls = test_exact_pattern() - print(f" ✓ Success! Created {len(walls)} walls") + grid, color_layer, walls = test_exact_pattern() + print(f" OK Success! Created {len(walls)} walls") + if len(walls) != 7: + failures.append(f"expected 7 walls, got {len(walls)}") except Exception as e: - print(f" ✗ Failed: {type(e).__name__}: {e}") + failures.append(f"exact pattern raised {type(e).__name__}: {e}") + print(f" FAIL Failed: {type(e).__name__}: {e}") import traceback traceback.print_exc() print() print("Test 2: Breaking it down step by step...") +def step(label, fn): + """Run a step; a raise here is a real failure, not something to swallow.""" + try: + result = fn() + print(f" OK {label}") + return result + except Exception as e: + failures.append(f"{label} raised {type(e).__name__}: {e}") + print(f" FAIL {label}: {type(e).__name__}: {e}") + return None + # Step 1: Scene and grid -try: - test2 = mcrfpy.Scene("test2") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - print(" ✓ Step 1: Scene and grid created") -except Exception as e: - print(f" ✗ Step 1 failed: {e}") +def _step1(): + mcrfpy.Scene("test2") + return mcrfpy.Grid(grid_size=(GRID_W, GRID_H)) +grid = step("Step 1: Scene and grid created", _step1) # Step 2: Set fill_color -try: +def _step2(): grid.fill_color = mcrfpy.Color(0, 0, 0) - print(" ✓ Step 2: fill_color set") -except Exception as e: - print(f" ✗ Step 2 failed: {e}") + assert grid.fill_color == mcrfpy.Color(0, 0, 0) +step("Step 2: fill_color set", _step2) -# Step 3: Nested loops with grid.at -try: - for y in range(15): - for x in range(25): +# Step 3: Nested loops with grid.at + ColorLayer.set (the suspect loop) +layer = mcrfpy.ColorLayer(name="floor2") +grid.add_layer(layer) +def _step3(): + for y in range(GRID_H): + for x in range(GRID_W): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = FLOOR_COLOR - print(" ✓ Step 3: Nested loops completed") -except Exception as e: - print(f" ✗ Step 3 failed: {e}") + layer.set((x, y), FLOOR_COLOR) +step("Step 3: Nested loops completed", _step3) + +# The mutations must have actually landed (not silently dropped) +if not (grid.at(0, 0).walkable and grid.at(GRID_W - 1, GRID_H - 1).walkable): + failures.append("Step 3: walkable was not persisted by the nested loop") +if not grid.at(GRID_W - 1, GRID_H - 1).transparent: + failures.append("Step 3: transparent was not persisted by the nested loop") +if layer.at((GRID_W - 1, GRID_H - 1)) != FLOOR_COLOR: + failures.append("Step 3: ColorLayer color was not persisted by the nested loop") # Step 4: Create walls list -try: - walls = [] - print(" ✓ Step 4: walls list created") -except Exception as e: - print(f" ✗ Step 4 failed: {e}") +def _step4(): + return [] +walls = step("Step 4: walls list created", _step4) -# Step 5: The failing line -try: +# Step 5: The line that used to blow up with an exception raised by *step 3*. +# This is the heart of the regression: after the nested loops, plain Python must +# still work. A stale pending error would explode right here. +def _step5(): for x in range(1, 8): walls.append((x, 1)) - print(f" ✓ Step 5: For loop worked, walls = {walls}") -except Exception as e: - print(f" ✗ Step 5 failed: {type(e).__name__}: {e}") - - # Check if exception was already pending - import sys - exc_info = sys.exc_info() - print(f" Exception info: {exc_info}") + return walls +step("Step 5: For loop worked", _step5) + +if walls != [(x, 1) for x in range(1, 8)]: + failures.append(f"Step 5: walls list wrong: {walls}") + +# Explicitly confirm no exception is left pending after the nested loops. +if sys.exc_info()[0] is not None: + failures.append(f"stale pending exception after loops: {sys.exc_info()}") print() -print("The error occurs at step 5, suggesting an exception was") -print("set during the nested loops but not immediately raised.") \ No newline at end of file +if failures: + print("FAIL: no exception may be deferred out of the nested cell-mutation loops") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("The nested cell-mutation loops raise nothing, persist their writes, and") +print("leave no pending exception to surface on a later line.") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_geometry.py b/tests/unit/test_geometry.py index 91db29c..e12ef0a 100644 --- a/tests/unit/test_geometry.py +++ b/tests/unit/test_geometry.py @@ -9,10 +9,13 @@ Tests cover: """ import sys +import os import math +import traceback -# Import the geometry module -sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts') +# Import the geometry module (src/scripts, relative to this test file) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.abspath(__file__)), '..', '..', 'src', 'scripts')) from geometry import ( # Utilities distance, distance_squared, angle_between, normalize_angle, @@ -601,4 +604,13 @@ def run_all_tests(): print("=" * 50) if __name__ == "__main__": - run_all_tests() + # Exit contract (#350): headless --exec scripts must call sys.exit() explicitly. + # Any failed assertion is a real failure -> report it and exit nonzero. + try: + run_all_tests() + except Exception: + traceback.print_exc() + print("FAIL") + sys.exit(1) + print("PASS") + sys.exit(0) diff --git a/tests/unit/test_grid_apply.py b/tests/unit/test_grid_apply.py index 65a365a..0199437 100644 --- a/tests/unit/test_grid_apply.py +++ b/tests/unit/test_grid_apply.py @@ -66,12 +66,16 @@ def test_apply_threshold_out_of_range(): def test_apply_threshold_returns_self(): - """apply_threshold returns self for chaining""" + """apply_threshold returns the GridData for chaining + + Current contract (#313/#361): the apply_* methods live on GridData, and the + Grid view forwards to it. They return the GridData ("self"), not the view. + """ grid = mcrfpy.Grid(grid_size=(10, 10)) hmap = mcrfpy.HeightMap((10, 10), fill=0.5) result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True) - assert result is grid, "apply_threshold should return self" + assert result is grid.grid_data, "apply_threshold should return the GridData (self)" print("PASS: test_apply_threshold_returns_self") @@ -159,7 +163,7 @@ def test_apply_ranges_returns_self(): result = grid.apply_ranges(hmap, [ ((0.0, 1.0), {"walkable": True}), ]) - assert result is grid, "apply_ranges should return self" + assert result is grid.grid_data, "apply_ranges should return the GridData (self)" print("PASS: test_apply_ranges_returns_self") @@ -275,7 +279,7 @@ def test_chaining(): ((0.4, 0.6), {"walkable": True, "transparent": True}), ])) - assert result is grid + assert result is grid.grid_data print("PASS: test_chaining") diff --git a/tests/unit/test_grid_background.py b/tests/unit/test_grid_background.py index 64975f5..7645239 100644 --- a/tests/unit/test_grid_background.py +++ b/tests/unit/test_grid_background.py @@ -1,27 +1,48 @@ #!/usr/bin/env python3 -"""Test Grid background color functionality""" +"""Test Grid background color functionality + +API notes (post-#313/#350): + - Grid.background_color was renamed to Grid.fill_color ("Background fill color + (Color). Drawn behind all tiles and entities."). Same property, new name. + - grid.add_layer() takes a layer OBJECT and no keyword arguments; the layer's + ctor takes the kwargs. ColorLayer.set() takes a (x, y) tuple. + - Headless time only advances via mcrfpy.step(); timers never fire on their own. +""" import mcrfpy import sys +failures = [] + +def check(label, actual, expected): + if actual == expected: + print(f"+ {label}: {actual}") + else: + print(f"FAIL {label}: expected {expected}, got {actual}") + failures.append(label) + +def rgb(color): + return (color.r, color.g, color.b) + def test_grid_background(): """Test Grid background color property""" print("Testing Grid Background Color...") - + # Create a test scene test = mcrfpy.Scene("test") ui = test.children - + # Create a grid with default background grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15)) ui.append(grid) # Add color layer for some tiles to see the background better - color_layer = grid.add_layer("color", z_index=-1) + color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) + grid.add_layer(color_layer) for x in range(5, 15): for y in range(5, 10): - color_layer.set(x, y, mcrfpy.Color(100, 150, 100)) - + color_layer.set((x, y), mcrfpy.Color(100, 150, 100)) + # Add UI to show current background color info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150), fill_color=mcrfpy.Color(40, 40, 40), @@ -38,85 +59,108 @@ def test_grid_background(): color_display.font_size = 12 color_display.fill_color = mcrfpy.Color(200, 200, 200) info_frame.children.append(color_display) - + # Activate the scene test.activate() - + + # Track which timer stages actually ran + stages = [] + def run_tests(timer, runtime): """Run background color tests""" timer.stop() - + stages.append("default") + print("\nTest 1: Default background color") - default_color = grid.background_color - print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}") + default_color = grid.fill_color + print(f"Default: R={default_color.r}, G={default_color.g}, " + f"B={default_color.b}, A={default_color.a}") color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}" - - def test_set_color(timer, runtime): - timer.stop() - print("\nTest 2: Set background to blue") - grid.background_color = mcrfpy.Color(20, 40, 100) - new_color = grid.background_color - print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}") - color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}" - def test_animation(timer, runtime): - timer.stop() - print("\nTest 3: Manual color cycling") - # Manually change color to test property is working - colors = [ - mcrfpy.Color(200, 20, 20), # Red - mcrfpy.Color(20, 200, 20), # Green - mcrfpy.Color(20, 20, 200), # Blue - ] + def test_set_color(timer, runtime): + timer.stop() + stages.append("set") + print("\nTest 2: Set background to blue") + grid.fill_color = mcrfpy.Color(20, 40, 100) + new_color = grid.fill_color + check("set background to (20, 40, 100)", rgb(new_color), (20, 40, 100)) + color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}" - color_index = [0] # Use list to allow modification in nested function + colors = [ + mcrfpy.Color(200, 20, 20), # Red + mcrfpy.Color(20, 200, 20), # Green + mcrfpy.Color(20, 20, 200), # Blue + ] - def cycle_red(t, r): - t.stop() - grid.background_color = colors[0] - c = grid.background_color - color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"+ Set to Red: R={c.r}, G={c.g}, B={c.b}") + def test_animation(timer, runtime): + timer.stop() + stages.append("cycle") + print("\nTest 3: Manual color cycling") - def cycle_green(t, r): - t.stop() - grid.background_color = colors[1] - c = grid.background_color - color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"+ Set to Green: R={c.r}, G={c.g}, B={c.b}") + def cycle_red(t, r): + t.stop() + grid.fill_color = colors[0] + c = grid.fill_color + color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" + check("cycle to Red", rgb(c), (200, 20, 20)) - def cycle_blue(t, r): - t.stop() - grid.background_color = colors[2] - c = grid.background_color - color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" - print(f"+ Set to Blue: R={c.r}, G={c.g}, B={c.b}") + def cycle_green(t, r): + t.stop() + grid.fill_color = colors[1] + c = grid.fill_color + color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" + check("cycle to Green", rgb(c), (20, 200, 20)) - # Cycle through colors - mcrfpy.Timer("cycle_0", cycle_red, 100, once=True) - mcrfpy.Timer("cycle_1", cycle_green, 400, once=True) - mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True) + def cycle_blue(t, r): + t.stop() + grid.fill_color = colors[2] + c = grid.fill_color + color_display.text = f"R:{c.r} G:{c.g} B:{c.b}" + check("cycle to Blue", rgb(c), (20, 20, 200)) - def test_complete(timer, runtime): - timer.stop() - print("\nTest 4: Final color check") - final_color = grid.background_color - print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}") + # Cycle through colors + mcrfpy.Timer("cycle_0", cycle_red, 100, once=True) + mcrfpy.Timer("cycle_1", cycle_green, 400, once=True) + mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True) - print("\n+ Grid background color tests completed!") - print("- Default background color works") - print("- Setting background color works") - print("- Color cycling works") + def test_complete(timer, runtime): + timer.stop() + stages.append("complete") + print("\nTest 4: Final color check") + final_color = grid.fill_color + # The last color set by the cycle stage must have stuck. + check("final color is Blue", rgb(final_color), (20, 20, 200)) - sys.exit(0) - - # Schedule tests - mcrfpy.Timer("test_set", test_set_color, 1000, once=True) - mcrfpy.Timer("test_anim", test_animation, 2000, once=True) - mcrfpy.Timer("complete", test_complete, 4500, once=True) - - # Start tests + # Schedule tests mcrfpy.Timer("run_tests", run_tests, 100, once=True) + mcrfpy.Timer("test_set", test_set_color, 1000, once=True) + mcrfpy.Timer("test_anim", test_animation, 2000, once=True) + mcrfpy.Timer("complete", test_complete, 4500, once=True) + + # Headless: mcrfpy.step() is the only clock. 5 simulated seconds at 50ms/step, + # which covers the 4500ms 'complete' timer with margin. + for _ in range(140): + mcrfpy.step(0.05) + + # Force a render so the grid (with its fill_color) actually goes through the + # render path -- the property is a render-time input, not just a stored value. + mcrfpy.automation.screenshot("test_grid_background.png") + + for stage in ("default", "set", "cycle", "complete"): + if stage not in stages: + print(f"FAIL: timer stage '{stage}' never ran") + failures.append(f"stage:{stage}") + + if failures: + print(f"\nFAIL: {len(failures)} check(s) failed: {failures}") + sys.exit(1) + + print("\n+ Grid background color tests completed!") + print("- Default background color works") + print("- Setting background color works") + print("- Color cycling works") + print("PASS") + sys.exit(0) if __name__ == "__main__": - test_grid_background() \ No newline at end of file + test_grid_background() diff --git a/tests/unit/test_grid_constructor_bug.py b/tests/unit/test_grid_constructor_bug.py index 1449a48..1b73574 100644 --- a/tests/unit/test_grid_constructor_bug.py +++ b/tests/unit/test_grid_constructor_bug.py @@ -1,124 +1,110 @@ #!/usr/bin/env python3 -"""Test Grid constructor to isolate the PyArg bug""" +"""Test Grid constructor to isolate the PyArg bug + +Original intent: the Grid constructor used to set a Python exception without +returning NULL, leaving a stale exception on the interpreter stack. The next +Python operation (e.g. range(1)) then blew up with a cryptic +"new style getargs format" error. This test constructs Grids of many sizes and +verifies that (a) construction succeeds, (b) the resulting Grid is actually the +size that was asked for, and (c) no stale exception is left behind. + +API notes (current, post-#313/#361): the Grid constructor takes +grid_size=(w, h); grid_w=/grid_h= are the legacy spelling and still work. +""" import mcrfpy import sys +failures = [] + +def check(cond, msg): + if cond: + print(f" PASS: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + +def no_stale_exception(label): + """Any pending-but-unraised exception trips the next Python operation.""" + try: + list(range(1)) + except Exception as e: + check(False, f"{label}: stale exception after Grid creation: {type(e).__name__}: {e}") + return False + check(True, f"{label}: no stale exception after Grid creation") + return True + print("Testing Grid constructor PyArg bug...") print("=" * 50) -# Test 1: Check if exception is set after Grid creation +# Test 1: Check exception state after Grid creation print("Test 1: Check exception state after Grid creation") try: - # Clear any existing exception - sys.exc_clear() if hasattr(sys, 'exc_clear') else None - - # Create grid with problematic dimensions - print(" Creating Grid(grid_w=25, grid_h=15)...") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - print(" Grid created successfully") - - # Check if there's a pending exception - exc = sys.exc_info() - if exc[0] is not None: - print(f" ⚠️ Pending exception detected: {exc}") - - # Try to trigger the error - print(" Calling range(1)...") - for i in range(1): - pass - print(" ✓ range(1) worked") - + print(" Creating Grid(grid_size=(25, 15))...") + grid = mcrfpy.Grid(grid_size=(25, 15)) + check(True, "Grid(grid_size=(25, 15)) constructed") + check((grid.grid_size.x, grid.grid_size.y) == (25, 15), + f"grid_size is (25, 15), got ({grid.grid_size.x}, {grid.grid_size.y})") + no_stale_exception("Test 1") except Exception as e: - print(f" ✗ Exception: {type(e).__name__}: {e}") + check(False, f"Grid(grid_size=(25, 15)) raised {type(e).__name__}: {e}") print() -# Test 2: Try different Grid constructor patterns -print("Test 2: Different Grid constructor calls") - -# Pattern 1: Positional arguments -try: - print(" Trying Grid(25, 15)...") - grid1 = mcrfpy.Grid(25, 15) - for i in range(1): pass - print(" ✓ Positional args worked") -except Exception as e: - print(f" ✗ Positional args failed: {e}") - -# Pattern 2: Different size -try: - print(" Trying Grid(grid_w=24, grid_h=15)...") - grid2 = mcrfpy.Grid(grid_w=24, grid_h=15) - for i in range(1): pass - print(" ✓ Size 24x15 worked") -except Exception as e: - print(f" ✗ Size 24x15 failed: {e}") - -# Pattern 3: Check if it's specifically 25 -try: - print(" Trying Grid(grid_w=26, grid_h=15)...") - grid3 = mcrfpy.Grid(grid_w=26, grid_h=15) - for i in range(1): pass - print(" ✓ Size 26x15 worked") -except Exception as e: - print(f" ✗ Size 26x15 failed: {e}") +# Test 2: legacy grid_w/grid_h spelling still constructs the same grid +print("Test 2: Legacy grid_w=/grid_h= keywords") +for w, h in [(24, 15), (25, 15), (26, 15)]: + try: + g = mcrfpy.Grid(grid_w=w, grid_h=h) + ok = (g.grid_size.x, g.grid_size.y) == (w, h) + check(ok, f"Grid(grid_w={w}, grid_h={h}) -> grid_size ({g.grid_size.x}, {g.grid_size.y})") + no_stale_exception(f"Grid({w}, {h})") + except Exception as e: + check(False, f"Grid(grid_w={w}, grid_h={h}) raised {type(e).__name__}: {e}") print() -# Test 3: Isolate the exact problem -print("Test 3: Isolating the problem") +# Test 3: Isolate the exact problem -- a sweep of sizes, each followed by a +# plain Python operation that would explode if an exception were left pending. +print("Test 3: Size sweep (construct, then immediately use the interpreter)") def test_grid_creation(x, y): """Test creating a grid and immediately using range()""" try: - grid = mcrfpy.Grid(grid_w=x, grid_h=y) - # Immediately test if exception is pending + g = mcrfpy.Grid(grid_size=(x, y)) + # Immediately test if an exception is pending list(range(1)) + if (g.grid_size.x, g.grid_size.y) != (x, y): + return False, f"wrong grid_size ({g.grid_size.x}, {g.grid_size.y})" return True, "Success" except Exception as e: return False, f"{type(e).__name__}: {e}" -# Test various sizes test_sizes = [(10, 10), (20, 20), (24, 15), (25, 14), (25, 15), (25, 16), (30, 30)] for x, y in test_sizes: success, msg = test_grid_creation(x, y) - if success: - print(f" Grid({x}, {y}): ✓") - else: - print(f" Grid({x}, {y}): ✗ {msg}") + check(success, f"Grid({x}, {y}): {msg}") print() -# Test 4: See if we can clear the exception -print("Test 4: Exception clearing") +# Test 4: bad arguments must raise properly (and not corrupt interpreter state) +print("Test 4: Invalid arguments raise instead of leaving a pending exception") try: - # Create the problematic grid - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - print(" Created Grid(25, 15)") - - # Try to clear any pending exception - try: - # This should fail if there's a pending exception - list(range(1)) - print(" No pending exception!") - except: - print(" ⚠️ Pending exception detected") - # Clear it - sys.exc_clear() if hasattr(sys, 'exc_clear') else None - - # Try again - try: - list(range(1)) - print(" ✓ Exception cleared, range() works now") - except: - print(" ✗ Exception persists") - + bad = mcrfpy.Grid(grid_size="not a size") + check(False, "Grid(grid_size='not a size') should have raised") +except (TypeError, ValueError) as e: + check(True, f"Grid(grid_size='not a size') raised {type(e).__name__} as expected") except Exception as e: - print(f" ✗ Failed: {e}") + check(False, f"Grid(grid_size='not a size') raised unexpected {type(e).__name__}: {e}") +no_stale_exception("Test 4") print() -print("Conclusion: The Grid constructor is setting a Python exception") -print("but not properly returning NULL to propagate it. This leaves") -print("the exception on the stack, causing the next Python operation") -print("to fail with the cryptic 'new style getargs format' error.") \ No newline at end of file +print("=" * 50) +if failures: + print(f"FAIL: {len(failures)} check(s) failed") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_grid_features.py b/tests/unit/test_grid_features.py index 7e407e1..75ee02c 100644 --- a/tests/unit/test_grid_features.py +++ b/tests/unit/test_grid_features.py @@ -7,7 +7,7 @@ print("Testing Grid features...") # Create a texture first print("Loading texture...") -texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) print(f"Texture loaded: {texture}") # Create grid @@ -54,5 +54,9 @@ if not hasattr(pos, 'x'): print(f"pos.x={pos.x}, pos.y={pos.y}") +if pos.x != 50 or pos.y != 100: + print(f"FAIL: pos should be (50, 100), got ({pos.x}, {pos.y})") + sys.exit(1) + print("PASS: Grid Vector properties work correctly!") sys.exit(0) diff --git a/tests/unit/test_grid_iteration.py b/tests/unit/test_grid_iteration.py index 46c7942..8f7af4b 100644 --- a/tests/unit/test_grid_iteration.py +++ b/tests/unit/test_grid_iteration.py @@ -2,6 +2,17 @@ """Test grid iteration patterns to find the exact cause""" import mcrfpy +import sys + +failures = [] + +def check(condition, label): + """Record a failed check instead of silently printing a checkmark.""" + if condition: + print(f" [ok] {label}") + else: + print(f" [FAIL] {label}") + failures.append(label) print("Testing grid iteration patterns...") print("=" * 50) @@ -11,21 +22,23 @@ print("Test 1: Basic grid.at() calls") try: test1 = mcrfpy.Scene("test1") grid = mcrfpy.Grid(grid_w=5, grid_h=5) - + # Single call grid.at(0, 0).walkable = True - print(" ✓ Single grid.at() call works") - + check(grid.at(0, 0).walkable is True, "Single grid.at() call works") + # Multiple calls grid.at(1, 1).walkable = True grid.at(2, 2).walkable = True - print(" ✓ Multiple grid.at() calls work") - + check(grid.at(1, 1).walkable and grid.at(2, 2).walkable, + "Multiple grid.at() calls work") + # Now try a print - print(" ✓ Print after grid.at() works") - + print(" [ok] Print after grid.at() works") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + print(f" [FAIL] Error: {type(e).__name__}: {e}") + failures.append(f"Test 1: {type(e).__name__}: {e}") print() @@ -34,16 +47,18 @@ print("Test 2: Grid.at() in simple loop") try: test2 = mcrfpy.Scene("test2") grid = mcrfpy.Grid(grid_w=5, grid_h=5) - + for i in range(3): grid.at(i, 0).walkable = True - print(" ✓ Single loop with grid.at() works") - + check(all(grid.at(i, 0).walkable for i in range(3)), + "Single loop with grid.at() works") + # Print after loop - print(" ✓ Print after loop works") - + print(" [ok] Print after loop works") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + print(f" [FAIL] Error: {type(e).__name__}: {e}") + failures.append(f"Test 2: {type(e).__name__}: {e}") print() @@ -52,16 +67,18 @@ print("Test 3: Nested loops with grid.at()") try: test3 = mcrfpy.Scene("test3") grid = mcrfpy.Grid(grid_w=5, grid_h=5) - + for y in range(3): for x in range(3): grid.at(x, y).walkable = True - - print(" ✓ Nested loops with grid.at() work") - print(" ✓ Print after nested loops works") - + + check(all(grid.at(x, y).walkable for y in range(3) for x in range(3)), + "Nested loops with grid.at() work") + print(" [ok] Print after nested loops works") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + print(f" [FAIL] Error: {type(e).__name__}: {e}") + failures.append(f"Test 3: {type(e).__name__}: {e}") print() @@ -71,38 +88,46 @@ try: test4 = mcrfpy.Scene("test4") grid = mcrfpy.Grid(grid_w=25, grid_h=15) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Per-cell color moved off GridPoint onto ColorLayer (Grid layer rework); + # GridPoint now exposes only entities/grid_pos/transparent/walkable. + colors = mcrfpy.ColorLayer(name="cell_color") + grid.add_layer(colors) + # This is the exact nested loop from the failing code for y in range(15): for x in range(25): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - - print(" ✓ Full nested loop completed") - - # This is where it fails + colors.set((x, y), mcrfpy.Color(200, 200, 220)) + + check(all(grid.at(x, y).walkable and grid.at(x, y).transparent + for y in range(15) for x in range(25)), + "Full nested loop completed (375 cells set)") + + # This is where it used to fail print(" About to test post-loop operations...") - + # Try different operations x = 5 - print(f" ✓ Variable assignment works: x={x}") - + check(x == 5, f"Variable assignment works: x={x}") + lst = [] - print(f" ✓ List creation works: {lst}") - + check(lst == [], f"List creation works: {lst}") + # The failing line for i in range(3): pass - print(" ✓ Empty for loop works") - + print(" [ok] Empty for loop works") + # With append for i in range(3): lst.append(i) - print(f" ✓ For loop with append works: {lst}") - + check(lst == [0, 1, 2], f"For loop with append works: {lst}") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + print(f" [FAIL] Error: {type(e).__name__}: {e}") import traceback traceback.print_exc() + failures.append(f"Test 4: {type(e).__name__}: {e}") print() @@ -111,28 +136,40 @@ print("Test 5: Testing grid.at() call limits") try: test5 = mcrfpy.Scene("test5") grid = mcrfpy.Grid(grid_w=10, grid_h=10) - + count = 0 for y in range(10): for x in range(10): grid.at(x, y).walkable = True count += 1 - + # Test print every 10 calls if count % 10 == 0: print(f" Processed {count} cells...") - - print(f" ✓ Processed all {count} cells") - + + check(count == 100, f"Processed all {count} cells") + check(all(grid.at(x, y).walkable for y in range(10) for x in range(10)), + "All 100 cells still readable after the loop") + # Now test operations print(" Testing post-processing operations...") for i in range(3): pass - print(" ✓ All operations work after 100 grid.at() calls") - + print(" [ok] All operations work after 100 grid.at() calls") + except Exception as e: - print(f" ✗ Error: {type(e).__name__}: {e}") + print(f" [FAIL] Error: {type(e).__name__}: {e}") import traceback traceback.print_exc() + failures.append(f"Test 5: {type(e).__name__}: {e}") print() -print("Tests complete.") \ No newline at end of file +print("Tests complete.") + +if failures: + print(f"FAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_headless_click.py b/tests/unit/test_headless_click.py index 500b717..5565a66 100644 --- a/tests/unit/test_headless_click.py +++ b/tests/unit/test_headless_click.py @@ -24,7 +24,7 @@ def on_click_handler(pos, button, action): frame.on_click = on_click_handler # Click inside the frame -automation.click(150, 150) +automation.click((150, 150)) mcrfpy.step(0.05) if len(start_clicks) >= 1: @@ -50,7 +50,7 @@ def on_miss_handler(pos, button, action): frame2.on_click = on_miss_handler # Click outside the frame -automation.click(50, 50) +automation.click((50, 50)) mcrfpy.step(0.05) if len(miss_clicks) > 0: @@ -58,7 +58,7 @@ if len(miss_clicks) > 0: # Test 3: Position tracking print("Testing position tracking...") -automation.moveTo(123, 456) +automation.moveTo((123, 456)) pos = automation.position() if pos[0] != 123 or pos[1] != 456: errors.append(f"Position tracking: expected (123,456), got {pos}") diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index 866a19e..a39c19f 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -1,18 +1,33 @@ #!/usr/bin/env python3 -"""Test script to verify the profiling metrics system""" +"""Test script to verify the profiling metrics system + +Updated for the #350 headless model: mcrfpy.step(dt) is the only clock (timers never +fire on their own), and step() never renders -- render counters (draw_calls, +ui_elements, visible_elements) are only published by an actual render pass, which we +force with automation.screenshot(). +""" import mcrfpy +from mcrfpy import automation import sys -import time +import os +import tempfile # Track success across callbacks success = True +done = False + +SHOT = os.path.join(tempfile.gettempdir(), "test_metrics.png") + def test_metrics(timer, runtime): - """Test the metrics after timer starts""" + """Test the metrics after the timer fires""" global success print("\nRunning metrics test...") + # step() never renders, so force a render pass to publish the render counters. + automation.screenshot(SHOT) + # Get metrics metrics = mcrfpy.get_metrics() @@ -43,14 +58,21 @@ def test_metrics(timer, runtime): else: print(f" PASS: FPS {metrics['fps']} is reasonable") - # UI elements count (may be 0 if scene hasn't rendered yet) - if metrics['ui_elements'] < 0: - print(f" FAIL: UI elements count {metrics['ui_elements']} is negative") + # UI elements count -- a render happened, so the scene's drawables were counted + if metrics['ui_elements'] <= 0: + print(f" FAIL: UI elements count {metrics['ui_elements']} should be positive after a render") success = False else: print(f" PASS: UI element count {metrics['ui_elements']} is valid") - # Visible elements should be <= total elements + # Draw calls should be positive after a render + if metrics['draw_calls'] <= 0: + print(f" FAIL: Draw calls {metrics['draw_calls']} should be positive after a render") + success = False + else: + print(f" PASS: Draw call count {metrics['draw_calls']} is valid") + + # Visible elements should be <= total elements (one frame is invisible) if metrics['visible_elements'] > metrics['ui_elements']: print(" FAIL: Visible elements > total elements") success = False @@ -78,9 +100,9 @@ def test_metrics(timer, runtime): initial_frame = metrics['current_frame'] initial_runtime = metrics['runtime'] - # Schedule another check after 100ms + # Schedule another check after 100ms of simulated time def check_later(timer2, runtime2): - global success + global success, done metrics2 = mcrfpy.get_metrics() print(f"\nMetrics after 100ms:") @@ -103,16 +125,11 @@ def test_metrics(timer, runtime): print(" FAIL: Runtime did not increase") success = False - print("\n" + "="*50) - if success: - print("ALL METRICS TESTS PASSED!") - else: - print("SOME METRICS TESTS FAILED!") - - sys.exit(0 if success else 1) + done = True mcrfpy.Timer("check_later", check_later, 100, once=True) + # Set up test scene print("Setting up metrics test scene...") metrics_test = mcrfpy.Scene("metrics_test") @@ -137,10 +154,33 @@ frame2 = mcrfpy.Frame(pos=(300, 10), size=(100, 100)) frame2.visible = False ui.append(frame2) -grid = mcrfpy.Grid(10, 10, mcrfpy.default_texture, (10, 200), (200, 200)) +# Grid ctor is keyword-based now; grid_size= describes a new GridData. +grid = mcrfpy.Grid(grid_size=(10, 10), pos=(10, 200), size=(200, 200)) ui.append(grid) print(f"Created {len(ui)} UI elements (1 invisible)") -# Schedule test to run after render loop starts +# Schedule test to run after 50ms of simulated time mcrfpy.Timer("test", test_metrics, 50, once=True) + +# step() is the clock in headless mode: drive time until both timers have fired. +for _ in range(200): + if done: + break + mcrfpy.step(0.016) + +if os.path.exists(SHOT): + os.remove(SHOT) + +print("\n" + "=" * 50) +if not done: + print("FAIL: timer callbacks never completed") + success = False + +if success: + print("ALL METRICS TESTS PASSED!") + print("PASS") + sys.exit(0) +else: + print("SOME METRICS TESTS FAILED!") + sys.exit(1) diff --git a/tests/unit/test_name_parameter.py b/tests/unit/test_name_parameter.py index dc39030..6f16cb7 100644 --- a/tests/unit/test_name_parameter.py +++ b/tests/unit/test_name_parameter.py @@ -2,47 +2,67 @@ """Test the name parameter in constructors""" import mcrfpy +import sys -# Test Frame with name parameter -try: - frame1 = mcrfpy.Frame(name="test_frame") - print(f"✓ Frame with name: {frame1.name}") -except Exception as e: - print(f"✗ Frame with name failed: {e}") +failures = [] -# Test Grid with name parameter -try: - grid1 = mcrfpy.Grid(name="test_grid") - print(f"✓ Grid with name: {grid1.name}") -except Exception as e: - print(f"✗ Grid with name failed: {e}") +def check(label, fn, expected_name): + """Construct an object, verify the name parameter round-trips.""" + try: + obj = fn() + except Exception as e: + failures.append(f"{label}: construction failed: {e!r}") + print(f"FAIL {label}: construction failed: {e!r}") + return None + if obj.name != expected_name: + failures.append(f"{label}: name is {obj.name!r}, expected {expected_name!r}") + print(f"FAIL {label}: name is {obj.name!r}, expected {expected_name!r}") + return obj + print(f"OK {label}: name={obj.name!r}") + return obj -# Test Sprite with name parameter -try: - sprite1 = mcrfpy.Sprite(name="test_sprite") - print(f"✓ Sprite with name: {sprite1.name}") -except Exception as e: - print(f"✗ Sprite with name failed: {e}") +# name= keyword accepted by every drawable/entity constructor +check("Frame", lambda: mcrfpy.Frame(name="test_frame"), "test_frame") +check("Grid", lambda: mcrfpy.Grid(name="test_grid"), "test_grid") +check("Sprite", lambda: mcrfpy.Sprite(name="test_sprite"), "test_sprite") +check("Caption", lambda: mcrfpy.Caption(name="test_caption"), "test_caption") +check("Entity", lambda: mcrfpy.Entity(name="test_entity"), "test_entity") -# Test Caption with name parameter -try: - caption1 = mcrfpy.Caption(name="test_caption") - print(f"✓ Caption with name: {caption1.name}") -except Exception as e: - print(f"✗ Caption with name failed: {e}") +# Mixed positional args and name= keyword: the positional args must not be +# disturbed by the trailing keyword. +frame2 = check( + "Frame(positional + name)", + lambda: mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame"), + "positioned_frame", +) +if frame2 is not None: + geometry = (frame2.x, frame2.y, frame2.w, frame2.h) + if geometry != (10.0, 10.0, 100.0, 100.0): + failures.append(f"Frame(positional + name): geometry is {geometry}, expected (10.0, 10.0, 100.0, 100.0)") + print(f"FAIL Frame(positional + name): geometry is {geometry}") + else: + print(f"OK Frame(positional + name): pos=({frame2.x}, {frame2.y}), size=({frame2.w}, {frame2.h})") -# Test Entity with name parameter -try: - entity1 = mcrfpy.Entity(name="test_entity") - print(f"✓ Entity with name: {entity1.name}") -except Exception as e: - print(f"✗ Entity with name failed: {e}") +# A name given at construction must be findable by mcrfpy.find() once the +# object is in a scene -- that is what the name parameter is FOR. +scene = mcrfpy.Scene("name_param_test") +named = mcrfpy.Frame(pos=(5, 5), size=(20, 20), name="findable_frame") +scene.children.append(named) +found = mcrfpy.find("findable_frame", "name_param_test") +if found is None: + failures.append("find(): could not find 'findable_frame' by its constructor-assigned name") + print("FAIL find(): could not find 'findable_frame' by its constructor-assigned name") +elif found is not named: + failures.append(f"find(): returned {found!r}, expected the same object appended to the scene") + print(f"FAIL find(): returned {found!r}, not the original object") +else: + print("OK find(): constructor-assigned name is findable in the scene") -# Test with mixed positional and name -try: - frame2 = mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame") - print(f"✓ Frame with positional args and name: pos=({frame2.x}, {frame2.y}), size=({frame2.w}, {frame2.h}), name={frame2.name}") -except Exception as e: - print(f"✗ Frame with positional and name failed: {e}") +if failures: + print(f"\nFAIL: {len(failures)} name parameter check(s) failed") + for f in failures: + print(f" - {f}") + sys.exit(1) -print("\n✅ All name parameter tests complete!") \ No newline at end of file +print("\nPASS: all name parameter tests complete") +sys.exit(0) diff --git a/tests/unit/test_oneline_for.py b/tests/unit/test_oneline_for.py index ae39cba..d346e43 100644 --- a/tests/unit/test_oneline_for.py +++ b/tests/unit/test_oneline_for.py @@ -1,20 +1,28 @@ #!/usr/bin/env python3 """Test single-line for loops which seem to be the issue""" +import sys +import traceback + import mcrfpy print("Testing single-line for loops...") print("=" * 50) +failures = [] + +EXPECTED_WALLS = [(x, 1) for x in range(1, 8)] + # Test 1: Simple single-line for print("Test 1: Simple single-line for") try: result = [] for x in range(3): result.append(x) + assert result == [0, 1, 2], result print(f" ✓ Success: {result}") except Exception as e: + failures.append(f"Test 1: {type(e).__name__}: {e}") print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() print() @@ -24,10 +32,11 @@ print("Test 2: Single-line with tuple append") try: walls = [] for x in range(1, 8): walls.append((x, 1)) + assert walls == EXPECTED_WALLS, walls print(f" ✓ Success: {walls}") except Exception as e: + failures.append(f"Test 2: {type(e).__name__}: {e}") print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() print() @@ -38,9 +47,12 @@ try: walls = [] for x in range(1, 8): walls.append((x, 1)) + assert walls == EXPECTED_WALLS, walls print(f" ✓ Success: {walls}") except Exception as e: + failures.append(f"Test 3: {type(e).__name__}: {e}") print(f" ✗ Error: {type(e).__name__}: {e}") + traceback.print_exc() print() @@ -48,14 +60,15 @@ print() print("Test 4: After creating mcrfpy scene/grid") try: test = mcrfpy.Scene("test") - grid = mcrfpy.Grid(grid_w=10, grid_h=10) - + grid = mcrfpy.Grid(grid_size=(10, 10)) + walls = [] for x in range(1, 8): walls.append((x, 1)) + assert walls == EXPECTED_WALLS, walls print(f" ✓ Success with mcrfpy objects: {walls}") except Exception as e: + failures.append(f"Test 4: {type(e).__name__}: {e}") print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() print() @@ -64,34 +77,49 @@ print() print("Test 5: Checking exact error location") def test_exact_pattern(): dijkstra_demo = mcrfpy.Scene("dijkstra_demo") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) + grid = mcrfpy.Grid(grid_size=(25, 15)) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Per-cell color now lives on a ColorLayer; GridPoint has no .color + colors = mcrfpy.ColorLayer(name="bg") + grid.add_layer(colors) + # Initialize all as floor for y in range(15): for x in range(25): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 220) - + colors.set((x, y), mcrfpy.Color(200, 200, 220)) + # Create an interesting dungeon layout walls = [] - + # Room walls # Top-left room print(" About to execute problem line...") for x in range(1, 8): walls.append((x, 1)) # Line 40 in original print(" ✓ Got past the problem line!") - + return grid, walls try: grid, walls = test_exact_pattern() + assert walls == EXPECTED_WALLS, walls + assert grid.at(0, 0).walkable is True + assert grid.at(24, 14).transparent is True print(f" Result: Created grid and {len(walls)} walls") except Exception as e: + failures.append(f"Test 5: {type(e).__name__}: {e}") print(f" ✗ Error: {type(e).__name__}: {e}") - import traceback traceback.print_exc() print() -print("Tests complete.") \ No newline at end of file +if failures: + print("Tests complete: FAIL") + for f in failures: + print(f" {f}") + sys.exit(1) + +print("Tests complete.") +print("PASS") +sys.exit(0) \ No newline at end of file diff --git a/tests/unit/test_path_colors.py b/tests/unit/test_path_colors.py index 35ffddc..e8f4518 100644 --- a/tests/unit/test_path_colors.py +++ b/tests/unit/test_path_colors.py @@ -7,18 +7,28 @@ import sys print("Testing path color changes...") print("=" * 50) +failures = [] + +def check(cond, msg): + if not cond: + failures.append(msg) + print(f" FAIL: {msg}") + # Create scene and small grid test = mcrfpy.Scene("test") -grid = mcrfpy.Grid(grid_w=5, grid_h=5) +grid = mcrfpy.Grid(grid_size=(5, 5), pos=(50, 50), size=(250, 250)) -# Add color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# Add color layer for cell coloring (layers are objects now; add_layer takes no kwargs) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.add_layer(color_layer) + +BASE_COLOR = mcrfpy.Color(200, 200, 200) # Light gray # Initialize for y in range(5): for x in range(5): grid.at(x, y).walkable = True - color_layer.set(x, y, mcrfpy.Color(200, 200, 200)) # Light gray + color_layer.set((x, y), BASE_COLOR) # Add entities e1 = mcrfpy.Entity((0, 0), grid=grid) @@ -26,12 +36,15 @@ e2 = mcrfpy.Entity((4, 4), grid=grid) e1.sprite_index = 64 e2.sprite_index = 69 -print(f"Entity 1 at ({e1.x}, {e1.y})") -print(f"Entity 2 at ({e2.x}, {e2.y})") +# .x/.y are PIXEL coords now; logical cell coords live on .cell_pos +print(f"Entity 1 at ({e1.cell_pos.x}, {e1.cell_pos.y})") +print(f"Entity 2 at ({e2.cell_pos.x}, {e2.cell_pos.y})") # Get path -path = e1.path_to(int(e2.x), int(e2.y)) +path = e1.path_to(int(e2.cell_pos.x), int(e2.cell_pos.y)) print(f"\nPath: {path}") +check(len(path) > 0, "path_to() returned an empty path on a fully walkable grid") +check(path[-1] == (4, 4), f"path does not end at the target: {path[-1] if path else None}") # Try to color the path PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green @@ -41,46 +54,63 @@ for x, y in path: # Check before before_c = color_layer.at(x, y) before = (before_c.r, before_c.g, before_c.b) + check(before == (200, 200, 200), f"cell ({x},{y}) was not the initialized gray: {before}") # Set color - color_layer.set(x, y, PATH_COLOR) + color_layer.set((x, y), PATH_COLOR) # Check after after_c = color_layer.at(x, y) after = (after_c.r, after_c.g, after_c.b) + check(after == (100, 255, 100), f"cell ({x},{y}) did not take the path color: {after}") print(f" Cell ({x},{y}): {before} -> {after}") -# Verify all path cells +# Verify all path cells (and that non-path cells were NOT touched) print("\nVerifying all cells in grid:") for y in range(5): for x in range(5): c = color_layer.at(x, y) color = (c.r, c.g, c.b) is_path = (x, y) in path + expected = (100, 255, 100) if is_path else (200, 200, 200) + check(color == expected, + f"cell ({x},{y}) in_path={is_path} expected {expected}, got {color}") print(f" ({x},{y}): color={color}, in_path={is_path}") -print("\nIf colors are changing in data but not visually, it may be a rendering issue.") - -# Quick visual test +# Quick visual test: colors must survive a real render, not just live in the data. def check_visual(timer, runtime): print("\nTimer fired - checking if scene is rendering...") - # Take screenshot to see actual rendering - try: - from mcrfpy import automation - automation.screenshot("path_color_test.png") - print("Screenshot saved as path_color_test.png") - except: - print("Could not take screenshot") - sys.exit(0) + from mcrfpy import automation + automation.screenshot("path_color_test.png") + print("Screenshot saved as path_color_test.png") + # Re-read after render: the render pass must not clobber the layer data + for x, y in path: + c = color_layer.at(x, y) + check((c.r, c.g, c.b) == (100, 255, 100), + f"cell ({x},{y}) lost its color after rendering: {(c.r, c.g, c.b)}") + timer_fired.append(True) # Set up minimal UI to test rendering ui = test.children ui.append(grid) -grid.pos = (50, 50) -grid.size = (250, 250) test.activate() +timer_fired = [] check_timer = mcrfpy.Timer("check", check_visual, 500, once=True) -print("\nStarting render test...") \ No newline at end of file +print("\nStarting render test...") +# Headless: mcrfpy.step() is the only clock. Drive it until the timer fires. +for _ in range(20): + mcrfpy.step(0.05) + if timer_fired: + break + +check(bool(timer_fired), "Timer never fired after stepping 1.0s of simulated time") + +print("=" * 50) +if failures: + print(f"FAIL ({len(failures)} check(s) failed)") + sys.exit(1) +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_pyarg_bug.py b/tests/unit/test_pyarg_bug.py index 40552bd..657d907 100644 --- a/tests/unit/test_pyarg_bug.py +++ b/tests/unit/test_pyarg_bug.py @@ -1,73 +1,121 @@ #!/usr/bin/env python3 -"""Test to confirm the PyArg bug in Grid constructor""" +"""Regression test for the PyArg bug in the Grid constructor. + +Original bug theory: UIGrid::init called PyArg_ParseTupleAndKeywords without +checking its return value, so a failed parse left an exception pending on the +stack. The grid was built anyway (with defaults), and the stale exception then +surfaced at some unrelated later call. + +This test pins the constructor's argument parsing down for real: + - every accepted spelling of "make a 25x15 grid" produces a 25x15 grid, + - no successful construction leaves a pending exception behind, + - every rejected spelling raises a clean exception AND leaves the interpreter + usable (no residue that poisons the next call). + +API note (#361): the Grid constructor's POSITIONAL order is +(pos, size, grid_size, texture) -- not (grid_w, grid_h). Grid dimensions are +given by grid_size=(w, h) or by the grid_w=/grid_h= keywords. The old +`Grid(25, 15)` positional spelling now means pos=25, size=15 and is not a way +to size a grid; that check is updated to the current contract below. +""" + +import sys import mcrfpy -print("Testing PyArg bug hypothesis...") +failures = [] + + +def check(condition, label): + if condition: + print(f" ok {label}") + else: + print(f" FAIL {label}") + failures.append(label) + + +def dims(grid): + return (int(grid.grid_size.x), int(grid.grid_size.y)) + + +def no_pending_exception(label): + """A pending-but-unreturned C exception surfaces at the next Python call.""" + try: + _ = list(range(1)) + except BaseException as e: # pragma: no cover - this is the bug being tested + check(False, f"{label}: stale exception leaked: {type(e).__name__}: {e}") + return + check(sys.exc_info() == (None, None, None), f"{label}: no exception pending") + + +print("Testing Grid constructor argument parsing...") print("=" * 50) -# The bug theory: When Grid is created with keyword args grid_w=25, grid_h=15 -# and the code takes the tuple parsing path, PyArg_ParseTupleAndKeywords -# at line 520 fails but doesn't check return value, leaving exception on stack - -# Test 1: Create Grid with different argument patterns -print("Test 1: Grid with positional args") -try: - grid1 = mcrfpy.Grid(25, 15) - # Force Python to check for pending exceptions - _ = list(range(1)) - print(" ✓ Grid(25, 15) works") -except Exception as e: - print(f" ✗ Grid(25, 15) failed: {type(e).__name__}: {e}") +print("Test 1: grid_size tuple") +grid1 = mcrfpy.Grid(grid_size=(25, 15)) +check(dims(grid1) == (25, 15), f"Grid(grid_size=(25,15)) -> {dims(grid1)}") +no_pending_exception("Grid(grid_size=(25,15))") print() -print("Test 2: Grid with keyword args (the failing case)") -try: - grid2 = mcrfpy.Grid(grid_w=25, grid_h=15) - # This should fail if exception is pending - _ = list(range(1)) - print(" ✓ Grid(grid_w=25, grid_h=15) works") -except Exception as e: - print(f" ✗ Grid(grid_w=25, grid_h=15) failed: {type(e).__name__}: {e}") +print("Test 2: grid_w/grid_h keyword args (the historically failing case)") +grid2 = mcrfpy.Grid(grid_w=25, grid_h=15) +check(dims(grid2) == (25, 15), f"Grid(grid_w=25, grid_h=15) -> {dims(grid2)}") +no_pending_exception("Grid(grid_w=25, grid_h=15)") print() print("Test 3: Check if it's specific to the values 25, 15") for x, y in [(24, 15), (25, 14), (25, 15), (26, 15), (25, 16)]: - try: - grid = mcrfpy.Grid(grid_w=x, grid_h=y) - _ = list(range(1)) - print(f" ✓ Grid(grid_w={x}, grid_h={y}) works") - except Exception as e: - print(f" ✗ Grid(grid_w={x}, grid_h={y}) failed: {type(e).__name__}") + grid = mcrfpy.Grid(grid_w=x, grid_h=y) + check(dims(grid) == (x, y), f"Grid(grid_w={x}, grid_h={y}) -> {dims(grid)}") + no_pending_exception(f"Grid(grid_w={x}, grid_h={y})") print() print("Test 4: Mix positional and keyword args") -try: - # This might trigger different code path - grid3 = mcrfpy.Grid(25, grid_h=15) - _ = list(range(1)) - print(" ✓ Grid(25, grid_h=15) works") -except Exception as e: - print(f" ✗ Grid(25, grid_h=15) failed: {type(e).__name__}: {e}") +# Positional slot 0 is pos (#361), so this is "a 25x15 grid drawn at (10, 20)". +grid3 = mcrfpy.Grid((10, 20), grid_w=25, grid_h=15) +check(dims(grid3) == (25, 15), f"Grid((10,20), grid_w=25, grid_h=15) -> {dims(grid3)}") +check((grid3.pos.x, grid3.pos.y) == (10, 20), f"positional pos honored -> {grid3.pos}") +no_pending_exception("Grid((10,20), grid_w=25, grid_h=15)") print() -print("Test 5: Test with additional arguments") -try: - # This might help identify which PyArg call fails - grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0)) - _ = list(range(1)) - print(" ✓ Grid with pos argument works") -except Exception as e: - print(f" ✗ Grid with pos failed: {type(e).__name__}: {e}") +print("Test 5: Additional arguments alongside the grid dimensions") +grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0)) +check(dims(grid4) == (25, 15), f"Grid(..., pos=(0,0)) -> {dims(grid4)}") +no_pending_exception("Grid(..., pos=(0,0))") -try: - grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None) - _ = list(range(1)) - print(" ✓ Grid with texture=None works") -except Exception as e: - print(f" ✗ Grid with texture=None failed: {type(e).__name__}: {e}") +grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None) +check(dims(grid5) == (25, 15), f"Grid(..., texture=None) -> {dims(grid5)}") +no_pending_exception("Grid(..., texture=None)") print() -print("Hypothesis: The bug is in UIGrid::init line 520-523") -print("PyArg_ParseTupleAndKeywords is called but return value not checked") -print("when parsing remaining arguments in tuple-based initialization path") \ No newline at end of file +print("Test 6: Bad arguments raise cleanly and leave no residue") +bad_calls = [ + ("grid_size not a tuple", lambda: mcrfpy.Grid(grid_size="nope"), TypeError), + ("unknown keyword", lambda: mcrfpy.Grid(grid_size=(4, 4), bogus=3), TypeError), + ("grid= with grid_size=", lambda: mcrfpy.Grid(grid=mcrfpy.Grid(grid_size=(4, 4)), + grid_size=(5, 5)), TypeError), +] +for label, call, expected in bad_calls: + try: + call() + except expected as e: + check(True, f"{label} -> {type(e).__name__}: {e}") + except BaseException as e: + check(False, f"{label} -> wrong exception {type(e).__name__}: {e}") + else: + check(False, f"{label} -> no exception raised") + # The failed parse must not poison the interpreter for the next caller. + survivor = mcrfpy.Grid(grid_w=7, grid_h=3) + check(dims(survivor) == (7, 3), f"{label}: next Grid() still correct") + no_pending_exception(f"{label}: after failure") + +print() +print("=" * 50) +if failures: + print(f"FAIL: {len(failures)} check(s) failed") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_python_builtins.py b/tests/unit/test_python_builtins.py index 7f09fe3..1f73015 100644 --- a/tests/unit/test_python_builtins.py +++ b/tests/unit/test_python_builtins.py @@ -1,105 +1,104 @@ #!/usr/bin/env python3 -"""Test Python builtins to diagnose the SystemError""" +"""Test Python builtins to diagnose the SystemError + +Regression coverage for the embedded CPython interpreter: range/len/iteration +and the builtins table itself must behave exactly as they do in stock Python. +(Historically these raised SystemError inside McRogueFace's interpreter.) +""" import sys +failures = [] + +def check(name, condition, detail=""): + """Record and report a single check.""" + if condition: + print(" [PASS]", name) + else: + print(" [FAIL]", name, "-", detail) + failures.append(name) + +def check_no_raise(name, fn, expected): + """Call fn(); it must not raise, and must return `expected`.""" + try: + actual = fn() + except Exception as e: + check(name, False, "raised %s: %s" % (type(e).__name__, e)) + return + check(name, actual == expected, "got %r, expected %r" % (actual, expected)) + print("Python version:", sys.version) print("=" * 50) # Test 1: Simple range print("Test 1: Simple range(5)") -try: - r = range(5) - print(" Created range:", r) - print(" Type:", type(r)) - for i in r: - print(" ", i) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) +r = range(5) +check("range(5) is a range", type(r) is range, "type was %r" % (type(r),)) +check_no_raise("iterate range(5)", lambda: [i for i in r], [0, 1, 2, 3, 4]) print() # Test 2: Range with start/stop print("Test 2: range(1, 5)") -try: - r = range(1, 5) - print(" Created range:", r) - for i in r: - print(" ", i) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) +check_no_raise("iterate range(1, 5)", lambda: list(range(1, 5)), [1, 2, 3, 4]) print() # Test 3: Range in list comprehension print("Test 3: List comprehension with range") -try: - lst = [x for x in range(3)] - print(" Result:", lst) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) +check_no_raise("[x for x in range(3)]", lambda: [x for x in range(3)], [0, 1, 2]) print() # Test 4: Range in for loop (the failing case) print("Test 4: for x in range(3):") -try: +def _for_loop(): + out = [] for x in range(3): - print(" ", x) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) + out.append(x) + return out +check_no_raise("for x in range(3)", _for_loop, [0, 1, 2]) print() # Test 5: len() on list print("Test 5: len() on list") -try: - lst = [1, 2, 3] - print(" List:", lst) - print(" Length:", len(lst)) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) +check_no_raise("len([1, 2, 3])", lambda: len([1, 2, 3]), 3) print() # Test 6: len() on tuple print("Test 6: len() on tuple") -try: - tup = (1, 2, 3) - print(" Tuple:", tup) - print(" Length:", len(tup)) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) +check_no_raise("len((1, 2, 3))", lambda: len((1, 2, 3)), 3) print() # Test 7: Nested function calls (reproducing the error context) print("Test 7: Nested context like in the failing code") -try: +def _walls(): walls = [] for x in range(1, 8): walls.append((x, 1)) - print(" Walls:", walls) - print(" ✓ Success") -except Exception as e: - print(" ✗ Error:", type(e).__name__, "-", e) - import traceback - traceback.print_exc() + return walls +check_no_raise("build wall list via range + append", _walls, + [(1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1)]) print() # Test 8: Check if builtins are intact print("Test 8: Builtin integrity check") -print(" range is:", range) -print(" len is:", len) -print(" type(range):", type(range)) -print(" type(len):", type(len)) +import builtins +check("range is builtins.range", range is builtins.range) +check("len is builtins.len", len is builtins.len) +check("type(range) is type", type(range) is type, "was %r" % (type(range),)) +check("len is a builtin_function_or_method", + type(len).__name__ == "builtin_function_or_method", + "was %r" % (type(len).__name__,)) print() -print("Tests complete.") \ No newline at end of file +if failures: + print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures))) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_range_25_bug.py b/tests/unit/test_range_25_bug.py index cea58bb..139d4a2 100644 --- a/tests/unit/test_range_25_bug.py +++ b/tests/unit/test_range_25_bug.py @@ -1,102 +1,87 @@ #!/usr/bin/env python3 -"""Demonstrate the range(25) bug precisely""" +"""Regression test for the historical range(25) bug. + +A nested grid.at()/walkable loop over a 25x15 grid used to corrupt the +interpreter (refcount / memory corruption in the C++ binding), so that a +plain `range(25)` afterwards raised. This test drives the exact scenario and +asserts that (a) the interpreter survives intact and (b) the grid.at() writes +actually stick. +""" import mcrfpy +import sys -print("Demonstrating range(25) bug...") -print("=" * 50) +failures = [] -# Test 1: range(25) works fine normally -print("Test 1: range(25) before any mcrfpy operations") -try: - for i in range(25): - pass - print(" ✓ range(25) works fine initially") -except Exception as e: - print(f" ✗ Error: {e}") -# Test 2: range(25) after creating scene/grid -print("\nTest 2: range(25) after creating 25x15 grid") -try: - test = mcrfpy.Scene("test") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - - for i in range(25): - pass - print(" ✓ range(25) still works after grid creation") -except Exception as e: - print(f" ✗ Error: {e}") +def check(condition, label): + if condition: + print(" PASS: %s" % label) + else: + print(" FAIL: %s" % label) + failures.append(label) -# Test 3: The killer combination -print("\nTest 3: range(25) after 15x25 grid.at() operations") -try: - test3 = mcrfpy.Scene("test3") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - - # Do the nested loop that triggers the bug + +def exercise_grid(w, h): + """Nested grid.at() loop -- the operation that used to corrupt memory.""" + grid = mcrfpy.Grid(grid_size=(w, h)) count = 0 - for y in range(15): - for x in range(25): + for y in range(h): + for x in range(w): grid.at(x, y).walkable = True count += 1 - - print(f" ✓ Completed {count} grid.at() calls") - - # This should fail - print(" Testing range(25) now...") - for i in range(25): - pass - print(" ✓ range(25) works (unexpected!)") - -except Exception as e: - print(f" ✗ range(25) failed as expected: {type(e).__name__}") + return grid, count -# Test 4: Does range(24) still work? + +print("Testing the range(25) bug scenario...") +print("=" * 50) + +# Test 1: range(25) works fine normally (baseline sanity) +print("Test 1: range(25) before any mcrfpy operations") +check(list(range(25)) == list(range(25)), "range(25) works initially") + +# Test 2: range(25) after creating a 25x15 grid +print("\nTest 2: range(25) after creating 25x15 grid") +mcrfpy.Scene("test2") +grid = mcrfpy.Grid(grid_size=(25, 15)) +check(len(list(range(25))) == 25, "range(25) still works after grid creation") +check(tuple(grid.grid_size) == (25, 15), "grid is 25x15") + +# Test 3: The killer combination -- 15x25 nested grid.at() loop, then range(25) +print("\nTest 3: range(25) after 15x25 grid.at() operations") +mcrfpy.Scene("test3") +grid, count = exercise_grid(25, 15) +check(count == 375, "completed %d grid.at() calls (expected 375)" % count) +check(list(range(25)) == list(range(25)), "range(25) works after grid.at() loop") +check(sum(range(25)) == 300, "range(25) still produces correct values") +check(all(grid.at(x, y).walkable for y in range(15) for x in range(25)), + "every cell readback is walkable (writes stuck)") + +# Test 4: range(24) after the same operations print("\nTest 4: range(24) after same operations") -try: - test4 = mcrfpy.Scene("test4") - grid = mcrfpy.Grid(grid_w=25, grid_h=15) - - for y in range(15): - for x in range(24): # One less - grid.at(x, y).walkable = True - - for i in range(24): - pass - print(" ✓ range(24) works") - - # What about range(25)? - for i in range(25): - pass - print(" ✓ range(25) also works when grid ops used range(24)") - -except Exception as e: - print(f" ✗ Error: {e}") +mcrfpy.Scene("test4") +grid = mcrfpy.Grid(grid_size=(25, 15)) +for y in range(15): + for x in range(24): # One less + grid.at(x, y).walkable = True +check(len(list(range(24))) == 24, "range(24) works") +check(len(list(range(25))) == 25, "range(25) also works when grid ops used range(24)") +check(not grid.at(24, 0).walkable, "untouched column 24 stayed unwalkable") -# Test 5: Is it about the specific combination of 15 and 25? +# Test 5: Different grid dimensions -- not specific to 15/25 print("\nTest 5: Different grid dimensions") -try: - test5 = mcrfpy.Scene("test5") - grid = mcrfpy.Grid(grid_w=30, grid_h=20) - - for y in range(20): - for x in range(30): - grid.at(x, y).walkable = True - - # Test various ranges - for i in range(25): - pass - print(" ✓ range(25) works with 30x20 grid") - - for i in range(30): - pass - print(" ✓ range(30) works with 30x20 grid") - -except Exception as e: - print(f" ✗ Error: {e}") +mcrfpy.Scene("test5") +grid, count = exercise_grid(30, 20) +check(count == 600, "completed %d grid.at() calls on 30x20 grid" % count) +check(len(list(range(25))) == 25, "range(25) works with 30x20 grid") +check(len(list(range(30))) == 30, "range(30) works with 30x20 grid") -print("\nConclusion: There's a specific bug triggered by:") -print("1. Creating a grid with grid_w=25") -print("2. Using range(25) in a nested loop with grid.at() calls") -print("3. Then trying to use range(25) again") -print("\nThis appears to be a memory corruption or reference counting issue in the C++ code.") \ No newline at end of file +print("\n" + "=" * 50) +if failures: + print("FAIL: %d check(s) failed:" % len(failures)) + for f in failures: + print(" - %s" % f) + sys.exit(1) + +print("PASS: the range(25) bug is fixed; grid.at() loops do not corrupt the interpreter") +sys.exit(0) diff --git a/tests/unit/test_range_threshold.py b/tests/unit/test_range_threshold.py index 7fd5b8d..146d3db 100644 --- a/tests/unit/test_range_threshold.py +++ b/tests/unit/test_range_threshold.py @@ -1,107 +1,128 @@ #!/usr/bin/env python3 -"""Find the exact threshold where range() starts failing""" +"""Regression: range() (and general interpreter state) must survive grid operations. + +Historically, iterating a grid with grid.at(x, y) at certain sizes corrupted the +Python interpreter state and a subsequent range() raised SystemError. This test +sweeps grid/range sizes and asserts that never happens again. +""" import mcrfpy +import sys print("Finding range() failure threshold...") print("=" * 50) +failures = [] + def test_range_size(n): - """Test if range(n) works after grid operations""" + """Test if range(n) works after grid operations on an n x n grid""" try: - mcrfpy.createScene(f"test_{n}") - grid = mcrfpy.Grid(grid_w=n, grid_h=n) - + mcrfpy.Scene(f"test_{n}") + grid = mcrfpy.Grid(grid_size=(n, n)) + # Do grid operations for y in range(min(n, 10)): # Limit outer loop for x in range(n): if x < n and y < n: grid.at(x, y).walkable = True - + # Now test if range(n) still works test_list = [] for i in range(n): test_list.append(i) - + return True, len(test_list) except SystemError as e: return False, str(e) except Exception as e: return False, f"Other error: {type(e).__name__}: {e}" -# Binary search for the threshold +# Sweep a range of sizes; every one of them must work. print("Testing different range sizes...") -# Test powers of 2 first for n in [2, 4, 8, 16, 32]: success, result = test_range_size(n) if success: - print(f" range({n:2d}): ✓ Success - created list of {result} items") + print(f" range({n:2d}): OK - created list of {result} items") + if result != n: + failures.append(f"range({n}) produced {result} items, expected {n}") else: - print(f" range({n:2d}): ✗ Failed - {result}") + print(f" range({n:2d}): FAIL - {result}") + failures.append(f"range({n}) after {n}x{n} grid ops: {result}") print() -# Narrow down between working and failing values -print("Narrowing down exact threshold...") +# The original bug was reported as "10 works, 25 fails" -- walk the whole span +# and require that every size in it works. +print("Sweeping the historically suspect span (10..25)...") -# From our tests: 10 works, 25 fails -low = 10 -high = 25 - -while low < high - 1: - mid = (low + high) // 2 - success, result = test_range_size(mid) - +for n in range(10, 26): + success, result = test_range_size(n) if success: - print(f" range({mid}): ✓ Works") - low = mid + print(f" range({n}): OK") + if result != n: + failures.append(f"range({n}) produced {result} items, expected {n}") else: - print(f" range({mid}): ✗ Fails") - high = mid + print(f" range({n}): FAIL - {result}") + failures.append(f"range({n}) after {n}x{n} grid ops: {result}") -print() -print(f"Threshold found: range({low}) works, range({high}) fails") - -# Test if it's specifically about range or about the grid size print() print("Testing if it's about grid size vs range size...") try: # Small grid, large range - test_small_grid = mcrfpy.Scene("test_small_grid") - grid = mcrfpy.Grid(grid_w=5, grid_h=5) - + mcrfpy.Scene("test_small_grid") + grid = mcrfpy.Grid(grid_size=(5, 5)) + # Do minimal grid operations grid.at(0, 0).walkable = True - + # Test large range + count = 0 for i in range(25): - pass - print(" ✓ range(25) works with small grid (5x5)") - + count += 1 + assert count == 25, f"range(25) yielded {count} items" + print(" OK: range(25) works with small grid (5x5)") + except Exception as e: - print(f" ✗ range(25) fails with small grid: {e}") + print(f" FAIL: range(25) fails with small grid: {type(e).__name__}: {e}") + failures.append(f"small grid / large range: {type(e).__name__}: {e}") try: # Large grid, see what happens - test_large_grid = mcrfpy.Scene("test_large_grid") - grid = mcrfpy.Grid(grid_w=20, grid_h=20) - + mcrfpy.Scene("test_large_grid") + grid = mcrfpy.Grid(grid_size=(20, 20)) + # Do operations on large grid + touched = 0 for y in range(20): for x in range(20): grid.at(x, y).walkable = True - - print(" ✓ Completed 20x20 grid operations") - + touched += 1 + assert touched == 400, f"touched {touched} cells, expected 400" + print(" OK: Completed 20x20 grid operations") + # Now test range + count = 0 for i in range(20): - pass - print(" ✓ range(20) works after 20x20 grid operations") - + count += 1 + assert count == 20, f"range(20) yielded {count} items" + print(" OK: range(20) works after 20x20 grid operations") + + # And the values written are readable back + assert grid.at(19, 19).walkable is True, "walkable did not persist at (19,19)" + except Exception as e: - print(f" ✗ Error with 20x20 grid: {e}") + print(f" FAIL: Error with 20x20 grid: {type(e).__name__}: {e}") + failures.append(f"20x20 grid: {type(e).__name__}: {e}") print() -print("Analysis complete.") \ No newline at end of file +if failures: + print("Analysis complete: FAILURES") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("Analysis complete.") +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_scene_transitions.py b/tests/unit/test_scene_transitions.py index b650c26..832c8b3 100644 --- a/tests/unit/test_scene_transitions.py +++ b/tests/unit/test_scene_transitions.py @@ -1,12 +1,27 @@ #!/usr/bin/env python3 -"""Test scene transitions to verify implementation and demonstrate usage.""" +"""Test scene transitions to verify implementation and demonstrate usage. + +Headless model (#350): mcrfpy.step(dt) is the only clock -- transitions only +advance when we step. current_scene stays on the OUTGOING scene for the whole +duration of a non-instant transition, and flips to the incoming scene when the +transition finishes. +""" import mcrfpy +from mcrfpy import automation import sys -import time red_scene, blue_scene, green_scene, menu_scene = None, None, None, None # global scoping +failures = [] + +def check(label, condition): + if condition: + print(f" ok : {label}") + else: + print(f" FAIL: {label}") + failures.append(label) + def create_test_scenes(): """Create several test scenes with different colored backgrounds.""" global red_scene, blue_scene, green_scene, menu_scene @@ -59,7 +74,7 @@ def create_test_scenes(): scene_info = mcrfpy.Caption(pos=(512, 300), text="R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.default_font) scene_info.fill_color = mcrfpy.Color(150, 150, 150, 255) ui4.append(scene_info) - + print("Created test scenes: red_scene, blue_scene, green_scene, menu_scene") # Track current transition type @@ -69,12 +84,10 @@ transition_duration = 1.0 def handle_key(key, action): """Handle keyboard input for scene transitions.""" global current_transition, transition_duration - + if action != mcrfpy.InputState.PRESSED: return - - current_scene = (mcrfpy.current_scene.name if mcrfpy.current_scene else None) - + # Number keys set transition type keyselections = { mcrfpy.Key.NUM_1: mcrfpy.Transition.FADE, @@ -87,25 +100,7 @@ def handle_key(key, action): if key in keyselections: current_transition = keyselections[key] print(f"Transition set to: {current_transition}") - #if key == mcrfpy.Key.NUM_1: - # current_transition = "fade" - # print("Transition set to: fade") - #elif key == mcrfpy.Key.NUM_2: - # current_transition = "slide_left" - # print("Transition set to: slide_left") - #elif key == mcrfpy.Key.NUM_3: - # current_transition = "slide_right" - # print("Transition set to: slide_right") - #elif key == mcrfpy.Key.NUM_4: - # current_transition = "slide_up" - # print("Transition set to: slide_up") - #elif key == mcrfpy.Key.NUM_5: - # current_transition = "slide_down" - # print("Transition set to: slide_down") - #elif key == mcrfpy.Key.NUM_6: - # current_transition = None # Instant - # print("Transition set to: instant") - + # Letter keys change scene keytransitions = { mcrfpy.Key.R: red_scene, @@ -115,62 +110,89 @@ def handle_key(key, action): } if key in keytransitions: if mcrfpy.current_scene != keytransitions[key]: + print(f"Transitioning to {keytransitions[key].name} with {current_transition}") keytransitions[key].activate(current_transition, transition_duration) - #elif key == mcrfpy.Key.R: - # if current_scene != "red_scene": - # print(f"Transitioning to red_scene with {current_transition}") - # if current_transition: - # mcrfpy.setScene("red_scene", current_transition, transition_duration) - # else: - # red_scene.activate() - #elif key == mcrfpy.Key.B: - # if current_scene != "blue_scene": - # print(f"Transitioning to blue_scene with {current_transition}") - # if current_transition: - # mcrfpy.setScene("blue_scene", current_transition, transition_duration) - # else: - # blue_scene.activate() - #elif key == mcrfpy.Key.G: - # if current_scene != "green_scene": - # print(f"Transitioning to green_scene with {current_transition}") - # if current_transition: - # mcrfpy.setScene("green_scene", current_transition, transition_duration) - # else: - # green_scene.activate() - #elif key == mcrfpy.Key.M: - # if current_scene != "menu_scene": - # print(f"Transitioning to menu_scene with {current_transition}") - # if current_transition: - # mcrfpy.setScene("menu_scene", current_transition, transition_duration) - # else: - # menu_scene.activate() - elif key == mcrfpy.Key.ESCAPE: - print("Exiting...") - sys.exit(0) -def test_automatic_transitions(delay): - """Run through all transitions automatically after a delay.""" - transitions = [ - ("fade", "red_scene"), - ("slide_left", "blue_scene"), - ("slide_right", "green_scene"), - ("slide_up", "red_scene"), - ("slide_down", "menu_scene"), - (None, "blue_scene"), # Instant - ] - + +def press(key_name): + """Send a real key press/release through the engine to the active scene.""" + automation.keyDown(key_name) + automation.keyUp(key_name) + + +def run_transition(target, trans_type, duration=1.0): + """Activate `target` with `trans_type` and drive the clock until it lands.""" + origin = mcrfpy.current_scene + target.activate(trans_type, duration) + + if trans_type == mcrfpy.Transition.NONE: + # Instant: no time needs to pass at all. + check(f"{trans_type}: instant switch to {target.name}", + mcrfpy.current_scene == target) + else: + # The outgoing scene stays current until the transition finishes. + check(f"{trans_type}: still on {origin.name} at t=0", + mcrfpy.current_scene == origin) + mcrfpy.step(duration / 2.0) + check(f"{trans_type}: still on {origin.name} mid-transition", + mcrfpy.current_scene == origin) + # Step past the end of the transition. + for _ in range(4): + mcrfpy.step(duration / 2.0) + if mcrfpy.current_scene == target: + break + check(f"{trans_type}: arrived at {target.name}", + mcrfpy.current_scene == target) + + check(f"{trans_type}: {target.name}.active", target.active) + if origin != target: + check(f"{trans_type}: {origin.name} deactivated", not origin.active) + + +def test_automatic_transitions(): + """Run through every transition type, asserting each one lands.""" print("\nRunning automatic transition test...") - for i, (trans_type, scene) in enumerate(transitions): - if trans_type: - print(f"Transition {i+1}: {trans_type} to {scene}") - mcrfpy.setScene(scene, trans_type, 1.0) - else: - print(f"Transition {i+1}: instant to {scene}") - mcrfpy.current_scene = scene - time.sleep(2) # Wait for transition to complete plus viewing time - + transitions = [ + (mcrfpy.Transition.FADE, red_scene), + (mcrfpy.Transition.SLIDE_LEFT, blue_scene), + (mcrfpy.Transition.SLIDE_RIGHT, green_scene), + (mcrfpy.Transition.SLIDE_UP, red_scene), + (mcrfpy.Transition.SLIDE_DOWN, menu_scene), + (mcrfpy.Transition.NONE, blue_scene), # Instant + ] + for trans_type, scene in transitions: + run_transition(scene, trans_type) print("Automatic test complete!") - sys.exit(0) + + +def test_key_driven_transitions(): + """Exercise the keyboard handler itself: number key picks the transition, + letter key picks the scene.""" + print("\nRunning key-driven transition test...") + + press("6") # Transition.NONE + press("m") # -> menu_scene, instantly + mcrfpy.step(0.0) + check("key '6'+'m': instant to menu_scene", mcrfpy.current_scene == menu_scene) + + press("3") # Transition.SLIDE_RIGHT + press("g") # -> green_scene over transition_duration + mcrfpy.step(0.0) + check("key '3'+'g': still on menu_scene while sliding", + mcrfpy.current_scene == menu_scene) + for _ in range(6): + mcrfpy.step(0.25) + if mcrfpy.current_scene == green_scene: + break + check("key '3'+'g': arrived at green_scene", + mcrfpy.current_scene == green_scene) + check("green_scene.active after key transition", green_scene.active) + + # Pressing the letter key for the scene we're already on is a no-op. + press("g") + mcrfpy.step(0.1) + check("key 'g' on green_scene is a no-op", mcrfpy.current_scene == green_scene) + # Main test setup print("=== Scene Transition Test ===") @@ -178,15 +200,22 @@ create_test_scenes() # Start with menu scene menu_scene.activate() +check("menu_scene is the starting scene", mcrfpy.current_scene == menu_scene) +check("menu_scene.active", menu_scene.active) +check("menu_scene has 5 children", len(menu_scene.children) == 5) # Set up keyboard handler for s in (red_scene, blue_scene, green_scene, menu_scene): s.on_key = handle_key -#menu_scene.on_key = handle_key -# Option to run automatic test -if len(sys.argv) > 1 and sys.argv[1] == "--auto": - mcrfpy.Timer("auto_test", lambda t, r: test_automatic_transitions(r), 1000, once=True) -else: - print("\nManual test mode. Use keyboard controls shown on screen.") - print("Run with --auto flag for automatic transition demo.") +test_automatic_transitions() +test_key_driven_transitions() + +if failures: + print(f"\nFAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("\nPASS") +sys.exit(0) diff --git a/tests/unit/test_scene_transitions_headless.py b/tests/unit/test_scene_transitions_headless.py index f8b79bc..faf5c1b 100644 --- a/tests/unit/test_scene_transitions_headless.py +++ b/tests/unit/test_scene_transitions_headless.py @@ -4,12 +4,21 @@ import mcrfpy import sys +failures = [] + +def check(cond, msg): + if cond: + print(f" ok: {msg}") + else: + print(f" FAIL: {msg}") + failures.append(msg) + def test_scene_transitions(): """Test all scene transition types.""" - + # Create two simple scenes print("Creating test scenes...") - + # Scene 1 scene1 = mcrfpy.Scene("scene1") ui1 = scene1.children @@ -21,40 +30,68 @@ def test_scene_transitions(): ui2 = scene2.children frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255)) ui2.append(frame2) - - # Test each transition type + + scenes = {"scene1": scene1, "scene2": scene2} + + # Test each transition type. mcrfpy.setScene(name, str, duration) is gone; + # transitions are now scene.activate(mcrfpy.Transition.X, duration). transitions = [ - ("fade", 0.5), - ("slide_left", 0.5), - ("slide_right", 0.5), - ("slide_up", 0.5), - ("slide_down", 0.5), + (mcrfpy.Transition.FADE, 0.5), + (mcrfpy.Transition.SLIDE_LEFT, 0.5), + (mcrfpy.Transition.SLIDE_RIGHT, 0.5), + (mcrfpy.Transition.SLIDE_UP, 0.5), + (mcrfpy.Transition.SLIDE_DOWN, 0.5), (None, 0.0), # Instant ] - + print("\nTesting scene transitions:") - + # Start with scene1 scene1.activate() - print(f"Initial scene: {(mcrfpy.current_scene.name if mcrfpy.current_scene else None)}") - + check(mcrfpy.current_scene.name == "scene1", + f"initial scene is scene1 (got {mcrfpy.current_scene.name})") + for trans_type, duration in transitions: - target = "scene2" if (mcrfpy.current_scene.name if mcrfpy.current_scene else None) == "scene1" else "scene1" - - if trans_type: - print(f"\nTransitioning to {target} with {trans_type} (duration: {duration}s)") - mcrfpy.setScene(target, trans_type, duration) + source = mcrfpy.current_scene.name + target = "scene2" if source == "scene1" else "scene1" + target_scene = scenes[target] + + if trans_type is not None: + print(f"\nTransitioning to {target} with {trans_type!r} (duration: {duration}s)") + target_scene.activate(trans_type, duration) + + # A transition is in flight: the outgoing scene stays current until it + # completes, and headless only advances when we call mcrfpy.step(). + check(mcrfpy.current_scene.name == source, + f"mid-transition current_scene is still {source}") + + # Half the duration elapsed -> still not switched. + steps = int(duration / 0.05) + for _ in range(steps // 2): + mcrfpy.step(0.05) + check(mcrfpy.current_scene.name == source, + f"halfway through {trans_type!r}, current_scene is still {source}") + + # Run past the end of the transition. + for _ in range(steps): + mcrfpy.step(0.05) else: print(f"\nTransitioning to {target} instantly") - mcrfpy.current_scene = target - - print(f"Current scene after transition: {(mcrfpy.current_scene.name if mcrfpy.current_scene else None)}") - - print("\n✓ All scene transition types tested successfully!") - print("\nNote: Visual transitions cannot be verified in headless mode.") - print("The transitions are implemented and working in the engine.") - - sys.exit(0) + mcrfpy.current_scene = target_scene -# Run the test immediately -test_scene_transitions() \ No newline at end of file + check(mcrfpy.current_scene.name == target, + f"after transition, current_scene is {target}") + check(target_scene.active is True, f"{target}.active is True") + check(scenes[source].active is False, f"{source}.active is False") + + print("\nAll scene transition types tested.") + +if __name__ == "__main__": + test_scene_transitions() + if failures: + print(f"\nFAIL: {len(failures)} check(s) failed") + for f in failures: + print(f" - {f}") + sys.exit(1) + print("\nPASS") + sys.exit(0) diff --git a/tests/unit/test_stdin_theory.py b/tests/unit/test_stdin_theory.py index ad1120d..d40d463 100644 --- a/tests/unit/test_stdin_theory.py +++ b/tests/unit/test_stdin_theory.py @@ -1,32 +1,98 @@ #!/usr/bin/env python3 -"""Test if closing stdin prevents the >>> prompt""" +"""Test that a --exec script never falls through into an interactive REPL + +Original intent: "is stdin the reason the >>> prompt appears after my script runs?" +The script tried closing / redirecting stdin and then eyeballed the terminal for a +">>>" prompt. It asserted nothing and never stated an exit status. + +Current contract: --exec scripts are run non-interactively. The engine never hands +stdin to the Python REPL, whether or not stdin is open, and whether or not the +script exits cleanly. That is verified here in child processes that are *given* a +readable stdin containing Python source: if the interpreter ever went interactive, +it would echo ">>>" and execute the fed-in line. +""" import mcrfpy -import sys import os +import subprocess +import sys + +failures = [] +scratch = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "..", "build")) print("=== Testing stdin theory ===") -print(f"stdin.isatty(): {sys.stdin.isatty()}") -print(f"stdin fileno: {sys.stdin.fileno()}") +print("stdin.isatty(): %s" % sys.stdin.isatty()) +print("stdin fileno: %s" % sys.stdin.fileno()) -# Set up a basic scene +# A --exec script is not interactive even though stdin is a real, readable fd. +if sys.stdin.isatty(): + failures.append("--exec script should not have a tty on stdin") + +# Set up a basic scene (the original script did this to have the engine "live") stdin_test = mcrfpy.Scene("stdin_test") stdin_test.activate() +if mcrfpy.current_scene is not stdin_test: + failures.append("scene.activate() did not set mcrfpy.current_scene") -# Try to prevent interactive mode by closing stdin -print("\nAttempting to prevent interactive mode...") -try: - # Method 1: Close stdin - sys.stdin.close() - print("Closed sys.stdin") -except: - print("Failed to close sys.stdin") +# --- 1. stdin fed Python source is NOT evaluated as a REPL --------------------- +# sys.executable is the mcrogueface binary itself. +FED = "print('REPL_LEAKED')\n" -try: - # Method 2: Redirect stdin to /dev/null - devnull = open(os.devnull, 'r') - os.dup2(devnull.fileno(), 0) - print("Redirected stdin to /dev/null") -except: - print("Failed to redirect stdin") +child_exit = os.path.join(scratch, "_stdin_theory_child_exit.py") +child_fallthrough = os.path.join(scratch, "_stdin_theory_child_fallthrough.py") +with open(child_exit, "w") as f: + f.write("import mcrfpy\nimport sys\nprint('CHILD_RAN')\nsys.exit(0)\n") +with open(child_fallthrough, "w") as f: + # Deliberately falls off the end: the historical case where >>> showed up. + f.write("import mcrfpy\nprint('CHILD_RAN')\n") -print("\nScript complete. If >>> still appears, the issue is elsewhere.") \ No newline at end of file +for name, path, expect_zero in (("clean-exit", child_exit, True), + ("fall-through", child_fallthrough, False)): + try: + proc = subprocess.run( + [sys.executable, "--headless", "--exec", path], + cwd=scratch, input=FED, capture_output=True, text=True, timeout=20) + except subprocess.TimeoutExpired: + failures.append("%s child hung: it is waiting on stdin (interactive mode)" % name) + continue + + out = proc.stdout + proc.stderr + if "CHILD_RAN" not in out: + failures.append("%s child never ran the script" % name) + if ">>>" in out: + failures.append("%s child printed a >>> prompt: --exec went interactive" % name) + if "REPL_LEAKED" in out: + failures.append("%s child executed source fed on stdin: --exec went interactive" + % name) + if expect_zero and proc.returncode != 0: + failures.append("%s child should exit 0, got %d" % (name, proc.returncode)) + if not expect_zero and proc.returncode == 0: + failures.append("fall-through child should exit nonzero (#350 exit contract)") + +for path in (child_exit, child_fallthrough): + if os.path.exists(path): + os.remove(path) + +# --- 2. The stdin manipulations the original script tried still work ---------- +# (They are unnecessary -- part 1 proves stdin was never the cause -- but the engine +# must not be destabilized by a script that closes or redirects fd 0.) +print("\nAttempting the original stdin manipulations...") +sys.stdin.close() +print("Closed sys.stdin") + +devnull = open(os.devnull, 'r') +os.dup2(devnull.fileno(), 0) +print("Redirected stdin to /dev/null") + +# Engine is still alive and steppable after fd 0 was yanked out from under it. +mcrfpy.step(0.016) +if mcrfpy.current_scene is not stdin_test: + failures.append("engine lost its scene after stdin was closed/redirected") + +if failures: + for f in failures: + print("FAIL: %s" % f) + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_stubs.py b/tests/unit/test_stubs.py index 0a6c672..0822821 100644 --- a/tests/unit/test_stubs.py +++ b/tests/unit/test_stubs.py @@ -5,147 +5,164 @@ import os import sys import ast +import mcrfpy + +# The stub lives in the repo, but tests run with cwd=build/. Anchor on this file. +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +STUB_PATH = os.path.join(REPO_ROOT, 'stubs', 'mcrfpy.pyi') + +def read_stub(): + with open(STUB_PATH, 'r') as f: + return f.read() + def test_stub_syntax(): """Test that the stub file has valid Python syntax.""" - stub_path = 'stubs/mcrfpy.pyi' - - if not os.path.exists(stub_path): - print(f"ERROR: Stub file not found at {stub_path}") + if not os.path.exists(STUB_PATH): + print(f"ERROR: Stub file not found at {STUB_PATH}") return False - + try: - with open(stub_path, 'r') as f: - content = f.read() - + content = read_stub() + # Parse the stub file tree = ast.parse(content) - print(f"✓ Stub file has valid Python syntax ({len(content)} bytes)") - + print(f"OK Stub file has valid Python syntax ({len(content)} bytes)") + # Count definitions classes = [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] functions = [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)] - - print(f"✓ Found {len(classes)} class definitions") - print(f"✓ Found {len(functions)} function/method definitions") - - # Check for key classes + + print(f"OK Found {len(classes)} class definitions") + print(f"OK Found {len(functions)} function/method definitions") + + # Check for key classes (Timer/Scene are the successors to setTimer/createScene) class_names = {cls.name for cls in classes} - expected_classes = {'Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Scene', 'Window'} + expected_classes = {'Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData', + 'Entity', 'Color', 'Vector', 'Scene', 'Timer', 'Window'} missing = expected_classes - class_names - + if missing: - print(f"✗ Missing classes: {missing}") + print(f"FAIL Missing classes: {missing}") return False else: - print("✓ All expected classes are defined") - - # Check for key functions + print("OK All expected classes are defined") + + # Check for key module-level functions (current API: setScene/createScene/ + # currentScene/setTimer/findAll are gone; Scene objects, Timer and find_all + # replaced them). top_level_funcs = [node.name for node in tree.body if isinstance(node, ast.FunctionDef)] - expected_funcs = {'createScene', 'setScene', 'currentScene', 'find', 'findAll', 'setTimer'} func_set = set(top_level_funcs) + expected_funcs = {'find', 'find_all', 'step', 'get_metrics', 'set_scale', 'exit'} missing_funcs = expected_funcs - func_set - + if missing_funcs: - print(f"✗ Missing functions: {missing_funcs}") + print(f"FAIL Missing functions: {missing_funcs}") return False else: - print("✓ All expected functions are defined") - + print("OK All expected functions are defined") + + # Every function the stub advertises must actually exist on the module, + # and removed APIs must not linger in the stub. + phantom = sorted(f for f in func_set if not hasattr(mcrfpy, f)) + if phantom: + print(f"FAIL Stub declares functions absent from mcrfpy: {phantom}") + return False + print(f"OK All {len(func_set)} stub functions exist on the mcrfpy module") + + phantom_classes = sorted(c for c in expected_classes if not hasattr(mcrfpy, c)) + if phantom_classes: + print(f"FAIL Stub declares classes absent from mcrfpy: {phantom_classes}") + return False + print("OK All expected stub classes exist on the mcrfpy module") + return True - + except SyntaxError as e: - print(f"✗ Syntax error in stub file: {e}") - return False - except Exception as e: - print(f"✗ Error parsing stub file: {e}") + print(f"FAIL Syntax error in stub file: {e}") return False def test_type_annotations(): """Test that type annotations are present and well-formed.""" - stub_path = 'stubs/mcrfpy.pyi' - - with open(stub_path, 'r') as f: - content = f.read() - + content = read_stub() + # Check for proper type imports if 'from typing import' not in content: - print("✗ Missing typing imports") + print("FAIL Missing typing imports") return False else: - print("✓ Has typing imports") - - # Check for Optional usage - if 'Optional[' in content: - print("✓ Uses Optional type hints") - - # Check for Union usage - if 'Union[' in content: - print("✓ Uses Union type hints") - - # Check for overload usage - if '@overload' in content: - print("✓ Uses @overload decorators") - + print("OK Has typing imports") + + # Optional/Union/@overload are nice-to-haves; the generator currently emits + # PEP 604 unions ("int | None") instead, so these are informational only. + for marker, label in (('Optional[', 'Optional type hints'), + ('Union[', 'Union type hints'), + ('@overload', '@overload decorators')): + if marker in content: + print(f"OK Uses {label}") + # Check return type annotations if '-> None:' in content and '-> int:' in content and '-> str:' in content: - print("✓ Has return type annotations") + print("OK Has return type annotations") else: - print("✗ Missing some return type annotations") - + print("FAIL Missing some return type annotations") + return False + return True def test_docstrings(): """Test that docstrings are preserved in stubs.""" - stub_path = 'stubs/mcrfpy.pyi' - - with open(stub_path, 'r') as f: - content = f.read() - + content = read_stub() + # Count docstrings docstring_count = content.count('"""') if docstring_count > 10: # Should have many docstrings - print(f"✓ Found {docstring_count // 2} docstrings") + print(f"OK Found {docstring_count // 2} docstrings") else: - print(f"✗ Too few docstrings found: {docstring_count // 2}") - - # Check for specific docstrings - if 'Core game engine interface' in content: - print("✓ Module docstring present") - - if 'A rectangular frame UI element' in content: - print("✓ Frame class docstring present") - - if 'Load a sound effect from a file' in content: - print("✓ Function docstrings present") - - return True + print(f"FAIL Too few docstrings found: {docstring_count // 2}") + return False + + # Check for specific docstrings (module, class, method) + ok = True + for needle, label in ( + ('Type stubs for McRogueFace Python API', 'Module docstring'), + ('A rectangular frame UI element', 'Frame class docstring'), + ('Take a screenshot', 'Method docstring'), + ): + if needle in content: + print(f"OK {label} present") + else: + print(f"FAIL {label} missing") + ok = False + + return ok def test_automation_module(): """Test that automation module is properly defined.""" - stub_path = 'stubs/mcrfpy.pyi' - - with open(stub_path, 'r') as f: - content = f.read() - - if 'class automation:' in content: - print("✓ automation class defined") + content = read_stub() + + # The generator emits the submodule as a _automation_module class plus an + # `automation:` module-level binding. + if 'class _automation_module:' in content and 'automation: _automation_module' in content: + print("OK automation submodule defined") else: - print("✗ automation class missing") + print("FAIL automation submodule missing") return False - + # Check for key automation methods automation_methods = ['screenshot', 'click', 'moveTo', 'keyDown', 'typewrite'] missing = [] for method in automation_methods: if f'def {method}' not in content: missing.append(method) - + elif not hasattr(mcrfpy.automation, method): + missing.append(method + " (stubbed but absent from mcrfpy.automation)") + if missing: - print(f"✗ Missing automation methods: {missing}") + print(f"FAIL Missing automation methods: {missing}") return False else: - print("✓ All key automation methods defined") - + print("OK All key automation methods defined") + return True def main(): @@ -176,10 +193,10 @@ def main(): print() if all_passed: - print("✅ All tests passed! Type stubs are valid and complete.") + print("PASS - All tests passed! Type stubs are valid and complete.") sys.exit(0) else: - print("❌ Some tests failed. Please review the stub file.") + print("FAIL - Some tests failed. Please review the stub file.") sys.exit(1) if __name__ == '__main__': diff --git a/tests/unit/test_text_input.py b/tests/unit/test_text_input.py index 68009b0..a9e6f52 100644 --- a/tests/unit/test_text_input.py +++ b/tests/unit/test_text_input.py @@ -1,18 +1,53 @@ #!/usr/bin/env python3 """ Test the text input widget system + +Exercises src/scripts/text_input_widget.py (FocusManager + TextInput) headlessly: +building the widgets into a scene, focus/blur, typing, editing keys, cursor +movement, placeholder visibility and on_change notifications. """ import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'scripts')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', '..', 'src', 'scripts')) import mcrfpy from text_input_widget import FocusManager, TextInput +failures = [] + + +def check(condition, message): + if not condition: + failures.append(message) + print(f"FAIL: {message}") + return condition + + +# The TextInput widget speaks a string key protocol; the engine delivers +# mcrfpy.Key enums (#184). Translate at the scene handler, as a real game would. +KEY_NAMES = { + mcrfpy.Key.BACKSPACE: "BackSpace", + mcrfpy.Key.DELETE: "Delete", + mcrfpy.Key.LEFT: "Left", + mcrfpy.Key.RIGHT: "Right", + mcrfpy.Key.HOME: "Home", + mcrfpy.Key.END: "End", + mcrfpy.Key.TAB: "Tab", + mcrfpy.Key.ENTER: "Return", + mcrfpy.Key.SPACE: " ", +} +for _letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + KEY_NAMES[getattr(mcrfpy.Key, _letter)] = _letter.lower() + + +def key_name(key): + return KEY_NAMES.get(key, "Unknown") + def create_demo(): - """Create demo scene with text inputs""" + """Create demo scene with text inputs; returns (scene, focus_mgr, inputs, status, on_key)""" # Create scene text_demo = mcrfpy.Scene("text_demo") scene = text_demo.children @@ -75,36 +110,116 @@ def create_demo(): for inp in inputs: inp.on_change = update_status - # Keyboard handler - def handle_keys(scene_name, key): - if not focus_mgr.handle_key(key): + # Keyboard handler (#184: receives Key and InputState enums) + def handle_keys(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if not focus_mgr.handle_key(key_name(key)): if key == mcrfpy.Key.TAB: focus_mgr.focus_next() elif key == mcrfpy.Key.ESCAPE: print("\nFinal values:") for i, inp in enumerate(inputs): print(f" Field {i+1}: '{inp.get_text()}'") - sys.exit(0) - text_demo.on_key = "text_demo", handle_keys + text_demo.on_key = handle_keys text_demo.activate() - # Run demo test - def run_test(timer, runtime): - print("\n=== Text Input Widget Test ===") - print("Features:") - print("- Click to focus fields") - print("- Tab to navigate between fields") - print("- Type to enter text") - print("- Backspace/Delete to edit") - print("- Home/End for cursor movement") - print("- Placeholder text") - print("- Visual focus indication") - print("- Press Escape to exit") - print("\nTry it out!") + return text_demo, focus_mgr, inputs, status, handle_keys - info_timer = mcrfpy.Timer("info", run_test, 100, once=True) + +def press(handler, key): + handler(key, mcrfpy.InputState.PRESSED) + + +def main(): + scene, focus_mgr, inputs, status, on_key = create_demo() + + print("\n=== Text Input Widget Test ===") + + # --- construction / registration --- + check(len(focus_mgr.widgets) == 4, "four inputs registered with the focus manager") + check(mcrfpy.current_scene is scene, "text_demo scene is active") + + # --- initial focus: first registered widget --- + first, second = inputs[0], inputs[1] + check(focus_mgr.focused_widget is first, "first input focused on registration") + check(first.focused is True, "first input reports focused") + check(first.cursor.visible is True, "focused input shows its cursor") + check(second.cursor.visible is False, "unfocused input hides its cursor") + check(second.placeholder_text.visible is True, + "unfocused empty input shows placeholder") + check(first.placeholder_text.visible is False, + "focused input hides placeholder") + + # --- typing --- + for key in (mcrfpy.Key.J, mcrfpy.Key.O, mcrfpy.Key.H, mcrfpy.Key.N): + press(on_key, key) + check(first.get_text() == "john", f"typing inserts text (got {first.get_text()!r})") + check(first.cursor_pos == 4, f"cursor advances with typing (got {first.cursor_pos})") + check(first.text_display.text == "john", "text display mirrors the text") + check(status.text.startswith("Data: john |"), + f"on_change updated the status caption (got {status.text!r})") + + # --- editing: backspace --- + press(on_key, mcrfpy.Key.BACKSPACE) + check(first.get_text() == "joh", f"backspace deletes before cursor (got {first.get_text()!r})") + check(first.cursor_pos == 3, "backspace moves cursor back") + + # --- cursor movement: Home / Left / Right / End + insert at cursor --- + press(on_key, mcrfpy.Key.HOME) + check(first.cursor_pos == 0, "Home moves cursor to start") + press(on_key, mcrfpy.Key.RIGHT) + check(first.cursor_pos == 1, "Right moves cursor forward") + press(on_key, mcrfpy.Key.LEFT) + check(first.cursor_pos == 0, "Left moves cursor back") + press(on_key, mcrfpy.Key.B) + check(first.get_text() == "bjoh", f"insertion happens at the cursor (got {first.get_text()!r})") + press(on_key, mcrfpy.Key.DELETE) + check(first.get_text() == "boh", f"Delete removes character after cursor (got {first.get_text()!r})") + press(on_key, mcrfpy.Key.END) + check(first.cursor_pos == 3, "End moves cursor to the end") + + # --- Tab navigation: unhandled by widget, focuses next --- + press(on_key, mcrfpy.Key.TAB) + check(focus_mgr.focused_widget is second, "Tab focuses the next input") + check(first.focused is False, "previous input blurred") + check(first.cursor.visible is False, "blurred input hides its cursor") + check(second.focused is True, "next input focused") + check(first.placeholder_text.visible is False, + "blurred but non-empty input keeps placeholder hidden") + + press(on_key, mcrfpy.Key.A) + check(second.get_text() == "a", "typing goes to the newly focused input") + check(first.get_text() == "boh", "previously focused input keeps its text") + + focus_mgr.focus_prev() + check(focus_mgr.focused_widget is first, "focus_prev returns to the first input") + + # --- programmatic set_text / get_text --- + inputs[3].set_text("hello world") + check(inputs[3].get_text() == "hello world", "set_text sets the text") + check(inputs[3].cursor_pos == len("hello world"), "set_text places cursor at end") + check(inputs[3].text_display.text == "hello world", "set_text updates the display") + check(inputs[3].placeholder_text.visible is False, + "placeholder hidden once the field has text") + + # --- timer + render still work with the widgets in the scene --- + fired = [] + mcrfpy.Timer("info", lambda t, rt: fired.append(rt), 100, once=True) + for _ in range(4): + mcrfpy.step(0.05) + check(len(fired) == 1, f"one-shot timer fired exactly once (got {len(fired)})") + + mcrfpy.automation.screenshot("test_text_input.png") + check(os.path.exists("test_text_input.png"), "scene with text inputs rendered") + + if failures: + print(f"\nFAIL: {len(failures)} check(s) failed") + sys.exit(1) + print("\nPASS") + sys.exit(0) if __name__ == "__main__": - create_demo() + main() diff --git a/tests/unit/test_texture_invalid_path.py b/tests/unit/test_texture_invalid_path.py index 79826a4..7a1bb53 100644 --- a/tests/unit/test_texture_invalid_path.py +++ b/tests/unit/test_texture_invalid_path.py @@ -8,39 +8,64 @@ except ImportError as e: print(f"Failed to import mcrfpy: {e}", file=sys.stderr) sys.exit(1) +failures = [] + # Test 1: Try to create a texture with a non-existent file print("Test 1: Creating texture with non-existent file...") try: texture = mcrfpy.Texture("this_file_does_not_exist.png", 16, 16) + failures.append("Test 1: Expected IOError but texture was created successfully") print("FAIL: Expected IOError but texture was created successfully") print(f"Texture: {texture}") except IOError as e: print("PASS: Got expected IOError:", e) except Exception as e: + failures.append(f"Test 1: Got unexpected exception type {type(e).__name__}: {e}") print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}") # Test 2: Try to create a texture with an empty filename print("\nTest 2: Creating texture with empty filename...") try: texture = mcrfpy.Texture("", 16, 16) + failures.append("Test 2: Expected IOError but texture was created successfully") print("FAIL: Expected IOError but texture was created successfully") except IOError as e: print("PASS: Got expected IOError:", e) except Exception as e: + failures.append(f"Test 2: Got unexpected exception type {type(e).__name__}: {e}") print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}") # Test 3: Verify a valid texture still works -print("\nTest 3: Creating texture with valid file (if exists)...") +# (the old path assets/sprites/tileset.png never existed, so this check used to be +# skipped as "INFO"; use a real shipped asset so it actually exercises the good path) +print("\nTest 3: Creating texture with valid file...") try: - # Try a common test asset path - texture = mcrfpy.Texture("assets/sprites/tileset.png", 16, 16) + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) print("PASS: Valid texture created successfully") print(f" Sheet dimensions: {texture.sheet_width}x{texture.sheet_height}") print(f" Sprite count: {texture.sprite_count}") -except IOError as e: - # This is OK if the asset doesn't exist in the test environment - print("INFO: Test texture file not found (expected in test environment):", e) + if texture.sprite_width != 16 or texture.sprite_height != 16: + failures.append( + f"Test 3: expected 16x16 sprites, got " + f"{texture.sprite_width}x{texture.sprite_height}" + ) + if texture.sheet_width <= 0 or texture.sheet_height <= 0: + failures.append("Test 3: sheet dimensions should be positive") + if texture.sprite_count != texture.sheet_width * texture.sheet_height: + failures.append( + f"Test 3: sprite_count {texture.sprite_count} != " + f"sheet_width * sheet_height ({texture.sheet_width * texture.sheet_height})" + ) except Exception as e: + failures.append(f"Test 3: Unexpected error with valid path: {type(e).__name__}: {e}") print(f"FAIL: Unexpected error with valid path: {type(e).__name__}: {e}") -print("\nAll tests completed. No segfault occurred!") \ No newline at end of file +print("\nAll tests completed. No segfault occurred!") + +if failures: + for f in failures: + print(f"FAILURE: {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_tilelayer_heightmap.py b/tests/unit/test_tilelayer_heightmap.py index 0e202ba..177253f 100644 --- a/tests/unit/test_tilelayer_heightmap.py +++ b/tests/unit/test_tilelayer_heightmap.py @@ -14,7 +14,7 @@ def test_apply_threshold_basic(): # Create a grid and get a tile layer grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Clear all tiles # Apply threshold - all cells should get tile 5 @@ -36,7 +36,7 @@ def test_apply_threshold_partial(): hmap.fill(0.0) # Start with 0 grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Apply threshold for range that doesn't match (0.5-1.0 when values are 0.0) @@ -52,7 +52,7 @@ def test_apply_threshold_preserves_outside(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(99) # Fill with marker value # Apply threshold for range that doesn't include 0.5 @@ -68,7 +68,7 @@ def test_apply_threshold_invalid_range(): hmap = mcrfpy.HeightMap((10, 10)) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) try: layer.apply_threshold(hmap, (1.0, 0.0), 5) # min > max @@ -85,7 +85,7 @@ def test_apply_threshold_size_mismatch(): hmap = mcrfpy.HeightMap((5, 5)) # Different size grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) try: layer.apply_threshold(hmap, (0.0, 1.0), 5) @@ -102,7 +102,7 @@ def test_apply_ranges_basic(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Apply ranges - 0.5 falls in the second range @@ -122,7 +122,7 @@ def test_apply_ranges_later_wins(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Apply overlapping ranges - later should win @@ -141,7 +141,7 @@ def test_apply_ranges_no_match_unchanged(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(99) # Apply ranges that don't match 0.5 @@ -160,7 +160,7 @@ def test_apply_ranges_invalid_format(): hmap = mcrfpy.HeightMap((10, 10)) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) # Missing tile index try: @@ -178,7 +178,7 @@ def test_apply_threshold_boundary(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Range includes 0.5 exactly @@ -193,7 +193,7 @@ def test_apply_threshold_accepts_list(): hmap = mcrfpy.HeightMap((10, 10), fill=0.5) grid = mcrfpy.Grid(grid_size=(10, 10)) - layer = grid.add_layer('tile', z_index=0) + layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0)) layer.fill(-1) # Use list instead of tuple @@ -225,5 +225,11 @@ def run_all_tests(): # Run tests directly -run_all_tests() +try: + run_all_tests() +except AssertionError as e: + print(f"FAIL: assertion failed: {e}") + sys.exit(1) + +print("PASS") sys.exit(0) diff --git a/tests/unit/test_uiarc.py b/tests/unit/test_uiarc.py index c6714f2..10787ba 100644 --- a/tests/unit/test_uiarc.py +++ b/tests/unit/test_uiarc.py @@ -58,9 +58,13 @@ print(f" Modified a1.radius = {a1.radius}") a1.start_angle = 30 a1.end_angle = 120 +assert a1.start_angle == 30, f"Expected start_angle 30, got {a1.start_angle}" +assert a1.end_angle == 120, f"Expected end_angle 120, got {a1.end_angle}" print(f" Modified a1 angles: {a1.start_angle} to {a1.end_angle}") a2.color = mcrfpy.Color(255, 0, 255) # Magenta +assert (a2.color.r, a2.color.g, a2.color.b) == (255, 0, 255), \ + f"Expected magenta, got {a2.color}" print(f" Modified a2.color") # Test 4: Test visibility and opacity @@ -69,25 +73,31 @@ a5 = mcrfpy.Arc(center=(100, 250), radius=30, start_angle=0, end_angle=180, color=mcrfpy.Color(255, 128, 0), thickness=3) a5.opacity = 0.5 ui.append(a5) +assert a5.opacity == 0.5, f"Expected opacity 0.5, got {a5.opacity}" print(f" a5.opacity = {a5.opacity}") a6 = mcrfpy.Arc(center=(200, 250), radius=30, start_angle=0, end_angle=180, color=mcrfpy.Color(255, 128, 0), thickness=3) a6.visible = False ui.append(a6) +assert a6.visible is False, f"Expected visible False, got {a6.visible}" print(f" a6.visible = {a6.visible}") # Test 5: Test z_index +# NOTE: UICollection.append() auto-assigns z_index (last + 10), so z_index must be +# set AFTER appending or the assignment is overwritten by the collection. print("\nTest 5: Testing z_index...") a7 = mcrfpy.Arc(center=(350, 250), radius=50, start_angle=0, end_angle=270, color=mcrfpy.Color(0, 255, 255), thickness=10) -a7.z_index = 100 ui.append(a7) +a7.z_index = 100 a8 = mcrfpy.Arc(center=(370, 250), radius=40, start_angle=0, end_angle=270, color=mcrfpy.Color(255, 0, 255), thickness=8) -a8.z_index = 50 ui.append(a8) +a8.z_index = 50 +assert a7.z_index == 100, f"Expected z_index 100, got {a7.z_index}" +assert a8.z_index == 50, f"Expected z_index 50, got {a8.z_index}" print(f" a7.z_index = {a7.z_index}, a8.z_index = {a8.z_index}") # Test 6: Test name property @@ -98,16 +108,32 @@ ui.append(a9) assert a9.name == "test_arc", f"Expected name 'test_arc', got '{a9.name}'" print(f" a9.name = '{a9.name}'") -# Test 7: Test get_bounds -print("\nTest 7: Testing get_bounds...") -bounds = a1.get_bounds() -print(f" a1.get_bounds() = {bounds}") +# Test 7: Test bounds +# NOTE: get_bounds() was replaced by the .bounds / .global_bounds properties. +# Each returns (offset: Vector, size: Vector); an arc's extent is the circle that +# contains it, so size == 2 * (radius + thickness/2) on both axes. +print("\nTest 7: Testing bounds...") +offset, size = a1.bounds +expected_extent = 2 * (a1.radius + a1.thickness / 2) +assert size.x == expected_extent, f"Expected bounds width {expected_extent}, got {size.x}" +assert size.y == expected_extent, f"Expected bounds height {expected_extent}, got {size.y}" +print(f" a1.bounds = ({offset}, {size})") + +g_offset, g_size = a1.global_bounds +assert (g_size.x, g_size.y) == (size.x, size.y), \ + f"global_bounds size {g_size} should match bounds size {size}" +print(f" a1.global_bounds = ({g_offset}, {g_size})") # Test 8: Test move method print("\nTest 8: Testing move method...") old_center = (a1.center.x, a1.center.y) a1.move(10, 10) new_center = (a1.center.x, a1.center.y) +assert new_center == (old_center[0] + 10, old_center[1] + 10), \ + f"Expected move to offset center by (10, 10): {old_center} -> {new_center}" +# global_bounds tracks the moved arc; local bounds size is unchanged by a move +assert a1.global_bounds[0].x == new_center[0], \ + f"global_bounds should follow center x {new_center[0]}, got {a1.global_bounds[0].x}" print(f" a1 moved from {old_center} to {new_center}") # Test 9: Negative angle span (draws in reverse) @@ -115,8 +141,13 @@ print("\nTest 9: Testing negative angle span...") a10 = mcrfpy.Arc(center=(100, 350), radius=40, start_angle=90, end_angle=0, color=mcrfpy.Color(128, 255, 128), thickness=4) ui.append(a10) +assert a10.start_angle == 90 and a10.end_angle == 0, \ + f"Reverse span should be preserved verbatim, got {a10.start_angle} to {a10.end_angle}" print(f" Arc 10 (reverse): {a10}") +# All 10 arcs should be in the scene +assert len(ui) == 10, f"Expected 10 arcs in the scene, got {len(ui)}" + # Render a frame and take screenshot mcrfpy.step(0.01) automation.screenshot("test_uiarc_result.png") diff --git a/tests/unit/test_uicircle.py b/tests/unit/test_uicircle.py index 4f0a0bd..935fe61 100644 --- a/tests/unit/test_uicircle.py +++ b/tests/unit/test_uicircle.py @@ -4,6 +4,9 @@ import mcrfpy from mcrfpy import automation import sys +def rgba(c): + return (c.r, c.g, c.b, c.a) + # Create a test scene test = mcrfpy.Scene("test") mcrfpy.current_scene = test @@ -55,8 +58,13 @@ print(f" c1.radius = {c1.radius}") # Check center center = c2.center +assert (center.x, center.y) == (250, 100), f"Expected center (250, 100), got ({center.x}, {center.y})" print(f" c2.center = ({center.x}, {center.y})") +assert c3.outline == 5.0, f"Expected outline 5.0, got {c3.outline}" +assert rgba(c4.fill_color) == (0, 0, 0, 0), f"Expected transparent fill, got {rgba(c4.fill_color)}" +print(f" c3.outline = {c3.outline}, c4.fill_color = {rgba(c4.fill_color)}") + # Test 3: Modify properties print("\nTest 3: Modifying properties...") c1.radius = 60 @@ -64,29 +72,36 @@ assert c1.radius == 60, f"Expected radius 60, got {c1.radius}" print(f" Modified c1.radius = {c1.radius}") c2.fill_color = mcrfpy.Color(128, 0, 128) # Purple -print(f" Modified c2.fill_color") +assert rgba(c2.fill_color) == (128, 0, 128, 255), f"Expected purple fill, got {rgba(c2.fill_color)}" +print(f" Modified c2.fill_color = {rgba(c2.fill_color)}") # Test 4: Test visibility and opacity print("\nTest 4: Testing visibility and opacity...") c5 = mcrfpy.Circle(radius=25, center=(100, 200), fill_color=mcrfpy.Color(255, 128, 0)) c5.opacity = 0.5 ui.append(c5) +assert abs(c5.opacity - 0.5) < 1e-6, f"Expected opacity 0.5, got {c5.opacity}" print(f" c5.opacity = {c5.opacity}") c6 = mcrfpy.Circle(radius=25, center=(175, 200), fill_color=mcrfpy.Color(255, 128, 0)) c6.visible = False ui.append(c6) +assert c6.visible is False, f"Expected visible False, got {c6.visible}" print(f" c6.visible = {c6.visible}") # Test 5: Test z_index +# NOTE: UICollection.append() assigns z_index by insertion order, so z_index must be +# set AFTER appending (setting it before append is silently overwritten). print("\nTest 5: Testing z_index...") c7 = mcrfpy.Circle(radius=40, center=(300, 200), fill_color=mcrfpy.Color(0, 255, 255)) -c7.z_index = 100 ui.append(c7) +c7.z_index = 100 c8 = mcrfpy.Circle(radius=30, center=(320, 200), fill_color=mcrfpy.Color(255, 0, 255)) -c8.z_index = 50 ui.append(c8) +c8.z_index = 50 +assert c7.z_index == 100, f"Expected z_index 100, got {c7.z_index}" +assert c8.z_index == 50, f"Expected z_index 50, got {c8.z_index}" print(f" c7.z_index = {c7.z_index}, c8.z_index = {c8.z_index}") # Test 6: Test name property @@ -96,16 +111,21 @@ ui.append(c9) assert c9.name == "test_circle", f"Expected name 'test_circle', got '{c9.name}'" print(f" c9.name = '{c9.name}'") -# Test 7: Test get_bounds -print("\nTest 7: Testing get_bounds...") -bounds = c1.get_bounds() -print(f" c1.get_bounds() = {bounds}") +# Test 7: Test bounds +# get_bounds() was replaced by the .bounds / .global_bounds properties. +print("\nTest 7: Testing bounds...") +pos, size = c1.bounds # c1: radius 60, center (100, 100) +assert (pos.x, pos.y) == (40, 40), f"Expected bounds pos (40, 40), got ({pos.x}, {pos.y})" +assert (size.x, size.y) == (120, 120), f"Expected bounds size (120, 120), got ({size.x}, {size.y})" +print(f" c1.bounds = (({pos.x}, {pos.y}), ({size.x}, {size.y}))") # Test 8: Test move method print("\nTest 8: Testing move method...") old_center = (c1.center.x, c1.center.y) c1.move(10, 10) new_center = (c1.center.x, c1.center.y) +assert new_center == (old_center[0] + 10, old_center[1] + 10), \ + f"Expected move to offset center by (10, 10): {old_center} -> {new_center}" print(f" c1 moved from {old_center} to {new_center}") # Render a frame and take screenshot diff --git a/tests/unit/test_visibility.py b/tests/unit/test_visibility.py index 38fa22f..7e92a04 100644 --- a/tests/unit/test_visibility.py +++ b/tests/unit/test_visibility.py @@ -4,31 +4,48 @@ Test Knowledge Stubs 1 Visibility System ======================================== Tests per-entity visibility tracking with perspective rendering. + +API notes (updated for current mcrfpy): + - entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap: + UNKNOWN / DISCOVERED / VISIBLE, exclusive states). + - grid.perspective is an Entity (or None for omniscient), not an int index. + - Cell colors come from a ColorLayer, not GridPoint.color. + - Headless time only advances via mcrfpy.step(). """ import mcrfpy +from mcrfpy import automation import sys -import time print("Knowledge Stubs 1 - Visibility System Test") print("==========================================") +failures = [] + +def check(condition, message): + if condition: + print(f" PASS: {message}") + else: + print(f" FAIL: {message}") + failures.append(message) + # Create scene and grid visibility_test = mcrfpy.Scene("visibility_test") -grid = mcrfpy.Grid(grid_w=20, grid_h=15) +grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(600, 450)) grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background # Add a color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.grid_data.add_layer(color_layer) # Initialize grid - all walkable and transparent print("\nInitializing 20x15 grid...") for y in range(15): for x in range(20): - cell = grid.at(x, y) + cell = grid.grid_data.at(x, y) cell.walkable = True cell.transparent = True - color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color + color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color # Create some walls to block vision print("Adding walls...") @@ -47,126 +64,152 @@ walls = [ for wall_group in walls: for x, y in wall_group: - cell = grid.at(x, y) + cell = grid.grid_data.at(x, y) cell.walkable = False cell.transparent = False - color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color + color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color # Create entities print("\nCreating entities...") entities = [ - mcrfpy.Entity((2, 7)), # Left side - mcrfpy.Entity((18, 7)), # Right side - mcrfpy.Entity((10, 1)), # Top center (above wall) + mcrfpy.Entity(grid_pos=(2, 7)), # Left side + mcrfpy.Entity(grid_pos=(18, 7)), # Right side + mcrfpy.Entity(grid_pos=(10, 1)), # Top center (above wall) ] for i, entity in enumerate(entities): entity.sprite_index = 64 + i # @, A, B grid.entities.append(entity) - print(f" Entity {i}: position ({entity.x}, {entity.y})") + print(f" Entity {i}: cell {entity.grid_x, entity.grid_y}") -# Test 1: Check initial gridstate -print("\nTest 1: Initial gridstate") +# Test 1: Check initial perspective_map (was: gridstate) +print("\nTest 1: Initial perspective_map") e0 = entities[0] -print(f" Entity 0 gridstate length: {len(e0.gridstate)}") -print(f" Expected: {20 * 15}") +pm0 = e0.perspective_map +print(f" Entity 0 perspective_map size: {pm0.size}") +check(pm0.size == (20, 15), "perspective_map covers all 20x15 cells") +check(pm0.enum_type is mcrfpy.Perspective, "perspective_map uses the Perspective enum") # Test 2: Update visibility for each entity print("\nTest 2: Updating visibility for each entity") +counts = [] for i, entity in enumerate(entities): entity.update_visibility() - # Count visible/discovered cells - visible_count = sum(1 for state in entity.gridstate if state.visible) - discovered_count = sum(1 for state in entity.gridstate if state.discovered) + pm = entity.perspective_map + # VISIBLE and DISCOVERED are exclusive states; "ever seen" = both. + visible_count = pm.count(mcrfpy.Perspective.VISIBLE) + discovered_count = visible_count + pm.count(mcrfpy.Perspective.DISCOVERED) + counts.append((visible_count, discovered_count)) print(f" Entity {i}: {visible_count} visible, {discovered_count} discovered") + check(visible_count > 0, f"Entity {i} sees at least one cell") + check(pm.get(entity.grid_pos) == mcrfpy.Perspective.VISIBLE, + f"Entity {i} sees its own cell") + check(visible_count < 20 * 15, f"Entity {i} does not see the whole map (walls block FOV)") + +# Walls must actually block vision: entity 0 (left of the vertical wall at x=10) +# must not see entity 1's cell (18, 7) on the far side of it. +pm0 = entities[0].perspective_map +check(pm0.get((18, 7)) == mcrfpy.Perspective.UNKNOWN, + "Entity 0 cannot see through the wall to (18, 7)") +# ...but the near face of a blocking wall is itself visible. +check(pm0.get((5, 7)) == mcrfpy.Perspective.VISIBLE, + "Entity 0 sees the wall cell (5, 7) that blocks it") # Test 3: Test perspective property +# NOTE: perspective is now an Entity (or None), not an integer index (#313/#361). print("\nTest 3: Testing perspective property") print(f" Initial perspective: {grid.perspective}") -grid.perspective = 0 +check(grid.perspective is None, "perspective defaults to None (omniscient)") +check(grid.perspective_enabled is False, "perspective_enabled defaults to False") + +grid.perspective = entities[0] print(f" Set to entity 0: {grid.perspective}") +check(grid.perspective is entities[0], "perspective returns the assigned entity") +check(grid.perspective_enabled is True, "assigning an entity enables perspective mode") -# Test invalid perspective +# Test invalid perspective (an int index is no longer accepted) try: - grid.perspective = 10 # Out of range - print(" ERROR: Should have raised exception for invalid perspective") -except IndexError as e: - print(f" ✓ Correctly rejected invalid perspective: {e}") + grid.perspective = 10 # Not an Entity + check(False, "invalid perspective should raise TypeError") +except TypeError as e: + print(f" Correctly rejected invalid perspective: {e}") + check(True, "invalid perspective raises TypeError") + +# Test 4: Visual demonstration - cycle perspectives via a timer, driven by step() +print("\nTest 4: cycling perspectives") +cycle_order = [None, entities[0], entities[1], entities[2]] +seen_perspectives = [] -# Test 4: Visual demonstration def visual_test(timer, runtime): - print(f"\nVisual test - cycling perspectives at {runtime}ms") + idx = len(seen_perspectives) + if idx >= len(cycle_order): + timer.stop() + return + grid.perspective = cycle_order[idx] + seen_perspectives.append(grid.perspective) + label = "omniscient" if grid.perspective is None else f"Entity {idx - 1}" + print(f" Switched to {label} perspective at {runtime}ms") + automation.screenshot(f"visibility_perspective_{idx}.png") - # Cycle through perspectives - current = grid.perspective - if current == -1: - grid.perspective = 0 - print(" Switched to Entity 0 perspective") - elif current == 0: - grid.perspective = 1 - print(" Switched to Entity 1 perspective") - elif current == 1: - grid.perspective = 2 - print(" Switched to Entity 2 perspective") - else: - grid.perspective = -1 - print(" Switched to omniscient view") - - # Take screenshot - from mcrfpy import automation - filename = f"visibility_perspective_{grid.perspective}.png" - automation.screenshot(filename) - print(f" Screenshot saved: {filename}") - -# Test 5: Movement and visibility update -print("\nTest 5: Movement and visibility update") -entity = entities[0] -print(f" Entity 0 initial position: ({entity.x}, {entity.y})") - -# Move entity -entity.x = 8 -entity.y = 7 -print(f" Moved to: ({entity.x}, {entity.y})") - -# Update visibility -entity.update_visibility() -visible_count = sum(1 for state in entity.gridstate if state.visible) -print(f" Visible cells after move: {visible_count}") - -# Set up UI +# Set scene and UI ui = visibility_test.children ui.append(grid) -grid.pos = (50, 50) -grid.size = (600, 450) # 20*30, 15*30 -# Add title title = mcrfpy.Caption(pos=(200, 10), text="Knowledge Stubs 1 - Visibility Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -# Add info -info = mcrfpy.Caption(pos=(50, 520), text="Perspective: -1 (omniscient)") +info = mcrfpy.Caption(pos=(50, 520), text="Perspective: None (omniscient)") info.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info) -# Add legend legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible") legend.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend) -# Set scene visibility_test.activate() -# Set timer to cycle perspectives -cycle_timer = mcrfpy.Timer("cycle", visual_test, 2000) # Every 2 seconds +cycle_timer = mcrfpy.Timer("cycle", visual_test, 100) +# Headless: mcrfpy.step() is the only clock; one timer event per step. +for _ in range(len(cycle_order) + 1): + mcrfpy.step(0.1) -print("\nTest complete! Visual demo cycling through perspectives...") -print("Perspectives will cycle: Omniscient → Entity 0 → Entity 1 → Entity 2 → Omniscient") +check(seen_perspectives == cycle_order, + f"cycled through all perspectives (got {len(seen_perspectives)} of {len(cycle_order)})") -# Quick test to exit after screenshots -def exit_timer_cb(timer, runtime): - print("\nExiting after demo...") - sys.exit(0) +# Test 5: Movement and visibility update +print("\nTest 5: Movement and visibility update") +entity = entities[0] +# NOTE: entity.x/.y are pixel draw coordinates; the logical cell is grid_x/grid_y. +print(f" Entity 0 initial cell: {entity.grid_x, entity.grid_y}") +before_visible, _ = counts[0] -exit_timer_obj = mcrfpy.Timer("exit", exit_timer_cb, 10000, once=True) # Exit after 10 seconds +# Move entity next to the horizontal wall opening +entity.grid_pos = (8, 7) +print(f" Moved to: {entity.grid_x, entity.grid_y}") +check((entity.grid_x, entity.grid_y) == (8, 7), "entity moved to (8, 7)") + +# Update visibility +entity.update_visibility() +pm0 = entity.perspective_map +visible_count = pm0.count(mcrfpy.Perspective.VISIBLE) +ever_seen = visible_count + pm0.count(mcrfpy.Perspective.DISCOVERED) +print(f" Visible cells after move: {visible_count} (ever seen: {ever_seen})") +check(visible_count > 0, "entity sees cells from its new position") +check(pm0.get((8, 7)) == mcrfpy.Perspective.VISIBLE, "entity sees its new cell") +check(ever_seen >= before_visible, + "memory is retained across movement (discovered cells persist)") +# Cells visible from the old spot but not the new one must be remembered, not forgotten. +check(pm0.get((2, 7)) in (mcrfpy.Perspective.DISCOVERED, mcrfpy.Perspective.VISIBLE), + "old position remains at least DISCOVERED after moving away") + +print() +if failures: + print(f"FAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_visual_path.py b/tests/unit/test_visual_path.py index 684698d..ad9f96e 100644 --- a/tests/unit/test_visual_path.py +++ b/tests/unit/test_visual_path.py @@ -9,21 +9,27 @@ WALL_COLOR = mcrfpy.Color(60, 30, 30) FLOOR_COLOR = mcrfpy.Color(200, 200, 220) PATH_COLOR = mcrfpy.Color(100, 255, 100) +failures = [] +timer_fired = False + # Create scene visual_test = mcrfpy.Scene("visual_test") # Create grid -grid = mcrfpy.Grid(grid_w=5, grid_h=5) +grid = mcrfpy.Grid(grid_size=(5, 5), pos=(50, 50), size=(250, 250)) grid.fill_color = mcrfpy.Color(0, 0, 0) -# Add color layer for cell coloring -color_layer = grid.add_layer("color", z_index=-1) +# Add color layer for cell coloring (GridPoint has no .color anymore; use a ColorLayer) +color_layer = mcrfpy.ColorLayer(name="color", z_index=-1) +grid.add_layer(color_layer) def check_render(timer, runtime): """Timer callback to verify rendering""" + global timer_fired + timer_fired = True print(f"\nTimer fired after {runtime}ms") - # Take screenshot + # Take screenshot (this is what forces a render in headless mode) from mcrfpy import automation automation.screenshot("visual_path_test.png") print("Screenshot saved as visual_path_test.png") @@ -31,43 +37,62 @@ def check_render(timer, runtime): # Sample some path cells to verify colors print("\nSampling path cell colors from grid:") for x, y in [(1, 1), (2, 2), (3, 3)]: - color = color_layer.at(x, y) + color = color_layer.at((x, y)) print(f" Cell ({x},{y}): color=({color.r}, {color.g}, {color.b})") + if (color.r, color.g, color.b) != (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b): + failures.append(f"cell ({x},{y}) is not PATH_COLOR: " + f"({color.r}, {color.g}, {color.b})") - sys.exit(0) + # A cell off the path must still be floor-colored + off = color_layer.at((0, 4)) + if (off.r, off.g, off.b) != (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b): + failures.append(f"off-path cell (0,4) is not FLOOR_COLOR: " + f"({off.r}, {off.g}, {off.b})") + + timer.stop() # Initialize all cells as floor print("Initializing grid...") for y in range(5): for x in range(5): grid.at(x, y).walkable = True - color_layer.set(x, y, FLOOR_COLOR) + color_layer.set((x, y), FLOOR_COLOR) # Create entities -e1 = mcrfpy.Entity((0, 0), grid=grid) -e2 = mcrfpy.Entity((4, 4), grid=grid) +e1 = mcrfpy.Entity(grid_pos=(0, 0)) +e2 = mcrfpy.Entity(grid_pos=(4, 4)) +grid.entities.append(e1) +grid.entities.append(e2) e1.sprite_index = 64 # @ e2.sprite_index = 69 # E -print(f"Entity 1 at ({e1.x}, {e1.y})") -print(f"Entity 2 at ({e2.x}, {e2.y})") +# .x/.y are pixel coords now; .cell_pos is the logical cell +print(f"Entity 1 at ({e1.cell_pos.x}, {e1.cell_pos.y})") +print(f"Entity 2 at ({e2.cell_pos.x}, {e2.cell_pos.y})") # Get path -path = e1.path_to(int(e2.x), int(e2.y)) +path = e1.path_to(int(e2.cell_pos.x), int(e2.cell_pos.y)) print(f"\nPath from E1 to E2: {path}") +if not path: + failures.append("path_to() returned no path between (0,0) and (4,4)") +else: + if path[-1] != (4, 4): + failures.append(f"path does not end at target: {path[-1]}") + for cell in [(1, 1), (2, 2), (3, 3)]: + if cell not in path: + failures.append(f"expected diagonal path cell {cell} missing from path") + # Color the path if path: print("\nColoring path cells green...") for x, y in path: - color_layer.set(x, y, PATH_COLOR) + color_layer.set((x, y), PATH_COLOR) print(f" Set ({x},{y}) to green") # Set up UI ui = visual_test.children ui.append(grid) -grid.pos = (50, 50) -grid.size = (250, 250) # Add title title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test") @@ -80,4 +105,19 @@ visual_test.activate() # Set timer to check rendering check_timer = mcrfpy.Timer("check", check_render, 500, once=True) -print("\nScene ready. Path should be visible in green.") \ No newline at end of file +print("\nScene ready. Path should be visible in green.") + +# Headless: mcrfpy.step() is the clock; the timer never fires on its own. +for _ in range(20): + mcrfpy.step(0.1) + +if not timer_fired: + failures.append("timer callback never fired; render checks did not run") + +if failures: + for f in failures: + print(f"FAIL: {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/ui_Entity_issue73_test.py b/tests/unit/ui_Entity_issue73_test.py index 46c5ba2..ebf1a91 100644 --- a/tests/unit/ui_Entity_issue73_test.py +++ b/tests/unit/ui_Entity_issue73_test.py @@ -1,116 +1,123 @@ #!/usr/bin/env python3 """Test for Entity class - Related to issue #73 (index() method)""" import mcrfpy -from datetime import datetime +import sys print("Test script starting...") +failures = [] + +def check(cond, msg): + if cond: + print(f":) {msg}") + else: + print(f"X {msg}") + failures.append(msg) + def test_Entity(): """Test Entity class and index() method for collection removal""" # Create test scene with grid entity_test = mcrfpy.Scene("entity_test") entity_test.activate() ui = entity_test.children - - # Create a grid - grid = mcrfpy.Grid(10, 10, - mcrfpy.default_texture, - mcrfpy.Vector(10, 10), - mcrfpy.Vector(400, 400)) + + # Create a grid (current ctor: grid_size/pos/size keywords) + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(10, 10), size=(400, 400), + texture=texture) ui.append(grid) entities = grid.entities - - # Create multiple entities - entity1 = mcrfpy.Entity(mcrfpy.Vector(2, 2), mcrfpy.default_texture, 0, grid) - entity2 = mcrfpy.Entity(mcrfpy.Vector(5, 5), mcrfpy.default_texture, 1, grid) - entity3 = mcrfpy.Entity(mcrfpy.Vector(7, 7), mcrfpy.default_texture, 2, grid) - + + # Create multiple entities (current ctor: grid_pos=, texture=, sprite_index=) + entity1 = mcrfpy.Entity(grid_pos=(2, 2), texture=texture, sprite_index=0) + entity2 = mcrfpy.Entity(grid_pos=(5, 5), texture=texture, sprite_index=1) + entity3 = mcrfpy.Entity(grid_pos=(7, 7), texture=texture, sprite_index=2) + entities.append(entity1) entities.append(entity2) entities.append(entity3) - - print(f"Created {len(entities)} entities") - - # Test entity properties - try: - print(f" Entity1 pos: {entity1.pos}") - print(f" Entity1 draw_pos: {entity1.draw_pos}") - print(f" Entity1 sprite_index: {entity1.sprite_index}") - # Modify properties - entity1.pos = mcrfpy.Vector(3, 3) - entity1.sprite_index = 5 - print(" Entity properties modified") - except Exception as e: - print(f"X Entity property access failed: {e}") - - # Test gridstate access - try: - gridstate = entity2.gridstate - print(" Entity gridstate accessible") - - # Test at() method - point_state = entity2.at()#.at(0, 0) - print(" Entity at() method works") - except Exception as e: - print(f"X Entity gridstate/at() failed: {e}") - + print(f"Created {len(entities)} entities") + check(len(entities) == 3, "3 entities in collection after append") + + # Test entity properties + print(f" Entity1 grid_pos: {entity1.grid_pos}") + print(f" Entity1 pos: {entity1.pos}") + print(f" Entity1 draw_pos: {entity1.draw_pos}") + print(f" Entity1 sprite_index: {entity1.sprite_index}") + check(entity1.sprite_index == 0, "entity1.sprite_index reads back as 0") + check((entity1.grid_pos.x, entity1.grid_pos.y) == (2, 2), + "entity1.grid_pos reads back as (2, 2)") + + # Modify properties + entity1.grid_pos = mcrfpy.Vector(3, 3) + entity1.sprite_index = 5 + check((entity1.grid_pos.x, entity1.grid_pos.y) == (3, 3), + "entity1.grid_pos writable") + check(entity1.sprite_index == 5, "entity1.sprite_index writable") + + # Entity association with a grid: entity.grid is the shared GridData + # (current contract, #313/#361 -- it is NOT the Grid view object) + check(isinstance(entity1.grid, mcrfpy.GridData), + "entity.grid returns the GridData") + check(entity1.grid is grid.grid_data, + "entity.grid is the same GridData as grid.grid_data") + + # Test perspective/visibility access (was: entity.gridstate) + pmap = entity2.perspective_map + check(pmap is not None, "Entity perspective_map accessible") + entity2.update_visibility() + + # Test at() method - returns the GridPoint if visible to this entity + point_state = entity2.at(5, 5) + check(point_state is not None, "Entity at() returns own (visible) cell") + check(point_state is None or tuple(point_state.grid_pos) == (5, 5), + "Entity at(5, 5) returns the GridPoint for (5, 5)") + # Test index() method (Issue #73) print("\nTesting index() method (Issue #73)...") - try: - # Try to find entity2's index - index = entity2.index() - print(f":) index() method works: entity2 is at index {index}") - - # Verify by checking collection - if entities[index] == entity2: - print("✓ Index is correct") - else: - print("✗ Index mismatch") - - # Remove using index - entities.remove(index) - print(f":) Removed entity using index, now {len(entities)} entities") - except AttributeError: - print("✗ index() method not implemented (Issue #73)") - # Try manual removal as workaround - try: - for i in range(len(entities)): - if entities[i] == entity2: - entities.remove(i) - print(":) Manual removal workaround succeeded") - break - except: - print("✗ Manual removal also failed") - except Exception as e: - print(f":) index() method error: {e}") - + index = entity2.index() + print(f":) index() method works: entity2 is at index {index}") + check(index == 1, "entity2.index() == 1") + check(entities[index] == entity2, "Index is correct (entities[index] is entity2)") + + # Remove using the index reported by index() (Issue #73's purpose) + removed = entities.pop(index) + check(removed == entity2, "pop(index) returned entity2") + check(len(entities) == 2, f"Removed entity using index, now {len(entities)} entities") + check(entity1.index() == 0 and entity3.index() == 1, + "index() reflects collection after removal") + # Test EntityCollection iteration - try: - positions = [] - for entity in entities: - positions.append(entity.pos) - print(f":) Entity iteration works: {len(positions)} entities") - except Exception as e: - print(f"X Entity iteration failed: {e}") - + positions = [e.grid_pos for e in entities] + check(len(positions) == 2, f"Entity iteration works: {len(positions)} entities") + check([(p.x, p.y) for p in positions] == [(3, 3), (7, 7)], + "Iteration yields the surviving entities in order") + # Test EntityCollection extend (Issue #27) - try: - new_entities = [ - mcrfpy.Entity(mcrfpy.Vector(1, 1), mcrfpy.default_texture, 3, grid), - mcrfpy.Entity(mcrfpy.Vector(9, 9), mcrfpy.default_texture, 4, grid) - ] - entities.extend(new_entities) - print(f":) extend() method works: now {len(entities)} entities") - except AttributeError: - print("✗ extend() method not implemented (Issue #27)") - except Exception as e: - print(f"X extend() method error: {e}") - - # Skip screenshot in headless mode - print("PASS") + new_entities = [ + mcrfpy.Entity(grid_pos=(1, 1), texture=texture, sprite_index=3), + mcrfpy.Entity(grid_pos=(9, 9), texture=texture, sprite_index=4) + ] + entities.extend(new_entities) + check(len(entities) == 4, f"extend() method works: now {len(entities)} entities") + check(new_entities[1].index() == 3, "extended entity reports its index()") + + # remove(entity) is the object-based counterpart to index()/pop() + entities.remove(entity3) + check(len(entities) == 3, "remove(entity) drops the entity") + # Run test immediately in headless mode print("Running test immediately...") test_Entity() print("Test completed.") + +if failures: + print(f"FAIL: {len(failures)} check(s) failed:") + for f in failures: + print(f" - {f}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/ui_Sprite_issue19_test.py b/tests/unit/ui_Sprite_issue19_test.py index ba0da38..666d5f1 100644 --- a/tests/unit/ui_Sprite_issue19_test.py +++ b/tests/unit/ui_Sprite_issue19_test.py @@ -1,69 +1,94 @@ #!/usr/bin/env python3 """Test for Sprite texture methods - Related to issue #19""" import mcrfpy +import sys print("Testing Sprite texture methods (Issue #19)...") +failures = [] + +def check(condition, message): + if condition: + print(f"PASS: {message}") + else: + print(f"FAIL: {message}") + failures.append(message) + # Create test scene sprite_texture_test = mcrfpy.Scene("sprite_texture_test") sprite_texture_test.activate() ui = sprite_texture_test.children # Create sprites -# Based on sprite2 syntax: Sprite(x, y, texture, sprite_index, scale) -sprite1 = mcrfpy.Sprite(10, 10, mcrfpy.default_texture, 0, 2.0) -sprite2 = mcrfpy.Sprite(100, 10, mcrfpy.default_texture, 5, 2.0) +# Current Sprite ctor: Sprite(pos=None, texture=None, sprite_index=0, **kwargs) +sprite1 = mcrfpy.Sprite(pos=(10, 10), texture=mcrfpy.default_texture, sprite_index=0, scale=2.0) +sprite2 = mcrfpy.Sprite(pos=(100, 10), texture=mcrfpy.default_texture, sprite_index=5, scale=2.0) ui.append(sprite1) ui.append(sprite2) -# Test getting texture -try: - texture1 = sprite1.texture - texture2 = sprite2.texture - print(f"✓ Got textures: {texture1}, {texture2}") - - if texture2 == mcrfpy.default_texture: - print("✓ Texture matches default_texture") -except Exception as e: - print(f"✗ Failed to get texture: {e}") +check(len(ui) == 2, "both sprites appended to the scene") -# Test setting texture (Issue #19 - get/set texture methods) -try: - # This should fail as texture is read-only currently - sprite1.texture = mcrfpy.default_texture - print("✗ Texture setter should not exist (Issue #19)") -except AttributeError: - print("✓ Texture is read-only (Issue #19 requests setter)") -except Exception as e: - print(f"✗ Unexpected error setting texture: {e}") +# Test getting texture +texture1 = sprite1.texture +texture2 = sprite2.texture +print(f"Got textures: {texture1}, {texture2}") +check(isinstance(texture1, mcrfpy.Texture) and isinstance(texture2, mcrfpy.Texture), + "Sprite.texture returns a Texture") +# Texture wrappers do not compare by identity; compare the underlying source. +check(texture2.source == mcrfpy.default_texture.source, + "sprite texture is the default texture that was passed in") + +# Test setting texture (Issue #19 asked for a texture setter; it now EXISTS, +# so the old "texture must be read-only" expectation is inverted here.) +other_texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16) +sprite1.sprite_index = 0 +sprite1.texture = other_texture +check(sprite1.texture.source == other_texture.source, + "Sprite.texture setter swaps the texture (Issue #19)") +check(sprite1.bounds[1].x == other_texture.sprite_width * sprite1.scale, + "swapped texture drives the sprite's rendered size") + +# Restore the default texture +sprite1.texture = mcrfpy.default_texture +check(sprite1.texture.source == mcrfpy.default_texture.source, + "Sprite.texture setter restores the default texture") # Test sprite_index property -try: - print(f"Sprite2 sprite_index: {sprite2.sprite_index}") - sprite2.sprite_index = 10 - print(f"sprite_index set to: {sprite2.sprite_index}") -except Exception as e: - print(f"sprite_index property failed: {e}") +print(f"Sprite2 sprite_index: {sprite2.sprite_index}") +check(sprite2.sprite_index == 5, "sprite_index reflects the constructor argument") +sprite2.sprite_index = 10 +print(f"sprite_index set to: {sprite2.sprite_index}") +check(sprite2.sprite_index == 10, "sprite_index setter takes effect") # Test sprite index validation (Issue #33) try: - # Try to set invalid sprite index sprite2.sprite_index = 9999 - print("Should validate sprite index against texture range (Issue #33)") -except Exception as e: + check(False, "out-of-range sprite_index must raise (Issue #33)") +except ValueError as e: print(f"Sprite index validation works: {e}") + check(True, "out-of-range sprite_index raises ValueError (Issue #33)") +check(sprite2.sprite_index == 10, "rejected sprite_index leaves the old value intact") # Create grid of sprites to show different indices y_offset = 100 for i in range(12): # Show first 12 sprites - sprite = mcrfpy.Sprite(10 + (i % 6) * 40, y_offset + (i // 6) * 40, - mcrfpy.default_texture, i, 2.0) + sprite = mcrfpy.Sprite(pos=(10 + (i % 6) * 40, y_offset + (i // 6) * 40), + texture=mcrfpy.default_texture, sprite_index=i, scale=2.0) ui.append(sprite) -caption = mcrfpy.Caption(mcrfpy.Vector(10, 200), - text="Issue #19: Sprites need texture setter", - fill_color=mcrfpy.Color(255, 255, 255)) +check(len(ui) == 14, "12 additional sprites appended") +check(all(ui[2 + i].sprite_index == i for i in range(12)), + "each sprite in the grid kept its own sprite_index") + +caption = mcrfpy.Caption(pos=(10, 200), + text="Issue #19: Sprite texture setter", + fill_color=mcrfpy.Color(255, 255, 255)) ui.append(caption) -print("PASS") \ No newline at end of file +if failures: + print(f"FAILED ({len(failures)} checks): {failures}") + sys.exit(1) + +print("PASS") +sys.exit(0) diff --git a/tests/unit/ui_UICollection_issue69_test.py b/tests/unit/ui_UICollection_issue69_test.py index 86fd3ef..4d08bfd 100644 --- a/tests/unit/ui_UICollection_issue69_test.py +++ b/tests/unit/ui_UICollection_issue69_test.py @@ -1,7 +1,17 @@ #!/usr/bin/env python3 """Test for UICollection - Related to issue #69 (Sequence Protocol)""" import mcrfpy -from datetime import datetime +import sys + +failures = [] + +def check(label, condition, detail=""): + if condition: + print(f"[ok] {label}") + else: + msg = f"{label}{': ' + detail if detail else ''}" + print(f"[FAIL] {msg}") + failures.append(msg) def test_UICollection(): """Test UICollection sequence protocol compliance""" @@ -9,96 +19,70 @@ def test_UICollection(): collection_test = mcrfpy.Scene("collection_test") collection_test.activate() ui = collection_test.children - + # Add various UI elements frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) caption = mcrfpy.Caption(pos=(120, 10), text="Test") + caption.name = "Test" # find() searches by name, not text # Skip sprite for now since it requires a texture - + ui.append(frame) ui.append(caption) - - print("Testing UICollection sequence protocol (Issue #69)...") - - # Test len() - try: - length = len(ui) - print(f"✓ len() works: {length} items") - except Exception as e: - print(f"✗ len() failed: {e}") - - # Test indexing - try: - item0 = ui[0] - item1 = ui[1] - print(f"✓ Indexing works: [{type(item0).__name__}, {type(item1).__name__}]") - - # Test negative indexing - last_item = ui[-1] - print(f"✓ Negative indexing works: ui[-1] = {type(last_item).__name__}") - except Exception as e: - print(f"✗ Indexing failed: {e}") - - # Test slicing (if implemented) - try: - slice_items = ui[0:2] - print(f"✓ Slicing works: got {len(slice_items)} items") - except Exception as e: - print(f"✗ Slicing not implemented (Issue #69): {e}") - - # Test iteration - try: - types = [] - for item in ui: - types.append(type(item).__name__) - print(f"✓ Iteration works: {types}") - except Exception as e: - print(f"✗ Iteration failed: {e}") - - # Test contains - try: - if frame in ui: - print("✓ 'in' operator works") - else: - print("✗ 'in' operator returned False for existing item") - except Exception as e: - print(f"✗ 'in' operator not implemented (Issue #69): {e}") - - # Test remove - try: - ui.remove(1) # Remove caption - print(f"✓ remove() works, now {len(ui)} items") - except Exception as e: - print(f"✗ remove() failed: {e}") - - # Test type preservation (Issue #76) - try: - # Add a frame with children to test nested collections - parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200), - fill_color=mcrfpy.Color(200, 200, 200)) - child_caption = mcrfpy.Caption(pos=(10, 10), text="Child") - parent_frame.children.append(child_caption) - ui.append(parent_frame) - - # Check if type is preserved when retrieving - retrieved = ui[-1] - if type(retrieved).__name__ == "Frame": - print("✓ Type preservation works") - else: - print(f"✗ Type not preserved (Issue #76): got {type(retrieved).__name__}") - except Exception as e: - print(f"✗ Type preservation test failed: {e}") - - # Test find by name (Issue #41 - not yet implemented) - try: - found = ui.find("Test") - print(f"✓ find() method works: {type(found).__name__}") - except AttributeError: - print("✗ find() method not implemented (Issue #41)") - except Exception as e: - print(f"✗ find() method error: {e}") - - print("PASS") -# Run test immediately -test_UICollection() \ No newline at end of file + print("Testing UICollection sequence protocol (Issue #69)...") + + # Test len() + check("len() works", len(ui) == 2, f"expected 2, got {len(ui)}") + + # Test indexing + check("indexing works", + type(ui[0]).__name__ == "Frame" and type(ui[1]).__name__ == "Caption", + f"got [{type(ui[0]).__name__}, {type(ui[1]).__name__}]") + + # Test negative indexing + check("negative indexing works", type(ui[-1]).__name__ == "Caption", + f"ui[-1] = {type(ui[-1]).__name__}") + + # Test slicing + slice_items = ui[0:2] + check("slicing works", len(slice_items) == 2, f"got {len(slice_items)} items") + + # Test iteration + types = [type(item).__name__ for item in ui] + check("iteration works", types == ["Frame", "Caption"], f"got {types}") + + # Test contains + check("'in' operator works", frame in ui, "'in' returned False for existing item") + + # Test remove -- current API takes the Drawable itself (list.remove semantics), + # not an index. pop(index) is the index-based removal. + ui.remove(caption) + check("remove(element) works", len(ui) == 1 and caption not in ui, + f"now {len(ui)} items") + + # Test type preservation (Issue #76) + parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200), + fill_color=mcrfpy.Color(200, 200, 200)) + child_caption = mcrfpy.Caption(pos=(10, 10), text="Child") + parent_frame.children.append(child_caption) + ui.append(parent_frame) + + retrieved = ui[-1] + check("type preservation works", type(retrieved).__name__ == "Frame", + f"got {type(retrieved).__name__} (Issue #76)") + check("retrieved object identity preserved", retrieved is parent_frame) + + # Test find by name (Issue #41) + parent_frame.name = "parent" + found = ui.find("parent") + check("find() by name works", found is parent_frame, + f"got {found!r}") + check("find() returns None for missing name", ui.find("nonexistent") is None) + +test_UICollection() + +if failures: + print(f"FAIL ({len(failures)} check(s) failed)") + sys.exit(1) +print("PASS") +sys.exit(0) diff --git a/tests/unit/validate_screenshot_test.py b/tests/unit/validate_screenshot_test.py index 96c8652..bbc972c 100644 --- a/tests/unit/validate_screenshot_test.py +++ b/tests/unit/validate_screenshot_test.py @@ -1,116 +1,249 @@ #!/usr/bin/env python3 -"""Validate screenshot functionality and analyze pixel data""" +"""Validate screenshot functionality and analyze pixel data + +The headless renderer must produce a PNG that actually contains the drawn +content -- not a transparent/blank image. This test draws frames of known +colors at known positions, screenshots them, decodes the PNG and asserts the +expected color is present at the expected pixel. It also covers filename edge +cases (spaces, empty, very long). +""" import mcrfpy from mcrfpy import automation from datetime import datetime +import os +import struct import sys +import zlib + +FAILURES = [] + +OUT_DIR = "test_screenshots" + + +def check(cond, msg): + if not cond: + print("FAIL: %s" % msg) + FAILURES.append(msg) + + +# --------------------------------------------------------------------------- # +# Minimal PNG reader (stdlib only): 8-bit RGB/RGBA, non-interlaced -- what the +# engine's screenshot encoder emits. (Same reader as +# tests/regression/issue_355_camera_rotation_hit_test.py) +# --------------------------------------------------------------------------- # +def read_png(path): + with open(path, "rb") as f: + data = f.read() + assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + pos = 8 + idat = b"" + width = height = depth = color = None + while pos < len(data): + (length,) = struct.unpack(">I", data[pos:pos + 4]) + ctype = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + length] + pos += 12 + length + if ctype == b"IHDR": + width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk) + assert depth == 8, "unexpected bit depth %d" % depth + assert color in (2, 6), "unexpected color type %d" % color + assert interlace == 0, "interlaced PNG not supported" + elif ctype == b"IDAT": + idat += chunk + elif ctype == b"IEND": + break + + raw = zlib.decompress(idat) + channels = 3 if color == 2 else 4 + stride = width * channels + out = bytearray(height * stride) + prev = bytearray(stride) + p = 0 + for y in range(height): + filt = raw[p] + p += 1 + line = bytearray(raw[p:p + stride]) + p += stride + if filt == 1: # Sub + for i in range(channels, stride): + line[i] = (line[i] + line[i - channels]) & 0xFF + elif filt == 2: # Up + for i in range(stride): + line[i] = (line[i] + prev[i]) & 0xFF + elif filt == 3: # Average + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF + elif filt == 4: # Paeth + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + b = prev[i] + c = prev[i - channels] if i >= channels else 0 + pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + elif filt != 0: + raise AssertionError("bad PNG filter %d" % filt) + out[y * stride:(y + 1) * stride] = line + prev = line + return width, height, channels, bytes(out) + + +class Image: + def __init__(self, path): + self.w, self.h, self.ch, self.px = read_png(path) + + def at(self, x, y): + i = (y * self.w + x) * self.ch + return tuple(self.px[i:i + 3]), (self.px[i + 3] if self.ch == 4 else 255) + def test_screenshot_validation(): """Create visible content and validate screenshot output""" print("=== Screenshot Validation Test ===\n") - + # Create a scene with bright, visible content screenshot_validation = mcrfpy.Scene("screenshot_validation") screenshot_validation.activate() ui = screenshot_validation.children - - # Create multiple colorful elements to ensure visibility + print("Creating UI elements...") - + + # Light gray background frame, added FIRST so it is behind everything. + # (The old version appended it last and tried ui.remove(index) to reorder; + # UICollection.remove takes a Drawable, not an index -- insert(0, ...) is + # the supported way to put a drawable at the back.) + background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=mcrfpy.Color(200, 200, 200)) + ui.append(background) + # Bright red frame with white outline frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200), - fill_color=mcrfpy.Color(255, 0, 0), # Bright red - outline_color=mcrfpy.Color(255, 255, 255), # White - outline=5.0) + fill_color=mcrfpy.Color(255, 0, 0), # Bright red + outline_color=mcrfpy.Color(255, 255, 255), # White + outline=5.0) ui.append(frame1) print("Added red frame at (50, 50)") # Bright green frame frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200), - fill_color=mcrfpy.Color(0, 255, 0), # Bright green - outline_color=mcrfpy.Color(0, 0, 0), # Black - outline=3.0) + fill_color=mcrfpy.Color(0, 255, 0), # Bright green + outline_color=mcrfpy.Color(0, 0, 0), # Black + outline=3.0) ui.append(frame2) print("Added green frame at (400, 50)") # Blue frame frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200), - fill_color=mcrfpy.Color(0, 0, 255), # Bright blue - outline_color=mcrfpy.Color(255, 255, 0), # Yellow - outline=4.0) + fill_color=mcrfpy.Color(0, 0, 255), # Bright blue + outline_color=mcrfpy.Color(255, 255, 0), # Yellow + outline=4.0) ui.append(frame3) print("Added blue frame at (50, 300)") - # Add text captions - caption1 = mcrfpy.Caption(pos=(60, 60), - text="RED FRAME TEST", - fill_color=mcrfpy.Color(255, 255, 255)) + # Add text captions (Caption.font is read-only as of #320 -- set in ctor) + caption1 = mcrfpy.Caption(pos=(10, 10), font=mcrfpy.default_font, + text="RED FRAME TEST", + fill_color=mcrfpy.Color(255, 255, 255)) caption1.font_size = 24 - frame1.children.append(caption1) + frame1.children.append(caption1) # child coords are frame-relative - caption2 = mcrfpy.Caption(pos=(410, 60), - text="GREEN FRAME TEST", - fill_color=mcrfpy.Color(0, 0, 0)) + caption2 = mcrfpy.Caption(pos=(410, 60), font=mcrfpy.default_font, + text="GREEN FRAME TEST", + fill_color=mcrfpy.Color(0, 0, 0)) caption2.font_size = 24 ui.append(caption2) - caption3 = mcrfpy.Caption(pos=(60, 310), - text="BLUE FRAME TEST", - fill_color=mcrfpy.Color(255, 255, 0)) + caption3 = mcrfpy.Caption(pos=(60, 310), font=mcrfpy.default_font, + text="BLUE FRAME TEST", + fill_color=mcrfpy.Color(255, 255, 0)) caption3.font_size = 24 ui.append(caption3) - # White background frame to ensure non-transparent background - background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), - fill_color=mcrfpy.Color(200, 200, 200)) # Light gray - # Insert at beginning so it's behind everything - ui.remove(len(ui) - 1) # Remove to re-add at start - ui.append(background) - # Re-add all other elements on top - for frame in [frame1, frame2, frame3, caption2, caption3]: - ui.append(frame) - - print(f"\nTotal UI elements: {len(ui)}") - - # Take multiple screenshots with different names + print("\nTotal UI elements: %d" % len(ui)) + check(len(ui) == 6, "expected 6 top-level drawables, got %d" % len(ui)) + + # Take multiple screenshots with different names (incl. spaces in filename) + os.makedirs(OUT_DIR, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - + screenshots = [ - f"validate_screenshot_basic_{timestamp}.png", - f"validate_screenshot_with_spaces {timestamp}.png", - f"validate_screenshot_final_{timestamp}.png" + os.path.join(OUT_DIR, "validate_screenshot_basic_%s.png" % timestamp), + os.path.join(OUT_DIR, "validate_screenshot_with_spaces %s.png" % timestamp), + os.path.join(OUT_DIR, "validate_screenshot_final_%s.png" % timestamp), ] - + print("\nTaking screenshots...") for i, filename in enumerate(screenshots): result = automation.screenshot(filename) - print(f"Screenshot {i+1}: {filename} - Result: {result}") - - # Test invalid cases + print("Screenshot %d: %s - Result: %s" % (i + 1, filename, result)) + check(result is True, "screenshot(%r) did not return True" % filename) + check(os.path.exists(filename) and os.path.getsize(filename) > 0, + "screenshot(%r) produced no file" % filename) + + # --- The actual point of this test: the PNG must contain the drawn content, + # --- not a transparent/blank image. + if not FAILURES: + img = Image(screenshots[0]) + print("\nDecoded %dx%d (%d channels)" % (img.w, img.h, img.ch)) + + expected = [ + ((900, 700), (200, 200, 200), "light gray background"), + ((200, 150), (255, 0, 0), "red frame interior"), + ((550, 150), (0, 255, 0), "green frame interior"), + ((200, 400), (0, 0, 255), "blue frame interior"), + # Outlines are stroked OUTWARD from the frame bounds, so sample just + # left of x=50: red frame outline is 5px (45..50), blue frame's 4px (46..50). + ((47, 150), (255, 255, 255), "white outline of red frame"), + ((47, 400), (255, 255, 0), "yellow outline of blue frame"), + ] + for (x, y), want, what in expected: + if x >= img.w or y >= img.h: + check(False, "sample point (%d,%d) outside %dx%d image" % (x, y, img.w, img.h)) + continue + rgb, alpha = img.at(x, y) + check(rgb == want, + "%s: pixel (%d,%d) is %s, expected %s" % (what, x, y, rgb, want)) + check(alpha == 255, + "%s: pixel (%d,%d) is transparent (alpha=%d) -- headless " + "renderer produced an empty image" % (what, x, y, alpha)) + + # Text must have been rasterized: white glyph pixels inside the red frame. + # caption1 sits at frame-relative (10,10) -> absolute (60,60), 24px font. + white_in_red = 0 + for y in range(60, 90): + for x in range(60, 300): + if img.at(x, y)[0] == (255, 255, 255): + white_in_red += 1 + check(white_in_red > 20, + "caption text not rendered inside red frame (only %d white pixels)" + % white_in_red) + + # Test invalid / edge-case filenames print("\nTesting edge cases...") - - # Empty filename + + # Empty filename: must fail cleanly (False), not raise or write anything result = automation.screenshot("") - print(f"Empty filename result: {result}") - - # Very long filename - long_name = "x" * 200 + ".png" + print("Empty filename result: %s" % result) + check(result is False, "screenshot('') should return False, got %r" % result) + + # Very long (but legal, <255) filename: must succeed and produce a file + long_name = os.path.join(OUT_DIR, "x" * 200 + ".png") result = automation.screenshot(long_name) - print(f"Long filename result: {result}") - + print("Long filename result: %s" % result) + check(result is True, "screenshot(<200-char name>) should return True, got %r" % result) + check(os.path.exists(long_name), "long-filename screenshot produced no file") + print("\n=== Test Complete ===") - print("Check the PNG files to see if they contain visible content.") - print("If they're transparent, the headless renderer may not be working correctly.") - - # List what should be visible - print("\nExpected content:") - print("- Light gray background (200, 200, 200)") - print("- Red frame with white outline at (50, 50)") - print("- Green frame with black outline at (400, 50)") - print("- Blue frame with yellow outline at (50, 300)") - print("- White, black, and yellow text labels") - + if FAILURES: + print("\n%d FAILURE(S):" % len(FAILURES)) + for f in FAILURES: + print(" - %s" % f) + sys.exit(1) + + print("PASS") sys.exit(0) -# Run the test immediately -test_screenshot_validation() \ No newline at end of file + +# Run the test immediately (no timer needed: headless screenshots are synchronous) +test_screenshot_validation() diff --git a/tools/api_delta.py b/tools/api_delta.py index 75c80c8..b9db5b6 100755 --- a/tools/api_delta.py +++ b/tools/api_delta.py @@ -210,6 +210,17 @@ def compute_delta(a, b, object_pages): elif kind == "doc": functions_doc_changed.append(m) + # #356: dynamic module attributes (current_scene, scenes, ...) are their own + # manifest section; without this they drift silently. + a_attrs = a.get("module_attributes", {}) + b_attrs = b.get("module_attributes", {}) + attrs_added = sorted(set(b_attrs) - set(a_attrs)) + attrs_removed = sorted(set(a_attrs) - set(b_attrs)) + attrs_changed = [] + for m in sorted(set(a_attrs) & set(b_attrs)): + if member_changed(a_attrs[m], b_attrs[m]): + attrs_changed.append(m) + return { "ref1": None, "ref2": None, @@ -220,13 +231,18 @@ def compute_delta(a, b, object_pages): "functions_removed": functions_removed, "functions_signature_changed": functions_sig_changed, "functions_doc_changed": functions_doc_changed, + "module_attributes_added": attrs_added, + "module_attributes_removed": attrs_removed, + "module_attributes_changed": attrs_changed, } def delta_is_empty(d): return not (d["objects_added"] or d["objects_removed"] or d["objects_changed"] or d["functions_added"] or d["functions_removed"] - or d["functions_signature_changed"] or d["functions_doc_changed"]) + or d["functions_signature_changed"] or d["functions_doc_changed"] + or d["module_attributes_added"] or d["module_attributes_removed"] + or d["module_attributes_changed"]) # --------------------------------------------------------------------------- @@ -308,6 +324,22 @@ def render_md(d): % (label, ", ".join("`%s`" % m for m in members))) out.append("") + attr_rows = [ + ("added", d["module_attributes_added"]), + ("removed", d["module_attributes_removed"]), + ("changed", d["module_attributes_changed"]), + ] + if any(members for _, members in attr_rows): + out.append("## Module attributes") + out.append("") + out.append("| change | attributes |") + out.append("| --- | --- |") + for label, members in attr_rows: + if members: + out.append("| %s | %s |" + % (label, ", ".join("`%s`" % m for m in members))) + out.append("") + return "\n".join(out) diff --git a/tools/generate_api_manifest.py b/tools/generate_api_manifest.py index 0f5cb78..5f62aad 100644 --- a/tools/generate_api_manifest.py +++ b/tools/generate_api_manifest.py @@ -215,11 +215,35 @@ def collect_classes(): return classes +def collect_module_attributes(): + """#356: dynamic module attributes, as getset descriptors on type(mcrfpy). + + dir()+getattr() cannot describe these -- getattr yields the live value (None for + current_scene at generation time), not the declared type -- so read the descriptor. + """ + import types as _types + attrs = {} + for name, descr in sorted(vars(type(mcrfpy)).items()): + if name.startswith("_"): + continue + if not isinstance(descr, _types.GetSetDescriptorType): + continue + doc = descr.__doc__ or "" + attrs[name] = { + "kind": "attribute", + "readonly": "read-only" in doc.lower(), + "doc": doc, + "doc_sha": doc_hash(doc), + } + return attrs + + def collect_functions(): """Return {func_name: full_entry_without_lifecycle}.""" functions = {} + dynamic = collect_module_attributes() for name in sorted(dir(mcrfpy)): - if name.startswith("_"): + if name.startswith("_") or name in dynamic: continue obj = getattr(mcrfpy, name) if isinstance(obj, type): @@ -266,11 +290,12 @@ def assign_lifecycle(cur, prev, new_since, version, commit, has_signature): return lifecycle -def build_tree(classes, functions, prev, first_run, base_version, version, commit): +def build_tree(classes, functions, module_attrs, prev, first_run, base_version, version, commit): """Produce the full tree (entries retain 'doc'); lifecycle is computed here.""" new_since = base_version if first_run else version prev_objects = (prev or {}).get("objects", {}) prev_functions = (prev or {}).get("functions", {}) + prev_attrs = (prev or {}).get("module_attributes", {}) objects_out = {} for cname, centry in classes.items(): @@ -303,7 +328,14 @@ def build_tree(classes, functions, prev, first_run, base_version, version, commi fentry, prev_fn, new_since, version, commit, has_signature=True) functions_out[fname] = fentry - return objects_out, functions_out + attrs_out = {} + for aname, aentry in module_attrs.items(): + aentry = dict(aentry) + aentry["lifecycle"] = assign_lifecycle( + aentry, prev_attrs.get(aname), new_since, version, commit, has_signature=False) + attrs_out[aname] = aentry + + return objects_out, functions_out, attrs_out def strip_docs(node): @@ -328,9 +360,10 @@ def main(): classes = collect_classes() functions = collect_functions() + module_attrs = collect_module_attributes() - objects_out, functions_out = build_tree( - classes, functions, prev, first_run, base_version, version, commit) + objects_out, functions_out, attrs_out = build_tree( + classes, functions, module_attrs, prev, first_run, base_version, version, commit) full = { "schema": SCHEMA_VERSION, @@ -340,6 +373,7 @@ def main(): "seeded": first_run, "objects": objects_out, "functions": functions_out, + "module_attributes": attrs_out, } manifest = strip_docs(full) @@ -366,7 +400,8 @@ def main(): print("manifest: %s" % manifest_path) print("full: %s" % full_path) print("version=%s commit=%s dirty=%s seeded=%s" % (version, commit, dirty, first_run)) - print("objects=%d members=%d functions=%d" % (n_obj, n_mem, n_fn)) + print("objects=%d members=%d functions=%d module_attributes=%d" + % (n_obj, n_mem, n_fn, len(attrs_out))) if __name__ == "__main__": diff --git a/tools/generate_dynamic_docs.py b/tools/generate_dynamic_docs.py index 6f2e27e..96d34b7 100644 --- a/tools/generate_dynamic_docs.py +++ b/tools/generate_dynamic_docs.py @@ -251,12 +251,34 @@ def get_constants(): } return constants +def get_module_attributes(): + """#356: dynamic module attributes (current_scene, scenes, timers, ...). + + They are getset descriptors on type(mcrfpy) rather than module-dict entries, so + the dir()+getattr() sweep above cannot describe them: getattr returns the live + *value* (None, at generation time), not the declared type. Read the descriptor. + """ + import types as _types + attrs = {} + for name, descr in vars(type(mcrfpy)).items(): + if name.startswith('_'): + continue + if isinstance(descr, _types.GetSetDescriptorType): + doc = descr.__doc__ or "" + attrs[name] = { + "name": name, + "doc": doc, + "readonly": "read-only" in doc.lower(), + } + return attrs + def generate_html_docs(): """Generate HTML documentation.""" functions = get_all_functions() classes = get_all_classes() constants = get_constants() - + module_attrs = get_module_attributes() + html_content = f""" @@ -385,10 +407,26 @@ def generate_html_docs(): html_content += """ +
  • Module Attributes
  • Constants
  • - + +

    Module Attributes

    +

    Dynamic attributes of the mcrfpy module itself.

    +""" + + for attr_name in sorted(module_attrs.keys()): + attr_info = module_attrs[attr_name] + ro = ' (read-only)' if attr_info["readonly"] else '' + html_content += f""" +
    +

    mcrfpy.{attr_name}{ro}

    +

    {html.escape(attr_info["doc"])}

    +
    +""" + + html_content += """

    Functions

    """ @@ -511,7 +549,8 @@ def generate_markdown_docs(): functions = get_all_functions() classes = get_all_classes() constants = get_constants() - + module_attrs = get_module_attributes() + md_content = f"""# McRogueFace API Reference *Generated on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* @@ -530,8 +569,17 @@ def generate_markdown_docs(): for class_name in sorted(classes.keys()): md_content += f" - [{class_name}](#{class_name.lower()})\n" + md_content += "- [Module Attributes](#module-attributes)\n" md_content += "- [Constants](#constants)\n\n" - + + # Module attributes (#356) + md_content += "## Module Attributes\n\n" + md_content += "Dynamic attributes of the `mcrfpy` module itself.\n\n" + for attr_name in sorted(module_attrs.keys()): + attr_info = module_attrs[attr_name] + ro = " *(read-only)*" if attr_info["readonly"] else "" + md_content += f"### `mcrfpy.{attr_name}`{ro}\n\n{attr_info['doc']}\n\n" + # Functions md_content += "## Functions\n\n" @@ -630,4 +678,5 @@ if __name__ == "__main__": print("Generating dynamic documentation from mcrfpy module...") generate_html_docs() generate_markdown_docs() - print("Documentation generation complete!") \ No newline at end of file + print("Documentation generation complete!") + sys.exit(0) # #350: headless --exec must declare its outcome \ No newline at end of file diff --git a/tools/generate_stubs_v2.py b/tools/generate_stubs_v2.py index bf7d0fe..184a894 100644 --- a/tools/generate_stubs_v2.py +++ b/tools/generate_stubs_v2.py @@ -14,6 +14,7 @@ Run via McRogueFace itself so the mcrfpy module is importable: import os import re import sys +import ast import types import inspect from pathlib import Path @@ -170,6 +171,20 @@ def _sanitize_params(params): return ", ".join(fixed) +def _sanitize_return(ret): + """Return type phrases in MCRF_SIG are authored as prose (e.g. 'context + manager'), which is not a valid annotation and makes the whole .pyi + unparseable. Keep anything that is a valid Python expression; otherwise + fall back to Any.""" + if not ret: + return "Any" + try: + ast.parse(f"def _() -> {ret}: ...") + except SyntaxError: + return "Any" + return ret + + def emit_function(name, doc, is_method=False, is_static=False): """Emit a def line for a free function or method. Always returns a str ending with `: ...` plus an optional one-line docstring.""" @@ -183,7 +198,7 @@ def emit_function(name, doc, is_method=False, is_static=False): params = _sanitize_params(params) if is_method and not is_static: params = "self" + (", " + params if params else "") - ret = ret or "Any" + ret = _sanitize_return(ret) decorator = ("@staticmethod\n" if is_static and is_method else "") summary = first_description_paragraph(doc) or first_line(doc) @@ -440,13 +455,31 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, overload ''' +def module_attributes(): + """#356: the module's dynamic attributes (current_scene, scenes, ...). + + These are getset descriptors on type(mcrfpy), not entries in the module dict. + Introspecting the *descriptor* gives the declared type and read-only flag from + its docstring; introspecting the module's live *value* does not -- at generation + time current_scene is None, which is how it was previously typed 'NoneType'. + """ + attrs = {} + for name, descr in vars(type(mcrfpy)).items(): + if name.startswith("_"): + continue + if isinstance(descr, types.GetSetDescriptorType): + attrs[name] = descr.__doc__ or "" + return attrs + + def classify_module(): classes = {} functions = {} constants = {} submodules = {} + dynamic = module_attributes() for name in sorted(dir(mcrfpy)): - if name.startswith("_"): + if name.startswith("_") or name in dynamic: continue attr = getattr(mcrfpy, name) if isinstance(attr, types.ModuleType): @@ -457,11 +490,11 @@ def classify_module(): functions[name] = attr else: constants[name] = attr - return classes, functions, constants, submodules + return classes, functions, constants, submodules, dynamic def main(): - classes, functions, constants, submodules = classify_module() + classes, functions, constants, submodules, dynamic = classify_module() out_lines = [HEADER] @@ -496,6 +529,14 @@ def main(): t = type(v).__name__ out_lines.append(f"{n}: {t}") + out_lines.append("") + out_lines.append("# --- Module-level dynamic attributes (#356) -----------------------------") + for n in sorted(dynamic): + doc = dynamic[n] + t = property_type_hint(doc) + note = " # read-only" if property_is_readonly(doc) else "" + out_lines.append(f"{n}: {t}{note}") + out_text = "\n".join(out_lines).rstrip() + "\n" stubs_dir = Path("stubs") @@ -505,8 +546,10 @@ def main(): print(f"Wrote stubs/mcrfpy.pyi ({len(out_text)} bytes, " f"{len(classes)} classes, {len(functions)} functions, " - f"{len(constants)} constants, {len(submodules)} submodules)") + f"{len(constants)} constants, {len(dynamic)} dynamic attributes, " + f"{len(submodules)} submodules)") if __name__ == "__main__": main() + sys.exit(0) # #350: headless --exec must declare its outcome