The map arrow pointed the wrong way wherever the player was not
`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's facing to both arrows and leaves them alone when there is no such object. Ours tested "is there a player" with `WorldMapState::player_uv.is_some()` — a different question, because that field is `GetPlayerMapPosition` on the **displayed** map. A view sitting on a continent, on another zone, or on an addon's stitched world answers `None` on every frame, so the arrow kept the heading it had when the window last sat on the player's own zone: the right place, pointing the wrong way. `player_facing_seen` is the real test, set the first time the feed pushes a facing — the feed runs only where there is a player, and the facing it pushes is unconditional. The existing test asserted the old behaviour on its second leg; that leg now asserts the heading following off the displayed map, and a new test holds the other half (before any feed, the login and loading screens, the arrow is left alone). `scripts/soften-zone-masks.py` is for the OpenMapClassic port rather than the client: its per-zone land masks are binary, so every map sheet ends on a crisp line against a differently-lit neighbour. It writes a softened copy — dilate, blur, times a rect ramp that reaches zero inside the sheet's own torn parchment border, which is the half that is not obvious. `docs/ADDON_COMPAT.md` writes up this round's five: the flashing arrow (two writers on one frame, measured `h h S h h S`), why correcting after 1.12 rather than suppressing it cost a full UI conversion every frame, pfQuest's pins being both behind the map and only ever for one zone, the softened masks, and the cities. Gates: fmt 0, clippy 0, benilla-ui 2121 pass / 0 fail, benilla-app 4233 pass / 12 fail (the recorded data-golden baseline). Corpus 86/87 load clean, 86/87 survive a session start and a UI probe, 0 raised on hover, click or drag — unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Soften OpenMapClassic's per-zone land masks, so neighbouring sheets blend instead of butting.
|
||||
|
||||
`Media/Textures/MapZoneDilate/<uiMapID>.tga` is one 256x256 alpha mask per Classic outdoor zone,
|
||||
Voronoi-dilated so that the forty of them tile each continent with no gap. They are **binary**:
|
||||
measured over four of them, 19k-48k pixels at alpha 0, 12k-51k at alpha 255, and under 250 pixels
|
||||
anywhere in between -- the anti-aliasing of a traced outline and nothing more.
|
||||
|
||||
A binary mask is why grom's map "looks super boxy and weird, edges of maps are the same". Every
|
||||
zone's sheet, and the shade over an unexplored one, ends on a crisp line against its neighbour,
|
||||
and the two sheets are different paintings of the same land at different brightnesses. Nothing
|
||||
blends because nothing was ever asked to.
|
||||
|
||||
So: keep the mask fully opaque everywhere it already was, and let it fall off *outwards* over a
|
||||
few pixels. `DILATE` pushes the coverage out so the ramp starts at the old boundary rather than
|
||||
eating into the zone, and `BLUR` is the ramp. Where two zones meet, the one drawn second fades in
|
||||
over the one drawn first across roughly `BLUR` mask pixels -- about a dozen screen pixels at the
|
||||
zoom where a continent fills the window, which is the zoom the complaint came from.
|
||||
|
||||
Outward, not symmetric, on purpose: a symmetric blur would take both neighbours to 50% at their
|
||||
shared border and let the sea-grey underlay show through as a bright seam -- trading a hard edge
|
||||
for a worse one.
|
||||
|
||||
Reads `MapZoneDilate`, writes `MapZoneSoft`; the source folder is left alone so this is
|
||||
re-runnable and the two can be compared. 256x256 uncompressed 32-bit TGA either way: RGB black,
|
||||
alpha the coverage, as the shipped masks are.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
# Mask pixels. A zone mask is 256 px across its own rect however far the map is zoomed, so these
|
||||
# are a fraction of a zone, not a count of screen pixels: 3 + 4 is a ramp a little under 3% of a
|
||||
# zone's width.
|
||||
DILATE = 2
|
||||
BLUR = 3.0
|
||||
# How far in from the sheet's own border the softened coverage is held at zero, as a fraction of
|
||||
# the sheet. A 1.12 zone sheet paints its own torn parchment frame and its own sea right to the
|
||||
# edge of its rect, and the shipped masks are cut so that none of that shows. A blur alone pulls
|
||||
# it back in -- measured: softening with no inset put a hard straight line of Wetlands' parchment
|
||||
# border across the view where the binary mask had a soft coast. So the ramp is multiplied by a
|
||||
# rect ramp that reaches zero at the border, and the neighbour's own (dilated) mask covers the
|
||||
# land the inset gives up.
|
||||
RECT_MARGIN = 0.05
|
||||
|
||||
|
||||
def rect_ramp(n: int, margin: float) -> np.ndarray:
|
||||
"""1 in the middle, 0 at both ends, smoothstep across `margin` of the span."""
|
||||
if margin <= 0:
|
||||
return np.ones(n)
|
||||
x = (np.arange(n) + 0.5) / n
|
||||
t = np.clip(np.minimum(x, 1.0 - x) / margin, 0.0, 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def soften(src: Path, dilate: int, blur: float, margin: float) -> Image.Image:
|
||||
im = Image.open(src).convert("RGBA")
|
||||
a = im.split()[-1]
|
||||
# MaxFilter is a square dilation; its size must be odd and is the full width, not the radius.
|
||||
if dilate > 0:
|
||||
a = a.filter(ImageFilter.MaxFilter(2 * dilate + 1))
|
||||
if blur > 0:
|
||||
a = a.filter(ImageFilter.GaussianBlur(blur))
|
||||
w, h = im.size
|
||||
cover = np.asarray(a, dtype=np.float32) / 255.0
|
||||
cover = cover * np.outer(rect_ramp(h, margin), rect_ramp(w, margin))
|
||||
out = np.zeros((h, w, 4), dtype=np.uint8)
|
||||
out[:, :, 3] = np.round(cover * 255.0).astype(np.uint8)
|
||||
return Image.fromarray(out, "RGBA")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("textures", type=Path, help="OpenMapClassic/Media/Textures")
|
||||
ap.add_argument("--from-dir", default="MapZoneDilate")
|
||||
ap.add_argument("--to-dir", default="MapZoneSoft")
|
||||
ap.add_argument("--dilate", type=int, default=DILATE)
|
||||
ap.add_argument("--blur", type=float, default=BLUR)
|
||||
ap.add_argument("--rect-margin", type=float, default=RECT_MARGIN)
|
||||
args = ap.parse_args()
|
||||
|
||||
src = args.textures / args.from_dir
|
||||
dst = args.textures / args.to_dir
|
||||
if not src.is_dir():
|
||||
print(f"not a directory: {src}", file=sys.stderr)
|
||||
return 2
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
n = 0
|
||||
for path in sorted(src.glob("*.tga")):
|
||||
soften(path, args.dilate, args.blur, args.rect_margin).save(dst / path.name)
|
||||
n += 1
|
||||
print(f"wrote {n} mask(s) to {dst} (dilate {args.dilate}, blur {args.blur}, rect margin {args.rect_margin})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user