diff --git a/docs/PROCEDURAL_GENERATION_SPEC.md b/docs/PROCEDURAL_GENERATION_SPEC.md deleted file mode 100644 index 91ca01a..0000000 --- a/docs/PROCEDURAL_GENERATION_SPEC.md +++ /dev/null @@ -1,1026 +0,0 @@ -# McRogueFace Procedural Generation System Specification - -**Version:** 1.0 Draft -**Date:** 2026-01-11 -**Status:** Design Complete, Pending Implementation - -## Overview - -This specification defines the procedural generation system for McRogueFace, exposing libtcod's noise, BSP, and heightmap capabilities through a Pythonic interface optimized for batch operations on Grid and Layer objects. - -### Design Philosophy - -1. **HeightMap as Universal Canvas**: All procedural data flows through HeightMap objects. Noise and BSP structures generate data *onto* HeightMaps, and Grids/Layers consume data *from* HeightMaps. - -2. **Data Stays in C++**: Python defines rules and configuration; C++ executes batch operations. Map data (potentially 1M+ cells) never crosses the Python/C++ boundary during generation. - -3. **Composability**: HeightMaps can be combined (add, multiply, lerp), allowing complex terrain from simple building blocks. - -4. **Vector-Based Coordinates**: All positions use `(x, y)` tuples. Regions use `((x, y), (w, h))` for bounds or `((x1, y1), (x2, y2))` for world regions. - -5. **Mutation with Chaining**: Operations mutate in place and return `self` for method chaining. Threshold operations return new HeightMaps to preserve originals. - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ SOURCES │ -│ │ -│ NoiseSource BSP │ -│ (infinite function, (structural partition, │ -│ stateless) tree of nodes) │ -│ │ │ │ -│ │ .sample() │ .to_heightmap() │ -│ ▼ ▼ │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ NoiseSample │ │ BSPMap │ │ -│ │ (HeightMap) │ │ (HeightMap) │ │ -│ └──────┬──────┘ └──────┬──────┘ │ -│ │ ▲ │ ▲ │ -│ │ │ .add_noise() │ │ .add_bsp() │ -│ │ │ .multiply_noise() │ │ .multiply_bsp() │ -│ ▼ │ ▼ │ │ -│ └────┴───────────┬───────────────────┴────┘ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ HeightMap │ │ -│ │ (2D float[]) │ │ -│ │ │ │ -│ │ Operations: │ │ -│ │ • add/multiply │ │ -│ │ • hills/erosion │ │ -│ │ • threshold │ │ -│ │ • normalize │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ┌──────────────┼──────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ -│ │ Grid │ │ TileLayer │ │ColorLayer │ │ -│ │ │ │ │ │ │ │ -│ │ walkable │ │ tile_index│ │ color │ │ -│ │transparent│ │ │ │ gradient │ │ -│ └───────────┘ └───────────┘ └───────────┘ │ -│ TARGETS │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Class Reference - -### mcrfpy.HeightMap - -The universal canvas for procedural generation. A 2D grid of float values that can be generated, manipulated, combined, and applied to game objects. - -#### Constructor - -```python -HeightMap(size: tuple[int, int], fill: float = 0.0) -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `size` | `(int, int)` | Width and height in cells. Immutable after creation. | -| `fill` | `float` | Initial value for all cells. Default 0.0. | - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `size` | `(int, int)` | Read-only. Width and height of the heightmap. | - -#### Scalar Operations - -All scalar operations mutate in place and return `self` for chaining. - -```python -def fill(self, value: float) -> HeightMap -``` -Set all cells to `value`. - -```python -def clear(self) -> HeightMap -``` -Set all cells to 0.0. Equivalent to `fill(0.0)`. - -```python -def add_constant(self, value: float) -> HeightMap -``` -Add `value` to every cell. - -```python -def scale(self, factor: float) -> HeightMap -``` -Multiply every cell by `factor`. - -```python -def clamp(self, min: float = 0.0, max: float = 1.0) -> HeightMap -``` -Clamp all values to the range [min, max]. - -```python -def normalize(self, min: float = 0.0, max: float = 1.0) -> HeightMap -``` -Linearly rescale values so the current minimum becomes `min` and current maximum becomes `max`. - -#### HeightMap Combination - -All combination operations mutate in place and return `self`. The `other` HeightMap must have the same size. - -```python -def add(self, other: HeightMap) -> HeightMap -``` -Add `other` to this heightmap cell-by-cell. - -```python -def subtract(self, other: HeightMap) -> HeightMap -``` -Subtract `other` from this heightmap cell-by-cell. - -```python -def multiply(self, other: HeightMap) -> HeightMap -``` -Multiply this heightmap by `other` cell-by-cell. Useful for masking. - -```python -def lerp(self, other: HeightMap, t: float) -> HeightMap -``` -Linear interpolation: `self = self + (other - self) * t`. - -```python -def copy_from(self, other: HeightMap) -> HeightMap -``` -Copy all values from `other` into this heightmap. - -```python -def max(self, other: HeightMap) -> HeightMap -``` -Per-cell maximum of this and `other`. - -```python -def min(self, other: HeightMap) -> HeightMap -``` -Per-cell minimum of this and `other`. - -#### Direct Source Sampling - -These methods sample from sources directly onto the heightmap without intermediate allocation. - -```python -def add_noise(self, - source: NoiseSource, - world_region: tuple = None, - mode: str = "fbm", - octaves: int = 4, - scale: float = 1.0) -> HeightMap -``` -Sample noise and add to current values. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `source` | `NoiseSource` | The noise generator to sample from. | -| `world_region` | `((x1,y1), (x2,y2))` | World coordinates to sample. Default: `((0,0), size)`. | -| `mode` | `str` | `"flat"`, `"fbm"`, or `"turbulence"`. Default: `"fbm"`. | -| `octaves` | `int` | Octaves for fbm/turbulence. Default: 4. | -| `scale` | `float` | Multiplier for sampled values. Default: 1.0. | - -```python -def multiply_noise(self, source: NoiseSource, **kwargs) -> HeightMap -``` -Sample noise and multiply with current values. Same parameters as `add_noise`. - -```python -def add_bsp(self, - bsp: BSP, - select: str = "leaves", - nodes: list[BSPNode] = None, - shrink: int = 0, - value: float = 1.0) -> HeightMap -``` -Add BSP node regions to heightmap. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `bsp` | `BSP` | The BSP tree to sample from. | -| `select` | `str` | `"leaves"`, `"all"`, or `"internal"`. Default: `"leaves"`. | -| `nodes` | `list[BSPNode]` | Override: specific nodes only. Default: None (use select). | -| `shrink` | `int` | Pixels to shrink from node bounds. Default: 0. | -| `value` | `float` | Value to add inside selected regions. Default: 1.0. | - -```python -def multiply_bsp(self, bsp: BSP, **kwargs) -> HeightMap -``` -Multiply by BSP regions. Same parameters as `add_bsp`. - -#### Terrain Generation - -These methods implement libtcod heightmap generation algorithms. - -```python -def add_hill(self, center: tuple[int, int], radius: float, height: float) -> HeightMap -``` -Add a hill (half-spheroid) at the specified position. - -```python -def dig_hill(self, center: tuple[int, int], radius: float, depth: float) -> HeightMap -``` -Dig a depression. Takes minimum of current value and hill shape (for carving rivers, pits). - -```python -def add_voronoi(self, - num_points: int, - coefficients: tuple[float, ...] = (1.0, -0.5), - seed: int = None) -> HeightMap -``` -Add Voronoi diagram values. Coefficients weight distance to Nth-closest point. - -```python -def mid_point_displacement(self, - roughness: float = 0.5, - seed: int = None) -> HeightMap -``` -Generate fractal terrain using diamond-square algorithm. Roughness should be 0.4-0.6. - -```python -def rain_erosion(self, - drops: int, - erosion: float = 0.1, - sedimentation: float = 0.05, - seed: int = None) -> HeightMap -``` -Simulate rain erosion. `drops` should be at least `width * height`. - -```python -def dig_bezier(self, - points: tuple[tuple[int, int], ...], - start_radius: float, - end_radius: float, - start_depth: float, - end_depth: float) -> HeightMap -``` -Carve a path along a cubic Bezier curve. Requires exactly 4 control points. - -```python -def smooth(self, iterations: int = 1) -> HeightMap -``` -Apply smoothing kernel. Each iteration averages cells with neighbors. - -#### Threshold Operations - -These methods return **new** HeightMap objects, preserving the original. - -```python -def threshold(self, range: tuple[float, float]) -> HeightMap -``` -Return new HeightMap with original values where in range, 0.0 elsewhere. - -```python -def threshold_binary(self, - range: tuple[float, float], - value: float = 1.0) -> HeightMap -``` -Return new HeightMap with `value` where in range, 0.0 elsewhere. - -```python -def inverse(self) -> HeightMap -``` -Return new HeightMap with `(1.0 - value)` for each cell. - -#### Queries - -These methods return values to Python for inspection. - -```python -def get(self, pos: tuple[int, int]) -> float -``` -Get value at integer coordinates. - -```python -def get_interpolated(self, pos: tuple[float, float]) -> float -``` -Get bilinearly interpolated value at float coordinates. - -```python -def get_slope(self, pos: tuple[int, int]) -> float -``` -Get slope (0 to π/2) at position. - -```python -def get_normal(self, pos: tuple[int, int]) -> tuple[float, float, float] -``` -Get normalized surface normal vector at position. - -```python -def min_max(self) -> tuple[float, float] -``` -Return (minimum_value, maximum_value) across all cells. - -```python -def count_in_range(self, range: tuple[float, float]) -> int -``` -Count cells with values in the specified range. - ---- - -### mcrfpy.NoiseSource - -A configured noise generator function. Stateless and infinite - the same coordinates always produce the same value. - -#### Constructor - -```python -NoiseSource(dimensions: int = 2, - algorithm: str = "simplex", - hurst: float = 0.5, - lacunarity: float = 2.0, - seed: int = None) -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `dimensions` | `int` | 1-4. Number of input dimensions. Default: 2. | -| `algorithm` | `str` | `"simplex"`, `"perlin"`, or `"wavelet"`. Default: `"simplex"`. | -| `hurst` | `float` | Fractal Hurst exponent for fbm/turbulence. Default: 0.5. | -| `lacunarity` | `float` | Frequency multiplier between octaves. Default: 2.0. | -| `seed` | `int` | Random seed. None for random. | - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `dimensions` | `int` | Read-only. | -| `algorithm` | `str` | Read-only. | -| `hurst` | `float` | Read-only. | -| `lacunarity` | `float` | Read-only. | -| `seed` | `int` | Read-only. | - -#### Point Queries - -```python -def get(self, pos: tuple[float, ...]) -> float -``` -Get flat noise value at coordinates. Tuple length must match dimensions. Returns -1.0 to 1.0. - -```python -def fbm(self, pos: tuple[float, ...], octaves: int = 4) -> float -``` -Get fractal brownian motion value. Returns -1.0 to 1.0. - -```python -def turbulence(self, pos: tuple[float, ...], octaves: int = 4) -> float -``` -Get turbulence (absolute fbm) value. Returns -1.0 to 1.0. - -#### Batch Sampling - -```python -def sample(self, - size: tuple[int, int], - world_region: tuple = None, - mode: str = "fbm", - octaves: int = 4) -> NoiseSample -``` -Create a NoiseSample (HeightMap subclass) by sampling a region. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `size` | `(int, int)` | Output dimensions in cells. | -| `world_region` | `((x1,y1), (x2,y2))` | World coordinates to sample. Default: `((0,0), size)`. | -| `mode` | `str` | `"flat"`, `"fbm"`, or `"turbulence"`. Default: `"fbm"`. | -| `octaves` | `int` | Octaves for fbm/turbulence. Default: 4. | - ---- - -### mcrfpy.NoiseSample - -A HeightMap created by sampling a NoiseSource. Tracks its origin for convenient adjacent sampling and rescaling. - -#### Inheritance - -`NoiseSample` extends `HeightMap` - all HeightMap methods are available. - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `source` | `NoiseSource` | Read-only. The generator that created this sample. | -| `world_region` | `((x1,y1), (x2,y2))` | Read-only. World coordinates that were sampled. | -| `mode` | `str` | Read-only. `"flat"`, `"fbm"`, or `"turbulence"`. | -| `octaves` | `int` | Read-only. Octaves used for sampling. | - -#### Adjacent Sampling - -```python -def next_left(self) -> NoiseSample -def next_right(self) -> NoiseSample -def next_up(self) -> NoiseSample -def next_down(self) -> NoiseSample -``` -Sample the adjacent region in the specified direction. Returns a new NoiseSample with the same size, mode, and octaves, but shifted world_region. - -#### Rescaling - -```python -def resample(self, size: tuple[int, int]) -> NoiseSample -``` -Resample the same world_region at a different output resolution. Useful for minimaps (smaller size) or detail views (larger size). - -```python -def zoom(self, factor: float) -> NoiseSample -``` -Sample a different-sized world region at the same output size. -- `factor > 1.0`: Zoom in (smaller world region, more detail) -- `factor < 1.0`: Zoom out (larger world region, overview) - ---- - -### mcrfpy.BSP - -Binary space partition tree for rectangular regions. Useful for dungeon rooms, zones, or any hierarchical spatial organization. - -#### Constructor - -```python -BSP(bounds: tuple[tuple[int, int], tuple[int, int]]) -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `bounds` | `((x, y), (w, h))` | Position and size of the root region. | - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `bounds` | `((x, y), (w, h))` | Read-only. Root node bounds. | -| `root` | `BSPNode` | Read-only. Reference to the root node. | - -#### Splitting - -```python -def split_once(self, horizontal: bool, position: int) -> BSP -``` -Split the root node once. Returns self for chaining. - -```python -def split_recursive(self, - depth: int, - min_size: tuple[int, int], - max_ratio: float = 1.5, - seed: int = None) -> BSP -``` -Recursively split to the specified depth. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `depth` | `int` | Maximum recursion depth. Creates up to 2^depth leaves. | -| `min_size` | `(int, int)` | Minimum (width, height) for a node to be split. | -| `max_ratio` | `float` | Maximum aspect ratio before forcing split direction. Default: 1.5. | -| `seed` | `int` | Random seed. None for random. | - -```python -def clear(self) -> BSP -``` -Remove all children, keeping only the root node with original bounds. - -#### Iteration - -```python -def leaves(self) -> Iterator[BSPNode] -``` -Iterate all leaf nodes (the actual rooms). - -```python -def traverse(self, order: Traversal = Traversal.LEVEL_ORDER) -> Iterator[BSPNode] -``` -Iterate all nodes in the specified order. - -#### Queries - -```python -def find(self, pos: tuple[int, int]) -> BSPNode -``` -Find the smallest (deepest) node containing the position. - -```python -def find_path(self, start: BSPNode, end: BSPNode) -> tuple[BSPNode, ...] -``` -Find a sequence of sibling-connected nodes between start and end leaves. Useful for corridor generation. - -#### HeightMap Generation - -```python -def to_heightmap(self, - size: tuple[int, int] = None, - select: str = "leaves", - nodes: list[BSPNode] = None, - shrink: int = 0, - value: float = 1.0) -> BSPMap -``` -Create a BSPMap (HeightMap subclass) from selected nodes. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `size` | `(int, int)` | Output size. Default: bounds size. | -| `select` | `str` | `"leaves"`, `"all"`, or `"internal"`. Default: `"leaves"`. | -| `nodes` | `list[BSPNode]` | Override: specific nodes only. | -| `shrink` | `int` | Pixels to shrink from each node's bounds. Default: 0. | -| `value` | `float` | Value inside selected regions. Default: 1.0. | - ---- - -### mcrfpy.BSPNode - -A lightweight reference to a node in a BSP tree. Read-only. - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `bounds` | `((x, y), (w, h))` | Position and size of this node. | -| `level` | `int` | Depth in tree (0 for root). | -| `index` | `int` | Unique index within the tree. | -| `is_leaf` | `bool` | True if this node has no children. | -| `split_horizontal` | `bool \| None` | Split orientation. None if leaf. | -| `split_position` | `int \| None` | Split coordinate. None if leaf. | - -#### Navigation - -| Property | Type | Description | -|----------|------|-------------| -| `left` | `BSPNode \| None` | Left child, or None if leaf. | -| `right` | `BSPNode \| None` | Right child, or None if leaf. | -| `parent` | `BSPNode \| None` | Parent node, or None if root. | -| `sibling` | `BSPNode \| None` | Other child of parent, or None. | - -#### Methods - -```python -def contains(self, pos: tuple[int, int]) -> bool -``` -Check if position is inside this node's bounds. - -```python -def center(self) -> tuple[int, int] -``` -Return the center point of this node's bounds. - ---- - -### mcrfpy.BSPMap - -A HeightMap created from BSP node selection. Tracks its origin for convenient re-querying. - -#### Inheritance - -`BSPMap` extends `HeightMap` - all HeightMap methods are available. - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `bsp` | `BSP` | Read-only. The tree that created this map. | -| `nodes` | `tuple[BSPNode, ...]` | Read-only. Nodes represented in this map. | -| `shrink` | `int` | Read-only. Margin applied from bounds. | - -#### BSP-Specific Operations - -```python -def inverse(self) -> BSPMap -``` -Return a new BSPMap with walls instead of rooms (inverts the selection within BSP bounds). - -```python -def expand(self, amount: int = 1) -> BSPMap -``` -Return a new BSPMap with node bounds grown by amount. - -```python -def contract(self, amount: int = 1) -> BSPMap -``` -Return a new BSPMap with node bounds shrunk by additional amount. - -#### Re-querying - -```python -def with_nodes(self, nodes: list[BSPNode]) -> BSPMap -``` -Return a new BSPMap with different node selection. - -```python -def with_shrink(self, shrink: int) -> BSPMap -``` -Return a new BSPMap with different shrink value. - ---- - -### mcrfpy.Traversal - -Enumeration for BSP tree traversal orders. - -```python -class Traversal(Enum): - PRE_ORDER = "pre" # Node, then left, then right - IN_ORDER = "in" # Left, then node, then right - POST_ORDER = "post" # Left, then right, then node - LEVEL_ORDER = "level" # Top to bottom, left to right - INVERTED_LEVEL_ORDER = "level_inverted" # Bottom to top, right to left -``` - ---- - -## Application to Grid and Layers - -### Grid.apply_threshold - -```python -def apply_threshold(self, - source: HeightMap, - range: tuple[float, float], - walkable: bool = None, - transparent: bool = None) -> Grid -``` -Set walkable/transparent properties where source value is in range. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `source` | `HeightMap` | Must match grid size. | -| `range` | `(float, float)` | (min, max) inclusive range. | -| `walkable` | `bool \| None` | Set walkable to this value. None = don't change. | -| `transparent` | `bool \| None` | Set transparent to this value. None = don't change. | - -### Grid.apply_ranges - -```python -def apply_ranges(self, - source: HeightMap, - ranges: list[tuple]) -> Grid -``` -Apply multiple thresholds in a single pass. - -```python -# Example -grid.apply_ranges(terrain, [ - ((0.0, 0.3), {"walkable": False, "transparent": True}), # Water - ((0.3, 0.8), {"walkable": True, "transparent": True}), # Land - ((0.8, 1.0), {"walkable": False, "transparent": False}), # Mountains -]) -``` - -### TileLayer.apply_threshold - -```python -def apply_threshold(self, - source: HeightMap, - range: tuple[float, float], - tile: int) -> TileLayer -``` -Set tile index where source value is in range. - -### TileLayer.apply_ranges - -```python -def apply_ranges(self, - source: HeightMap, - ranges: list[tuple]) -> TileLayer -``` -Apply multiple tile assignments in a single pass. - -```python -# Example -tiles.apply_ranges(terrain, [ - ((0.0, 0.2), DEEP_WATER), - ((0.2, 0.3), SHALLOW_WATER), - ((0.3, 0.5), SAND), - ((0.5, 0.7), GRASS), - ((0.7, 0.85), ROCK), - ((0.85, 1.0), SNOW), -]) -``` - -### ColorLayer.apply_threshold - -```python -def apply_threshold(self, - source: HeightMap, - range: tuple[float, float], - color: tuple[int, ...]) -> ColorLayer -``` -Set fixed color where source value is in range. Color is (R, G, B) or (R, G, B, A). - -### ColorLayer.apply_gradient - -```python -def apply_gradient(self, - source: HeightMap, - range: tuple[float, float], - color_low: tuple[int, ...], - color_high: tuple[int, ...]) -> ColorLayer -``` -Interpolate between colors based on source value within range. -- At range minimum → color_low -- At range maximum → color_high - -### ColorLayer.apply_ranges - -```python -def apply_ranges(self, - source: HeightMap, - ranges: list[tuple]) -> ColorLayer -``` -Apply multiple color assignments. Each range maps to either a fixed color or a gradient. - -```python -# Example -colors.apply_ranges(terrain, [ - ((0.0, 0.3), (0, 0, 180)), # Fixed blue - ((0.3, 0.7), ((50, 120, 50), (100, 200, 100))), # Gradient green (tuple of 2) - ((0.7, 1.0), ((100, 100, 100), (255, 255, 255))), # Gradient gray to white -]) -``` - ---- - -## Usage Examples - -### Example 1: Simple Dungeon - -```python -import mcrfpy - -# Create BSP dungeon layout -bsp = mcrfpy.BSP(bounds=((0, 0), (80, 50))) -bsp.split_recursive(depth=4, min_size=(6, 6), max_ratio=1.5) - -# Generate room mask (1.0 inside rooms, 0.0 in walls) -rooms = bsp.to_heightmap(select="leaves", shrink=1) - -# Apply to grid -grid.apply_threshold(rooms, range=(0.5, 1.0), walkable=True, transparent=True) - -# Apply floor tiles -tiles.apply_threshold(rooms, range=(0.5, 1.0), tile=FLOOR_TILE) - -# Walls are left as default (not in threshold range) -``` - -### Example 2: Natural Caves - -```python -import mcrfpy - -# Create noise generator -noise = mcrfpy.NoiseSource(algorithm="simplex", seed=42) - -# Sample onto heightmap -cave = noise.sample( - size=(100, 100), - world_region=((0, 0), (10, 10)), # 10x zoom for smooth features - mode="fbm", - octaves=4 -) -cave.normalize() - -# Open areas where noise > 0.45 -grid.apply_threshold(cave, range=(0.45, 1.0), walkable=True, transparent=True) -tiles.apply_threshold(cave, range=(0.45, 1.0), tile=CAVE_FLOOR) -tiles.apply_threshold(cave, range=(0.0, 0.45), tile=CAVE_WALL) -``` - -### Example 3: Overworld with Rivers - -```python -import mcrfpy - -# Base terrain -terrain = mcrfpy.HeightMap(size=(200, 200)) -terrain.mid_point_displacement(roughness=0.5) - -# Add hills -terrain.add_hill(center=(50, 80), radius=20, height=0.4) -terrain.add_hill(center=(150, 120), radius=25, height=0.5) - -# Carve river -terrain.dig_bezier( - points=((10, 100), (60, 80), (140, 120), (190, 100)), - start_radius=2, end_radius=5, - start_depth=0.5, end_depth=0.5 -) - -# Erosion for realism -terrain.rain_erosion(drops=8000) -terrain.normalize() - -# Apply terrain bands -grid.apply_ranges(terrain, [ - ((0.0, 0.25), {"walkable": False, "transparent": True}), # Water - ((0.25, 0.75), {"walkable": True, "transparent": True}), # Land - ((0.75, 1.0), {"walkable": False, "transparent": False}), # Mountains -]) - -tiles.apply_ranges(terrain, [ - ((0.0, 0.25), WATER_TILE), - ((0.25, 0.4), SAND_TILE), - ((0.4, 0.65), GRASS_TILE), - ((0.65, 0.8), ROCK_TILE), - ((0.8, 1.0), SNOW_TILE), -]) - -colors.apply_ranges(terrain, [ - ((0.0, 0.25), ((0, 50, 150), (0, 100, 200))), - ((0.25, 0.65), ((80, 160, 80), (120, 200, 120))), - ((0.65, 1.0), ((120, 120, 120), (255, 255, 255))), -]) -``` - -### Example 4: Dungeon with Varied Room Terrain - -```python -import mcrfpy - -# Create dungeon structure -bsp = mcrfpy.BSP(bounds=((0, 0), (100, 100))) -bsp.split_recursive(depth=5, min_size=(8, 8)) - -# Room mask -rooms = bsp.to_heightmap(select="leaves", shrink=1) - -# Create terrain variation -noise = mcrfpy.NoiseSource(seed=123) -terrain = mcrfpy.HeightMap(size=(100, 100)) -terrain.add_noise(noise, mode="fbm", octaves=4) -terrain.normalize() - -# Mask terrain to rooms only -terrain.multiply(rooms) - -# Apply varied floor tiles within rooms -tiles.apply_threshold(rooms, range=(0.5, 1.0), tile=STONE_FLOOR) # Base floor -tiles.apply_threshold(terrain, range=(0.3, 0.5), tile=MOSSY_FLOOR) -tiles.apply_threshold(terrain, range=(0.5, 0.7), tile=CRACKED_FLOOR) - -# Walkability from rooms mask -grid.apply_threshold(rooms, range=(0.5, 1.0), walkable=True, transparent=True) -``` - -### Example 5: Infinite World with Chunks - -```python -import mcrfpy - -# Persistent world generator -WORLD_SEED = 12345 -world_noise = mcrfpy.NoiseSource(seed=WORLD_SEED) - -CHUNK_SIZE = 64 - -def generate_chunk(chunk_x: int, chunk_y: int) -> mcrfpy.NoiseSample: - """Generate terrain for a world chunk.""" - return world_noise.sample( - size=(CHUNK_SIZE, CHUNK_SIZE), - world_region=( - (chunk_x * CHUNK_SIZE, chunk_y * CHUNK_SIZE), - ((chunk_x + 1) * CHUNK_SIZE, (chunk_y + 1) * CHUNK_SIZE) - ), - mode="fbm", - octaves=6 - ) - -# Generate current chunk -current_chunk = generate_chunk(0, 0) - -# When player moves right, get adjacent chunk -next_chunk = current_chunk.next_right() - -# Generate minimap of large area -minimap = world_noise.sample( - size=(100, 100), - world_region=((0, 0), (1000, 1000)), - mode="fbm", - octaves=3 -) -``` - -### Example 6: Biome Blending - -```python -import mcrfpy - -# Multiple noise layers -elevation_noise = mcrfpy.NoiseSource(seed=1) -moisture_noise = mcrfpy.NoiseSource(seed=2) - -# Sample both -elevation = mcrfpy.HeightMap(size=(200, 200)) -elevation.add_noise(elevation_noise, mode="fbm", octaves=6) -elevation.normalize() - -moisture = mcrfpy.HeightMap(size=(200, 200)) -moisture.add_noise(moisture_noise, mode="fbm", octaves=4) -moisture.normalize() - -# Desert: low moisture AND medium elevation -desert_mask = moisture.threshold((0.0, 0.3)) -desert_mask.multiply(elevation.threshold((0.2, 0.6))) - -# Forest: high moisture AND low elevation -forest_mask = moisture.threshold((0.6, 1.0)) -forest_mask.multiply(elevation.threshold((0.1, 0.5))) - -# Swamp: high moisture AND very low elevation -swamp_mask = moisture.threshold((0.7, 1.0)) -swamp_mask.multiply(elevation.threshold((0.0, 0.2))) - -# Apply biome tiles (later biomes override earlier) -tiles.apply_threshold(elevation, range=(0.0, 1.0), tile=GRASS_TILE) # Default -tiles.apply_threshold(desert_mask, range=(0.5, 1.0), tile=SAND_TILE) -tiles.apply_threshold(forest_mask, range=(0.5, 1.0), tile=TREE_TILE) -tiles.apply_threshold(swamp_mask, range=(0.5, 1.0), tile=SWAMP_TILE) -``` - ---- - -## Implementation Notes - -### Size Matching - -All HeightMap operations between two heightmaps require matching sizes. The `apply_*` methods on Grid/Layer also require the source HeightMap to match the Grid's `grid_size`. - -```python -# This will raise ValueError -small = mcrfpy.HeightMap(size=(50, 50)) -large = mcrfpy.HeightMap(size=(100, 100)) -large.add(small) # Error: size mismatch -``` - -### Value Ranges - -- NoiseSource outputs: -1.0 to 1.0 -- HeightMap after `normalize()`: 0.0 to 1.0 (by default) -- Threshold operations: work on any float range - -Recommendation: Always `normalize()` before applying to Grid/Layer for predictable threshold behavior. - -### Performance Considerations - -For grids up to 1000×1000 (1M cells): - -| Operation | Approximate Cost | -|-----------|------------------| -| HeightMap creation | O(n) | -| add/multiply/scale | O(n), parallelizable | -| add_noise | O(n × octaves), parallelizable | -| mid_point_displacement | O(n log n) | -| rain_erosion | O(drops) | -| apply_ranges | O(n × num_ranges), single pass | - -All operations execute entirely in C++ with no Python callbacks. - ---- - -## Future Considerations - -The following features are explicitly **out of scope** for the initial implementation but should not be precluded by the design: - -1. **Serialization**: Saving/loading HeightMaps to disk for pre-generated worlds. - -2. **Region Operations**: Applying operations to sub-regions when HeightMap sizes don't match. - -3. **Corridor Generation**: Built-in BSP corridor creation (currently user code using `find_path` and `dig_bezier` or manual fills). - -4. **Custom Kernels**: User-defined kernel transforms beyond simple smoothing. - ---- - -## Appendix: libtcod Function Mapping - -| libtcod Function | McRogueFace Equivalent | -|------------------|------------------------| -| `TCOD_noise_new` | `NoiseSource()` | -| `TCOD_noise_get` | `NoiseSource.get()` | -| `TCOD_noise_get_fbm` | `NoiseSource.fbm()` | -| `TCOD_noise_get_turbulence` | `NoiseSource.turbulence()` | -| `TCOD_heightmap_new` | `HeightMap()` | -| `TCOD_heightmap_add_hill` | `HeightMap.add_hill()` | -| `TCOD_heightmap_dig_hill` | `HeightMap.dig_hill()` | -| `TCOD_heightmap_rain_erosion` | `HeightMap.rain_erosion()` | -| `TCOD_heightmap_add_voronoi` | `HeightMap.add_voronoi()` | -| `TCOD_heightmap_mid_point_displacement` | `HeightMap.mid_point_displacement()` | -| `TCOD_heightmap_dig_bezier` | `HeightMap.dig_bezier()` | -| `TCOD_heightmap_add_fbm` | `HeightMap.add_noise(..., mode="fbm")` | -| `TCOD_heightmap_scale_fbm` | `HeightMap.multiply_noise(..., mode="fbm")` | -| `TCOD_heightmap_normalize` | `HeightMap.normalize()` | -| `TCOD_heightmap_clamp` | `HeightMap.clamp()` | -| `TCOD_heightmap_add` | `HeightMap.add()` | -| `TCOD_heightmap_multiply` | `HeightMap.multiply()` | -| `TCOD_heightmap_lerp` | `HeightMap.lerp()` | -| `TCOD_bsp_new_with_size` | `BSP()` | -| `TCOD_bsp_split_once` | `BSP.split_once()` | -| `TCOD_bsp_split_recursive` | `BSP.split_recursive()` | -| `TCOD_bsp_traverse_*` | `BSP.traverse(order=...)` | -| `TCOD_bsp_is_leaf` | `BSPNode.is_leaf` | -| `TCOD_bsp_contains` | `BSPNode.contains()` | -| `TCOD_bsp_find_node` | `BSP.find()` | diff --git a/src/Common.h b/src/Common.h index f3c3b34..2bdc3ba 100644 --- a/src/Common.h +++ b/src/Common.h @@ -2,10 +2,6 @@ #include #include -// Maximum dimension for grids, layers, and heightmaps (8192x8192 = 256MB of float data) -// Prevents integer overflow in size calculations and limits memory allocation -constexpr int GRID_MAX = 8192; - #include #include #include diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 8fd857b..e0dc9de 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -20,8 +20,6 @@ #include "PyMusic.h" #include "PyKeyboard.h" #include "PyMouse.h" -#include "UIGridPathfinding.h" // AStarPath and DijkstraMap types -#include "PyHeightMap.h" // Procedural generation heightmap (#193) #include "McRogueFaceVersion.h" #include "GameEngine.h" #include "ImGuiConsole.h" @@ -412,13 +410,6 @@ PyObject* PyInit_mcrfpy() /*mouse state (#186)*/ &PyMouseType, - /*pathfinding result types*/ - &mcrfpydef::PyAStarPathType, - &mcrfpydef::PyDijkstraMapType, - - /*procedural generation (#192)*/ - &mcrfpydef::PyHeightMapType, - nullptr}; // Types that are used internally but NOT exported to module namespace (#189) @@ -431,9 +422,6 @@ PyObject* PyInit_mcrfpy() &PyUICollectionType, &PyUICollectionIterType, &PyUIEntityCollectionType, &PyUIEntityCollectionIterType, - /*pathfinding iterator - returned by AStarPath.__iter__() but not directly instantiable*/ - &mcrfpydef::PyAStarPathIterType, - nullptr}; // Set up PyWindowType methods and getsetters before PyType_Ready @@ -443,11 +431,7 @@ PyObject* PyInit_mcrfpy() // Set up PySceneType methods and getsetters PySceneType.tp_methods = PySceneClass::methods; PySceneType.tp_getset = PySceneClass::getsetters; - - // Set up PyHeightMapType methods and getsetters (#193) - mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods; - mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters; - + // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/McRFPy_Libtcod.cpp b/src/McRFPy_Libtcod.cpp index 71b20df..f94d0a9 100644 --- a/src/McRFPy_Libtcod.cpp +++ b/src/McRFPy_Libtcod.cpp @@ -10,13 +10,13 @@ static UIGrid* get_grid_from_pyobject(PyObject* obj) { PyErr_SetString(PyExc_RuntimeError, "Could not find Grid type"); return nullptr; } - + if (!PyObject_IsInstance(obj, (PyObject*)grid_type)) { Py_DECREF(grid_type); PyErr_SetString(PyExc_TypeError, "First argument must be a Grid object"); return nullptr; } - + Py_DECREF(grid_type); PyUIGridObject* pygrid = (PyUIGridObject*)obj; return pygrid->data.get(); @@ -28,18 +28,18 @@ static PyObject* McRFPy_Libtcod::compute_fov(PyObject* self, PyObject* args) { int x, y, radius; int light_walls = 1; int algorithm = FOV_BASIC; - - if (!PyArg_ParseTuple(args, "Oiii|ii", &grid_obj, &x, &y, &radius, + + if (!PyArg_ParseTuple(args, "Oiii|ii", &grid_obj, &x, &y, &radius, &light_walls, &algorithm)) { return NULL; } - + UIGrid* grid = get_grid_from_pyobject(grid_obj); if (!grid) return NULL; - + // Compute FOV using grid's method grid->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm); - + // Return list of visible cells PyObject* visible_list = PyList_New(0); for (int gy = 0; gy < grid->grid_h; gy++) { @@ -51,31 +51,57 @@ static PyObject* McRFPy_Libtcod::compute_fov(PyObject* self, PyObject* args) { } } } - + return visible_list; } +// A* Pathfinding +static PyObject* McRFPy_Libtcod::find_path(PyObject* self, PyObject* args) { + PyObject* grid_obj; + int x1, y1, x2, y2; + float diagonal_cost = 1.41f; + + if (!PyArg_ParseTuple(args, "Oiiii|f", &grid_obj, &x1, &y1, &x2, &y2, &diagonal_cost)) { + return NULL; + } + + UIGrid* grid = get_grid_from_pyobject(grid_obj); + if (!grid) return NULL; + + // Get path from grid + std::vector> path = grid->findPath(x1, y1, x2, y2, diagonal_cost); + + // Convert to Python list + PyObject* path_list = PyList_New(path.size()); + for (size_t i = 0; i < path.size(); i++) { + PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second); + PyList_SetItem(path_list, i, pos); // steals reference + } + + return path_list; +} + // Line drawing algorithm static PyObject* McRFPy_Libtcod::line(PyObject* self, PyObject* args) { int x1, y1, x2, y2; - + if (!PyArg_ParseTuple(args, "iiii", &x1, &y1, &x2, &y2)) { return NULL; } - + // Use TCOD's line algorithm TCODLine::init(x1, y1, x2, y2); - + PyObject* line_list = PyList_New(0); int x, y; - + // Step through line while (!TCODLine::step(&x, &y)) { PyObject* pos = Py_BuildValue("(ii)", x, y); PyList_Append(line_list, pos); Py_DECREF(pos); } - + return line_list; } @@ -86,8 +112,80 @@ static PyObject* McRFPy_Libtcod::line_iter(PyObject* self, PyObject* args) { return line(self, args); } -// Pathfinding functions removed - use Grid.find_path() and Grid.get_dijkstra_map() instead -// These return AStarPath and DijkstraMap objects (see UIGridPathfinding.h) +// Dijkstra pathfinding +static PyObject* McRFPy_Libtcod::dijkstra_new(PyObject* self, PyObject* args) { + PyObject* grid_obj; + float diagonal_cost = 1.41f; + + if (!PyArg_ParseTuple(args, "O|f", &grid_obj, &diagonal_cost)) { + return NULL; + } + + UIGrid* grid = get_grid_from_pyobject(grid_obj); + if (!grid) return NULL; + + // For now, just return the grid object since Dijkstra is part of the grid + Py_INCREF(grid_obj); + return grid_obj; +} + +static PyObject* McRFPy_Libtcod::dijkstra_compute(PyObject* self, PyObject* args) { + PyObject* grid_obj; + int root_x, root_y; + + if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &root_x, &root_y)) { + return NULL; + } + + UIGrid* grid = get_grid_from_pyobject(grid_obj); + if (!grid) return NULL; + + grid->computeDijkstra(root_x, root_y); + Py_RETURN_NONE; +} + +static PyObject* McRFPy_Libtcod::dijkstra_get_distance(PyObject* self, PyObject* args) { + PyObject* grid_obj; + int x, y; + + if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) { + return NULL; + } + + UIGrid* grid = get_grid_from_pyobject(grid_obj); + if (!grid) return NULL; + + float distance = grid->getDijkstraDistance(x, y); + if (distance < 0) { + Py_RETURN_NONE; + } + + return PyFloat_FromDouble(distance); +} + +static PyObject* McRFPy_Libtcod::dijkstra_path_to(PyObject* self, PyObject* args) { + PyObject* grid_obj; + int x, y; + + if (!PyArg_ParseTuple(args, "Oii", &grid_obj, &x, &y)) { + return NULL; + } + + UIGrid* grid = get_grid_from_pyobject(grid_obj); + if (!grid) return NULL; + + std::vector> path = grid->getDijkstraPath(x, y); + + PyObject* path_list = PyList_New(path.size()); + for (size_t i = 0; i < path.size(); i++) { + PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second); + PyList_SetItem(path_list, i, pos); // steals reference + } + + return path_list; +} + +// FOV algorithm constants removed - use mcrfpy.FOV enum instead (#114) // Method definitions static PyMethodDef libtcodMethods[] = { @@ -102,7 +200,18 @@ static PyMethodDef libtcodMethods[] = { " algorithm: FOV algorithm (mcrfpy.FOV.BASIC, mcrfpy.FOV.SHADOW, etc.)\n\n" "Returns:\n" " List of (x, y) tuples for visible cells"}, - + + {"find_path", McRFPy_Libtcod::find_path, METH_VARARGS, + "find_path(grid, x1, y1, x2, y2, diagonal_cost=1.41)\n\n" + "Find shortest path between two points using A*.\n\n" + "Args:\n" + " grid: Grid object to pathfind on\n" + " x1, y1: Starting position\n" + " x2, y2: Target position\n" + " diagonal_cost: Cost of diagonal movement\n\n" + "Returns:\n" + " List of (x, y) tuples representing the path, or empty list if no path exists"}, + {"line", McRFPy_Libtcod::line, METH_VARARGS, "line(x1, y1, x2, y2)\n\n" "Get cells along a line using Bresenham's algorithm.\n\n" @@ -111,7 +220,7 @@ static PyMethodDef libtcodMethods[] = { " x2, y2: Ending position\n\n" "Returns:\n" " List of (x, y) tuples along the line"}, - + {"line_iter", McRFPy_Libtcod::line_iter, METH_VARARGS, "line_iter(x1, y1, x2, y2)\n\n" "Iterate over cells along a line.\n\n" @@ -120,7 +229,41 @@ static PyMethodDef libtcodMethods[] = { " x2, y2: Ending position\n\n" "Returns:\n" " Iterator of (x, y) tuples along the line"}, - + + {"dijkstra_new", McRFPy_Libtcod::dijkstra_new, METH_VARARGS, + "dijkstra_new(grid, diagonal_cost=1.41)\n\n" + "Create a Dijkstra pathfinding context for a grid.\n\n" + "Args:\n" + " grid: Grid object to use for pathfinding\n" + " diagonal_cost: Cost of diagonal movement\n\n" + "Returns:\n" + " Grid object configured for Dijkstra pathfinding"}, + + {"dijkstra_compute", McRFPy_Libtcod::dijkstra_compute, METH_VARARGS, + "dijkstra_compute(grid, root_x, root_y)\n\n" + "Compute Dijkstra distance map from root position.\n\n" + "Args:\n" + " grid: Grid object with Dijkstra context\n" + " root_x, root_y: Root position to compute distances from"}, + + {"dijkstra_get_distance", McRFPy_Libtcod::dijkstra_get_distance, METH_VARARGS, + "dijkstra_get_distance(grid, x, y)\n\n" + "Get distance from root to a position.\n\n" + "Args:\n" + " grid: Grid object with computed Dijkstra map\n" + " x, y: Position to get distance for\n\n" + "Returns:\n" + " Float distance or None if position is invalid/unreachable"}, + + {"dijkstra_path_to", McRFPy_Libtcod::dijkstra_path_to, METH_VARARGS, + "dijkstra_path_to(grid, x, y)\n\n" + "Get shortest path from position to Dijkstra root.\n\n" + "Args:\n" + " grid: Grid object with computed Dijkstra map\n" + " x, y: Starting position\n\n" + "Returns:\n" + " List of (x, y) tuples representing the path to root"}, + {NULL, NULL, 0, NULL} }; @@ -128,7 +271,7 @@ static PyMethodDef libtcodMethods[] = { static PyModuleDef libtcodModule = { PyModuleDef_HEAD_INIT, "mcrfpy.libtcod", - "TCOD-compatible algorithms for field of view and line drawing.\n\n" + "TCOD-compatible algorithms for field of view, pathfinding, and line drawing.\n\n" "This module provides access to TCOD's algorithms integrated with McRogueFace grids.\n" "Unlike the original TCOD, these functions work directly with Grid objects.\n\n" "FOV Algorithms (use mcrfpy.FOV enum):\n" @@ -138,15 +281,12 @@ static PyModuleDef libtcodModule = { " mcrfpy.FOV.PERMISSIVE_0 through PERMISSIVE_8 - Permissive variants\n" " mcrfpy.FOV.RESTRICTIVE - Most restrictive FOV\n" " mcrfpy.FOV.SYMMETRIC_SHADOWCAST - Symmetric shadow casting\n\n" - "Pathfinding:\n" - " Use Grid.find_path() for A* pathfinding (returns AStarPath objects)\n" - " Use Grid.get_dijkstra_map() for Dijkstra pathfinding (returns DijkstraMap objects)\n\n" "Example:\n" " import mcrfpy\n" " from mcrfpy import libtcod\n\n" " grid = mcrfpy.Grid(50, 50)\n" " visible = libtcod.compute_fov(grid, 25, 25, 10)\n" - " path = grid.find_path((0, 0), (49, 49)) # Returns AStarPath", + " path = libtcod.find_path(grid, 0, 0, 49, 49)", -1, libtcodMethods }; @@ -157,8 +297,8 @@ PyObject* McRFPy_Libtcod::init_libtcod_module() { if (m == NULL) { return NULL; } - + // FOV algorithm constants now provided by mcrfpy.FOV enum (#114) return m; -} +} \ No newline at end of file diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp deleted file mode 100644 index e263d14..0000000 --- a/src/PyHeightMap.cpp +++ /dev/null @@ -1,302 +0,0 @@ -#include "PyHeightMap.h" -#include "McRFPy_API.h" -#include "McRFPy_Doc.h" -#include - -// Property definitions -PyGetSetDef PyHeightMap::getsetters[] = { - {"size", (getter)PyHeightMap::get_size, NULL, - MCRF_PROPERTY(size, "Dimensions (width, height) of the heightmap. Read-only."), NULL}, - {NULL} -}; - -// Method definitions -PyMethodDef PyHeightMap::methods[] = { - {"fill", (PyCFunction)PyHeightMap::fill, METH_VARARGS, - MCRF_METHOD(HeightMap, fill, - MCRF_SIG("(value: float)", "HeightMap"), - MCRF_DESC("Set all cells to the specified value."), - MCRF_ARGS_START - MCRF_ARG("value", "The value to set for all cells") - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {"clear", (PyCFunction)PyHeightMap::clear, METH_NOARGS, - MCRF_METHOD(HeightMap, clear, - MCRF_SIG("()", "HeightMap"), - MCRF_DESC("Set all cells to 0.0. Equivalent to fill(0.0)."), - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {"add_constant", (PyCFunction)PyHeightMap::add_constant, METH_VARARGS, - MCRF_METHOD(HeightMap, add_constant, - MCRF_SIG("(value: float)", "HeightMap"), - MCRF_DESC("Add a constant value to every cell."), - MCRF_ARGS_START - MCRF_ARG("value", "The value to add to each cell") - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {"scale", (PyCFunction)PyHeightMap::scale, METH_VARARGS, - MCRF_METHOD(HeightMap, scale, - MCRF_SIG("(factor: float)", "HeightMap"), - MCRF_DESC("Multiply every cell by a factor."), - MCRF_ARGS_START - MCRF_ARG("factor", "The multiplier for each cell") - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {"clamp", (PyCFunction)PyHeightMap::clamp, METH_VARARGS | METH_KEYWORDS, - MCRF_METHOD(HeightMap, clamp, - MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), - MCRF_DESC("Clamp all values to the specified range."), - MCRF_ARGS_START - MCRF_ARG("min", "Minimum value (default 0.0)") - MCRF_ARG("max", "Maximum value (default 1.0)") - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {"normalize", (PyCFunction)PyHeightMap::normalize, METH_VARARGS | METH_KEYWORDS, - MCRF_METHOD(HeightMap, normalize, - MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), - MCRF_DESC("Linearly rescale values so the current minimum becomes min and current maximum becomes max."), - MCRF_ARGS_START - MCRF_ARG("min", "Target minimum value (default 0.0)") - MCRF_ARG("max", "Target maximum value (default 1.0)") - MCRF_RETURNS("HeightMap: self, for method chaining") - )}, - {NULL} -}; - -// Constructor -PyObject* PyHeightMap::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) -{ - PyHeightMapObject* self = (PyHeightMapObject*)type->tp_alloc(type, 0); - if (self) { - self->heightmap = nullptr; - } - return (PyObject*)self; -} - -int PyHeightMap::init(PyHeightMapObject* self, PyObject* args, PyObject* kwds) -{ - static const char* keywords[] = {"size", "fill", nullptr}; - PyObject* size_obj = nullptr; - float fill_value = 0.0f; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(keywords), - &size_obj, &fill_value)) { - return -1; - } - - // Parse size tuple - if (!PyTuple_Check(size_obj) || PyTuple_Size(size_obj) != 2) { - PyErr_SetString(PyExc_TypeError, "size must be a tuple of (width, height)"); - return -1; - } - - int width = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 0)); - int height = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 1)); - - if (PyErr_Occurred()) { - return -1; - } - - if (width <= 0 || height <= 0) { - PyErr_SetString(PyExc_ValueError, "width and height must be positive integers"); - return -1; - } - - if (width > GRID_MAX || height > GRID_MAX) { - PyErr_Format(PyExc_ValueError, - "HeightMap dimensions cannot exceed %d (got %dx%d)", - GRID_MAX, width, height); - return -1; - } - - // Clean up any existing heightmap - if (self->heightmap) { - TCOD_heightmap_delete(self->heightmap); - } - - // Create new libtcod heightmap - self->heightmap = TCOD_heightmap_new(width, height); - if (!self->heightmap) { - PyErr_SetString(PyExc_MemoryError, "Failed to allocate heightmap"); - return -1; - } - - // Fill with initial value if not zero - if (fill_value != 0.0f) { - // libtcod's TCOD_heightmap_add adds to all cells, so we use it after clear - TCOD_heightmap_clear(self->heightmap); - TCOD_heightmap_add(self->heightmap, fill_value); - } - - return 0; -} - -void PyHeightMap::dealloc(PyHeightMapObject* self) -{ - if (self->heightmap) { - TCOD_heightmap_delete(self->heightmap); - self->heightmap = nullptr; - } - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* PyHeightMap::repr(PyObject* obj) -{ - PyHeightMapObject* self = (PyHeightMapObject*)obj; - std::ostringstream ss; - - if (self->heightmap) { - ss << "heightmap->w << " x " << self->heightmap->h << ")>"; - } else { - ss << ""; - } - - return PyUnicode_FromString(ss.str().c_str()); -} - -// Property: size -PyObject* PyHeightMap::get_size(PyHeightMapObject* self, void* closure) -{ - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - return Py_BuildValue("(ii)", self->heightmap->w, self->heightmap->h); -} - -// Method: fill(value) -> HeightMap -PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args) -{ - float value; - if (!PyArg_ParseTuple(args, "f", &value)) { - return nullptr; - } - - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - // Clear and then add the value (libtcod doesn't have a direct "set all" function) - TCOD_heightmap_clear(self->heightmap); - if (value != 0.0f) { - TCOD_heightmap_add(self->heightmap, value); - } - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: clear() -> HeightMap -PyObject* PyHeightMap::clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)) -{ - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - TCOD_heightmap_clear(self->heightmap); - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: add_constant(value) -> HeightMap -PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args) -{ - float value; - if (!PyArg_ParseTuple(args, "f", &value)) { - return nullptr; - } - - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - TCOD_heightmap_add(self->heightmap, value); - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: scale(factor) -> HeightMap -PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args) -{ - float factor; - if (!PyArg_ParseTuple(args, "f", &factor)) { - return nullptr; - } - - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - TCOD_heightmap_scale(self->heightmap, factor); - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: clamp(min=0.0, max=1.0) -> HeightMap -PyObject* PyHeightMap::clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) -{ - static const char* keywords[] = {"min", "max", nullptr}; - float min_val = 0.0f; - float max_val = 1.0f; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), - &min_val, &max_val)) { - return nullptr; - } - - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - if (min_val > max_val) { - PyErr_SetString(PyExc_ValueError, "min must be less than or equal to max"); - return nullptr; - } - - TCOD_heightmap_clamp(self->heightmap, min_val, max_val); - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: normalize(min=0.0, max=1.0) -> HeightMap -PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds) -{ - static const char* keywords[] = {"min", "max", nullptr}; - float min_val = 0.0f; - float max_val = 1.0f; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), - &min_val, &max_val)) { - return nullptr; - } - - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - if (min_val > max_val) { - PyErr_SetString(PyExc_ValueError, "min must be less than or equal to max"); - return nullptr; - } - - TCOD_heightmap_normalize(self->heightmap, min_val, max_val); - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h deleted file mode 100644 index df02af6..0000000 --- a/src/PyHeightMap.h +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once -#include "Common.h" -#include "Python.h" -#include - -// Forward declaration -class PyHeightMap; - -// Python object structure -typedef struct { - PyObject_HEAD - TCOD_heightmap_t* heightmap; // libtcod heightmap pointer -} PyHeightMapObject; - -class PyHeightMap -{ -public: - // Python type interface - static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); - static int init(PyHeightMapObject* self, PyObject* args, PyObject* kwds); - static void dealloc(PyHeightMapObject* self); - static PyObject* repr(PyObject* obj); - - // Properties - static PyObject* get_size(PyHeightMapObject* self, void* closure); - - // Scalar operations (all return self for chaining) - static PyObject* fill(PyHeightMapObject* self, PyObject* args); - static PyObject* clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); - static PyObject* add_constant(PyHeightMapObject* self, PyObject* args); - static PyObject* scale(PyHeightMapObject* self, PyObject* args); - static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); - static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds); - - // Method and property definitions - static PyMethodDef methods[]; - static PyGetSetDef getsetters[]; -}; - -namespace mcrfpydef { - static PyTypeObject PyHeightMapType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.HeightMap", - .tp_basicsize = sizeof(PyHeightMapObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)PyHeightMap::dealloc, - .tp_repr = PyHeightMap::repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "HeightMap(size: tuple[int, int], fill: float = 0.0)\n\n" - "A 2D grid of float values for procedural generation.\n\n" - "HeightMap is the universal canvas for procedural generation. It stores " - "float values that can be manipulated, combined, and applied to Grid and " - "Layer objects.\n\n" - "Args:\n" - " size: (width, height) dimensions of the heightmap. Immutable after creation.\n" - " fill: Initial value for all cells. Default 0.0.\n\n" - "Example:\n" - " hmap = mcrfpy.HeightMap((100, 100))\n" - " hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0)\n" - ), - .tp_methods = nullptr, // Set in McRFPy_API.cpp before PyType_Ready - .tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready - .tp_init = (initproc)PyHeightMap::init, - .tp_new = PyHeightMap::pynew, - }; -} diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index c1586bf..7950c90 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -3,7 +3,6 @@ #include "McRFPy_API.h" #include #include -#include #include "PyObjectUtils.h" #include "PyVector.h" #include "PythonObjectCache.h" @@ -750,45 +749,39 @@ PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kw PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to compute paths"); return NULL; } - + // Get current position int current_x = static_cast(self->data->position.x); int current_y = static_cast(self->data->position.y); - + // Validate target position auto grid = self->data->grid; if (target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) { - PyErr_Format(PyExc_ValueError, "Target position (%d, %d) is out of grid bounds (0-%d, 0-%d)", + PyErr_Format(PyExc_ValueError, "Target position (%d, %d) is out of grid bounds (0-%d, 0-%d)", target_x, target_y, grid->grid_w - 1, grid->grid_h - 1); return NULL; } - - // Use A* pathfinding via temporary TCODPath - TCODPath tcod_path(grid->getTCODMap(), 1.41f); - if (!tcod_path.compute(current_x, current_y, target_x, target_y)) { - // No path found - return empty list - return PyList_New(0); - } - + + // Use the grid's Dijkstra implementation + grid->computeDijkstra(current_x, current_y); + auto path = grid->getDijkstraPath(target_x, target_y); + // Convert path to Python list of tuples - PyObject* path_list = PyList_New(tcod_path.size()); + PyObject* path_list = PyList_New(path.size()); if (!path_list) return PyErr_NoMemory(); - - for (int i = 0; i < tcod_path.size(); ++i) { - int px, py; - tcod_path.get(i, &px, &py); - + + for (size_t i = 0; i < path.size(); ++i) { PyObject* coord_tuple = PyTuple_New(2); if (!coord_tuple) { Py_DECREF(path_list); return PyErr_NoMemory(); } - - PyTuple_SetItem(coord_tuple, 0, PyLong_FromLong(px)); - PyTuple_SetItem(coord_tuple, 1, PyLong_FromLong(py)); + + PyTuple_SetItem(coord_tuple, 0, PyLong_FromLong(path[i].first)); + PyTuple_SetItem(coord_tuple, 1, PyLong_FromLong(path[i].second)); PyList_SetItem(path_list, i, coord_tuple); } - + return path_list; } diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index cff7649..35a254e 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -1,5 +1,4 @@ #include "UIGrid.h" -#include "UIGridPathfinding.h" // New pathfinding API #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" @@ -18,7 +17,7 @@ UIGrid::UIGrid() : grid_w(0), grid_h(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr), - fill_color(8, 8, 8, 255), tcod_map(nullptr), + fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10), use_chunks(false) // Default to omniscient view { @@ -50,7 +49,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x : grid_w(gx), grid_h(gy), zoom(1.0f), ptex(_ptex), - fill_color(8, 8, 8, 255), tcod_map(nullptr), + fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10), use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids { @@ -85,10 +84,14 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x // textures are upside-down inside renderTexture output.setTexture(renderTexture.getTexture()); - // Create TCOD map for FOV and as source for pathfinding + // Create TCOD map tcod_map = new TCODMap(gx, gy); - // Note: DijkstraMap objects are created on-demand via get_dijkstra_map() - // A* paths are computed on-demand via find_path() + + // Create TCOD dijkstra pathfinder + tcod_dijkstra = new TCODDijkstra(tcod_map); + + // Create TCOD A* pathfinder + tcod_path = new TCODPath(tcod_map); // #123 - Initialize storage based on grid size if (use_chunks) { @@ -365,9 +368,14 @@ UIGridPoint& UIGrid::at(int x, int y) UIGrid::~UIGrid() { - // Clear Dijkstra maps first (they reference tcod_map) - dijkstra_maps.clear(); - + if (tcod_path) { + delete tcod_path; + tcod_path = nullptr; + } + if (tcod_dijkstra) { + delete tcod_dijkstra; + tcod_dijkstra = nullptr; + } if (tcod_map) { delete tcod_map; tcod_map = nullptr; @@ -468,9 +476,98 @@ bool UIGrid::isInFOV(int x, int y) const return tcod_map->isInFov(x, y); } -// Pathfinding methods moved to UIGridPathfinding.cpp -// - Grid.find_path() returns AStarPath objects -// - Grid.get_dijkstra_map() returns DijkstraMap objects (cached) +std::vector> UIGrid::findPath(int x1, int y1, int x2, int y2, float diagonalCost) +{ + std::vector> path; + + if (!tcod_map || x1 < 0 || x1 >= grid_w || y1 < 0 || y1 >= grid_h || + x2 < 0 || x2 >= grid_w || y2 < 0 || y2 >= grid_h) { + return path; + } + + TCODPath tcod_path(tcod_map, diagonalCost); + if (tcod_path.compute(x1, y1, x2, y2)) { + for (int i = 0; i < tcod_path.size(); i++) { + int x, y; + tcod_path.get(i, &x, &y); + path.push_back(std::make_pair(x, y)); + } + } + + return path; +} + +void UIGrid::computeDijkstra(int rootX, int rootY, float diagonalCost) +{ + if (!tcod_map || !tcod_dijkstra || rootX < 0 || rootX >= grid_w || rootY < 0 || rootY >= grid_h) return; + + // Compute the Dijkstra map from the root position + tcod_dijkstra->compute(rootX, rootY); +} + +float UIGrid::getDijkstraDistance(int x, int y) const +{ + if (!tcod_dijkstra || x < 0 || x >= grid_w || y < 0 || y >= grid_h) { + return -1.0f; // Invalid position + } + + return tcod_dijkstra->getDistance(x, y); +} + +std::vector> UIGrid::getDijkstraPath(int x, int y) const +{ + std::vector> path; + + if (!tcod_dijkstra || x < 0 || x >= grid_w || y < 0 || y >= grid_h) { + return path; // Empty path for invalid position + } + + // Set the destination + if (tcod_dijkstra->setPath(x, y)) { + // Walk the path and collect points + int px, py; + while (tcod_dijkstra->walk(&px, &py)) { + path.push_back(std::make_pair(px, py)); + } + } + + return path; +} + +// A* pathfinding implementation +std::vector> UIGrid::computeAStarPath(int x1, int y1, int x2, int y2, float diagonalCost) +{ + std::vector> path; + + // Validate inputs + if (!tcod_map || !tcod_path || + x1 < 0 || x1 >= grid_w || y1 < 0 || y1 >= grid_h || + x2 < 0 || x2 >= grid_w || y2 < 0 || y2 >= grid_h) { + return path; // Return empty path + } + + // Set diagonal cost (TCODPath doesn't take it as parameter to compute) + // Instead, diagonal cost is set during TCODPath construction + // For now, we'll use the default diagonal cost from the constructor + + // Compute the path + bool success = tcod_path->compute(x1, y1, x2, y2); + + if (success) { + // Get the computed path + int pathSize = tcod_path->size(); + path.reserve(pathSize); + + // TCOD path includes the starting position, so we start from index 0 + for (int i = 0; i < pathSize; i++) { + int px, py; + tcod_path->get(i, &px, &py); + path.push_back(std::make_pair(px, py)); + } + } + + return path; +} // Phase 1 implementations sf::FloatRect UIGrid::get_bounds() const @@ -1356,9 +1453,128 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k return PyBool_FromLong(in_fov); } -// Old pathfinding Python methods removed - see UIGridPathfinding.cpp for new implementation -// Grid.find_path() now returns AStarPath objects -// Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root) +PyObject* UIGrid::py_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; + PyObject* start_obj = NULL; + PyObject* end_obj = NULL; + float diagonal_cost = 1.41f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast(kwlist), + &start_obj, &end_obj, &diagonal_cost)) { + return NULL; + } + + int x1, y1, x2, y2; + if (!PyPosition_FromObjectInt(start_obj, &x1, &y1)) { + return NULL; + } + if (!PyPosition_FromObjectInt(end_obj, &x2, &y2)) { + return NULL; + } + + std::vector> path = self->data->findPath(x1, y1, x2, y2, diagonal_cost); + + PyObject* path_list = PyList_New(path.size()); + if (!path_list) return NULL; + + for (size_t i = 0; i < path.size(); i++) { + PyObject* coord = Py_BuildValue("(ii)", path[i].first, path[i].second); + if (!coord) { + Py_DECREF(path_list); + return NULL; + } + PyList_SET_ITEM(path_list, i, coord); + } + + return path_list; +} + +PyObject* UIGrid::py_compute_dijkstra(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"root", "diagonal_cost", NULL}; + PyObject* root_obj = NULL; + float diagonal_cost = 1.41f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(kwlist), + &root_obj, &diagonal_cost)) { + return NULL; + } + + int root_x, root_y; + if (!PyPosition_FromObjectInt(root_obj, &root_x, &root_y)) { + return NULL; + } + + self->data->computeDijkstra(root_x, root_y, diagonal_cost); + Py_RETURN_NONE; +} + +PyObject* UIGrid::py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + int x, y; + if (!PyPosition_ParseInt(args, kwds, &x, &y)) { + return NULL; + } + + float distance = self->data->getDijkstraDistance(x, y); + if (distance < 0) { + Py_RETURN_NONE; // Invalid position + } + + return PyFloat_FromDouble(distance); +} + +PyObject* UIGrid::py_get_dijkstra_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + int x, y; + if (!PyPosition_ParseInt(args, kwds, &x, &y)) { + return NULL; + } + + std::vector> path = self->data->getDijkstraPath(x, y); + + PyObject* path_list = PyList_New(path.size()); + for (size_t i = 0; i < path.size(); i++) { + PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second); + PyList_SetItem(path_list, i, pos); // Steals reference + } + + return path_list; +} + +PyObject* UIGrid::py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; + PyObject* start_obj = NULL; + PyObject* end_obj = NULL; + float diagonal_cost = 1.41f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast(kwlist), + &start_obj, &end_obj, &diagonal_cost)) { + return NULL; + } + + int x1, y1, x2, y2; + if (!PyPosition_FromObjectInt(start_obj, &x1, &y1)) { + return NULL; + } + if (!PyPosition_FromObjectInt(end_obj, &x2, &y2)) { + return NULL; + } + + // Compute A* path + std::vector> path = self->data->computeAStarPath(x1, y1, x2, y2, diagonal_cost); + + // Convert to Python list + PyObject* path_list = PyList_New(path.size()); + for (size_t i = 0; i < path.size(); i++) { + PyObject* pos = Py_BuildValue("(ii)", path[i].first, path[i].second); + PyList_SetItem(path_list, i, pos); // Steals reference + } + + return path_list; +} // #147 - Layer system Python API PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds) { @@ -1709,32 +1925,51 @@ PyMethodDef UIGrid::methods[] = { "Returns:\n" " True if the cell is visible, False otherwise\n\n" "Must call compute_fov() first to calculate visibility."}, - {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" + {"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" + "Find A* path between two points.\n\n" + "Args:\n" + " start: Starting position as (x, y) tuple, list, or Vector\n" + " end: Target position as (x, y) tuple, list, or Vector\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + "Returns:\n" + " List of (x, y) tuples representing the path, empty list if no path exists\n\n" + "Uses A* algorithm with walkability from grid cells."}, + {"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS, + "compute_dijkstra(root, diagonal_cost: float = 1.41) -> None\n\n" + "Compute Dijkstra map from root position.\n\n" + "Args:\n" + " root: Root position as (x, y) tuple, list, or Vector\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + "Precomputes distances from all reachable cells to the root.\n" + "Use get_dijkstra_distance() and get_dijkstra_path() to query results.\n" + "Useful for multiple entities pathfinding to the same target."}, + {"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_distance(pos) -> Optional[float]\n\n" + "Get distance from Dijkstra root to position.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " Distance as float, or None if position is unreachable or invalid\n\n" + "Must call compute_dijkstra() first."}, + {"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_path(pos) -> List[Tuple[int, int]]\n\n" + "Get path from position to Dijkstra root.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " List of (x, y) tuples representing path to root, empty if unreachable\n\n" + "Must call compute_dijkstra() first. Path includes start but not root position."}, + {"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS, + "compute_astar_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" "Compute A* path between two points.\n\n" "Args:\n" - " start: Starting position as Vector, Entity, or (x, y) tuple\n" - " end: Target position as Vector, Entity, or (x, y) tuple\n" + " start: Starting position as (x, y) tuple, list, or Vector\n" + " end: Target position as (x, y) tuple, list, or Vector\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" "Returns:\n" - " AStarPath object if path exists, None otherwise.\n\n" - "The returned AStarPath can be iterated or walked step-by-step."}, - {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" - "Get or create a Dijkstra distance map for a root position.\n\n" - "Args:\n" - " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" - "Returns:\n" - " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by root position. Multiple requests for the\n" - "same root return the same cached map. Call clear_dijkstra_maps() after\n" - "changing grid walkability to invalidate the cache."}, - {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, - "clear_dijkstra_maps() -> None\n\n" - "Clear all cached Dijkstra maps.\n\n" - "Call this after modifying grid cell walkability to ensure pathfinding\n" - "uses updated walkability data."}, + " List of (x, y) tuples representing the path, empty list if no path exists\n\n" + "Alternative A* implementation. Prefer find_path() for consistency."}, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer"}, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, @@ -1786,32 +2021,51 @@ PyMethodDef UIGrid_all_methods[] = { "Returns:\n" " True if the cell is visible, False otherwise\n\n" "Must call compute_fov() first to calculate visibility."}, - {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" + {"find_path", (PyCFunction)UIGrid::py_find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" + "Find A* path between two points.\n\n" + "Args:\n" + " start: Starting position as (x, y) tuple, list, or Vector\n" + " end: Target position as (x, y) tuple, list, or Vector\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + "Returns:\n" + " List of (x, y) tuples representing the path, empty list if no path exists\n\n" + "Uses A* algorithm with walkability from grid cells."}, + {"compute_dijkstra", (PyCFunction)UIGrid::py_compute_dijkstra, METH_VARARGS | METH_KEYWORDS, + "compute_dijkstra(root, diagonal_cost: float = 1.41) -> None\n\n" + "Compute Dijkstra map from root position.\n\n" + "Args:\n" + " root: Root position as (x, y) tuple, list, or Vector\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" + "Precomputes distances from all reachable cells to the root.\n" + "Use get_dijkstra_distance() and get_dijkstra_path() to query results.\n" + "Useful for multiple entities pathfinding to the same target."}, + {"get_dijkstra_distance", (PyCFunction)UIGrid::py_get_dijkstra_distance, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_distance(pos) -> Optional[float]\n\n" + "Get distance from Dijkstra root to position.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " Distance as float, or None if position is unreachable or invalid\n\n" + "Must call compute_dijkstra() first."}, + {"get_dijkstra_path", (PyCFunction)UIGrid::py_get_dijkstra_path, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_path(pos) -> List[Tuple[int, int]]\n\n" + "Get path from position to Dijkstra root.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " List of (x, y) tuples representing path to root, empty if unreachable\n\n" + "Must call compute_dijkstra() first. Path includes start but not root position."}, + {"compute_astar_path", (PyCFunction)UIGrid::py_compute_astar_path, METH_VARARGS | METH_KEYWORDS, + "compute_astar_path(start, end, diagonal_cost: float = 1.41) -> List[Tuple[int, int]]\n\n" "Compute A* path between two points.\n\n" "Args:\n" - " start: Starting position as Vector, Entity, or (x, y) tuple\n" - " end: Target position as Vector, Entity, or (x, y) tuple\n" + " start: Starting position as (x, y) tuple, list, or Vector\n" + " end: Target position as (x, y) tuple, list, or Vector\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" "Returns:\n" - " AStarPath object if path exists, None otherwise.\n\n" - "The returned AStarPath can be iterated or walked step-by-step."}, - {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" - "Get or create a Dijkstra distance map for a root position.\n\n" - "Args:\n" - " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" - "Returns:\n" - " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by root position. Multiple requests for the\n" - "same root return the same cached map. Call clear_dijkstra_maps() after\n" - "changing grid walkability to invalidate the cache."}, - {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, - "clear_dijkstra_maps() -> None\n\n" - "Clear all cached Dijkstra maps.\n\n" - "Call this after modifying grid cell walkability to ensure pathfinding\n" - "uses updated walkability data."}, + " List of (x, y) tuples representing the path, empty list if no path exists\n\n" + "Alternative A* implementation. Prefer find_path() for consistency."}, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer\n\n" "Add a new layer to the grid.\n\n" diff --git a/src/UIGrid.h b/src/UIGrid.h index 3d1a56a..b8a9d2d 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -8,8 +8,6 @@ #include #include #include -#include -#include #include "PyCallable.h" #include "PyTexture.h" @@ -27,9 +25,6 @@ #include "SpatialHash.h" #include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid) -// Forward declaration for pathfinding -class DijkstraMap; - class UIGrid: public UIDrawable { private: @@ -38,13 +33,10 @@ private: static constexpr int DEFAULT_CELL_WIDTH = 16; static constexpr int DEFAULT_CELL_HEIGHT = 16; TCODMap* tcod_map; // TCOD map for FOV and pathfinding + TCODDijkstra* tcod_dijkstra; // Dijkstra pathfinding + TCODPath* tcod_path; // A* pathfinding mutable std::mutex fov_mutex; // Mutex for thread-safe FOV operations - -public: - // Dijkstra map cache - keyed by root position - // Public so UIGridPathfinding can access it - std::map, std::shared_ptr> dijkstra_maps; - + public: UIGrid(); //UIGrid(int, int, IndexTexture*, float, float, float, float); @@ -62,12 +54,15 @@ public: void syncTCODMapCell(int x, int y); // Sync a single cell to TCOD map void computeFOV(int x, int y, int radius, bool light_walls = true, TCOD_fov_algorithm_t algo = FOV_BASIC); bool isInFOV(int x, int y) const; - TCODMap* getTCODMap() const { return tcod_map; } // Access for pathfinding - // Pathfinding - new API creates AStarPath/DijkstraMap objects - // See UIGridPathfinding.h for the new pathfinding API - // Grid.find_path() now returns AStarPath objects - // Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root position) + // Pathfinding methods + std::vector> findPath(int x1, int y1, int x2, int y2, float diagonalCost = 1.41f); + void computeDijkstra(int rootX, int rootY, float diagonalCost = 1.41f); + float getDijkstraDistance(int x, int y) const; + std::vector> getDijkstraPath(int x, int y) const; + + // A* pathfinding methods + std::vector> computeAStarPath(int x1, int y1, int x2, int y2, float diagonalCost = 1.41f); // Phase 1 virtual method implementations sf::FloatRect get_bounds() const override; @@ -172,10 +167,11 @@ public: static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds); - // Pathfinding methods moved to UIGridPathfinding.cpp - // py_find_path -> UIGridPathfinding::Grid_find_path (returns AStarPath) - // py_get_dijkstra_map -> UIGridPathfinding::Grid_get_dijkstra_map (returns DijkstraMap) - // py_clear_dijkstra_maps -> UIGridPathfinding::Grid_clear_dijkstra_maps + static PyObject* py_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_compute_dijkstra(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169 diff --git a/src/UIGridPathfinding.cpp b/src/UIGridPathfinding.cpp deleted file mode 100644 index d232262..0000000 --- a/src/UIGridPathfinding.cpp +++ /dev/null @@ -1,688 +0,0 @@ -#include "UIGridPathfinding.h" -#include "UIGrid.h" -#include "UIEntity.h" -#include "PyVector.h" -#include "McRFPy_API.h" - -//============================================================================= -// DijkstraMap Implementation -//============================================================================= - -DijkstraMap::DijkstraMap(TCODMap* map, int root_x, int root_y, float diag_cost) - : tcod_map(map) - , root(root_x, root_y) - , diagonal_cost(diag_cost) -{ - tcod_dijkstra = new TCODDijkstra(tcod_map, diagonal_cost); - tcod_dijkstra->compute(root_x, root_y); // Compute immediately at creation -} - -DijkstraMap::~DijkstraMap() { - if (tcod_dijkstra) { - delete tcod_dijkstra; - tcod_dijkstra = nullptr; - } -} - -float DijkstraMap::getDistance(int x, int y) const { - if (!tcod_dijkstra) return -1.0f; - return tcod_dijkstra->getDistance(x, y); -} - -std::vector DijkstraMap::getPathFrom(int x, int y) const { - std::vector path; - if (!tcod_dijkstra) return path; - - if (tcod_dijkstra->setPath(x, y)) { - int px, py; - while (tcod_dijkstra->walk(&px, &py)) { - path.push_back(sf::Vector2i(px, py)); - } - } - return path; -} - -sf::Vector2i DijkstraMap::stepFrom(int x, int y, bool* valid) const { - if (!tcod_dijkstra) { - if (valid) *valid = false; - return sf::Vector2i(-1, -1); - } - - if (!tcod_dijkstra->setPath(x, y)) { - if (valid) *valid = false; - return sf::Vector2i(-1, -1); - } - - int px, py; - if (tcod_dijkstra->walk(&px, &py)) { - if (valid) *valid = true; - return sf::Vector2i(px, py); - } - - // At root or no path - if (valid) *valid = false; - return sf::Vector2i(-1, -1); -} - -//============================================================================= -// Helper Functions -//============================================================================= - -bool UIGridPathfinding::ExtractPosition(PyObject* obj, int* x, int* y, - UIGrid* expected_grid, - const char* arg_name) { - // Get types from module to avoid static type instance issues - PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); - - // Check if it's an Entity - if (entity_type && PyObject_IsInstance(obj, entity_type)) { - Py_DECREF(entity_type); - Py_XDECREF(vector_type); - auto* entity = (PyUIEntityObject*)obj; - if (!entity->data) { - PyErr_Format(PyExc_RuntimeError, - "%s: Entity has no data", arg_name); - return false; - } - if (!entity->data->grid) { - PyErr_Format(PyExc_RuntimeError, - "%s: Entity is not attached to any grid", arg_name); - return false; - } - if (expected_grid && entity->data->grid.get() != expected_grid) { - PyErr_Format(PyExc_RuntimeError, - "%s: Entity belongs to a different grid", arg_name); - return false; - } - *x = static_cast(entity->data->position.x); - *y = static_cast(entity->data->position.y); - return true; - } - Py_XDECREF(entity_type); - - // Check if it's a Vector - if (vector_type && PyObject_IsInstance(obj, vector_type)) { - Py_DECREF(vector_type); - auto* vec = (PyVectorObject*)obj; - *x = static_cast(vec->data.x); - *y = static_cast(vec->data.y); - return true; - } - Py_XDECREF(vector_type); - - // Try tuple/list - if (PySequence_Check(obj) && PySequence_Size(obj) == 2) { - PyObject* x_obj = PySequence_GetItem(obj, 0); - PyObject* y_obj = PySequence_GetItem(obj, 1); - - bool ok = false; - if (x_obj && y_obj && PyNumber_Check(x_obj) && PyNumber_Check(y_obj)) { - PyObject* x_long = PyNumber_Long(x_obj); - PyObject* y_long = PyNumber_Long(y_obj); - if (x_long && y_long) { - *x = PyLong_AsLong(x_long); - *y = PyLong_AsLong(y_long); - ok = !PyErr_Occurred(); - Py_DECREF(x_long); - Py_DECREF(y_long); - } - Py_XDECREF(x_long); - Py_XDECREF(y_long); - } - - Py_XDECREF(x_obj); - Py_XDECREF(y_obj); - - if (ok) return true; - } - - PyErr_Format(PyExc_TypeError, - "%s: expected Vector, Entity, or (x, y) tuple", arg_name); - return false; -} - -//============================================================================= -// AStarPath Python Methods -//============================================================================= - -PyObject* UIGridPathfinding::AStarPath_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyAStarPathObject* self = (PyAStarPathObject*)type->tp_alloc(type, 0); - if (self) { - new (&self->path) std::vector(); // Placement new - self->current_index = 0; - self->origin = sf::Vector2i(0, 0); - self->destination = sf::Vector2i(0, 0); - } - return (PyObject*)self; -} - -int UIGridPathfinding::AStarPath_init(PyAStarPathObject* self, PyObject* args, PyObject* kwds) { - // AStarPath should not be created directly from Python - PyErr_SetString(PyExc_TypeError, - "AStarPath cannot be instantiated directly. Use Grid.find_path() instead."); - return -1; -} - -void UIGridPathfinding::AStarPath_dealloc(PyAStarPathObject* self) { - self->path.~vector(); // Explicitly destroy - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* UIGridPathfinding::AStarPath_repr(PyAStarPathObject* self) { - size_t remaining = self->path.size() - self->current_index; - return PyUnicode_FromFormat("", - self->origin.x, self->origin.y, - self->destination.x, self->destination.y, - remaining); -} - -PyObject* UIGridPathfinding::AStarPath_walk(PyAStarPathObject* self, PyObject* args) { - if (self->current_index >= self->path.size()) { - PyErr_SetString(PyExc_IndexError, "Path exhausted - no more steps"); - return NULL; - } - - sf::Vector2i pos = self->path[self->current_index++]; - return PyVector(sf::Vector2f(static_cast(pos.x), static_cast(pos.y))).pyObject(); -} - -PyObject* UIGridPathfinding::AStarPath_peek(PyAStarPathObject* self, PyObject* args) { - if (self->current_index >= self->path.size()) { - PyErr_SetString(PyExc_IndexError, "Path exhausted - no more steps"); - return NULL; - } - - sf::Vector2i pos = self->path[self->current_index]; - return PyVector(sf::Vector2f(static_cast(pos.x), static_cast(pos.y))).pyObject(); -} - -PyObject* UIGridPathfinding::AStarPath_get_origin(PyAStarPathObject* self, void* closure) { - return PyVector(sf::Vector2f(static_cast(self->origin.x), - static_cast(self->origin.y))).pyObject(); -} - -PyObject* UIGridPathfinding::AStarPath_get_destination(PyAStarPathObject* self, void* closure) { - return PyVector(sf::Vector2f(static_cast(self->destination.x), - static_cast(self->destination.y))).pyObject(); -} - -PyObject* UIGridPathfinding::AStarPath_get_remaining(PyAStarPathObject* self, void* closure) { - size_t remaining = self->path.size() - self->current_index; - return PyLong_FromSize_t(remaining); -} - -Py_ssize_t UIGridPathfinding::AStarPath_len(PyAStarPathObject* self) { - return static_cast(self->path.size() - self->current_index); -} - -int UIGridPathfinding::AStarPath_bool(PyObject* obj) { - PyAStarPathObject* self = (PyAStarPathObject*)obj; - return self->current_index < self->path.size() ? 1 : 0; -} - -PyObject* UIGridPathfinding::AStarPath_iter(PyAStarPathObject* self) { - // Create iterator object - mcrfpydef::PyAStarPathIterObject* iter = PyObject_New( - mcrfpydef::PyAStarPathIterObject, &mcrfpydef::PyAStarPathIterType); - if (!iter) return NULL; - - Py_INCREF(self); - iter->path = self; - iter->iter_index = self->current_index; - - return (PyObject*)iter; -} - -// Iterator implementation -static void AStarPathIter_dealloc(mcrfpydef::PyAStarPathIterObject* self) { - Py_XDECREF(self->path); - Py_TYPE(self)->tp_free((PyObject*)self); -} - -static PyObject* AStarPathIter_next(mcrfpydef::PyAStarPathIterObject* self) { - if (!self->path || self->iter_index >= self->path->path.size()) { - return NULL; // StopIteration - } - - sf::Vector2i pos = self->path->path[self->iter_index++]; - // Note: Iterating is consuming for this iterator - self->path->current_index = self->iter_index; - - return PyVector(sf::Vector2f(static_cast(pos.x), static_cast(pos.y))).pyObject(); -} - -static PyObject* AStarPathIter_iter(mcrfpydef::PyAStarPathIterObject* self) { - Py_INCREF(self); - return (PyObject*)self; -} - -//============================================================================= -// DijkstraMap Python Methods -//============================================================================= - -PyObject* UIGridPathfinding::DijkstraMap_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyDijkstraMapObject* self = (PyDijkstraMapObject*)type->tp_alloc(type, 0); - if (self) { - new (&self->data) std::shared_ptr(); - } - return (PyObject*)self; -} - -int UIGridPathfinding::DijkstraMap_init(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { - PyErr_SetString(PyExc_TypeError, - "DijkstraMap cannot be instantiated directly. Use Grid.get_dijkstra_map() instead."); - return -1; -} - -void UIGridPathfinding::DijkstraMap_dealloc(PyDijkstraMapObject* self) { - self->data.~shared_ptr(); - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* UIGridPathfinding::DijkstraMap_repr(PyDijkstraMapObject* self) { - if (!self->data) { - return PyUnicode_FromString(""); - } - sf::Vector2i root = self->data->getRoot(); - return PyUnicode_FromFormat("", root.x, root.y); -} - -PyObject* UIGridPathfinding::DijkstraMap_distance(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"pos", NULL}; - PyObject* pos_obj = NULL; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(kwlist), &pos_obj)) { - return NULL; - } - - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid"); - return NULL; - } - - int x, y; - if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) { - return NULL; - } - - float dist = self->data->getDistance(x, y); - if (dist < 0) { - Py_RETURN_NONE; // Unreachable - } - - return PyFloat_FromDouble(dist); -} - -PyObject* UIGridPathfinding::DijkstraMap_path_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"pos", NULL}; - PyObject* pos_obj = NULL; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(kwlist), &pos_obj)) { - return NULL; - } - - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid"); - return NULL; - } - - int x, y; - if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) { - return NULL; - } - - std::vector path = self->data->getPathFrom(x, y); - - // Create an AStarPath object to return - PyAStarPathObject* result = (PyAStarPathObject*)mcrfpydef::PyAStarPathType.tp_alloc( - &mcrfpydef::PyAStarPathType, 0); - if (!result) return NULL; - - new (&result->path) std::vector(std::move(path)); - result->current_index = 0; - result->origin = sf::Vector2i(x, y); - result->destination = self->data->getRoot(); - - return (PyObject*)result; -} - -PyObject* UIGridPathfinding::DijkstraMap_step_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"pos", NULL}; - PyObject* pos_obj = NULL; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(kwlist), &pos_obj)) { - return NULL; - } - - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "DijkstraMap is invalid"); - return NULL; - } - - int x, y; - if (!ExtractPosition(pos_obj, &x, &y, nullptr, "pos")) { - return NULL; - } - - bool valid = false; - sf::Vector2i step = self->data->stepFrom(x, y, &valid); - - if (!valid) { - Py_RETURN_NONE; // At root or unreachable - } - - return PyVector(sf::Vector2f(static_cast(step.x), static_cast(step.y))).pyObject(); -} - -PyObject* UIGridPathfinding::DijkstraMap_get_root(PyDijkstraMapObject* self, void* closure) { - if (!self->data) { - Py_RETURN_NONE; - } - sf::Vector2i root = self->data->getRoot(); - return PyVector(sf::Vector2f(static_cast(root.x), static_cast(root.y))).pyObject(); -} - -//============================================================================= -// Grid Factory Methods -//============================================================================= - -PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; - PyObject* start_obj = NULL; - PyObject* end_obj = NULL; - float diagonal_cost = 1.41f; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast(kwlist), - &start_obj, &end_obj, &diagonal_cost)) { - return NULL; - } - - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "Grid is invalid"); - return NULL; - } - - int x1, y1, x2, y2; - if (!ExtractPosition(start_obj, &x1, &y1, self->data.get(), "start")) { - return NULL; - } - if (!ExtractPosition(end_obj, &x2, &y2, self->data.get(), "end")) { - return NULL; - } - - // Bounds check - if (x1 < 0 || x1 >= self->data->grid_w || y1 < 0 || y1 >= self->data->grid_h || - x2 < 0 || x2 >= self->data->grid_w || y2 < 0 || y2 >= self->data->grid_h) { - PyErr_SetString(PyExc_ValueError, "Position out of grid bounds"); - return NULL; - } - - // Compute path using temporary TCODPath - TCODPath tcod_path(self->data->getTCODMap(), diagonal_cost); - if (!tcod_path.compute(x1, y1, x2, y2)) { - Py_RETURN_NONE; // No path exists - } - - // Create AStarPath result object - PyAStarPathObject* result = (PyAStarPathObject*)mcrfpydef::PyAStarPathType.tp_alloc( - &mcrfpydef::PyAStarPathType, 0); - if (!result) return NULL; - - // Initialize - new (&result->path) std::vector(); - result->current_index = 0; - result->origin = sf::Vector2i(x1, y1); - result->destination = sf::Vector2i(x2, y2); - - // Copy path data - result->path.reserve(tcod_path.size()); - for (int i = 0; i < tcod_path.size(); i++) { - int px, py; - tcod_path.get(i, &px, &py); - result->path.push_back(sf::Vector2i(px, py)); - } - - return (PyObject*)result; -} - -PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"root", "diagonal_cost", NULL}; - PyObject* root_obj = NULL; - float diagonal_cost = 1.41f; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(kwlist), - &root_obj, &diagonal_cost)) { - return NULL; - } - - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "Grid is invalid"); - return NULL; - } - - int root_x, root_y; - if (!ExtractPosition(root_obj, &root_x, &root_y, self->data.get(), "root")) { - return NULL; - } - - // Bounds check - if (root_x < 0 || root_x >= self->data->grid_w || root_y < 0 || root_y >= self->data->grid_h) { - PyErr_SetString(PyExc_ValueError, "Root position out of grid bounds"); - return NULL; - } - - auto key = std::make_pair(root_x, root_y); - - // Check cache - auto it = self->data->dijkstra_maps.find(key); - if (it != self->data->dijkstra_maps.end()) { - // Check diagonal cost matches (or we could ignore this) - if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) { - // Return existing - PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( - &mcrfpydef::PyDijkstraMapType, 0); - if (!result) return NULL; - new (&result->data) std::shared_ptr(it->second); - return (PyObject*)result; - } - // Different diagonal cost - remove old one - self->data->dijkstra_maps.erase(it); - } - - // Create new DijkstraMap - auto dijkstra = std::make_shared( - self->data->getTCODMap(), root_x, root_y, diagonal_cost); - - // Cache it - self->data->dijkstra_maps[key] = dijkstra; - - // Return Python wrapper - PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( - &mcrfpydef::PyDijkstraMapType, 0); - if (!result) return NULL; - - new (&result->data) std::shared_ptr(dijkstra); - return (PyObject*)result; -} - -PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args) { - if (!self->data) { - PyErr_SetString(PyExc_RuntimeError, "Grid is invalid"); - return NULL; - } - - self->data->dijkstra_maps.clear(); - Py_RETURN_NONE; -} - -//============================================================================= -// Python Type Definitions -//============================================================================= - -namespace mcrfpydef { - -// AStarPath methods -PyMethodDef PyAStarPath_methods[] = { - {"walk", (PyCFunction)UIGridPathfinding::AStarPath_walk, METH_NOARGS, - "walk() -> Vector\n\n" - "Get and consume next step in the path.\n\n" - "Returns:\n" - " Next position as Vector.\n\n" - "Raises:\n" - " IndexError: If path is exhausted."}, - - {"peek", (PyCFunction)UIGridPathfinding::AStarPath_peek, METH_NOARGS, - "peek() -> Vector\n\n" - "See next step without consuming it.\n\n" - "Returns:\n" - " Next position as Vector.\n\n" - "Raises:\n" - " IndexError: If path is exhausted."}, - - {NULL} -}; - -// AStarPath getsetters -PyGetSetDef PyAStarPath_getsetters[] = { - {"origin", (getter)UIGridPathfinding::AStarPath_get_origin, NULL, - "Starting position of the path (Vector, read-only).", NULL}, - - {"destination", (getter)UIGridPathfinding::AStarPath_get_destination, NULL, - "Ending position of the path (Vector, read-only).", NULL}, - - {"remaining", (getter)UIGridPathfinding::AStarPath_get_remaining, NULL, - "Number of steps remaining in the path (int, read-only).", NULL}, - - {NULL} -}; - -// AStarPath number methods (for bool) -PyNumberMethods PyAStarPath_as_number = { - .nb_bool = UIGridPathfinding::AStarPath_bool, -}; - -// AStarPath sequence methods (for len) -PySequenceMethods PyAStarPath_as_sequence = { - .sq_length = (lenfunc)UIGridPathfinding::AStarPath_len, -}; - -// AStarPath type -PyTypeObject PyAStarPathType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.AStarPath", - .tp_basicsize = sizeof(PyAStarPathObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)UIGridPathfinding::AStarPath_dealloc, - .tp_repr = (reprfunc)UIGridPathfinding::AStarPath_repr, - .tp_as_number = &PyAStarPath_as_number, - .tp_as_sequence = &PyAStarPath_as_sequence, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "A computed A* path result, consumed step by step.\n\n" - "Created by Grid.find_path(). Cannot be instantiated directly.\n\n" - "Use walk() to get and consume each step, or iterate directly.\n" - "Use peek() to see the next step without consuming it.\n" - "Use bool(path) or len(path) to check if steps remain.\n\n" - "Properties:\n" - " origin (Vector): Starting position (read-only)\n" - " destination (Vector): Ending position (read-only)\n" - " remaining (int): Steps remaining (read-only)\n\n" - "Example:\n" - " path = grid.find_path(start, end)\n" - " if path:\n" - " while path:\n" - " next_pos = path.walk()\n" - " entity.pos = next_pos"), - .tp_iter = (getiterfunc)UIGridPathfinding::AStarPath_iter, - .tp_methods = PyAStarPath_methods, - .tp_getset = PyAStarPath_getsetters, - .tp_init = (initproc)UIGridPathfinding::AStarPath_init, - .tp_new = UIGridPathfinding::AStarPath_new, -}; - -// AStarPath iterator type -PyTypeObject PyAStarPathIterType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.AStarPathIterator", - .tp_basicsize = sizeof(PyAStarPathIterObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)AStarPathIter_dealloc, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_iter = (getiterfunc)AStarPathIter_iter, - .tp_iternext = (iternextfunc)AStarPathIter_next, -}; - -// DijkstraMap methods -PyMethodDef PyDijkstraMap_methods[] = { - {"distance", (PyCFunction)UIGridPathfinding::DijkstraMap_distance, METH_VARARGS | METH_KEYWORDS, - "distance(pos) -> float | None\n\n" - "Get distance from position to root.\n\n" - "Args:\n" - " pos: Position as Vector, Entity, or (x, y) tuple.\n\n" - "Returns:\n" - " Float distance, or None if position is unreachable."}, - - {"path_from", (PyCFunction)UIGridPathfinding::DijkstraMap_path_from, METH_VARARGS | METH_KEYWORDS, - "path_from(pos) -> AStarPath\n\n" - "Get full path from position to root.\n\n" - "Args:\n" - " pos: Starting position as Vector, Entity, or (x, y) tuple.\n\n" - "Returns:\n" - " AStarPath from pos toward root."}, - - {"step_from", (PyCFunction)UIGridPathfinding::DijkstraMap_step_from, METH_VARARGS | METH_KEYWORDS, - "step_from(pos) -> Vector | None\n\n" - "Get single step from position toward root.\n\n" - "Args:\n" - " pos: Current position as Vector, Entity, or (x, y) tuple.\n\n" - "Returns:\n" - " Next position as Vector, or None if at root or unreachable."}, - - {NULL} -}; - -// DijkstraMap getsetters -PyGetSetDef PyDijkstraMap_getsetters[] = { - {"root", (getter)UIGridPathfinding::DijkstraMap_get_root, NULL, - "Root position that distances are measured from (Vector, read-only).", NULL}, - - {NULL} -}; - -// DijkstraMap type -PyTypeObject PyDijkstraMapType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.DijkstraMap", - .tp_basicsize = sizeof(PyDijkstraMapObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)UIGridPathfinding::DijkstraMap_dealloc, - .tp_repr = (reprfunc)UIGridPathfinding::DijkstraMap_repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "A Dijkstra distance map from a fixed root position.\n\n" - "Created by Grid.get_dijkstra_map(). Cannot be instantiated directly.\n\n" - "Grid caches these maps - multiple requests for the same root return\n" - "the same map. Call Grid.clear_dijkstra_maps() after changing grid\n" - "walkability to invalidate the cache.\n\n" - "Properties:\n" - " root (Vector): Root position (read-only)\n\n" - "Methods:\n" - " distance(pos) -> float | None: Get distance to root\n" - " path_from(pos) -> AStarPath: Get full path to root\n" - " step_from(pos) -> Vector | None: Get single step toward root\n\n" - "Example:\n" - " dijkstra = grid.get_dijkstra_map(player.pos)\n" - " for enemy in enemies:\n" - " dist = dijkstra.distance(enemy.pos)\n" - " if dist and dist < 10:\n" - " step = dijkstra.step_from(enemy.pos)\n" - " if step:\n" - " enemy.pos = step"), - .tp_methods = PyDijkstraMap_methods, - .tp_getset = PyDijkstraMap_getsetters, - .tp_init = (initproc)UIGridPathfinding::DijkstraMap_init, - .tp_new = UIGridPathfinding::DijkstraMap_new, -}; - -} // namespace mcrfpydef diff --git a/src/UIGridPathfinding.h b/src/UIGridPathfinding.h deleted file mode 100644 index 07294d0..0000000 --- a/src/UIGridPathfinding.h +++ /dev/null @@ -1,152 +0,0 @@ -#pragma once -#include "Common.h" -#include "Python.h" -#include "UIBase.h" // For PyUIGridObject typedef -#include -#include -#include -#include -#include - -// Forward declarations -class UIGrid; - -//============================================================================= -// AStarPath - A computed A* path result, consumed like an iterator -//============================================================================= - -struct PyAStarPathObject { - PyObject_HEAD - std::vector path; // Pre-computed path positions - size_t current_index; // Next step to return - sf::Vector2i origin; // Fixed at creation - sf::Vector2i destination; // Fixed at creation -}; - -//============================================================================= -// DijkstraMap - A Dijkstra distance field from a fixed root -//============================================================================= - -class DijkstraMap { -public: - DijkstraMap(TCODMap* map, int root_x, int root_y, float diagonal_cost); - ~DijkstraMap(); - - // Non-copyable (owns TCODDijkstra) - DijkstraMap(const DijkstraMap&) = delete; - DijkstraMap& operator=(const DijkstraMap&) = delete; - - // Queries - float getDistance(int x, int y) const; - std::vector getPathFrom(int x, int y) const; - sf::Vector2i stepFrom(int x, int y, bool* valid = nullptr) const; - - // Accessors - sf::Vector2i getRoot() const { return root; } - float getDiagonalCost() const { return diagonal_cost; } - -private: - TCODDijkstra* tcod_dijkstra; // Owned by this object - TCODMap* tcod_map; // Borrowed from Grid - sf::Vector2i root; - float diagonal_cost; -}; - -struct PyDijkstraMapObject { - PyObject_HEAD - std::shared_ptr data; // Shared with Grid's collection -}; - -//============================================================================= -// Helper Functions -//============================================================================= - -namespace UIGridPathfinding { - // Extract grid position from Vector, Entity, or tuple - // Sets Python error and returns false on failure - // If expected_grid is provided and obj is Entity, validates grid membership - bool ExtractPosition(PyObject* obj, int* x, int* y, - UIGrid* expected_grid = nullptr, - const char* arg_name = "position"); - - //========================================================================= - // AStarPath Python Type Methods - //========================================================================= - - PyObject* AStarPath_new(PyTypeObject* type, PyObject* args, PyObject* kwds); - int AStarPath_init(PyAStarPathObject* self, PyObject* args, PyObject* kwds); - void AStarPath_dealloc(PyAStarPathObject* self); - PyObject* AStarPath_repr(PyAStarPathObject* self); - - // Methods - PyObject* AStarPath_walk(PyAStarPathObject* self, PyObject* args); - PyObject* AStarPath_peek(PyAStarPathObject* self, PyObject* args); - - // Properties - PyObject* AStarPath_get_origin(PyAStarPathObject* self, void* closure); - PyObject* AStarPath_get_destination(PyAStarPathObject* self, void* closure); - PyObject* AStarPath_get_remaining(PyAStarPathObject* self, void* closure); - - // Sequence protocol - Py_ssize_t AStarPath_len(PyAStarPathObject* self); - int AStarPath_bool(PyObject* self); - PyObject* AStarPath_iter(PyAStarPathObject* self); - PyObject* AStarPath_iternext(PyAStarPathObject* self); - - //========================================================================= - // DijkstraMap Python Type Methods - //========================================================================= - - PyObject* DijkstraMap_new(PyTypeObject* type, PyObject* args, PyObject* kwds); - int DijkstraMap_init(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); - void DijkstraMap_dealloc(PyDijkstraMapObject* self); - PyObject* DijkstraMap_repr(PyDijkstraMapObject* self); - - // Methods - PyObject* DijkstraMap_distance(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); - PyObject* DijkstraMap_path_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); - PyObject* DijkstraMap_step_from(PyDijkstraMapObject* self, PyObject* args, PyObject* kwds); - - // Properties - PyObject* DijkstraMap_get_root(PyDijkstraMapObject* self, void* closure); - - //========================================================================= - // Grid Factory Methods (called from UIGrid Python bindings) - //========================================================================= - - // Grid.find_path() -> AStarPath | None - PyObject* Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); - - // Grid.get_dijkstra_map() -> DijkstraMap - PyObject* Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds); - - // Grid.clear_dijkstra_maps() -> None - PyObject* Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args); -} - -//============================================================================= -// Python Type Definitions -//============================================================================= - -namespace mcrfpydef { - - // AStarPath iterator type - struct PyAStarPathIterObject { - PyObject_HEAD - PyAStarPathObject* path; // Reference to path being iterated - size_t iter_index; // Current iteration position - }; - - extern PyNumberMethods PyAStarPath_as_number; - extern PySequenceMethods PyAStarPath_as_sequence; - extern PyMethodDef PyAStarPath_methods[]; - extern PyGetSetDef PyAStarPath_getsetters[]; - - extern PyTypeObject PyAStarPathType; - extern PyTypeObject PyAStarPathIterType; - - extern PyMethodDef PyDijkstraMap_methods[]; - extern PyGetSetDef PyDijkstraMap_getsetters[]; - - extern PyTypeObject PyDijkstraMapType; -} diff --git a/tests/unit/test_heightmap_basic.py b/tests/unit/test_heightmap_basic.py deleted file mode 100644 index 2191508..0000000 --- a/tests/unit/test_heightmap_basic.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for mcrfpy.HeightMap core functionality (#193) - -Tests the HeightMap class constructor, size property, and scalar operations. -""" - -import sys -import mcrfpy - - -def test_constructor_basic(): - """HeightMap can be created with a size tuple""" - hmap = mcrfpy.HeightMap((100, 50)) - assert hmap is not None - print("PASS: test_constructor_basic") - - -def test_constructor_with_fill(): - """HeightMap can be created with a fill value""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - assert hmap is not None - print("PASS: test_constructor_with_fill") - - -def test_size_property(): - """size property returns correct dimensions""" - hmap = mcrfpy.HeightMap((100, 50)) - size = hmap.size - assert size == (100, 50), f"Expected (100, 50), got {size}" - print("PASS: test_size_property") - - -def test_size_immutable(): - """size property is read-only""" - hmap = mcrfpy.HeightMap((100, 50)) - try: - hmap.size = (200, 100) - print("FAIL: test_size_immutable - should have raised AttributeError") - sys.exit(1) - except AttributeError: - pass - print("PASS: test_size_immutable") - - -def test_fill_method(): - """fill() sets all cells and returns self""" - hmap = mcrfpy.HeightMap((10, 10)) - result = hmap.fill(0.5) - assert result is hmap, "fill() should return self" - print("PASS: test_fill_method") - - -def test_clear_method(): - """clear() sets all cells to 0.0 and returns self""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - result = hmap.clear() - assert result is hmap, "clear() should return self" - print("PASS: test_clear_method") - - -def test_add_constant_method(): - """add_constant() adds to all cells and returns self""" - hmap = mcrfpy.HeightMap((10, 10)) - result = hmap.add_constant(0.25) - assert result is hmap, "add_constant() should return self" - print("PASS: test_add_constant_method") - - -def test_scale_method(): - """scale() multiplies all cells and returns self""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - result = hmap.scale(2.0) - assert result is hmap, "scale() should return self" - print("PASS: test_scale_method") - - -def test_clamp_method(): - """clamp() clamps values and returns self""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - result = hmap.clamp(0.0, 1.0) - assert result is hmap, "clamp() should return self" - print("PASS: test_clamp_method") - - -def test_clamp_with_defaults(): - """clamp() works with default parameters""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - result = hmap.clamp() # Uses defaults 0.0, 1.0 - assert result is hmap - print("PASS: test_clamp_with_defaults") - - -def test_normalize_method(): - """normalize() rescales values and returns self""" - hmap = mcrfpy.HeightMap((10, 10)) - hmap.fill(0.25).add_constant(0.1) # Some values - result = hmap.normalize(0.0, 1.0) - assert result is hmap, "normalize() should return self" - print("PASS: test_normalize_method") - - -def test_normalize_with_defaults(): - """normalize() works with default parameters""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - result = hmap.normalize() # Uses defaults 0.0, 1.0 - assert result is hmap - print("PASS: test_normalize_with_defaults") - - -def test_method_chaining(): - """Methods can be chained""" - hmap = mcrfpy.HeightMap((10, 10)) - result = hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0) - assert result is hmap, "Chained methods should return self" - print("PASS: test_method_chaining") - - -def test_complex_chaining(): - """Complex chains work correctly""" - hmap = mcrfpy.HeightMap((100, 100)) - result = (hmap - .fill(0.0) - .add_constant(0.5) - .scale(1.5) - .clamp(0.0, 1.0) - .normalize(0.2, 0.8)) - assert result is hmap - print("PASS: test_complex_chaining") - - -def test_repr(): - """repr() returns a readable string""" - hmap = mcrfpy.HeightMap((100, 50)) - r = repr(hmap) - assert "HeightMap" in r - assert "100" in r and "50" in r - print(f"PASS: test_repr - {r}") - - -def test_invalid_size(): - """Negative or zero size raises ValueError""" - try: - mcrfpy.HeightMap((0, 10)) - print("FAIL: test_invalid_size - should have raised ValueError for width=0") - sys.exit(1) - except ValueError: - pass - - try: - mcrfpy.HeightMap((10, -5)) - print("FAIL: test_invalid_size - should have raised ValueError for height=-5") - sys.exit(1) - except ValueError: - pass - - print("PASS: test_invalid_size") - - -def test_invalid_size_type(): - """Non-tuple size raises TypeError""" - try: - mcrfpy.HeightMap([100, 50]) # list instead of tuple - print("FAIL: test_invalid_size_type - should have raised TypeError") - sys.exit(1) - except TypeError: - pass - print("PASS: test_invalid_size_type") - - -def test_size_exceeds_grid_max(): - """Size exceeding GRID_MAX (8192) raises ValueError""" - # Test width exceeds limit - try: - mcrfpy.HeightMap((10000, 100)) - print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for width=10000") - sys.exit(1) - except ValueError as e: - assert "8192" in str(e) or "cannot exceed" in str(e).lower() - - # Test height exceeds limit - try: - mcrfpy.HeightMap((100, 10000)) - print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for height=10000") - sys.exit(1) - except ValueError as e: - assert "8192" in str(e) or "cannot exceed" in str(e).lower() - - # Test both exceed limit (would cause integer overflow without validation) - try: - mcrfpy.HeightMap((65536, 65536)) - print("FAIL: test_size_exceeds_grid_max - should have raised ValueError for 65536x65536") - sys.exit(1) - except ValueError: - pass - - print("PASS: test_size_exceeds_grid_max") - - -def test_clamp_min_greater_than_max(): - """clamp() with min > max raises ValueError""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - try: - hmap.clamp(min=1.0, max=0.0) - print("FAIL: test_clamp_min_greater_than_max - should have raised ValueError") - sys.exit(1) - except ValueError as e: - assert "min" in str(e).lower() and "max" in str(e).lower() - print("PASS: test_clamp_min_greater_than_max") - - -def test_normalize_min_greater_than_max(): - """normalize() with min > max raises ValueError""" - hmap = mcrfpy.HeightMap((10, 10), fill=0.5) - try: - hmap.normalize(min=1.0, max=0.0) - print("FAIL: test_normalize_min_greater_than_max - should have raised ValueError") - sys.exit(1) - except ValueError as e: - assert "min" in str(e).lower() and "max" in str(e).lower() - print("PASS: test_normalize_min_greater_than_max") - - -def test_max_valid_size(): - """Size at GRID_MAX boundary works""" - # Test at the exact limit - this should work - hmap = mcrfpy.HeightMap((8192, 1)) - assert hmap.size == (8192, 1) - - hmap2 = mcrfpy.HeightMap((1, 8192)) - assert hmap2.size == (1, 8192) - - print("PASS: test_max_valid_size") - - -def run_all_tests(): - """Run all tests""" - print("Running HeightMap basic tests...") - print() - - test_constructor_basic() - test_constructor_with_fill() - test_size_property() - test_size_immutable() - test_fill_method() - test_clear_method() - test_add_constant_method() - test_scale_method() - test_clamp_method() - test_clamp_with_defaults() - test_normalize_method() - test_normalize_with_defaults() - test_method_chaining() - test_complex_chaining() - test_repr() - test_invalid_size() - test_invalid_size_type() - test_size_exceeds_grid_max() - test_clamp_min_greater_than_max() - test_normalize_min_greater_than_max() - test_max_valid_size() - - print() - print("All HeightMap basic tests PASSED!") - - -# Run tests directly -run_all_tests() -sys.exit(0)