642f449b9e
CRB Magic - Aiming a Spell: a line 'starts from any corner of your square', so the caster's own square is outside the template (SRD Lightning Bolt: 'The bolt begins at your fingertips' - it leaves the square, it does not fill it). line_cells now drops the origin and takes the length_cells squares beyond it; a degenerate aim affects nothing. Updated the Lightning Bolt and Line Sparks tests (caster no longer caught) and the README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
124 lines
5.3 KiB
Python
124 lines
5.3 KiB
Python
"""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
|