"""PF1e area-shape geometry: cone and line templates on the 5-ft square grid. PF1e (CRB, Magic — Aiming a Spell): - "A cone-shaped spell shoots away from you in a quarter-circle in the direction you designate. It starts from any corner of your square and widens out as it goes." — modeled here as every cell whose center lies within the cone's length of the origin and within ±45° of the aim direction (a 90°-wide quarter circle), excluding the origin cell. - "A line-shaped spell shoots away from you in a line in the direction you designate. It starts from any corner of your square and extends to the limit of its range [...]. A line-shaped spell affects all creatures in squares through which the line passes." — modeled here as the 5-ft-wide Bresenham ray from the origin through the aim cell, truncated to the line's length. The line "starts from any corner of your square", so the CASTER's own square is NOT one of the affected squares: the affected squares are the ones beyond the origin along the ray (SRD Lightning Bolt: "The bolt begins at your fingertips" — it leaves your square, it does not fill it). Bursts need no template here: they are circles around a center cell and are resolved by ``CombatEngine._creatures_in_range`` (unchanged). """ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from pf1e_simulator.grid import Grid from pf1e_simulator.map import Pos def cone_cells( grid: Grid, origin: Pos, aim: Pos, radius_cells: int ) -> frozenset[Pos]: """Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``. The cone opens toward ``aim`` (any cell in the aim direction, usually the resolved target or point-cast center). A cell is affected when its offset from the origin is within ``radius_cells`` (5-10-5 grid distance), points strictly into the cone's forward half-plane, and stays within ±45° of the aim bearing. The origin cell itself is never affected (CRB: the cone "shoots away from you"). A degenerate aim (origin == aim) affects nothing. The ±45° test is exact integer arithmetic: with v the cell offset and d the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to ``2 * (v·d)^2 >= |v|^2 * |d|^2`` together with ``v·d > 0``. """ d_row = aim[0] - origin[0] d_col = aim[1] - origin[1] if d_row == 0 and d_col == 0: return frozenset() aim_sq = d_row * d_row + d_col * d_col cells: set[Pos] = set() for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1): for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1): cell = (row, col) if cell == origin or not grid.in_bounds(cell): continue v_row = row - origin[0] v_col = col - origin[1] dot = v_row * d_row + v_col * d_col if dot <= 0: continue if 2 * dot * dot < (v_row * v_row + v_col * v_col) * aim_sq: continue if grid.distance(origin, cell) > radius_cells: continue cells.add(cell) return frozenset(cells) def line_cells( grid: Grid, origin: Pos, aim: Pos, length_cells: int ) -> frozenset[Pos]: """Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``. The line runs along the Bresenham ray from the origin toward ``aim``, extended to ``length_cells`` squares (CRB: it "extends to the limit of its range"). The line "starts from any corner of your square" (CRB, Magic — Aiming a Spell), so the CASTER's own square is NOT affected: the ``length_cells`` affected squares are the ones *beyond* the origin along the ray. Out-of-bounds cells simply drop out of the template (creatures only exist in bounds). A degenerate aim (origin == aim) affects nothing. """ if length_cells <= 0: return frozenset() d_row = aim[0] - origin[0] d_col = aim[1] - origin[1] if d_row == 0 and d_col == 0: return frozenset() # Extend the aim vector so the ray reaches `length_cells` squares BEYOND # the origin (the dominant-axis step count of a Bresenham line is # max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop. scale = (length_cells + 1) / max(abs(d_row), abs(d_col)) end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale)) # The line is `length_cells` squares LONG starting just past the caster: # 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning # Bolt: "The bolt begins at your fingertips" — it leaves your square). ray = _bresenham(origin, end) return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell)) def _bresenham(start: Pos, end: Pos) -> list[Pos]: """Inclusive Bresenham cell list from ``start`` to ``end`` (both ends kept).""" row, col = start end_row, end_col = end d_row = abs(end_row - row) d_col = abs(end_col - col) step_row = 1 if row < end_row else -1 step_col = 1 if col < end_col else -1 err = d_row - d_col cells: list[Pos] = [(row, col)] while (row, col) != (end_row, end_col): e2 = 2 * err if e2 > -d_col: err -= d_col row += step_row if e2 < d_row: err += d_row col += step_col cells.append((row, col)) return cells