"""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, sector=None): """Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited. sector = (direction, half angle) in radians limits the view to a cone.""" 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)] if sector: d, half = sector rel = [(a - d + math.pi) % (2 * math.pi) - math.pi for a in angles] rel = [a for a in rel if abs(a) <= half] + [-half, half] + [-half + i * (2 * half / 48) for i in range(49)] angles = [d + a for a in sorted(rel)] else: 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 [(ox, oy)] + pts if sector else 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 inside_any(pt, polys) -> bool: return any(inside(pt, p) for p in polys) def cut(path, polys, key): """The part of the image inside the polygon (everything else transparent), 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 poly in polys for p in poly], [p[1] for poly in polys 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) draw = ImageDraw.Draw(mask) for poly in polys: if len(poly) > 2: draw.polygon([(x - x0, y - y0) for x, y in poly], fill=255) piece = im.crop((x0, y0, x0 + w, y0 + h)).convert("RGBA") piece.putalpha(mask) # transparent outside the view, so remembered (dimmed) ground underneath stays visible out = io.BytesIO() piece.save(out, "WEBP", quality=82, method=1) res = (out.getvalue(), (x0, y0, w, h)) _cuts.put(key, res) return res