Files
pf1e-simulator/src/pf1e_simulator/areas.py
T

217 lines
10 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 caster's whole
space (every cell of the footprint, not just the anchor).
- "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 space is NOT one of the affected squares AND does not shorten
the effect: the whole footprint (every cell of a multi-cell caster, not
just the anchor) is skipped without spending one of the ``length_cells``
slots, so the line runs its full length starting just past the caster
(SRD Lightning Bolt: "The bolt begins at your fingertips" - it leaves your
square, it does not fill it, and a 120 ft bolt is 24 squares beyond the
caster). The line also stops at the first square that blocks line of
effect (CRB, Magic - Aiming a Spell, Line: it "extends to the limit of its
range or until it strikes a barrier that blocks line of effect"): the
barrier square is the last square of the template and everything beyond
it is outside, so a bolt never reaches creatures behind a solid wall.
- Cones require line of effect from their origin (CRB, Magic - Line of
Effect): "A burst, cone, cylinder, or emanation spell affects only an
area, creature, or object to which it has line of effect from its origin
(a spherical burst's center point, a cone-shaped burst's starting
point, ...)". ``cone_cells`` therefore drops every geometric cell that
has no corner-to-corner line of effect from the origin square (a solid
wall between them shields everything behind it).
Bursts need no template here: they are circles around a center cell and are
resolved by ``CombatEngine._area_creatures`` (which additionally drops
creatures without line of effect from the burst's center).
BURST MEASUREMENT - DOCUMENTED DEFERRED APPROXIMATION (cell vs intersection).
The SRD ("Aiming a Spell - Area") measures a burst from a point of origin that
is ALWAYS a grid intersection: "instead of counting from the center of one
square to the center of the next, you count from intersection to intersection",
and a square is inside the area only "if the far edge of a square is within the
spell's area" (a square the area "only touches ... [at] the near edge" is
unaffected). The engine cannot apply that rule faithfully because its burst
origin is a CELL, not an intersection: ``Action.target_pos``, the
``centered_on_caster`` caster square and the resolved target's square are all
cells, and ``CombatEngine._creatures_in_range`` counts center-to-center 5-10-5
(``grid.distance(center, cell) <= radius_cells``). Two consequences make a
faithful fix out of scope for a contract-preserving pass:
1. A cell has four corner intersections, and which one is the point of origin
is a caster choice the engine never records. The far-edge rule's boundary
result (which diagonal squares are in vs out) depends entirely on that
choice, so there is no single "SRD-correct" square set to compute from a
cell center without first adding an intersection coordinate to the data
model.
2. Representing the origin as an intersection would change ``AreaEffect``,
``Action.target_pos`` and the observation ``area`` snapshot contract (a
documented Phase 2 LLM frontier), and would rework the burst line-of-effect
and Reflex-cover origins (``{aim}`` / ``{center}`` cells).
Until that representation change is made, bursts stay centered on the resolved
cell and measured center-to-center under 5-10-5 - a symmetric, conservative
approximation whose boundary squares can differ from RAW by one square on
diagonals (e.g. a 15/20-ft burst's far diagonal cells). This is the residual
recorded in docs/conformance-srd.md ("origin mesuré centre-à-centre plutôt
qu'intersection-à-intersection").
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pf1e_simulator.los import has_line_of_effect
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,
caster_cells: frozenset[Pos] = frozenset(),
) -> 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 caster's own space is never affected: the cone "starts
from any corner of your square" (CRB: it "shoots away from you"), so
EVERY cell in ``caster_cells`` (the caster's whole footprint; the anchor
``origin`` is always one of them) is outside the template, not just the
origin. A cell with no line of effect from the origin
is dropped (CRB, Line of Effect: a cone "affects only an area, creature,
or object to which it has line of effect from its origin" - a solid wall
stops the cone, it does not pass through). 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()
skip = caster_cells | {origin}
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 in skip 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
if not has_line_of_effect(grid, origin, cell):
continue
cells.add(cell)
return frozenset(cells)
def line_cells(
grid: Grid,
origin: Pos,
aim: Pos,
length_cells: int,
caster_cells: frozenset[Pos] = frozenset(),
) -> 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 whole space is NOT affected AND does
not shorten the effect: every caster cell the ray grazes (the anchor
``origin`` included, i.e. all of ``caster_cells``) is origin space skipped
WITHOUT spending one of the ``length_cells`` slots. The line is therefore
``length_cells`` squares long starting just past the caster's footprint
(SRD Lightning Bolt: "The bolt begins at your fingertips" - it leaves your
square, and a 120 ft bolt is 24 squares beyond the caster, not beyond the
anchor). For a 1x1 caster the only skipped cell is the origin (ray[0]),
so the result is bit-identical to counting the ``length_cells`` squares
*beyond* the origin. Out-of-bounds cells drop out of the template but
never re-enter (a straight ray leaves a convex map once), so creatures
only ever exist in bounds. The ray stops at the first cell that blocks
line of effect (CRB, Line: "or until it strikes a barrier that blocks line
of effect") - the barrier cell itself is the last cell of the template (the
line strikes it) and everything beyond it is excluded. 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()
skip = caster_cells | {origin}
# Extend the aim vector so the ray still yields `length_cells` real
# squares AFTER the caster's own space is skipped: the dominant-axis step
# count of a Bresenham line is max(|d_row|, |d_col|); the +1 drops the
# origin square and the extra `len(caster_cells)` steps cover every
# footprint square the ray can graze before it leaves the caster's space
# (those are origin space and spend no length slot).
dominant = max(abs(d_row), abs(d_col))
reach = length_cells + 1 + len(caster_cells)
scale = reach / dominant
end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale))
ray = _bresenham(origin, end)
cells: list[Pos] = []
for cell in ray[1:]:
if cell in skip:
continue # origin space: the line starts past it, no slot spent
if not grid.in_bounds(cell):
continue # off the map: a straight ray never re-enters
cells.append(cell)
if grid.terrain_at(cell).blocks_los:
break # the line strikes this barrier: last cell of the template
if len(cells) >= length_cells:
break
return frozenset(cells)
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