Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
134 lines
4.1 KiB
Python
134 lines
4.1 KiB
Python
"""Line of sight: visibility polygon from a point around wall segments, and cutting the map image to it.
|
|
|
|
The server cuts the image, so a player's browser only ever receives the pixels their character can see (no peeking through
|
|
dev tools). Pure Python on purpose (no numpy): a few hundred wall segments are fast enough, and results are cached."""
|
|
import io
|
|
import math
|
|
from collections import OrderedDict
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
EPS = 1e-5
|
|
|
|
|
|
def polygon(origin, segs, w, h, radius=0.0):
|
|
"""Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited."""
|
|
ox, oy = origin
|
|
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
|
|
if radius:
|
|
r2 = (radius + 1) ** 2
|
|
def near(s):
|
|
x1, y1, x2, y2 = s
|
|
dx, dy = x2 - x1, y2 - y1
|
|
L = dx * dx + dy * dy
|
|
t = 0 if L == 0 else max(0, min(1, ((ox - x1) * dx + (oy - y1) * dy) / L))
|
|
return (x1 + t * dx - ox) ** 2 + (y1 + t * dy - oy) ** 2 <= r2
|
|
segs = [s for s in segs if near(s)]
|
|
allsegs = segs + border
|
|
angles = []
|
|
for x1, y1, x2, y2 in allsegs:
|
|
for x, y in ((x1, y1), (x2, y2)):
|
|
if radius and (x - ox) ** 2 + (y - oy) ** 2 > (radius * 3) ** 2:
|
|
continue
|
|
a = math.atan2(y - oy, x - ox)
|
|
angles += (a - EPS, a, a + EPS)
|
|
if radius:
|
|
angles += [i * (2 * math.pi / 120) for i in range(120)]
|
|
angles.sort()
|
|
pts = []
|
|
for a in angles:
|
|
dx, dy = math.cos(a), math.sin(a)
|
|
best = radius if radius else float("inf")
|
|
for x1, y1, x2, y2 in allsegs:
|
|
ex, ey = x2 - x1, y2 - y1
|
|
den = dx * ey - dy * ex
|
|
if abs(den) < 1e-12:
|
|
continue
|
|
px, py = x1 - ox, y1 - oy
|
|
t = (px * ey - py * ex) / den
|
|
if t < 0 or t >= best:
|
|
continue
|
|
u = (px * dy - py * dx) / den
|
|
if -1e-9 <= u <= 1 + 1e-9:
|
|
best = t
|
|
if best == float("inf"):
|
|
continue
|
|
pts.append((ox + dx * best, oy + dy * best))
|
|
return pts
|
|
|
|
|
|
def inside(pt, poly) -> bool:
|
|
x, y = pt
|
|
c = False
|
|
j = len(poly) - 1
|
|
for i in range(len(poly)):
|
|
xi, yi = poly[i]
|
|
xj, yj = poly[j]
|
|
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
|
|
c = not c
|
|
j = i
|
|
return c
|
|
|
|
|
|
class Lru(OrderedDict):
|
|
def __init__(self, size):
|
|
super().__init__()
|
|
self.size = size
|
|
|
|
def put(self, k, v):
|
|
self[k] = v
|
|
self.move_to_end(k)
|
|
while len(self) > self.size:
|
|
self.popitem(last=False)
|
|
|
|
def fetch(self, k):
|
|
if k in self:
|
|
self.move_to_end(k)
|
|
return self[k]
|
|
|
|
|
|
_images = Lru(2)
|
|
_polys = Lru(128)
|
|
_cuts = Lru(24)
|
|
fog_cache = Lru(12)
|
|
|
|
|
|
def cached_polygon(key, make):
|
|
v = _polys.fetch(key)
|
|
if v is None:
|
|
v = make()
|
|
_polys.put(key, v)
|
|
return v
|
|
|
|
|
|
def source(path):
|
|
key = (str(path), path.stat().st_mtime_ns)
|
|
im = _images.fetch(key)
|
|
if im is None:
|
|
im = Image.open(path)
|
|
im.load()
|
|
im = im.convert("RGB")
|
|
_images.put(key, im)
|
|
return im
|
|
|
|
|
|
def cut(path, poly, key):
|
|
"""The part of the image inside the polygon (everything else black), cropped to its bounding box.
|
|
Returns (webp bytes, (x, y, w, h))."""
|
|
hit = _cuts.fetch(key)
|
|
if hit:
|
|
return hit
|
|
im = source(path)
|
|
xs, ys = [p[0] for p in poly], [p[1] for p in poly]
|
|
x0, y0 = max(0, int(math.floor(min(xs)))), max(0, int(math.floor(min(ys))))
|
|
x1, y1 = min(im.width, int(math.ceil(max(xs)))), min(im.height, int(math.ceil(max(ys))))
|
|
w, h = max(1, x1 - x0), max(1, y1 - y0)
|
|
mask = Image.new("L", (w, h), 0)
|
|
ImageDraw.Draw(mask).polygon([(x - x0, y - y0) for x, y in poly], fill=255)
|
|
piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask)
|
|
out = io.BytesIO()
|
|
piece.save(out, "WEBP", quality=82, method=1)
|
|
res = (out.getvalue(), (x0, y0, w, h))
|
|
_cuts.put(key, res)
|
|
return res
|