diff --git a/.gitignore b/.gitignore index 39c46e1..edae67d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ __lib_windows/ build-windows/ build_windows/ _oldscripts/ + +# Audit tooling virtualenv (tools/audit_pymethoddef.py) +.venv-audit/ assets/ cellular_automata_fire/ deps/ diff --git a/ROADMAP.md b/ROADMAP.md index 9468f3a..9fa4178 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # McRogueFace - Development Roadmap -**Version**: 0.2.6-prerelease | **Era**: McRogueFace (2D roguelikes) +**Version**: 0.2.7-prerelease | **Era**: McRogueFace (2D roguelikes) -- on the road to 1.0 For detailed architecture, philosophy, and decision framework, see the [Strategic Direction](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Strategic-Direction) wiki page. For per-issue tracking, see the [Issue Roadmap](https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki/Issue-Roadmap). @@ -10,7 +10,7 @@ For detailed architecture, philosophy, and decision framework, see the [Strategi **Alpha 0.1** (2024) -- First complete release. Milestone: all datatypes behaving. -**0.2 series** (Jan-Feb 2026) -- Weekly updates to GitHub. Key additions: +**0.2 series** (Jan-Mar 2026) -- Weekly updates to GitHub. Key additions: - 3D/Voxel pipeline (experimental): Viewport3D, Camera3D, Entity3D, VoxelGrid with greedy meshing and serialization - Procedural generation: HeightMap, BSP, NoiseSource, DiscreteMap - Tiled and LDtk import with Wang tile / AutoRule resolution @@ -19,37 +19,52 @@ For detailed architecture, philosophy, and decision framework, see the [Strategi - Multi-layer grid system with chunk-based rendering and dirty-flag caching - Documentation macro system with auto-generated API docs, man pages, and type stubs - Windows cross-compilation, mobile-ish WASM support, SDL2_mixer audio +- Behavior/Trigger turn manager: `grid.step()`, entity labels, `cell_pos`, Dijkstra-backed pathfinding (#295-#303) -**Proving grounds**: Crypt of Sokoban (7DRL 2025) was the first complete game. 7DRL 2026 is the current target. +**Proving grounds**: Crypt of Sokoban (7DRL 2025), then 7DRL 2026 -- both shipped on the same engine. The 2026 jam surfaced hotfix-worthy issues (SDL key scancodes, composite textures) that have since landed on master. --- -## Current Focus: 7DRL 2026 +## Current Focus: API Freeze + Memory Safety Sweep -**Dates**: February 28 -- March 8, 2026 +7DRL 2026 is behind us (Feb 28 -- Mar 8). The engine has two concurrent tracks to 1.0: -Engine preparation is complete. All 2D systems are production-ready. The jam will expose remaining rough edges in the workflow of building a complete game on McRogueFace. +### Track 1: API Freeze +The process is underway. Closed in this pass: camelCase module functions (#304), deprecated `sprite_number` (#305), legacy string enum comparisons (#306), `Color.__eq__`/`__ne__` (#307), `Grid.position` alias (#308). -Open prep items: -- **#248** -- Crypt of Sokoban Remaster (game content for the jam) - ---- - -## Post-7DRL: The Road to 1.0 - -After 7DRL, the priority shifts from feature development to **API stability**. 1.0 means the Python API is frozen: documented, stable, and not going to break. - -### API Freeze Process -1. Catalog every public Python class, method, and property -2. Identify anything that should change before committing (naming, signatures, defaults) -3. Make breaking changes in a single coordinated pass +Remaining freeze work: +1. Catalog every public Python class, method, and property -- audit against `stubs/mcrfpy.pyi` and generated docs +2. Identify any last naming/signature/default changes before committing +3. Final breaking-change pass, bundled 4. Document the stable API as the contract -5. Experimental modules (3D/Voxel) get an explicit `experimental` label and are exempt from the freeze +5. Experimental modules (3D/Voxel) stay out of the freeze with an `experimental` label -### Post-Jam Priorities -- Fix pain points discovered during actual 7DRL game development +### Track 2: Fuzz-Driven Bug Sweep +The libFuzzer+ASan harness (#283) has nine work tranches merged: build plumbing (W1), native harness (W2/W3), then six targeted fuzzers under `tests/fuzz/`: +- `fuzz_grid_entity` -- EntityCollection lifetime (W4, fixed #258-#263, #273, #274) +- `fuzz_property_types` -- refcount / type confusion (W5, fixed #267, #268, #272) +- `fuzz_anim_timer_scene` -- animation/timer/scene lifecycles (W6) +- `fuzz_fov` -- compute_fov parameters (W8, fixed #310) +- `fuzz_maps_procgen` -- HeightMap/DiscreteMap interfaces (W7) +- `fuzz_pathfinding_behavior` -- Dijkstra + turn manager (W9, fixed #311) + +The active tier1 queue is empty. The last three findings (#309 Caption float→uint, #310 FOV enum, #311 DijkstraMap OOB) all landed on master in mid-April. Coverage extension to remaining public API surface is tracked under #312. + +### Recently Shipped (April 2026) +- **#294** -- `entity.perspective_map` replaces flat `vector` with a 3-state DiscreteMap (UNKNOWN/DISCOVERED/VISIBLE). Per-entity FOV memory is now serializable, swappable, and structurally enforces visible-as-subset-of-discovered. +- **#315** -- Pathfinding API extended with built-in heuristics (Euclidean/Manhattan/Chebyshev/Diagonal/Zero), multi-root Dijkstra, FLEE primitives (invert + descent), and an interactive demo. EntityBehavior SEEK/FLEE refactored to a `PathProvider` strategy. +- **Phase 5.2** -- six performance benchmark scripts under `tests/benchmarks/` covering grid.step(), FOV writeback cost, spatial hash vs. O(n), pathfinding with collision labels, multi-GridView render, and Dijkstra variants. Baselines under `tests/benchmarks/baseline/phase5_2/`. +- **Phase 5.3** -- documentation regenerated; `tools/generate_stubs_v2.py` rewritten as introspection-based so it can no longer drift from the C++ source. + +### Active Follow-Ups +- **#312** Extend fuzz coverage to remaining public API surface +- **#313** Migrate `UIEntity::grid` from `shared_ptr` to `shared_ptr` (post-#252 refactor cleanup) +- **#314** API audit follow-through: close gaps from `docs/api-audit-2026-04.md` +- **#316** Sparse perspective writeback in `UIEntity::updateVisibility` (Phase 5.2 finding: full-grid demote+promote dominates over TCOD FOV cost) + +### Other Post-7DRL Priorities - Progress on the r/roguelikedev tutorial series (#167) -- API consistency audit and freeze +- Complete the API freeze catalog pass (#314) - Better pip/virtualenv integration for adding packages to McRogueFace's embedded interpreter --- @@ -98,15 +113,18 @@ Rather than inverting the architecture to make McRogueFace a pip-installable pac ## Open Issues by Area -30 open issues across the tracker. Key groupings: +25 open issues across the tracker. Key groupings: -- **Multi-tile entities** (#233-#237) -- Oversized sprites, composite entities, origin offsets -- **Grid enhancements** (#152, #149, #67) -- Sparse layers, refactoring, infinite worlds +- **Recent follow-ups** (#312, #313, #314, #316) -- Fuzz coverage extension, UIEntity grid refactor, API audit follow-through, sparse perspective writeback +- **7DRL 2026 carry-over** (#248) -- Crypt of Sokoban remaster, superseded by the 7DRL 2026 entry but still relevant as a demo +- **Tooling / infrastructure** (#282, #255) -- Modern Clang for TSan/fuzzing, performance profiling +- **Demos / tutorials** (#167, #154, #156, #55) -- r/roguelikedev series, LLM agent simulations +- **Grid enhancements** (#152, #67) -- Sparse layers, infinite worlds - **Performance** (#117, #124, #145) -- Memory pools, grid point animation, texture reuse -- **LLM agent testbed** (#154, #156, #55) -- Multi-agent simulation, turn-based orchestration - **Platform/distribution** (#70, #54, #62, #53) -- Packaging, Jupyter, multiple windows, input methods -- **WASM tooling** (#238-#240) -- Debug infrastructure, automated browser testing, troubleshooting docs -- **Rendering** (#107, #218) -- Particle system, Color/Vector animation targets +- **WASM tooling** (#239) -- Automated browser testing +- **Rendering** (#107) -- Particle system +- **Deferred** (#220, #46, #45) -- Subinterpreter support / tests, accessibility modes See the [Gitea issue tracker](https://gamedev.ffwf.net/gitea/john/McRogueFace/issues) for current status. diff --git a/docs/API_REFERENCE_DYNAMIC.md b/docs/API_REFERENCE_DYNAMIC.md index 9c225ec..26b7299 100644 --- a/docs/API_REFERENCE_DYNAMIC.md +++ b/docs/API_REFERENCE_DYNAMIC.md @@ -1,6 +1,6 @@ # McRogueFace API Reference -*Generated on 2026-04-18 07:28:57* +*Generated on 2026-04-18 13:35:02* *This documentation was dynamically generated from the compiled module.* @@ -10,7 +10,6 @@ - [Classes](#classes) - [AStarPath](#astarpath) - [Alignment](#alignment) - - [Animation](#animation) - [Arc](#arc) - [AutoRuleSet](#autoruleset) - [BSP](#bsp) @@ -35,6 +34,7 @@ - [Grid](#grid) - [GridView](#gridview) - [HeightMap](#heightmap) + - [Heuristic](#heuristic) - [InputState](#inputstate) - [Key](#key) - [Keyboard](#keyboard) @@ -356,122 +356,6 @@ Return an array of bytes representing an integer. If signed is False and a negative integer is given, an OverflowError is raised. -### Animation - -Animation(property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, loop: bool = False, callback: Callable = None) - -Create an animation that interpolates a property value over time. - -Args: - property: Property name to animate. Valid properties depend on target type: - - Position/Size: 'x', 'y', 'w', 'h', 'pos', 'size' - - Appearance: 'fill_color', 'outline_color', 'outline', 'opacity' - - Sprite: 'sprite_index', 'scale' - - Grid: 'center', 'zoom' - - Caption: 'text' - - Sub-properties: 'fill_color.r', 'fill_color.g', 'fill_color.b', 'fill_color.a' - target: Target value for the animation. Type depends on property: - - float: For numeric properties (x, y, w, h, scale, opacity, zoom) - - int: For integer properties (sprite_index) - - tuple (r, g, b[, a]): For color properties - - tuple (x, y): For vector properties (pos, size, center) - - list[int]: For sprite animation sequences - - str: For text animation - duration: Animation duration in seconds. - easing: Easing function name. Options: - - 'linear' (default) - - 'easeIn', 'easeOut', 'easeInOut' - - 'easeInQuad', 'easeOutQuad', 'easeInOutQuad' - - 'easeInCubic', 'easeOutCubic', 'easeInOutCubic' - - 'easeInQuart', 'easeOutQuart', 'easeInOutQuart' - - 'easeInSine', 'easeOutSine', 'easeInOutSine' - - 'easeInExpo', 'easeOutExpo', 'easeInOutExpo' - - 'easeInCirc', 'easeOutCirc', 'easeInOutCirc' - - 'easeInElastic', 'easeOutElastic', 'easeInOutElastic' - - 'easeInBack', 'easeOutBack', 'easeInOutBack' - - 'easeInBounce', 'easeOutBounce', 'easeInOutBounce' - delta: If True, target is relative to start value (additive). Default False. - loop: If True, animation repeats from start when it reaches the end. Default False. - callback: Function(target, property, value) called when animation completes. - Not called for looping animations (since they never complete). - -Example: - # Move a frame from current position to x=500 over 2 seconds - anim = mcrfpy.Animation('x', 500.0, 2.0, 'easeInOut') - anim.start(my_frame) - - # Looping sprite animation - walk = mcrfpy.Animation('sprite_index', [0,1,2,3,2,1], 0.6, loop=True) - walk.start(my_sprite) - - -**Properties:** -- `duration` *(read-only)*: Animation duration in seconds (float, read-only). Total time for the animation to complete. -- `elapsed` *(read-only)*: Elapsed time in seconds (float, read-only). Time since the animation started. -- `is_complete` *(read-only)*: Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called. -- `is_delta` *(read-only)*: Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value. -- `is_looping` *(read-only)*: Whether animation loops (bool, read-only). Looping animations repeat from the start when they reach the end. -- `property` *(read-only)*: Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index'). - -**Methods:** - -#### `complete() -> None` - -Complete the animation immediately by jumping to the final value. - -Note: - -**Returns:** None Sets elapsed = duration and applies target value immediately. Completion callback will be called if set. - -#### `get_current_value() -> Any` - -Get the current interpolated value of the animation. - -Note: - -**Returns:** Any: Current value (type depends on property: float, int, Color tuple, Vector tuple, or str) Return type matches the target property type. For sprite_index returns int, for pos returns (x, y), for fill_color returns (r, g, b, a). - -#### `hasValidTarget() -> bool` - -Check if the animation still has a valid target. - -Note: - -**Returns:** bool: True if the target still exists, False if it was destroyed Animations automatically clean up when targets are destroyed. Use this to check if manual cleanup is needed. - -#### `start(target: UIDrawable, conflict_mode: str = 'replace') -> None` - -Start the animation on a target UI element. - -Note: - -**Arguments:** -- `target`: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity) -- `conflict_mode`: How to handle conflicts if property is already animating: 'replace' (default) - complete existing animation and start new one; 'queue' - wait for existing animation to complete; 'error' - raise RuntimeError if property is busy - -**Returns:** None - -**Raises:** RuntimeError: When conflict_mode='error' and property is already animating The animation will automatically stop if the target is destroyed. - -#### `stop() -> None` - -Stop the animation without completing it. - -Note: - -**Returns:** None Unlike complete(), this does NOT apply the final value and does NOT trigger the callback. The animation is simply cancelled and will be removed from the AnimationManager. - -#### `update(delta_time: float) -> bool` - -Update the animation by the given time delta. - -Note: - -**Arguments:** -- `delta_time`: Time elapsed since last update in seconds - -**Returns:** bool: True if animation is still running, False if complete Typically called by AnimationManager automatically. Manual calls only needed for custom animation control. - ### Arc *Inherits from: Drawable* @@ -1418,6 +1302,18 @@ Example: **Methods:** +#### `descent_step(pos) -> Vector | None` + +Get the adjacent cell with the lowest distance (steepest descent). +Unlike step_from (which follows the path set by path_from), descent_step +always returns the best neighbor in a single hop. Useful for AI that +reacts to the current distance field rather than following a fixed path. + +**Arguments:** +- `pos`: Current position as Vector, Entity, or (x, y) tuple. + +**Returns:** Next position as Vector, or None if pos is a local minimum or off-grid. + #### `distance(pos) -> float | None` Get distance from position to root. @@ -1427,6 +1323,16 @@ Get distance from position to root. **Returns:** Float distance, or None if position is unreachable. +#### `invert() -> DijkstraMap` + +Return a NEW DijkstraMap whose distance field is the safety field. +Cells near a root become high values and cells far from any root become +low values. Combined with step_from or descent_step, this gives flee +behavior: descend the inverted map to move away from the original roots. +The original DijkstraMap is unchanged. + +**Returns:** New DijkstraMap with inverted distances. + #### `path_from(pos) -> AStarPath` Get full path from position to root. @@ -3052,6 +2958,99 @@ Return NEW HeightMap with uniform value where in range, 0.0 elsewhere. **Raises:** ValueError: min > max +### Heuristic + +*Inherits from: IntEnum* + +Built-in A* heuristic function selector. + +Values: + EUCLIDEAN: sqrt((dx)^2 + (dy)^2). Admissible, default. + MANHATTAN: |dx| + |dy|. Admissible on 4-connected grids. + CHEBYSHEV: max(|dx|, |dy|). Admissible on 8-connected (diag=1). + DIAGONAL: Octile distance. Admissible on 8-connected (diag=sqrt(2)). + ZERO: Always returns 0. A* degenerates to Dijkstra. + + +**Properties:** +- `denominator`: the denominator of a rational number in lowest terms +- `imag`: the imaginary part of a complex number +- `numerator`: the numerator of a rational number in lowest terms +- `real`: the real part of a complex number + +**Methods:** + +#### `as_integer_ratio(...)` + +Return a pair of integers, whose ratio is equal to the original int. +The ratio is in lowest terms and has a positive denominator. +>>> (10).as_integer_ratio() +(10, 1) +>>> (-10).as_integer_ratio() +(-10, 1) +>>> (0).as_integer_ratio() +(0, 1) + +#### `bit_count(...)` + +Number of ones in the binary representation of the absolute value of self. +Also known as the population count. +>>> bin(13) +'0b1101' +>>> (13).bit_count() +3 + +#### `bit_length(...)` + +Number of bits necessary to represent self in binary. +>>> bin(37) +'0b100101' +>>> (37).bit_length() +6 + +#### `conjugate(...)` + +Returns self, the complex conjugate of any int. + +#### `from_bytes(...)` + +Return the integer represented by the given array of bytes. + bytes + Holds the array of bytes to convert. The argument must either + support the buffer protocol or be an iterable object producing bytes. + Bytes and bytearray are examples of built-in objects that support the + buffer protocol. + byteorder + The byte order used to represent the integer. If byteorder is 'big', + the most significant byte is at the beginning of the byte array. If + byteorder is 'little', the most significant byte is at the end of the + byte array. To request the native byte order of the host system, use + sys.byteorder as the byte order value. Default is to use 'big'. + signed + Indicates whether two's complement is used to represent the integer. + +#### `is_integer(...)` + +Returns True. Exists for duck type compatibility with float.is_integer. + +#### `to_bytes(...)` + +Return an array of bytes representing an integer. + length + Length of bytes object to use. An OverflowError is raised if the + integer is not representable with the given number of bytes. Default + is length 1. + byteorder + The byte order used to represent the integer. If byteorder is 'big', + the most significant byte is at the beginning of the byte array. If + byteorder is 'little', the most significant byte is at the end of the + byte array. To request the native byte order of the host system, use + sys.byteorder as the byte order value. Default is to use 'big'. + signed + Determines whether two's complement is used to represent the integer. + If signed is False and a negative integer is given, an OverflowError + is raised. + ### InputState *Inherits from: IntEnum* diff --git a/docs/api_reference_dynamic.html b/docs/api_reference_dynamic.html index 48b4abd..f33bb90 100644 --- a/docs/api_reference_dynamic.html +++ b/docs/api_reference_dynamic.html @@ -108,7 +108,7 @@

McRogueFace API Reference

-

Generated on 2026-04-18 07:28:57

+

Generated on 2026-04-18 13:35:02

This documentation was dynamically generated from the compiled module.

@@ -119,7 +119,6 @@
-
-

Animation

-

Animation(property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, loop: bool = False, callback: Callable = None) - -Create an animation that interpolates a property value over time. - -Args: - property: Property name to animate. Valid properties depend on target type: - - Position/Size: 'x', 'y', 'w', 'h', 'pos', 'size' - - Appearance: 'fill_color', 'outline_color', 'outline', 'opacity' - - Sprite: 'sprite_index', 'scale' - - Grid: 'center', 'zoom' - - Caption: 'text' - - Sub-properties: 'fill_color.r', 'fill_color.g', 'fill_color.b', 'fill_color.a' - target: Target value for the animation. Type depends on property: - - float: For numeric properties (x, y, w, h, scale, opacity, zoom) - - int: For integer properties (sprite_index) - - tuple (r, g, b[, a]): For color properties - - tuple (x, y): For vector properties (pos, size, center) - - list[int]: For sprite animation sequences - - str: For text animation - duration: Animation duration in seconds. - easing: Easing function name. Options: - - 'linear' (default) - - 'easeIn', 'easeOut', 'easeInOut' - - 'easeInQuad', 'easeOutQuad', 'easeInOutQuad' - - 'easeInCubic', 'easeOutCubic', 'easeInOutCubic' - - 'easeInQuart', 'easeOutQuart', 'easeInOutQuart' - - 'easeInSine', 'easeOutSine', 'easeInOutSine' - - 'easeInExpo', 'easeOutExpo', 'easeInOutExpo' - - 'easeInCirc', 'easeOutCirc', 'easeInOutCirc' - - 'easeInElastic', 'easeOutElastic', 'easeInOutElastic' - - 'easeInBack', 'easeOutBack', 'easeInOutBack' - - 'easeInBounce', 'easeOutBounce', 'easeInOutBounce' - delta: If True, target is relative to start value (additive). Default False. - loop: If True, animation repeats from start when it reaches the end. Default False. - callback: Function(target, property, value) called when animation completes. - Not called for looping animations (since they never complete). - -Example: - # Move a frame from current position to x=500 over 2 seconds - anim = mcrfpy.Animation('x', 500.0, 2.0, 'easeInOut') - anim.start(my_frame) - - # Looping sprite animation - walk = mcrfpy.Animation('sprite_index', [0,1,2,3,2,1], 0.6, loop=True) - walk.start(my_sprite) -

-

Properties:

-
    -
  • duration (read-only): Animation duration in seconds (float, read-only). Total time for the animation to complete.
  • -
  • elapsed (read-only): Elapsed time in seconds (float, read-only). Time since the animation started.
  • -
  • is_complete (read-only): Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called.
  • -
  • is_delta (read-only): Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value.
  • -
  • is_looping (read-only): Whether animation loops (bool, read-only). Looping animations repeat from the start when they reach the end.
  • -
  • property (read-only): Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index').
  • -
-

Methods:

- -
-
complete() -> None
-

Complete the animation immediately by jumping to the final value. - -Note:

-

Returns: None Sets elapsed = duration and applies target value immediately. Completion callback will be called if set.

-
- -
-
get_current_value() -> Any
-

Get the current interpolated value of the animation. - -Note:

-

Returns: Any: Current value (type depends on property: float, int, Color tuple, Vector tuple, or str) Return type matches the target property type. For sprite_index returns int, for pos returns (x, y), for fill_color returns (r, g, b, a).

-
- -
-
hasValidTarget() -> bool
-

Check if the animation still has a valid target. - -Note:

-

Returns: bool: True if the target still exists, False if it was destroyed Animations automatically clean up when targets are destroyed. Use this to check if manual cleanup is needed.

-
- -
-
start(target: UIDrawable, conflict_mode: str = 'replace') -> None
-

Start the animation on a target UI element. - -Note:

-
-
target: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity)
-
conflict_mode: How to handle conflicts if property is already animating: 'replace' (default) - complete existing animation and start new one; 'queue' - wait for existing animation to complete; 'error' - raise RuntimeError if property is busy
-
-

Returns: None

-

Raises: RuntimeError: When conflict_mode='error' and property is already animating The animation will automatically stop if the target is destroyed.

-
- -
-
stop() -> None
-

Stop the animation without completing it. - -Note:

-

Returns: None Unlike complete(), this does NOT apply the final value and does NOT trigger the callback. The animation is simply cancelled and will be removed from the AnimationManager.

-
- -
-
update(delta_time: float) -> bool
-

Update the animation by the given time delta. - -Note:

-
-
delta_time: Time elapsed since last update in seconds
-
-

Returns: bool: True if animation is still running, False if complete Typically called by AnimationManager automatically. Manual calls only needed for custom animation control.

-
-
-

Arc

Inherits from: Drawable

@@ -1567,6 +1451,18 @@ Example:

Methods:

+
+
descent_step(pos) -> Vector | None
+

Get the adjacent cell with the lowest distance (steepest descent). +Unlike step_from (which follows the path set by path_from), descent_step +always returns the best neighbor in a single hop. Useful for AI that +reacts to the current distance field rather than following a fixed path.

+
+
pos: Current position as Vector, Entity, or (x, y) tuple.
+
+

Returns: Next position as Vector, or None if pos is a local minimum or off-grid.

+
+
distance(pos) -> float | None

Get distance from position to root.

@@ -1576,6 +1472,16 @@ Example:

Returns: Float distance, or None if position is unreachable.

+
+
invert() -> DijkstraMap
+

Return a NEW DijkstraMap whose distance field is the safety field. +Cells near a root become high values and cells far from any root become +low values. Combined with step_from or descent_step, this gives flee +behavior: descend the inverted map to move away from the original roots. +The original DijkstraMap is unchanged.

+

Returns: New DijkstraMap with inverted distances.

+
+
path_from(pos) -> AStarPath

Get full path from position to root.

@@ -3247,6 +3153,106 @@ Note:

+
+

Heuristic

+

Inherits from: IntEnum

+

Built-in A* heuristic function selector. + +Values: + EUCLIDEAN: sqrt((dx)^2 + (dy)^2). Admissible, default. + MANHATTAN: |dx| + |dy|. Admissible on 4-connected grids. + CHEBYSHEV: max(|dx|, |dy|). Admissible on 8-connected (diag=1). + DIAGONAL: Octile distance. Admissible on 8-connected (diag=sqrt(2)). + ZERO: Always returns 0. A* degenerates to Dijkstra. +

+

Properties:

+
    +
  • denominator: the denominator of a rational number in lowest terms
  • +
  • imag: the imaginary part of a complex number
  • +
  • numerator: the numerator of a rational number in lowest terms
  • +
  • real: the real part of a complex number
  • +
+

Methods:

+ +
+
as_integer_ratio(...)
+

Return a pair of integers, whose ratio is equal to the original int. +The ratio is in lowest terms and has a positive denominator. +>>> (10).as_integer_ratio() +(10, 1) +>>> (-10).as_integer_ratio() +(-10, 1) +>>> (0).as_integer_ratio() +(0, 1)

+
+ +
+
bit_count(...)
+

Number of ones in the binary representation of the absolute value of self. +Also known as the population count. +>>> bin(13) +'0b1101' +>>> (13).bit_count() +3

+
+ +
+
bit_length(...)
+

Number of bits necessary to represent self in binary. +>>> bin(37) +'0b100101' +>>> (37).bit_length() +6

+
+ +
+
conjugate(...)
+

Returns self, the complex conjugate of any int.

+
+ +
+
from_bytes(...)
+

Return the integer represented by the given array of bytes. + bytes + Holds the array of bytes to convert. The argument must either + support the buffer protocol or be an iterable object producing bytes. + Bytes and bytearray are examples of built-in objects that support the + buffer protocol. + byteorder + The byte order used to represent the integer. If byteorder is 'big', + the most significant byte is at the beginning of the byte array. If + byteorder is 'little', the most significant byte is at the end of the + byte array. To request the native byte order of the host system, use + sys.byteorder as the byte order value. Default is to use 'big'. + signed + Indicates whether two's complement is used to represent the integer.

+
+ +
+
is_integer(...)
+

Returns True. Exists for duck type compatibility with float.is_integer.

+
+ +
+
to_bytes(...)
+

Return an array of bytes representing an integer. + length + Length of bytes object to use. An OverflowError is raised if the + integer is not representable with the given number of bytes. Default + is length 1. + byteorder + The byte order used to represent the integer. If byteorder is 'big', + the most significant byte is at the beginning of the byte array. If + byteorder is 'little', the most significant byte is at the end of the + byte array. To request the native byte order of the host system, use + sys.byteorder as the byte order value. Default is to use 'big'. + signed + Determines whether two's complement is used to represent the integer. + If signed is False and a negative integer is given, an OverflowError + is raised.

+
+
+

InputState

Inherits from: IntEnum

diff --git a/docs/mcrfpy.3 b/docs/mcrfpy.3 index a9f78ab..0008515 100644 --- a/docs/mcrfpy.3 +++ b/docs/mcrfpy.3 @@ -14,11 +14,11 @@ . ftr VB CB . ftr VBI CBI .\} -.TH "MCRFPY" "3" "2026-04-18" "McRogueFace 0.2.7-prerelease-7drl2026-79-g59e7221" "" +.TH "MCRFPY" "3" "2026-04-18" "McRogueFace 0.2.7-prerelease-7drl2026-93-g0f7254e" "" .hy .SH McRogueFace API Reference .PP -\f[I]Generated on 2026-04-18 07:28:57\f[R] +\f[I]Generated on 2026-04-18 13:35:02\f[R] .PP \f[I]This documentation was dynamically generated from the compiled module.\f[R] @@ -33,8 +33,6 @@ AStarPath .IP \[bu] 2 Alignment .IP \[bu] 2 -Animation -.IP \[bu] 2 Arc .IP \[bu] 2 AutoRuleSet @@ -83,6 +81,8 @@ GridView .IP \[bu] 2 HeightMap .IP \[bu] 2 +Heuristic +.IP \[bu] 2 InputState .IP \[bu] 2 Key @@ -436,146 +436,6 @@ signed Determines whether two\[cq]s complement is used to represent the integer. If signed is False and a negative integer is given, an OverflowError is raised. -.SS Animation -.PP -Animation(property: str, target: Any, duration: float, easing: str = -`linear', delta: bool = False, loop: bool = False, callback: Callable = -None) -.PP -Create an animation that interpolates a property value over time. -.PP -Args: property: Property name to animate. -Valid properties depend on target type: - Position/Size: `x', `y', `w', -`h', `pos', `size' - Appearance: `fill_color', `outline_color', -`outline', `opacity' - Sprite: `sprite_index', `scale' - Grid: `center', -`zoom' - Caption: `text' - Sub-properties: `fill_color.r', -`fill_color.g', `fill_color.b', `fill_color.a' target: Target value for -the animation. -Type depends on property: - float: For numeric properties (x, y, w, h, -scale, opacity, zoom) - int: For integer properties (sprite_index) - -tuple (r, g, b[, a]): For color properties - tuple (x, y): For vector -properties (pos, size, center) - list[int]: For sprite animation -sequences - str: For text animation duration: Animation duration in -seconds. -easing: Easing function name. -Options: - `linear' (default) - `easeIn', `easeOut', `easeInOut' - -`easeInQuad', `easeOutQuad', `easeInOutQuad' - `easeInCubic', -`easeOutCubic', `easeInOutCubic' - `easeInQuart', `easeOutQuart', -`easeInOutQuart' - `easeInSine', `easeOutSine', `easeInOutSine' - -`easeInExpo', `easeOutExpo', `easeInOutExpo' - `easeInCirc', -`easeOutCirc', `easeInOutCirc' - `easeInElastic', `easeOutElastic', -`easeInOutElastic' - `easeInBack', `easeOutBack', `easeInOutBack' - -`easeInBounce', `easeOutBounce', `easeInOutBounce' delta: If True, -target is relative to start value (additive). -Default False. -loop: If True, animation repeats from start when it reaches the end. -Default False. -callback: Function(target, property, value) called when animation -completes. -Not called for looping animations (since they never complete). -.PP -Example: # Move a frame from current position to x=500 over 2 seconds -anim = mcrfpy.Animation(`x', 500.0, 2.0, `easeInOut') -anim.start(my_frame) -.IP -.nf -\f[C] -# Looping sprite animation -walk = mcrfpy.Animation(\[aq]sprite_index\[aq], [0,1,2,3,2,1], 0.6, loop=True) -walk.start(my_sprite) -\f[R] -.fi -.PP -\f[B]Properties:\f[R] - \f[V]duration\f[R] \f[I](read-only)\f[R]: -Animation duration in seconds (float, read-only). -Total time for the animation to complete. -- \f[V]elapsed\f[R] \f[I](read-only)\f[R]: Elapsed time in seconds -(float, read-only). -Time since the animation started. -- \f[V]is_complete\f[R] \f[I](read-only)\f[R]: Whether animation is -complete (bool, read-only). -True when elapsed >= duration or complete() was called. -- \f[V]is_delta\f[R] \f[I](read-only)\f[R]: Whether animation uses delta -mode (bool, read-only). -In delta mode, the target value is added to the starting value. -- \f[V]is_looping\f[R] \f[I](read-only)\f[R]: Whether animation loops -(bool, read-only). -Looping animations repeat from the start when they reach the end. -- \f[V]property\f[R] \f[I](read-only)\f[R]: Target property name (str, -read-only). -The property being animated (e.g., `pos', `opacity', `sprite_index'). -.PP -\f[B]Methods:\f[R] -.SS \f[V]complete() -> None\f[R] -.PP -Complete the animation immediately by jumping to the final value. -.PP -Note: -.PP -\f[B]Returns:\f[R] None Sets elapsed = duration and applies target value -immediately. -Completion callback will be called if set. -.SS \f[V]get_current_value() -> Any\f[R] -.PP -Get the current interpolated value of the animation. -.PP -Note: -.PP -\f[B]Returns:\f[R] Any: Current value (type depends on property: float, -int, Color tuple, Vector tuple, or str) Return type matches the target -property type. -For sprite_index returns int, for pos returns (x, y), for fill_color -returns (r, g, b, a). -.SS \f[V]hasValidTarget() -> bool\f[R] -.PP -Check if the animation still has a valid target. -.PP -Note: -.PP -\f[B]Returns:\f[R] bool: True if the target still exists, False if it -was destroyed Animations automatically clean up when targets are -destroyed. -Use this to check if manual cleanup is needed. -.SS \f[V]start(target: UIDrawable, conflict_mode: str = \[aq]replace\[aq]) -> None\f[R] -.PP -Start the animation on a target UI element. -.PP -Note: -.PP -\f[B]Arguments:\f[R] - \f[V]target\f[R]: The UI element to animate -(Frame, Caption, Sprite, Grid, or Entity) - \f[V]conflict_mode\f[R]: How -to handle conflicts if property is already animating: `replace' -(default) - complete existing animation and start new one; `queue' - -wait for existing animation to complete; `error' - raise RuntimeError if -property is busy -.PP -\f[B]Returns:\f[R] None -.PP -\f[B]Raises:\f[R] RuntimeError: When conflict_mode=`error' and property -is already animating The animation will automatically stop if the target -is destroyed. -.SS \f[V]stop() -> None\f[R] -.PP -Stop the animation without completing it. -.PP -Note: -.PP -\f[B]Returns:\f[R] None Unlike complete(), this does NOT apply the final -value and does NOT trigger the callback. -The animation is simply cancelled and will be removed from the -AnimationManager. -.SS \f[V]update(delta_time: float) -> bool\f[R] -.PP -Update the animation by the given time delta. -.PP -Note: -.PP -\f[B]Arguments:\f[R] - \f[V]delta_time\f[R]: Time elapsed since last -update in seconds -.PP -\f[B]Returns:\f[R] bool: True if animation is still running, False if -complete Typically called by AnimationManager automatically. -Manual calls only needed for custom animation control. .SS Arc .PP \f[I]Inherits from: Drawable\f[R] @@ -1602,6 +1462,19 @@ enemies: dist = dijkstra.distance(enemy.pos) if dist and dist < 10: step position that distances are measured from (Vector, read-only). .PP \f[B]Methods:\f[R] +.SS \f[V]descent_step(pos) -> Vector | None\f[R] +.PP +Get the adjacent cell with the lowest distance (steepest descent). +Unlike step_from (which follows the path set by path_from), descent_step +always returns the best neighbor in a single hop. +Useful for AI that reacts to the current distance field rather than +following a fixed path. +.PP +\f[B]Arguments:\f[R] - \f[V]pos\f[R]: Current position as Vector, +Entity, or (x, y) tuple. +.PP +\f[B]Returns:\f[R] Next position as Vector, or None if pos is a local +minimum or off-grid. .SS \f[V]distance(pos) -> float | None\f[R] .PP Get distance from position to root. @@ -1610,6 +1483,16 @@ Get distance from position to root. y) tuple. .PP \f[B]Returns:\f[R] Float distance, or None if position is unreachable. +.SS \f[V]invert() -> DijkstraMap\f[R] +.PP +Return a NEW DijkstraMap whose distance field is the safety field. +Cells near a root become high values and cells far from any root become +low values. +Combined with step_from or descent_step, this gives flee behavior: +descend the inverted map to move away from the original roots. +The original DijkstraMap is unchanged. +.PP +\f[B]Returns:\f[R] New DijkstraMap with inverted distances. .SS \f[V]path_from(pos) -> AStarPath\f[R] .PP Get full path from position to root. @@ -3307,6 +3190,90 @@ or list, inclusive - \f[V]value\f[R]: Value to set for cells in range \f[B]Returns:\f[R] HeightMap: New HeightMap (original is unchanged) .PP \f[B]Raises:\f[R] ValueError: min > max +.SS Heuristic +.PP +\f[I]Inherits from: IntEnum\f[R] +.PP +Built-in A* heuristic function selector. +.PP +Values: EUCLIDEAN: sqrt((dx)\[ha]2 + (dy)\[ha]2). +Admissible, default. +MANHATTAN: |dx| + |dy|. +Admissible on 4-connected grids. +CHEBYSHEV: max(|dx|, |dy|). +Admissible on 8-connected (diag=1). +DIAGONAL: Octile distance. +Admissible on 8-connected (diag=sqrt(2)). +ZERO: Always returns 0. +A* degenerates to Dijkstra. +.PP +\f[B]Properties:\f[R] - \f[V]denominator\f[R]: the denominator of a +rational number in lowest terms - \f[V]imag\f[R]: the imaginary part of +a complex number - \f[V]numerator\f[R]: the numerator of a rational +number in lowest terms - \f[V]real\f[R]: the real part of a complex +number +.PP +\f[B]Methods:\f[R] +.SS \f[V]as_integer_ratio(...)\f[R] +.PP +Return a pair of integers, whose ratio is equal to the original int. +The ratio is in lowest terms and has a positive denominator. +>>> (10).as_integer_ratio() (10, 1) >>> (-10).as_integer_ratio() (-10, +1) >>> (0).as_integer_ratio() (0, 1) +.SS \f[V]bit_count(...)\f[R] +.PP +Number of ones in the binary representation of the absolute value of +self. +Also known as the population count. +>>> bin(13) `0b1101' >>> (13).bit_count() 3 +.SS \f[V]bit_length(...)\f[R] +.PP +Number of bits necessary to represent self in binary. +>>> bin(37) `0b100101' >>> (37).bit_length() 6 +.SS \f[V]conjugate(...)\f[R] +.PP +Returns self, the complex conjugate of any int. +.SS \f[V]from_bytes(...)\f[R] +.PP +Return the integer represented by the given array of bytes. +bytes Holds the array of bytes to convert. +The argument must either support the buffer protocol or be an iterable +object producing bytes. +Bytes and bytearray are examples of built-in objects that support the +buffer protocol. +byteorder The byte order used to represent the integer. +If byteorder is `big', the most significant byte is at the beginning of +the byte array. +If byteorder is `little', the most significant byte is at the end of the +byte array. +To request the native byte order of the host system, use sys.byteorder +as the byte order value. +Default is to use `big'. +signed Indicates whether two\[cq]s complement is used to represent the +integer. +.SS \f[V]is_integer(...)\f[R] +.PP +Returns True. +Exists for duck type compatibility with float.is_integer. +.SS \f[V]to_bytes(...)\f[R] +.PP +Return an array of bytes representing an integer. +length Length of bytes object to use. +An OverflowError is raised if the integer is not representable with the +given number of bytes. +Default is length 1. +byteorder The byte order used to represent the integer. +If byteorder is `big', the most significant byte is at the beginning of +the byte array. +If byteorder is `little', the most significant byte is at the end of the +byte array. +To request the native byte order of the host system, use sys.byteorder +as the byte order value. +Default is to use `big'. +signed Determines whether two\[cq]s complement is used to represent the +integer. +If signed is False and a negative integer is given, an OverflowError is +raised. .SS InputState .PP \f[I]Inherits from: IntEnum\f[R] diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index f5d452e..770fc93 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -814,14 +814,24 @@ PyGetSetDef PyGridLayerAPI::ColorLayer_getsetters[] = { }; int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"z_index", "name", "grid_size", NULL}; + // 1.0 API freeze: positional order is now (name, z_index, ...). + static const char* kwlist[] = {"name", "z_index", "grid_size", "grid", NULL}; int z_index = -1; const char* name_str = nullptr; PyObject* grid_size_obj = nullptr; + PyObject* grid_obj = nullptr; int grid_x = 0, grid_y = 0; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izO", const_cast(kwlist), - &z_index, &name_str, &grid_size_obj)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ziOO", const_cast(kwlist), + &name_str, &z_index, &grid_size_obj, &grid_obj)) { + return -1; + } + + // Validate grid kwarg type up-front (before allocating data). + if (grid_obj && grid_obj != Py_None && + !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) && + !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) { + PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance"); return -1; } @@ -851,6 +861,15 @@ int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, Py } self->grid.reset(); + // Auto-attach to grid via grid.add_layer(self) if grid= was supplied. + if (grid_obj && grid_obj != Py_None) { + PyObject* result = PyObject_CallMethod(grid_obj, "add_layer", "O", (PyObject*)self); + if (!result) { + return -1; + } + Py_DECREF(result); + } + return 0; } @@ -945,6 +964,90 @@ PyObject* PyGridLayerAPI::ColorLayer_set(PyColorLayerObject* self, PyObject* arg Py_RETURN_NONE; } +// ============================================================================= +// Subscript protocol: layer[x, y] / layer[x, y] = value +// ============================================================================= + +// Helper: parse a 2-tuple key into (x, y) ints. Sets TypeError on bad keys. +static bool parse_subscript_key(PyObject* key, int* x, int* y) { + if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) { + PyErr_SetString(PyExc_TypeError, + "Layer indices must be a 2-tuple (x, y)"); + return false; + } + PyObject* x_obj = PyTuple_GetItem(key, 0); + PyObject* y_obj = PyTuple_GetItem(key, 1); + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "Layer indices must be integers"); + return false; + } + *x = (int)PyLong_AsLong(x_obj); + *y = (int)PyLong_AsLong(y_obj); + return true; +} + +PyObject* PyGridLayerAPI::ColorLayer_subscript(PyColorLayerObject* self, PyObject* key) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + int x, y; + if (!parse_subscript_key(key, &x, &y)) return NULL; + + if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for ColorLayer of size (%d, %d)", + x, y, self->data->grid_x, self->data->grid_y); + return NULL; + } + + const sf::Color& color = self->data->at(x, y); + + // Wrap as mcrfpy.Color + auto* color_type = (PyTypeObject*)PyObject_GetAttrString( + PyImport_ImportModule("mcrfpy"), "Color"); + if (!color_type) return NULL; + PyColorObject* color_obj = (PyColorObject*)color_type->tp_alloc(color_type, 0); + Py_DECREF(color_type); + if (!color_obj) return NULL; + color_obj->data = color; + return (PyObject*)color_obj; +} + +int PyGridLayerAPI::ColorLayer_subscript_assign(PyColorLayerObject* self, PyObject* key, PyObject* value) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + if (value == nullptr) { + PyErr_SetString(PyExc_TypeError, "cannot delete ColorLayer cells"); + return -1; + } + int x, y; + if (!parse_subscript_key(key, &x, &y)) return -1; + + if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for ColorLayer of size (%d, %d)", + x, y, self->data->grid_x, self->data->grid_y); + return -1; + } + + // PyColor::fromPy accepts Color objects, tuples, lists, ints; sets PyErr on failure. + sf::Color color = PyColor::fromPy(value); + if (PyErr_Occurred()) return -1; + + self->data->at(x, y) = color; + self->data->markDirty(x, y); + return 0; +} + +PyMappingMethods PyGridLayerAPI::ColorLayer_mapping_methods = { + .mp_length = nullptr, + .mp_subscript = (binaryfunc)PyGridLayerAPI::ColorLayer_subscript, + .mp_ass_subscript = (objobjargproc)PyGridLayerAPI::ColorLayer_subscript_assign, +}; + PyObject* PyGridLayerAPI::ColorLayer_fill(PyColorLayerObject* self, PyObject* args) { PyObject* color_obj; if (!PyArg_ParseTuple(args, "O", &color_obj)) { @@ -1844,15 +1947,25 @@ PyGetSetDef PyGridLayerAPI::TileLayer_getsetters[] = { }; int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"z_index", "name", "texture", "grid_size", NULL}; + // 1.0 API freeze: positional order is now (name, z_index, ...). + static const char* kwlist[] = {"name", "z_index", "texture", "grid_size", "grid", NULL}; int z_index = -1; const char* name_str = nullptr; PyObject* texture_obj = nullptr; PyObject* grid_size_obj = nullptr; + PyObject* grid_obj = nullptr; int grid_x = 0, grid_y = 0; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izOO", const_cast(kwlist), - &z_index, &name_str, &texture_obj, &grid_size_obj)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ziOOO", const_cast(kwlist), + &name_str, &z_index, &texture_obj, &grid_size_obj, &grid_obj)) { + return -1; + } + + // Validate grid kwarg type up-front (before allocating data). + if (grid_obj && grid_obj != Py_None && + !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) && + !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) { + PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance"); return -1; } @@ -1903,6 +2016,15 @@ int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyOb } self->grid.reset(); + // Auto-attach to grid via grid.add_layer(self) if grid= was supplied. + if (grid_obj && grid_obj != Py_None) { + PyObject* result = PyObject_CallMethod(grid_obj, "add_layer", "O", (PyObject*)self); + if (!result) { + return -1; + } + Py_DECREF(result); + } + return 0; } @@ -1952,6 +2074,64 @@ PyObject* PyGridLayerAPI::TileLayer_set(PyTileLayerObject* self, PyObject* args) Py_RETURN_NONE; } +// ============================================================================= +// TileLayer subscript: tl[x, y] / tl[x, y] = index +// ============================================================================= + +PyObject* PyGridLayerAPI::TileLayer_subscript(PyTileLayerObject* self, PyObject* key) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + int x, y; + if (!parse_subscript_key(key, &x, &y)) return NULL; + + if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for TileLayer of size (%d, %d)", + x, y, self->data->grid_x, self->data->grid_y); + return NULL; + } + + return PyLong_FromLong(self->data->at(x, y)); +} + +int PyGridLayerAPI::TileLayer_subscript_assign(PyTileLayerObject* self, PyObject* key, PyObject* value) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + if (value == nullptr) { + PyErr_SetString(PyExc_TypeError, "cannot delete TileLayer cells"); + return -1; + } + int x, y; + if (!parse_subscript_key(key, &x, &y)) return -1; + + if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for TileLayer of size (%d, %d)", + x, y, self->data->grid_x, self->data->grid_y); + return -1; + } + + if (!PyLong_Check(value)) { + PyErr_SetString(PyExc_TypeError, "tile index must be an int"); + return -1; + } + int index = (int)PyLong_AsLong(value); + + self->data->at(x, y) = index; + self->data->markDirty(x, y); + return 0; +} + +PyMappingMethods PyGridLayerAPI::TileLayer_mapping_methods = { + .mp_length = nullptr, + .mp_subscript = (binaryfunc)PyGridLayerAPI::TileLayer_subscript, + .mp_ass_subscript = (objobjargproc)PyGridLayerAPI::TileLayer_subscript_assign, +}; + PyObject* PyGridLayerAPI::TileLayer_fill(PyTileLayerObject* self, PyObject* args) { int index; if (!PyArg_ParseTuple(args, "i", &index)) { diff --git a/src/GridLayers.h b/src/GridLayers.h index 94ebe43..00da248 100644 --- a/src/GridLayers.h +++ b/src/GridLayers.h @@ -218,6 +218,11 @@ public: static int ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure); static PyObject* ColorLayer_repr(PyColorLayerObject* self); + // Subscript protocol: layer[x, y] / layer[x, y] = value + static PyObject* ColorLayer_subscript(PyColorLayerObject* self, PyObject* key); + static int ColorLayer_subscript_assign(PyColorLayerObject* self, PyObject* key, PyObject* value); + static PyMappingMethods ColorLayer_mapping_methods; + // TileLayer methods static int TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds); static PyObject* TileLayer_at(PyTileLayerObject* self, PyObject* args, PyObject* kwds); @@ -238,6 +243,11 @@ public: static int TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure); static PyObject* TileLayer_repr(PyTileLayerObject* self); + // Subscript protocol: layer[x, y] / layer[x, y] = value + static PyObject* TileLayer_subscript(PyTileLayerObject* self, PyObject* key); + static int TileLayer_subscript_assign(PyTileLayerObject* self, PyObject* key, PyObject* value); + static PyMappingMethods TileLayer_mapping_methods; + // Method and getset arrays static PyMethodDef ColorLayer_methods[]; static PyGetSetDef ColorLayer_getsetters[]; @@ -259,6 +269,7 @@ namespace mcrfpydef { Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)PyGridLayerAPI::ColorLayer_repr, + .tp_as_mapping = &PyGridLayerAPI::ColorLayer_mapping_methods, // layer[x, y] .tp_flags = Py_TPFLAGS_DEFAULT, .tp_doc = PyDoc_STR("ColorLayer(z_index=-1, name=None, grid_size=None)\n\n" "A grid layer that stores RGBA colors per cell for background/overlay effects.\n\n" @@ -312,6 +323,7 @@ namespace mcrfpydef { Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)PyGridLayerAPI::TileLayer_repr, + .tp_as_mapping = &PyGridLayerAPI::TileLayer_mapping_methods, // layer[x, y] .tp_flags = Py_TPFLAGS_DEFAULT, .tp_doc = PyDoc_STR("TileLayer(z_index=-1, name=None, texture=None, grid_size=None)\n\n" "A grid layer that stores sprite indices per cell for tile-based rendering.\n\n" diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index e944be5..23d5f24 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -497,9 +497,6 @@ PyObject* PyInit_mcrfpy() /*grid layers (#147)*/ &PyColorLayerType, &PyTileLayerType, - /*animation*/ - &PyAnimationType, - /*timer*/ &PyTimerType, @@ -574,6 +571,10 @@ PyObject* PyInit_mcrfpy() /*shader uniform collection - returned by drawable.uniforms but not directly instantiable (#106)*/ &mcrfpydef::PyUniformCollectionType, + /*animation - constructed internally by drawable.animate() and returned by mcrfpy.animations, + but not directly instantiable from Python (pre-1.0 API freeze)*/ + &PyAnimationType, + nullptr}; // Set up PyWindowType methods and getsetters before PyType_Ready diff --git a/src/UIArc.cpp b/src/UIArc.cpp index 2f4a7eb..d7c5b12 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -2,6 +2,10 @@ #include "McRFPy_API.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include "UIFrame.h" // parent= kwarg: Frame parent type +#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation +#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type +#include "PySceneObject.h" // parent= kwarg: Scene parent type #include #include @@ -556,19 +560,22 @@ int UIArc::init(PyUIArcObject* self, PyObject* args, PyObject* kwds) { float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = nullptr; // Auto-attach parent (Frame, Scene, or Grid) static const char* kwlist[] = { "center", "radius", "start_angle", "end_angle", "color", "thickness", "on_click", "visible", "opacity", "z_index", "name", "align", "margin", "horiz_margin", "vert_margin", + "parent", nullptr }; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfffOfOifizOfff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfffOfOifizOfffO", const_cast(kwlist), ¢er_obj, &radius, &start_angle, &end_angle, &color_obj, &thickness, &click_handler, &visible, &opacity, &z_index, &name, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -639,5 +646,8 @@ int UIArc::init(PyUIArcObject* self, PyObject* args, PyObject* kwds) { // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIArcType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/src/UIBase.h b/src/UIBase.h index c09cac5..06d1ed3 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -158,6 +158,33 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds) } \ } while (0) +// Macro for auto-attaching a newly constructed UI drawable to a parent's children. +// Usage: UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); +// parent_obj must be a Frame, Scene, or Grid (anything with a .children UICollection). +// Returns -1 on error (suitable for use in tp_init functions). No-op when parent_obj is null/None. +#define UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self) \ + do { \ + if ((parent_obj) && (parent_obj) != Py_None) { \ + if (!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIFrameType) && \ + !PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PySceneType) && \ + !PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridType) && \ + !PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridViewType)) { \ + PyErr_SetString(PyExc_TypeError, "parent must be a Frame, Scene, or Grid"); \ + return -1; \ + } \ + PyObject* _children = PyObject_GetAttrString((parent_obj), "children"); \ + if (!_children) { \ + return -1; \ + } \ + PyObject* _result = PyObject_CallMethod(_children, "append", "O", (PyObject*)(self)); \ + Py_DECREF(_children); \ + if (!_result) { \ + return -1; \ + } \ + Py_DECREF(_result); \ + } \ + } while (0) + // Property getters/setters for visible and opacity template static PyObject* UIDrawable_get_visible(T* self, void* closure) diff --git a/src/UICaption.cpp b/src/UICaption.cpp index 33ccb33..bd25c70 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -7,6 +7,9 @@ #include "PyAlignment.h" #include "PyShader.h" // #106: Shader support #include "PyUniformCollection.h" // #106: Uniform collection support +#include "UIFrame.h" // parent= kwarg: Frame parent type +#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type +#include "PySceneObject.h" // parent= kwarg: Scene parent type // UIDrawable methods now in UIBase.h #include @@ -448,23 +451,27 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds) float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = nullptr; // Auto-attach parent (Frame, Scene, or Grid) - // Keywords list matches the new spec: positional args first, then all keyword args + // Keywords list: pos and text are positional-or-keyword. Everything after + // the '$' separator (font and friends) is keyword-only. static const char* kwlist[] = { - "pos", "font", "text", // Positional args (as per spec) - // Keyword-only args - "fill_color", "outline_color", "outline", "font_size", "on_click", + "pos", "text", + // Keyword-only args follow: + "font", "fill_color", "outline_color", "outline", "font_size", "on_click", "visible", "opacity", "z_index", "name", "x", "y", "align", "margin", "horiz_margin", "vert_margin", + "parent", nullptr }; - // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOzOOffOifizffOfff", const_cast(kwlist), - &pos_obj, &font, &text, // Positional - &fill_color, &outline_color, &outline, &font_size, &click_handler, + // '$' marker makes all following args keyword-only (Python 3.3+ format extension). + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|Oz$OOOffOifizffOfffO", const_cast(kwlist), + &pos_obj, &text, // pos+text are positional-or-keyword + &font, &fill_color, &outline_color, &outline, &font_size, &click_handler, &visible, &opacity, &z_index, &name, &x, &y, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -597,6 +604,9 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds) // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUICaptionType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/src/UICircle.cpp b/src/UICircle.cpp index 7f503ed..7127d46 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -5,6 +5,10 @@ #include "PyColor.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include "UIFrame.h" // parent= kwarg: Frame parent type +#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation +#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type +#include "PySceneObject.h" // parent= kwarg: Scene parent type #include UICircle::UICircle() @@ -479,10 +483,13 @@ PyObject* UICircle::repr(PyUICircleObject* self) { } int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { + // 1.0 API freeze: positional order is now (center, radius, ...). The old + // (radius, center) ordering is no longer supported. static const char* kwlist[] = { - "radius", "center", "fill_color", "outline_color", "outline", + "center", "radius", "fill_color", "outline_color", "outline", "on_click", "visible", "opacity", "z_index", "name", - "align", "margin", "horiz_margin", "vert_margin", NULL + "align", "margin", "horiz_margin", "vert_margin", + "parent", NULL }; float radius = 10.0f; @@ -501,11 +508,13 @@ int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = NULL; // Auto-attach parent (Frame, Scene, or Grid) - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|fOOOfOpfisOfff", (char**)kwlist, - &radius, ¢er_obj, &fill_color_obj, &outline_color_obj, &outline, + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfOOfOpfisOfffO", (char**)kwlist, + ¢er_obj, &radius, &fill_color_obj, &outline_color_obj, &outline, &click_obj, &visible, &opacity_val, &z_index, &name, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -597,5 +606,8 @@ int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUICircleType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index ff86acf..e2b89ad 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -5,6 +5,7 @@ #include "UICaption.h" #include "UISprite.h" #include "UIGrid.h" +#include "PySceneObject.h" // parent= kwarg: Scene parent type #include "McRFPy_API.h" #include "PythonObjectCache.h" #include "PyAlignment.h" @@ -601,6 +602,7 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = nullptr; // Auto-attach parent (Frame, Scene, or Grid) // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { @@ -609,15 +611,17 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) "fill_color", "outline_color", "outline", "children", "on_click", "visible", "opacity", "z_index", "name", "x", "y", "w", "h", "clip_children", "cache_subtree", "align", "margin", "horiz_margin", "vert_margin", + "parent", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOfOOifizffffiiOfff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOfOOifizffffiiOfffO", const_cast(kwlist), &pos_obj, &size_obj, // Positional &fill_color, &outline_color, &outline, &children_arg, &click_handler, &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &clip_children, &cache_subtree, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -798,6 +802,9 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIFrameType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/src/UIGridPyMethods.cpp b/src/UIGridPyMethods.cpp index 0562287..cfabe1e 100644 --- a/src/UIGridPyMethods.cpp +++ b/src/UIGridPyMethods.cpp @@ -82,10 +82,19 @@ PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key) return (PyObject*)obj; } +// Setitem on _GridData / Grid: GridPoints are views, not assignable. +static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject* value) +{ + (void)self; (void)key; (void)value; + PyErr_SetString(PyExc_TypeError, + "Grid points are not assignable; modify properties on the returned point"); + return -1; +} + PyMappingMethods UIGrid::mpmethods = { .mp_length = NULL, .mp_subscript = (binaryfunc)UIGrid::subscript, - .mp_ass_subscript = NULL + .mp_ass_subscript = (objobjargproc)UIGrid_subscript_assign, }; // ========================================================================= diff --git a/src/UIGridView.cpp b/src/UIGridView.cpp index 16d253b..d005e6b 100644 --- a/src/UIGridView.cpp +++ b/src/UIGridView.cpp @@ -1,6 +1,7 @@ // UIGridView.cpp - Rendering view for GridData (#252) #include "UIGridView.h" #include "UIGrid.h" +#include "UIGridPoint.h" #include "UIEntity.h" #include "GameEngine.h" #include "McRFPy_API.h" @@ -11,6 +12,7 @@ #include "PyPositionHelper.h" #include "PyVector.h" #include "PythonObjectCache.h" +#include "PySceneObject.h" // parent= kwarg: Scene is a valid parent type #include #include @@ -371,15 +373,45 @@ bool UIGridView::hasProperty(const std::string& name) const int UIGridView::init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds) { + // Extract parent= up front so it doesn't confuse downstream parsing in + // either init mode. parent_obj is borrowed from the original kwds (which + // the caller owns and outlives this function), so no INCREF is needed -- + // but we must not delete from the caller's dict, so make a working copy. + PyObject* parent_obj = nullptr; + PyObject* dispatch_kwds = kwds; + if (kwds) { + parent_obj = PyDict_GetItemString(kwds, "parent"); // borrowed ref + if (parent_obj) { + dispatch_kwds = PyDict_Copy(kwds); + if (!dispatch_kwds) return -1; + PyDict_DelItemString(dispatch_kwds, "parent"); + } + } + // Determine mode by checking for 'grid' kwarg PyObject* grid_kwarg = nullptr; - if (kwds) { - grid_kwarg = PyDict_GetItemString(kwds, "grid"); // borrowed ref + if (dispatch_kwds) { + grid_kwarg = PyDict_GetItemString(dispatch_kwds, "grid"); // borrowed ref } bool explicit_view = (grid_kwarg && grid_kwarg != Py_None); - if (explicit_view) { + int rc = explicit_view + ? init_explicit_view(self, args, dispatch_kwds) + : init_with_data(self, args, dispatch_kwds); + + if (dispatch_kwds != kwds) Py_DECREF(dispatch_kwds); + if (rc != 0) return rc; + + if (parent_obj) { + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + } + return 0; +} + +int UIGridView::init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyObject* kwds) +{ + { // Mode 1: View of existing grid data static const char* kwlist[] = {"grid", "pos", "size", "zoom", "fill_color", "name", nullptr}; PyObject* grid_obj = nullptr; @@ -453,9 +485,6 @@ int UIGridView::init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridViewType; return 0; - } else { - // Mode 2: Factory mode - create UIGrid internally - return init_with_data(self, args, kwds); } } @@ -780,6 +809,69 @@ int UIGridView::set_float_member_gv(PyUIGridViewObject* self, PyObject* value, v return 0; } +// ========================================================================= +// Subscript protocol: grid[x, y] -> GridPoint (delegates to GridData). +// Setitem raises TypeError (GridPoints are views, not assignable). +// ========================================================================= +PyObject* UIGridView::subscript(PyUIGridViewObject* self, PyObject* key) +{ + if (!self->data || !self->data->grid_data) { + PyErr_SetString(PyExc_RuntimeError, "Grid has no underlying data"); + return NULL; + } + if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be a 2-tuple (x, y)"); + return NULL; + } + PyObject* x_obj = PyTuple_GetItem(key, 0); + PyObject* y_obj = PyTuple_GetItem(key, 1); + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be integers"); + return NULL; + } + int x = (int)PyLong_AsLong(x_obj); + int y = (int)PyLong_AsLong(y_obj); + + auto& grid_data = self->data->grid_data; + if (x < 0 || x >= grid_data->grid_w) { + PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", + x, grid_data->grid_w); + return NULL; + } + if (y < 0 || y >= grid_data->grid_h) { + PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", + y, grid_data->grid_h); + return NULL; + } + + // Reconstruct shared_ptr from GridData via aliasing constructor + // (mirrors UIGridView::get_grid). + auto grid_ptr = static_cast(grid_data.get()); + auto grid_as_uigrid = std::shared_ptr(grid_data, grid_ptr); + + auto type = &mcrfpydef::PyUIGridPointType; + auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + obj->grid = grid_as_uigrid; + obj->x = x; + obj->y = y; + return (PyObject*)obj; +} + +int UIGridView::subscript_assign(PyUIGridViewObject* self, PyObject* key, PyObject* value) +{ + (void)self; (void)key; (void)value; + PyErr_SetString(PyExc_TypeError, + "Grid points are not assignable; modify properties on the returned point"); + return -1; +} + +PyMappingMethods UIGridView::mpmethods = { + .mp_length = NULL, + .mp_subscript = (binaryfunc)UIGridView::subscript, + .mp_ass_subscript = (objobjargproc)UIGridView::subscript_assign, +}; + // #252: PyObjectType typedef for UIDRAWABLE_* macros typedef PyUIGridViewObject PyObjectType; diff --git a/src/UIGridView.h b/src/UIGridView.h index db054bd..ee46af7 100644 --- a/src/UIGridView.h +++ b/src/UIGridView.h @@ -86,6 +86,7 @@ public: // Python API // ========================================================================= static int init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds); + static int init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyObject* kwds); static int init_with_data(PyUIGridViewObject* self, PyObject* args, PyObject* kwds); static PyObject* repr(PyUIGridViewObject* self); @@ -110,6 +111,12 @@ public: static PyMethodDef methods[]; static PyGetSetDef getsetters[]; + + // Subscript protocol: grid[x, y] (delegates to underlying GridData). + // Setitem raises TypeError (GridPoints are views). + static PyObject* subscript(PyUIGridViewObject* self, PyObject* key); + static int subscript_assign(PyUIGridViewObject* self, PyObject* key, PyObject* value); + static PyMappingMethods mpmethods; }; // Forward declaration of methods array @@ -139,6 +146,7 @@ namespace mcrfpydef { Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)UIGridView::repr, + .tp_as_mapping = &UIGridView::mpmethods, // grid[x, y] (delegates to GridData) .tp_getattro = UIGridView::getattro, // #252: attribute delegation to Grid .tp_setattro = UIGridView::setattro, // #252: attribute delegation to Grid .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, diff --git a/src/UILine.cpp b/src/UILine.cpp index c02b399..f85f090 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -5,6 +5,10 @@ #include "PyColor.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include "UIFrame.h" // parent= kwarg: Frame parent type +#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation +#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type +#include "PySceneObject.h" // parent= kwarg: Scene parent type #include UILine::UILine() @@ -565,18 +569,21 @@ int UILine::init(PyUILineObject* self, PyObject* args, PyObject* kwds) { float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = nullptr; // Auto-attach parent (Frame, Scene, or Grid) static const char* kwlist[] = { "start", "end", "thickness", "color", "on_click", "visible", "opacity", "z_index", "name", "align", "margin", "horiz_margin", "vert_margin", + "parent", nullptr }; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOfOOifizOfff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOfOOifizOfffO", const_cast(kwlist), &start_obj, &end_obj, &thickness, &color_obj, &click_handler, &visible, &opacity, &z_index, &name, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -663,5 +670,8 @@ int UILine::init(PyUILineObject* self, PyObject* args, PyObject* kwds) { // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUILineType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/src/UISprite.cpp b/src/UISprite.cpp index 474e2c9..c8d5ef6 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -3,6 +3,9 @@ #include "PyVector.h" #include "PythonObjectCache.h" #include "UIFrame.h" // #144: For snapshot= parameter +#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation +#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type +#include "PySceneObject.h" // parent= kwarg: Scene parent type #include "PyAlignment.h" #include "PyShader.h" // #106: Shader support #include "PyUniformCollection.h" // #106: Uniform collection support @@ -476,6 +479,7 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) float margin = 0.0f; float horiz_margin = -1.0f; float vert_margin = -1.0f; + PyObject* parent_obj = nullptr; // Auto-attach parent (Frame, Scene, or Grid) // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { @@ -484,15 +488,17 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) "scale", "scale_x", "scale_y", "on_click", "visible", "opacity", "z_index", "name", "x", "y", "snapshot", "align", "margin", "horiz_margin", "vert_margin", + "parent", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOifffOifizffOOfff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOifffOifizffOOfffO", const_cast(kwlist), &pos_obj, &texture, &sprite_index, // Positional &scale, &scale_x, &scale_y, &click_handler, &visible, &opacity, &z_index, &name, &x, &y, &snapshot, - &align_obj, &margin, &horiz_margin, &vert_margin)) { + &align_obj, &margin, &horiz_margin, &vert_margin, + &parent_obj)) { return -1; } @@ -627,6 +633,9 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) // #184: Check if this is a Python subclass (for callback method support) self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUISpriteType; + // Auto-attach to parent's children collection if parent= was supplied + UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self); + return 0; } diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index 9852545..b635983 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -92,6 +92,14 @@ class FOV(IntEnum): SHADOW: int SYMMETRIC_SHADOWCAST: int +class Heuristic(IntEnum): + """Built-in A* heuristic function selector.""" + CHEBYSHEV: int + DIAGONAL: int + EUCLIDEAN: int + MANHATTAN: int + ZERO: int + class InputState(IntEnum): """Enum representing input event states (pressed/released).""" PRESSED: int @@ -255,34 +263,6 @@ class AStarPath: """Get and consume next step in the path.""" ... -class Animation: - """Create an animation that interpolates a property value over time.""" - def __init__(self, property: str, target: Any, duration: float, easing: str = 'linear', delta: bool = False, loop: bool = False, callback: Callable = None) -> None: ... - duration: float # Animation duration in seconds (float, read-only). Total time for the animation to complete. - elapsed: float # Elapsed time in seconds (float, read-only). Time since the animation started. - is_complete: bool # Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called. - is_delta: bool # Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value. - is_looping: bool # Whether animation loops (bool, read-only). Looping animations repeat from the start when they reach the end. - property: str # Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index'). - def complete(self) -> None: - """Complete the animation immediately by jumping to the final value.""" - ... - def get_current_value(self) -> Any: - """Get the current interpolated value of the animation.""" - ... - def hasValidTarget(self) -> bool: - """Check if the animation still has a valid target.""" - ... - def start(self, target: UIDrawable, conflict_mode: str = 'replace') -> None: - """Start the animation on a target UI element.""" - ... - def stop(self) -> None: - """Stop the animation without completing it.""" - ... - def update(self, delta_time: float) -> bool: - """Update the animation by the given time delta.""" - ... - class Arc: """An arc UI element for drawing curved line segments.""" def __init__(self, center=None, radius=0, start_angle=0, end_angle=90, color=None, thickness=1, **kwargs) -> None: ... @@ -558,9 +538,15 @@ class DijkstraMap: """A Dijkstra distance map from a fixed root position.""" def __init__(self, *args, **kwargs) -> None: ... root: Vector # Root position that distances are measured from (Vector, read-only). + def descent_step(self, pos) -> Vector | None: + """Get the adjacent cell with the lowest distance (steepest descent).""" + ... def distance(self, pos) -> float | None: """Get distance from position to root.""" ... + def invert(self) -> DijkstraMap: + """Return a NEW DijkstraMap whose distance field is the safety field.""" + ... def path_from(self, pos) -> AStarPath: """Get full path from position to root.""" ... diff --git a/tools/audit_pymethoddef.py b/tools/audit_pymethoddef.py new file mode 100755 index 0000000..f3f86ce --- /dev/null +++ b/tools/audit_pymethoddef.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" +audit_pymethoddef.py - Static-analysis tool for McRogueFace Python bindings. + +Walks src/**/*.cpp, parses each file with tree-sitter-cpp, and locates every +`PyMethodDef [] = {...}` and `PyGetSetDef [] = {...}` declaration. +For each entry inside those array initializers, classifies the docstring slot: + + MACRO - uses MCRF_METHOD(...) or MCRF_PROPERTY(...) + RAW_STRING - inline C string literal (or concatenated string literals) + NULL - explicit NULL literal + MISSING - entry too short to have a doc field (probably malformed) + +The `MACRO` classification is the project's compliance target. RAW_STRING and +NULL entries should be migrated to the macro system before the 1.0 API freeze. + +Sentinel terminator entries (e.g. `{NULL}`, `{0}`) are skipped. + +Usage: + python3 tools/audit_pymethoddef.py [--strict] [--quiet] + [--paths PATH [PATH ...]] + +Flags: + --strict Exit nonzero if any non-MACRO entries are found (CI mode). + --quiet Suppress per-file output, print only the summary. + --paths Restrict scan to the given files/directories. Defaults to src/. +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional, Tuple + +try: + import tree_sitter_cpp + from tree_sitter import Language, Parser +except ImportError as e: + sys.stderr.write( + "ERROR: tree-sitter / tree-sitter-cpp not installed.\n" + "Activate the audit venv first:\n" + " source .venv-audit/bin/activate\n" + f"(import error: {e})\n" + ) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Tree-sitter setup +# --------------------------------------------------------------------------- + +_LANG = Language(tree_sitter_cpp.language()) +_PARSER = Parser(_LANG) + +# Indices of the docstring field in the struct initializer. +# PyMethodDef: {ml_name, ml_meth, ml_flags, ml_doc} -> idx 3 +# PyGetSetDef: {name, get, set, doc, closure} -> idx 3 +DOC_FIELD_INDEX = 3 +EXPECTED_MIN_FIELDS = { + "PyMethodDef": 4, # need at least 4 to have ml_doc + "PyGetSetDef": 4, # need at least 4 to have doc (closure can be omitted) +} + +# Punctuation/structural child node types we ignore when walking entry fields. +_PUNCT_TYPES = {"{", "}", ",", "(", ")", "[", "]", ";"} + +# Macros that mark a docstring slot as compliant. +_COMPLIANT_MACROS = {"MCRF_METHOD", "MCRF_PROPERTY"} + + +# --------------------------------------------------------------------------- +# Data records +# --------------------------------------------------------------------------- + +@dataclass +class EntryRecord: + file_path: Path + line: int # 1-based + array_kind: str # "PyMethodDef" or "PyGetSetDef" + array_name: str # e.g. "PyAnimation::methods" + entry_name: str # ml_name / name string, or "" + classification: str # MACRO / RAW_STRING / NULL / MISSING + + +# --------------------------------------------------------------------------- +# Tree-sitter helpers +# --------------------------------------------------------------------------- + +def _node_text(src: bytes, node) -> str: + return src[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + +def _meaningful_children(node) -> List: + """Return children of an initializer_list, skipping punctuation tokens.""" + return [c for c in node.children if c.type not in _PUNCT_TYPES] + + +def _classify_doc_field(src: bytes, doc_node) -> str: + """Map a docstring AST node to a classification string.""" + t = doc_node.type + if t == "null": + return "NULL" + if t in ("string_literal", "concatenated_string", "raw_string_literal"): + return "RAW_STRING" + if t == "call_expression": + # The first child of call_expression is the callee identifier. + callee = doc_node.child_by_field_name("function") + if callee is None and doc_node.children: + callee = doc_node.children[0] + if callee is not None: + name = _node_text(src, callee).strip() + if name in _COMPLIANT_MACROS: + return "MACRO" + return "RAW_STRING" # call expression to something non-MACRO + if t == "identifier": + # Bare identifier in the doc slot - could be a #define alias. Treat as + # raw (non-compliant) so the user investigates it. + text = _node_text(src, doc_node).strip() + if text == "NULL": + return "NULL" + return "RAW_STRING" + # Anything else (parenthesized_expression, etc.) - inspect text fallback. + text = _node_text(src, doc_node).strip() + stripped = text.lstrip("(").lstrip() + for macro in _COMPLIANT_MACROS: + if stripped.startswith(macro + "("): + return "MACRO" + if stripped == "NULL": + return "NULL" + return "RAW_STRING" + + +def _entry_name(src: bytes, entry_node) -> str: + """Return the first string literal in an entry initializer (the ml_name).""" + for c in _meaningful_children(entry_node): + if c.type == "string_literal": + # string_literal contains string_content children + for sub in c.children: + if sub.type == "string_content": + return _node_text(src, sub) + return _node_text(src, c).strip('"') + if c.type == "concatenated_string": + for lit in c.children: + if lit.type == "string_literal": + for sub in lit.children: + if sub.type == "string_content": + return _node_text(src, sub) + return _node_text(src, c) + # Stop at the first non-string field - the name should come first. + break + return "" + + +def _is_sentinel(src: bytes, entry_node) -> bool: + """True if the entry looks like a sentinel terminator (e.g. {NULL} / {0}).""" + fields = _meaningful_children(entry_node) + if not fields: + return True + if len(fields) == 1: + only = fields[0] + if only.type == "null": + return True + if only.type == "number_literal" and _node_text(src, only).strip() == "0": + return True + # Some codebases write {NULL, NULL, NULL, NULL}. Treat all-NULL/0 as sentinel. + for f in fields: + if f.type == "null": + continue + if f.type == "number_literal" and _node_text(src, f).strip() == "0": + continue + return False + return True + + +def _array_name_from_init_declarator(src: bytes, init_decl) -> Optional[str]: + """Extract the array name from an init_declarator containing array_declarator.""" + arr = None + for c in init_decl.children: + if c.type == "array_declarator": + arr = c + break + if arr is None: + return None + # The first child is the declarator (identifier or qualified_identifier). + for c in arr.children: + if c.type in ("identifier", "qualified_identifier", "field_identifier"): + return _node_text(src, c) + return None + + +def _outer_initializer_list(init_decl): + """Get the top-level initializer_list child of an init_declarator, if any.""" + for c in init_decl.children: + if c.type == "initializer_list": + return c + return None + + +# --------------------------------------------------------------------------- +# Per-file scan +# --------------------------------------------------------------------------- + +def _walk_declarations(node, out: list) -> None: + """Collect all `declaration` nodes under `node` (recursive).""" + if node.type == "declaration": + out.append(node) + for c in node.children: + _walk_declarations(c, out) + + +def scan_file(path: Path) -> List[EntryRecord]: + try: + src = path.read_bytes() + except OSError as e: + sys.stderr.write(f"WARNING: cannot read {path}: {e}\n") + return [] + + tree = _PARSER.parse(src) + decls: list = [] + _walk_declarations(tree.root_node, decls) + + records: List[EntryRecord] = [] + for decl in decls: + # Find the type_identifier child to determine if this is one of ours. + type_kind = None + for c in decl.children: + if c.type == "type_identifier": + txt = _node_text(src, c).strip() + if txt in EXPECTED_MIN_FIELDS: + type_kind = txt + break + if type_kind is None: + continue + + # Each declaration may have multiple init_declarators (rare for arrays + # but cheap to handle). + for c in decl.children: + if c.type != "init_declarator": + continue + outer_init = _outer_initializer_list(c) + if outer_init is None: + continue # forward decl or extern - no initializer + array_name = _array_name_from_init_declarator(src, c) or "" + + # Each direct child initializer_list is an entry. + for entry in outer_init.children: + if entry.type != "initializer_list": + continue + if _is_sentinel(src, entry): + continue + + fields = _meaningful_children(entry) + line = entry.start_point[0] + 1 # tree-sitter is 0-based + name = _entry_name(src, entry) + + if len(fields) <= DOC_FIELD_INDEX: + records.append(EntryRecord( + file_path=path, + line=line, + array_kind=type_kind, + array_name=array_name, + entry_name=name, + classification="MISSING", + )) + continue + + doc_node = fields[DOC_FIELD_INDEX] + classification = _classify_doc_field(src, doc_node) + records.append(EntryRecord( + file_path=path, + line=line, + array_kind=type_kind, + array_name=array_name, + entry_name=name, + classification=classification, + )) + return records + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + +def _iter_cpp_files(roots: Iterable[Path]) -> Iterable[Path]: + for root in roots: + if root.is_file(): + if root.suffix == ".cpp": + yield root + continue + if not root.exists(): + sys.stderr.write(f"WARNING: path does not exist: {root}\n") + continue + for dirpath, _dirnames, filenames in os.walk(root): + for fn in filenames: + if fn.endswith(".cpp"): + yield Path(dirpath) / fn + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + +def _print_file_table(records: List[EntryRecord], project_root: Path) -> None: + by_file: dict[Path, List[EntryRecord]] = defaultdict(list) + for r in records: + by_file[r.file_path].append(r) + + for path in sorted(by_file): + try: + rel = path.relative_to(project_root) + except ValueError: + rel = path + entries = sorted(by_file[path], key=lambda r: r.line) + + # Compute column widths for this file. + loc_w = max(len(f"{rel}:{e.line}") for e in entries) + arr_w = max(len(e.array_name) for e in entries) + ent_w = max(len(e.entry_name) for e in entries) + loc_w = max(loc_w, len("file:line")) + arr_w = max(arr_w, len("array")) + ent_w = max(ent_w, len("entry")) + + header = ( + f"{'file:line':<{loc_w}} " + f"{'array':<{arr_w}} " + f"{'entry':<{ent_w}} " + f"classification" + ) + print(header) + print("-" * len(header)) + for e in entries: + loc = f"{rel}:{e.line}" + print( + f"{loc:<{loc_w}} " + f"{e.array_name:<{arr_w}} " + f"{e.entry_name:<{ent_w}} " + f"{e.classification}" + ) + print() + + +def _print_summary(records: List[EntryRecord]) -> None: + total = len(records) + counts = Counter(r.classification for r in records) + macro = counts.get("MACRO", 0) + raw = counts.get("RAW_STRING", 0) + null = counts.get("NULL", 0) + missing = counts.get("MISSING", 0) + pct = (macro / total * 100.0) if total else 0.0 + + print("=" * 60) + print("PyMethodDef / PyGetSetDef Documentation Audit Summary") + print("=" * 60) + print(f"Total entries scanned : {total}") + print(f" MACRO compliant : {macro}") + print(f" RAW_STRING : {raw}") + print(f" NULL : {null}") + print(f" MISSING : {missing}") + print(f"MACRO compliance : {pct:.1f}%") + + # Per-kind breakdown. + by_kind = defaultdict(Counter) + for r in records: + by_kind[r.array_kind][r.classification] += 1 + if by_kind: + print() + print("Breakdown by kind:") + for kind in sorted(by_kind): + kc = by_kind[kind] + kt = sum(kc.values()) + kp = (kc.get("MACRO", 0) / kt * 100.0) if kt else 0.0 + print( + f" {kind:<13} total={kt:<4} " + f"MACRO={kc.get('MACRO', 0):<4} " + f"RAW={kc.get('RAW_STRING', 0):<4} " + f"NULL={kc.get('NULL', 0):<4} " + f"MISSING={kc.get('MISSING', 0):<4} " + f"({kp:.1f}% compliant)" + ) + + # Top offenders. + offenders: dict[Path, int] = defaultdict(int) + for r in records: + if r.classification != "MACRO": + offenders[r.file_path] += 1 + if offenders: + print() + print("Top non-compliant files:") + ranked = sorted(offenders.items(), key=lambda kv: kv[1], reverse=True) + for path, count in ranked[:10]: + try: + rel = path.relative_to(Path.cwd()) + except ValueError: + rel = path + print(f" {count:>4} {rel}") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def _default_roots() -> List[Path]: + cwd = Path.cwd() + src = cwd / "src" + return [src] if src.exists() else [cwd] + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Audit PyMethodDef / PyGetSetDef entries in McRogueFace C++ " + "sources for MCRF_METHOD / MCRF_PROPERTY documentation macro use." + ), + ) + parser.add_argument( + "--strict", action="store_true", + help="Exit nonzero if any non-MACRO entries are found (CI mode)." + ) + parser.add_argument( + "--quiet", action="store_true", + help="Print only the summary, omit per-file tables." + ) + parser.add_argument( + "--paths", nargs="+", type=Path, default=None, + help="Files or directories to scan (default: ./src)." + ) + args = parser.parse_args(argv) + + roots = args.paths if args.paths else _default_roots() + project_root = Path.cwd() + + files = sorted(set(_iter_cpp_files(roots))) + if not files: + sys.stderr.write("WARNING: no .cpp files found.\n") + return 0 + + all_records: List[EntryRecord] = [] + for f in files: + all_records.extend(scan_file(f)) + + if not args.quiet: + if all_records: + _print_file_table(all_records, project_root) + else: + print("(no PyMethodDef / PyGetSetDef arrays found)") + print() + + _print_summary(all_records) + + if args.strict: + non_macro = sum( + 1 for r in all_records if r.classification != "MACRO" + ) + if non_macro: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/capture_audio_synth_header.py b/tools/capture_audio_synth_header.py new file mode 100644 index 0000000..9f71584 --- /dev/null +++ b/tools/capture_audio_synth_header.py @@ -0,0 +1,16 @@ +import mcrfpy +from mcrfpy import automation +import runpy, sys, pathlib + +OUT = sys.argv[1] if len(sys.argv) > 1 else "image00.png" + +demo_path = (pathlib.Path(__file__).resolve().parent.parent / + "tests" / "demo" / "audio_synth_demo.py") +runpy.run_path(str(demo_path), run_name="__demo__") + +def shot(timer, runtime): + automation.screenshot(OUT) + print(f"Wrote {OUT}") + sys.exit(0) + +mcrfpy.Timer("header_shot", shot, 150) diff --git a/tools/generate_all_docs.sh b/tools/generate_all_docs.sh index 7c0234d..024c3d3 100755 --- a/tools/generate_all_docs.sh +++ b/tools/generate_all_docs.sh @@ -26,3 +26,24 @@ echo " HTML: docs/api_reference_dynamic.html" echo " Markdown: docs/API_REFERENCE_DYNAMIC.md" echo " Man page: docs/mcrfpy.3" echo " Stubs: stubs/mcrfpy.pyi" + +# --------------------------------------------------------------------------- +# Static-analysis audit: report MCRF_METHOD / MCRF_PROPERTY macro compliance +# across every PyMethodDef / PyGetSetDef array in src/. This is informational +# only (no --strict) so it cannot break the doc build, but the summary makes +# pre-1.0 documentation drift visible alongside doc generation. +# +# Requires the .venv-audit virtual environment with tree-sitter + +# tree-sitter-cpp installed. The audit is skipped silently if absent so +# contributors without the venv aren't blocked. +# --------------------------------------------------------------------------- +if [ -x "./.venv-audit/bin/python3" ] && [ -f "./tools/audit_pymethoddef.py" ]; then + echo "" + echo "=== PyMethodDef / PyGetSetDef Macro Compliance Audit ===" + ./.venv-audit/bin/python3 ./tools/audit_pymethoddef.py --quiet || true +elif [ -f "./tools/audit_pymethoddef.py" ]; then + echo "" + echo "(skipping audit_pymethoddef.py: .venv-audit not found - run" + echo " 'python3 -m venv .venv-audit && .venv-audit/bin/pip install" + echo " tree-sitter tree-sitter-cpp' to enable)" +fi