217 lines
10 KiB
Python
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
|