diff --git a/Py4GWCoreLib/Map.py b/Py4GWCoreLib/Map.py index a78cafaf9..b997d6634 100644 --- a/Py4GWCoreLib/Map.py +++ b/Py4GWCoreLib/Map.py @@ -15,6 +15,9 @@ from Py4GWCoreLib.enums import outposts import PyOverlay +import PyMap + +from .native_src.internals import raycast_math as _rc from .enums import FlagPreference from typing import List, Optional @@ -2306,6 +2309,138 @@ def sign(x1, y1, x2, y2, x3, y3): return False + class Raycast: + """World-collision raycast primitives (terrain + props), backed by native PyMap. + + Geometry-only and agent-agnostic: callers pass exact world (x, y, z) endpoints. + ALL math (normalize, the blocked decision, hit reconstruction, the prop-mesh v*M + transform) is done here in Python on top of the raw native bridge -- the native + side returns engine numbers verbatim. Results are NOT @frame_cache'd (they vary by + arbitrary start/end). GW world Z is negative-up; any eye-height lift is the + caller's concern and is kept out of these primitives. + + Mirrors the Map.Pathing nested-class precedent. Result types are re-exported as + Map.Raycast.RaycastHit / PropHit / PropInfo. + """ + + RaycastHit = _rc.RaycastHit + PropHit = _rc.PropHit + PropInfo = _rc.PropInfo + DEFAULT_SLACK = _rc.DEFAULT_SLACK + + @staticmethod + def Cast(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit: + """Combined terrain + walkable-prop cast (engine MapCliQueryIntersection). + + .blocked is the line-of-sight answer; .point is the contact; .source is + 'terrain' or 'props' decided from the engine prop_layer. + """ + unit_dir, seg_len = _rc.direction(start, end) + if unit_dir is None: + return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE) + try: + has_hit, hx, hy, hz, prop_layer = PyMap.RayCast( + (start[0], start[1], start[2]), unit_dir) + except Exception: + return _rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE) + if not has_hit: + return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE) + point = (float(hx), float(hy), float(hz)) + hit_dist = _rc.segment_length(start, point) + if _rc.is_blocked(hit_dist, seg_len, slack): + source = _rc.SOURCE_TERRAIN if int(prop_layer) == 0 else _rc.SOURCE_PROPS + return _rc.RaycastHit(True, point, hit_dist, source) + return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE) + + @staticmethod + def CastTerrain(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit: + """Terrain-only cast (engine TerrainQueryIntersection). + + The bound terrain kernel writes the hit as a parametric FRACTION in [0,1] along + start->end, NOT a world distance, so it is scaled by seg_len here to get the + world hit distance (hit = start + frac*(end-start)). + """ + unit_dir, seg_len = _rc.direction(start, end) + if unit_dir is None: + return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE) + try: + has_hit, frac = PyMap.RayCastTerrain( + (start[0], start[1], start[2]), (end[0], end[1], end[2])) + except Exception: + return _rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE) + dist = float(frac) * seg_len # fraction [0,1] along the segment -> world distance + if has_hit and _rc.is_blocked(dist, seg_len, slack): + point = _rc.point_along(start, unit_dir, dist) + return _rc.RaycastHit(True, point, dist, _rc.SOURCE_TERRAIN) + return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE) + + @staticmethod + def CastInteractive(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.PropHit: + """Interactive-prop mesh cast (engine IProps::IntersectGeometry over every prop). + + n_scanned == -1 signals the probe could not run (stale DLL); n_scanned == 0 from + a successful call means the map has no interactive-prop meshes (a clear result). + """ + unit_dir, seg_len = _rc.direction(start, end) + if unit_dir is None: + return _rc.PropHit(False, None, None, -1, 0) + try: + has_hit, dist, prop_id, n_scanned = PyMap.RayCastInteractive( + (start[0], start[1], start[2]), unit_dir, seg_len) + except Exception: + return _rc.PropHit(False, None, None, -1, -1) + dist = float(dist) + prop_id = int(prop_id) + n_scanned = int(n_scanned) + if has_hit and _rc.is_blocked(dist, seg_len, slack): + point = _rc.point_along(start, unit_dir, dist) + return _rc.PropHit(True, point, dist, prop_id, n_scanned) + return _rc.PropHit(False, None, None, -1, n_scanned) + + @staticmethod + def HasLos(start, end, terrain: bool = True, props: bool = True, + slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit: + """Reference HasLos: nearest-block combine of CastTerrain + CastInteractive. + + blocked = terrain OR a prop; the nearest block wins. .blocked is the LoS answer, + .point is the nearest blocking contact. Probes a source only when its flag is set, + so toggling one off both skips its native raycast and excludes it from blocking. + """ + hits = [] + if terrain: + hits.append(Map.Raycast.CastTerrain(start, end, slack)) + if props: + prop = Map.Raycast.CastInteractive(start, end, slack) + if prop.n_scanned < 0: # probe unavailable (stale DLL) + hits.append(_rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE)) + else: + src = _rc.SOURCE_PROPS if prop.blocked else _rc.SOURCE_NONE + hits.append(_rc.RaycastHit(prop.blocked, prop.point, prop.distance, src)) + return _rc.nearest_block(hits) + + @staticmethod + def GetProps() -> "list[_rc.PropInfo]": + """Enumerate all map props (world position + collision flags).""" + try: + raw = PyMap.GetProps() + except Exception: + return [] + return [_rc.PropInfo(int(p[0]), float(p[1]), float(p[2]), float(p[3]), + bool(p[4]), int(p[5])) for p in raw] + + @staticmethod + def GetPropGeometry(prop_id: int) -> "list[_rc.Triangle]": + """One prop's collision mesh as world-space triangles (applies v*M in Python). + + Returns a flat list of 9-float (x1,y1,z1, x2,y2,z2, x3,y3,z3) world triangles, + ready for Overlay.DrawTriangle3D. Empty if the prop has no collision geometry. + """ + try: + submodels = PyMap.GetPropGeometry(int(prop_id)) + except Exception: + return [] + return _rc.transform_local_triangles(submodels) + diff --git a/Py4GWCoreLib/__init__.py b/Py4GWCoreLib/__init__.py index 7aaeef498..89140a1e7 100644 --- a/Py4GWCoreLib/__init__.py +++ b/Py4GWCoreLib/__init__.py @@ -55,6 +55,7 @@ def find_system_python(): import PyCamera import Py2DRenderer import PyCombatEvents +import PyMap from .enums import * from .ImGui_src.IconsFontAwesome5 import IconsFontAwesome5 @@ -132,6 +133,7 @@ def find_system_python(): PyCamera = PyCamera Py2DRenderer = Py2DRenderer PyCombatEvents = PyCombatEvents +PyMap = PyMap GLOBAL_CACHE = GLOBAL_CACHE AutoPathing = AutoPathing IconsFontAwesome5 = IconsFontAwesome5 diff --git a/Py4GWCoreLib/native_src/internals/raycast_math.py b/Py4GWCoreLib/native_src/internals/raycast_math.py new file mode 100644 index 000000000..d74b6665f --- /dev/null +++ b/Py4GWCoreLib/native_src/internals/raycast_math.py @@ -0,0 +1,148 @@ +"""Pure-Python math + result types for ``Map.Raycast``. + +This module is the home for ALL the geometry math that was moved off the native +PyMap bridge: segment length, normalize, hit-point reconstruction, the blocked +decision and the prop-mesh ``v*M`` transform. It has NO native (DLL) dependency, +so it is unit-testable in isolation -- the native side returns raw engine numbers +and everything derived happens here. + +GW world Z is negative-up. These primitives are geometry-only and agent-agnostic: +callers pass exact world endpoints. Any eye-height lift is the caller's concern and +is deliberately kept out of here. +""" +import math +from typing import Iterable, List, NamedTuple, Optional, Tuple + +Vec3 = Tuple[float, float, float] +Matrix12 = Tuple[float, float, float, float, float, float, + float, float, float, float, float, float] +Triangle = Tuple[float, float, float, float, float, float, float, float, float] + +# An obstruction within this many world units of the segment end is treated as the +# endpoint's own footing / float noise, not a block. Matches the literal the C++ side +# used before the blocked decision moved to Python. +DEFAULT_SLACK = 8.0 + +# Segments shorter than this are degenerate; normalize would divide by ~0, so the +# raycast primitives report "clear" without calling the engine. Matches the old native +# ``seg_len <= 1.0`` short-circuit. +MIN_SEGMENT_LENGTH = 1.0 + +# Result-source tags for RaycastHit.source. +SOURCE_NONE = "none" # clear line of sight +SOURCE_TERRAIN = "terrain" # blocked by terrain +SOURCE_PROPS = "props" # blocked by a prop / interactive mesh +SOURCE_UNAVAILABLE = "unavailable" # probe could not run (stale DLL / no map context) + + +class RaycastHit(NamedTuple): + """Result of a point->point cast / line-of-sight query. + + blocked : True if an obstruction sits before the segment end (minus slack). + point : nearest contact point (world x,y,z), or None when clear / unavailable. + distance : distance from start to ``point``, or None. + source : which test produced the answer -- 'terrain', 'props', 'none' (clear) or + 'unavailable' (probe could not run). For the combined engine cast, + 'terrain'/'props' is decided from the engine's prop_layer. + """ + blocked: bool + point: Optional[Vec3] + distance: Optional[float] + source: str + + +class PropHit(NamedTuple): + """Result of an interactive-prop mesh cast (adds prop_id / n_scanned). + + n_scanned == -1 is the sentinel for "probe could not run" (the native call raised: + stale DLL / missing symbol); n_scanned == 0 from a successful call simply means the + map carries no interactive-prop meshes (a legitimate clear). + """ + blocked: bool + point: Optional[Vec3] + distance: Optional[float] + prop_id: int + n_scanned: int + + +class PropInfo(NamedTuple): + """One enumerated map prop (``Map.Raycast.GetProps``).""" + prop_id: int + x: float + y: float + z: float + is_interactive: bool + rec_count: int + + +# ----- vector helpers ------------------------------------------------------- + +def segment_length(start: Vec3, end: Vec3) -> float: + """Euclidean distance between two world points.""" + return math.dist(start, end) + + +def direction(start: Vec3, end: Vec3) -> Tuple[Optional[Vec3], float]: + """Return (unit_dir, seg_len). unit_dir is None for a degenerate segment.""" + seg_len = math.dist(start, end) + if seg_len <= MIN_SEGMENT_LENGTH: + return None, seg_len + inv = 1.0 / seg_len + return (((end[0] - start[0]) * inv, + (end[1] - start[1]) * inv, + (end[2] - start[2]) * inv), seg_len) + + +def point_along(start: Vec3, unit_dir: Vec3, dist: float) -> Vec3: + """Reconstruct the world point at ``dist`` along unit_dir from start.""" + return (start[0] + dist * unit_dir[0], + start[1] + dist * unit_dir[1], + start[2] + dist * unit_dir[2]) + + +def is_blocked(hit_dist: float, seg_len: float, slack: float = DEFAULT_SLACK) -> bool: + """A hit blocks LoS only if it sits before the segment end minus slack.""" + return hit_dist < seg_len - slack + + +def transform_point(matrix12: Matrix12, v: Vec3) -> Vec3: + """Apply a row-major 3x4 affine world matrix to a local vertex (v*M).""" + lx, ly, lz = v + m = matrix12 + return (m[0] * lx + m[1] * ly + m[2] * lz + m[3], + m[4] * lx + m[5] * ly + m[6] * lz + m[7], + m[8] * lx + m[9] * ly + m[10] * lz + m[11]) + + +def transform_local_triangles(submodels: Iterable) -> List[Triangle]: + """Flatten native GetPropGeometry output to world-space triangles. + + Input: an iterable of (matrix12, tris_local) per submodel, where each local + triangle is a 9-float (lx1,ly1,lz1, lx2,ly2,lz2, lx3,ly3,lz3). Applies v*M per + vertex and returns a flat list of world-space 9-float triangles. + """ + world: List[Triangle] = [] + for matrix12, tris_local in submodels: + for tri in tris_local: + w0 = transform_point(matrix12, (tri[0], tri[1], tri[2])) + w1 = transform_point(matrix12, (tri[3], tri[4], tri[5])) + w2 = transform_point(matrix12, (tri[6], tri[7], tri[8])) + world.append((w0[0], w0[1], w0[2], + w1[0], w1[1], w1[2], + w2[0], w2[1], w2[2])) + return world + + +def nearest_block(hits: Iterable[RaycastHit]) -> RaycastHit: + """Combine cast results: the nearest blocking hit wins; clear if none block. + + If no source blocks but every supplied probe was unavailable, the combined result + is 'unavailable' (so callers do not read a failed probe as clear line of sight). + """ + hits = list(hits) + blocking = [h for h in hits if h.blocked and h.distance is not None] + if blocking: + return min(blocking, key=lambda h: h.distance) + if hits and all(h.source == SOURCE_UNAVAILABLE for h in hits): + return RaycastHit(False, None, None, SOURCE_UNAVAILABLE) + return RaycastHit(False, None, None, SOURCE_NONE) diff --git a/Widgets/Coding/Debug/Guild Wars/Raycaster Tester.py b/Widgets/Coding/Debug/Guild Wars/Raycaster Tester.py new file mode 100644 index 000000000..4b4e401c2 --- /dev/null +++ b/Widgets/Coding/Debug/Guild Wars/Raycaster Tester.py @@ -0,0 +1,630 @@ +""" +Raycaster Tester — Line-of-Sight / raycast probe debugger widget. + +Draws a ray from the player to the current target: GREEN = clear, RED = blocked. + +This is the reference `HasLos` approach, now consuming the Py4GWCoreLib Map.Raycast API +(which wraps the native PyMap bridge and does the math in Python): + * terrain Map.Raycast.CastTerrain -> TerrainQueryIntersection (terrain raycast). + * props Map.Raycast.CastInteractive -> IProps::IntersectGeometry over every prop's + collision geosets (the reference's PropIntersect / PropIntersectMod, + via the GrModel intersect resolved by a stable assertion anchor). +blocked = terrain OR props; the red line stops at the NEAREST block. + +(No navmesh and no combined MapCliQueryIntersection: the navmesh 2D sampling produced +false positives, and the reference HasLos uses terrain + per-prop geometry only.) + +Prop debug overlay: Map.Raycast.GetProps() enumerates every map prop. Each prop near the +player is drawn as a 3D ring + ID label so you can see which prop the prop-probe hits +(the blocking prop, by prop_id, is highlighted). Interactive props that carry collision +geosets -- the exact set RayCastInteractive scans -- are cyan; the rest are dimmer. + +Live probe debug: per-frame readout of each source + 3D markers at the hit points +(orange = terrain block, blue = prop block). + +GUI: master on/off, "Draw 3D line", per-source toggles (terrain / props), prop overlay +toggles, manual Line Z lift slider/field. +""" + +import math + +import PyImGui +from Py4GWCoreLib import Routines, ImGui, Color, Map +from Py4GWCoreLib.IniManager import IniManager +from Py4GWCoreLib.Overlay import Overlay +from Py4GWCoreLib.Player import Player +from Py4GWCoreLib.Agent import Agent +from Py4GWCoreLib.AgentArray import AgentArray + +MODULE_NAME = "Raycaster Tester" + +INI_KEY = "" +INI_PATH = "Widgets/RaycasterTester" +INI_FILENAME = "RaycasterTester.ini" + +# Overlay colors are ABGR (0xAABBGGRR), matching ImGui's IM_COL32. +COLOR_CLEAR = 0xFF00FF00 # opaque green +COLOR_BLOCKED = 0xFF0000FF # opaque red +COLOR_TERRAIN = 0xFF00A5FF # orange (terrain block point) +COLOR_PROP = 0xFFFF6000 # blue (prop block point) +LINE_THICKNESS = 3.0 +MARKER_SIZE = 150.0 + +# Prop overlay colors (ABGR). +COLOR_PROP_COLLIDE = 0xFFFFFF00 # cyan interactive prop WITH collision geosets +COLOR_PROP_NOMESH = 0xFF00FFFF # yellow interactive prop, no geosets +COLOR_PROP_OTHER = 0x80909090 # faint gray non-interactive prop +COLOR_PROP_HILITE = 0xFFFF00FF # magenta the prop the ray currently blocks on +PROP_DRAW_RADIUS = 3500.0 # only draw props within this XY range of the player +PROP_RING_RADIUS = 60.0 +PROP_RING_BIG = 120.0 +PROP_RING_SEGMENTS = 12 + +# Z lift (world units): positive = up (GW world Z is negative-up, so applied as a +# subtraction in _do_ray). Raises both endpoints so the ray does not graze terrain. +Z_LIFT_DEFAULT = 40.0 +Z_LIFT_MAX = 500.0 + +# Forward cone: a flat horizontal fan of HasLos rays around the player's facing +# direction (no target needed). Each ray green=clear / red=blocked; enemies inside +# the arc are ringed (bright=clear LoS, dim=occluded). +COLOR_CONE_CLEAR = 0xFF00FF00 # green ray (clear) +COLOR_CONE_BLOCKED = 0xFF0000FF # red ray (blocked) +COLOR_ENEMY_VIS = 0xFF9314FF # hot pink: enemy in cone, clear LoS +COLOR_ENEMY_OCC = 0x809314FF # dim hot pink: enemy in cone, LoS occluded +COLOR_ENEMY_UNPROBED = 0x80B0B0B0 # faint gray: in cone but beyond the LoS-probe cap +CONE_RAY_THICKNESS = 2.0 +ENEMY_RING_RADIUS = 90.0 +ENEMY_RING_SEGMENTS = 10 +CONE_MAX_ENEMY_PROBES = 12 # nearest enemies that get a real LoS raycast per frame +CONE_MAX_PROP_MESHES = 12 # distinct cone-hit props whose collision mesh is drawn per frame + +CONE_HALF_ANGLE_DEFAULT = 45.0 # degrees to each side of facing +CONE_HALF_ANGLE_MAX = 170.0 +CONE_RANGE_DEFAULT = 1500.0 +CONE_RANGE_MAX = 5000.0 +CONE_RAYS_DEFAULT = 11 +CONE_RAYS_MIN = 3 +CONE_RAYS_MAX = 200 + +initialized = False +_status = {"text": "idle", "backend": "-", "color": (0.7, 0.7, 0.7, 1.0), "lines": [], "block_prop_id": -1} +_cone_status = {"text": "idle", "color": (0.7, 0.7, 0.7, 1.0), "lines": []} + + +def _add_config_vars(): + global INI_KEY + IniManager().add_bool(INI_KEY, "enabled", "RaycasterTester", "enabled", default=True) + IniManager().add_bool(INI_KEY, "draw_line", "RaycasterTester", "draw_line", default=True) + IniManager().add_bool(INI_KEY, "ter_blocks", "RaycasterTester", "ter_blocks", default=True) + IniManager().add_bool(INI_KEY, "prop_blocks", "RaycasterTester", "prop_blocks", default=True) + IniManager().add_bool(INI_KEY, "draw_props", "RaycasterTester", "draw_props", default=True) + IniManager().add_bool(INI_KEY, "props_interactive_only", "RaycasterTester", "props_interactive_only", default=True) + IniManager().add_bool(INI_KEY, "draw_prop_mesh", "RaycasterTester", "draw_prop_mesh", default=True) + IniManager().add_float(INI_KEY, "z_lift", "RaycasterTester", "z_lift", default=Z_LIFT_DEFAULT) + IniManager().add_bool(INI_KEY, "cone_enabled", "RaycasterTester", "cone_enabled", default=False) + IniManager().add_float(INI_KEY, "cone_half_angle", "RaycasterTester", "cone_half_angle", default=CONE_HALF_ANGLE_DEFAULT) + IniManager().add_float(INI_KEY, "cone_range", "RaycasterTester", "cone_range", default=CONE_RANGE_DEFAULT) + IniManager().add_int(INI_KEY, "cone_rays", "RaycasterTester", "cone_rays", default=CONE_RAYS_DEFAULT) + IniManager().add_bool(INI_KEY, "cone_mark_enemies", "RaycasterTester", "cone_mark_enemies", default=True) + IniManager().add_bool(INI_KEY, "cone_terrain", "RaycasterTester", "cone_terrain", default=True) + IniManager().add_bool(INI_KEY, "cone_props", "RaycasterTester", "cone_props", default=True) + IniManager().add_bool(INI_KEY, "cone_draw_prop_mesh", "RaycasterTester", "cone_draw_prop_mesh", default=True) + + +def _draw_marker(ov, p, color): + """Draw a small 3D cross at world point p.""" + x, y, z = p + s = MARKER_SIZE + ov.DrawLine3D(x - s, y, z, x + s, y, z, color, 2.0) + ov.DrawLine3D(x, y - s, z, x, y + s, z, color, 2.0) + ov.DrawLine3D(x, y, z - s, x, y, z + s, color, 2.0) + + +def _draw_props(): + """Draw a ring + ID label for every prop near the player (Map.Raycast.GetProps). + + Cyan = interactive prop with collision geosets (RayCastInteractive scans these), + yellow = interactive prop with no geosets, gray = other props. The prop the ray + currently blocks on is drawn larger in magenta. + """ + try: + if not Routines.Checks.Map.MapValid(): + return + player_id = Player.GetAgentID() + if not player_id or not Agent.IsValid(player_id): + return + props = Map.Raycast.GetProps() + if not props: + return + + px, py, _pz = Agent.GetXYZ(player_id) + interactive_only = bool(IniManager().get(INI_KEY, "props_interactive_only", True)) + block_id = _status.get("block_prop_id", -1) + r2 = PROP_DRAW_RADIUS * PROP_DRAW_RADIUS + + ov = Overlay() + began = False + try: + ov.BeginDraw() + began = True + for prop_id, x, y, z, is_interactive, rec_count in props: + if interactive_only and not is_interactive: + continue + dx, dy = x - px, y - py + if dx * dx + dy * dy > r2: + continue + if prop_id == block_id: + color, radius = COLOR_PROP_HILITE, PROP_RING_BIG + elif is_interactive and rec_count > 0: + color, radius = COLOR_PROP_COLLIDE, PROP_RING_RADIUS + elif is_interactive: + color, radius = COLOR_PROP_NOMESH, PROP_RING_RADIUS + else: + color, radius = COLOR_PROP_OTHER, PROP_RING_RADIUS + ov.DrawPoly3D(x, y, z, radius, color, PROP_RING_SEGMENTS, 2.0, False) + ov.DrawText3D(x, y, z, str(prop_id), color, False, True, 1.0) + finally: + if began: + ov.EndDraw() + except Exception: + pass + + +def _draw_prop_mesh(prop_id, color): + """Draw a prop's full collision mesh as a 3D wireframe (Map.Raycast.GetPropGeometry). + + The triangles come from the exact geosets the prop raycast tests, so this shows + the real shape that blocks LoS (not just a flat ring at the prop's center). + """ + try: + if prop_id is None or prop_id < 0: + return + tris = Map.Raycast.GetPropGeometry(int(prop_id)) + if not tris: + return + ov = Overlay() + began = False + try: + ov.BeginDraw() + began = True + for x1, y1, z1, x2, y2, z2, x3, y3, z3 in tris: + ov.DrawTriangle3D(x1, y1, z1, x2, y2, z2, x3, y3, z3, color, 1.5) + finally: + if began: + ov.EndDraw() + except Exception: + pass + + +def _do_ray(draw_line): + """Compute reference HasLos player -> target, update _status, and draw ray + probes.""" + global _status + try: + if not Routines.Checks.Map.MapValid(): + _status = {"text": "not in an explorable map", "backend": "-", "color": (0.6, 0.6, 0.6, 1.0), "lines": [], "block_prop_id": -1} + return + player_id = Player.GetAgentID() + if not player_id or not Agent.IsValid(player_id): + _status = {"text": "no player agent", "backend": "-", "color": (0.6, 0.6, 0.6, 1.0), "lines": [], "block_prop_id": -1} + return + target_id = Player.GetTargetID() + if not target_id or target_id == player_id or not Agent.IsValid(target_id): + _status = {"text": "no target selected", "backend": "-", "color": (0.8, 0.8, 0.4, 1.0), "lines": [], "block_prop_id": -1} + return + + z_lift = IniManager().getFloat(INI_KEY, "z_lift", Z_LIFT_DEFAULT) + px, py, pz = Agent.GetXYZ(player_id) + tx, ty, tz = Agent.GetXYZ(target_id) + # GW world Z is negative-up, so a positive lift is SUBTRACTED to raise the ray. + start = (px, py, pz - z_lift) + end = (tx, ty, tz - z_lift) + seg_len = math.dist(start, end) + + ter_on = bool(IniManager().get(INI_KEY, "ter_blocks", True)) + prop_on = bool(IniManager().get(INI_KEY, "prop_blocks", True)) + + # terrain probe (Map.Raycast.CastTerrain -> TerrainQueryIntersection) + ter = Map.Raycast.CastTerrain(start, end) + ter_ok = ter.source != "unavailable" # source=='unavailable' => probe could not run + ter_block = (ter.point, ter.distance) if (ter_ok and ter.blocked and ter.point) else None + + # per-prop mesh probe (Map.Raycast.CastInteractive -> reference PropIntersect) + prop = Map.Raycast.CastInteractive(start, end) + prop_ok = prop.n_scanned >= 0 # n_scanned==-1 => probe could not run + prop_id, prop_n = prop.prop_id, prop.n_scanned + prop_block = (prop.point, prop.distance) if (prop_ok and prop.blocked and prop.point) else None + + # combine (reference HasLos): blocked if terrain OR a prop; nearest block wins + candidates = [b for b in (ter_block if ter_on else None, + prop_block if prop_on else None) if b is not None] + if candidates: + block_point, _ = min(candidates, key=lambda b: b[1]) + blocked = True + else: + block_point, blocked = None, False + + srcs = [] + if ter_ok: + srcs.append("terrain") + if prop_ok: + srcs.append("props") + backend = "+".join(srcs) if srcs else "none" + + # status + debug readout + lines = [] + if not ter_ok: + lines.append(("terrain: unavailable (rebuild DLL)", (0.7, 0.7, 0.7, 1.0))) + elif ter_block is not None: + tag = " (blocked, hit_xyz|None, dist|None, source|None, prop_id). + source is 'terrain' or 'props' for the nearest block; prop_id is the hit prop index when + source=='props' (else -1). Probes a source only when it is enabled, so toggling one off both + skips its native raycast and excludes it from blocking. Uses the Map.Raycast library + primitives for the per-segment terrain+prop combine; enemy/cone detection stays in this widget.""" + candidates = [] + if ter_on: + ter = Map.Raycast.CastTerrain(start, end) + if ter.blocked and ter.point: + candidates.append((ter.point, ter.distance, "terrain", -1)) + if prop_on: + prop = Map.Raycast.CastInteractive(start, end) + if prop.blocked and prop.point: + candidates.append((prop.point, prop.distance, "props", prop.prop_id)) + if candidates: + pt, dist, source, prop_id = min(candidates, key=lambda b: b[1]) + return True, pt, dist, source, prop_id + return False, None, None, None, -1 + + +def _angle_diff(a, b): + """Signed smallest difference (a - b) wrapped to [-pi, pi].""" + return (a - b + math.pi) % (2.0 * math.pi) - math.pi + + +def _cone_ray_endpoints(apex, facing, half_rad, rng, n): + """Flat horizontal fan: n endpoints spread across [facing-half, facing+half] at apex height.""" + ax, ay, az = apex + ends = [] + for i in range(n): + t = 0.5 if n == 1 else i / (n - 1) + ang = facing - half_rad + t * (2.0 * half_rad) + ends.append((ax + rng * math.cos(ang), ay + rng * math.sin(ang), az)) + return ends + + +def _mark_enemies_in_cone(ov, apex, facing, half_rad, rng, z_lift, ter_on, prop_on): + """Ring living enemies inside the cone arc+range. The nearest CONE_MAX_ENEMY_PROBES get a real + LoS raycast (bright=clear, dim=occluded); farther ones are ringed faint-gray with no probe, so + the per-frame native cost stays bounded in crowds. Returns (in_cone, visible, probed).""" + ax, ay, az = apex + try: + enemies = AgentArray.GetEnemyArray() + except Exception: + return 0, 0, 0 + rng2 = rng * rng + + # cheap XY + arc filter first (no native raycasts); collect (dist2, lifted xyz) + in_arc = [] + for eid in enemies: + try: + if not Agent.IsValid(eid) or not Agent.IsAlive(eid): + continue + ex, ey, ez = Agent.GetXYZ(eid) + if ex == 0.0 and ey == 0.0 and ez == 0.0: + continue # GetXYZ sentinel for an agent invalidated mid-frame + dx, dy = ex - ax, ey - ay + d2 = dx * dx + dy * dy + if d2 > rng2: + continue + if abs(_angle_diff(math.atan2(dy, dx), facing)) > half_rad: + continue + in_arc.append((d2, ex, ey, ez - z_lift)) # lifted to match ray/apex height + except Exception: + continue + + in_arc.sort(key=lambda e: e[0]) + visible = probed = 0 + for i, (_d2, ex, ey, ez_lift) in enumerate(in_arc): + try: + if i < CONE_MAX_ENEMY_PROBES: + blocked, _pt, _d, _src, _pid = _segment_block(apex, (ex, ey, ez_lift), ter_on, prop_on) + probed += 1 + if not blocked: + visible += 1 + color = COLOR_ENEMY_OCC if blocked else COLOR_ENEMY_VIS + else: + color = COLOR_ENEMY_UNPROBED + ov.DrawPoly3D(ex, ey, ez_lift, ENEMY_RING_RADIUS, color, ENEMY_RING_SEGMENTS, 2.0, False) + ov.DrawLine3D(ax, ay, az, ex, ey, ez_lift, color, CONE_RAY_THICKNESS) + except Exception: + continue + return len(in_arc), visible, probed + + +def _do_cone(): + """Cast a flat horizontal fan of HasLos rays in front of the player; highlight enemies inside it.""" + global _cone_status + try: + if not Routines.Checks.Map.MapValid(): + _cone_status = {"text": "not in an explorable map", "color": (0.6, 0.6, 0.6, 1.0), "lines": []} + return + player_id = Player.GetAgentID() + if not player_id or not Agent.IsValid(player_id): + _cone_status = {"text": "no player agent", "color": (0.6, 0.6, 0.6, 1.0), "lines": []} + return + + z_lift = IniManager().getFloat(INI_KEY, "z_lift", Z_LIFT_DEFAULT) + half_deg = IniManager().getFloat(INI_KEY, "cone_half_angle", CONE_HALF_ANGLE_DEFAULT) + rng = IniManager().getFloat(INI_KEY, "cone_range", CONE_RANGE_DEFAULT) + n_rays = max(CONE_RAYS_MIN, min(CONE_RAYS_MAX, int(IniManager().getInt(INI_KEY, "cone_rays", CONE_RAYS_DEFAULT)))) + ter_on = bool(IniManager().get(INI_KEY, "cone_terrain", True)) + prop_on = bool(IniManager().get(INI_KEY, "cone_props", True)) + mark_enemies = bool(IniManager().get(INI_KEY, "cone_mark_enemies", True)) + draw_mesh = bool(IniManager().get(INI_KEY, "cone_draw_prop_mesh", True)) + + px, py, pz = Agent.GetXYZ(player_id) + facing = float(Agent.GetRotationAngle(player_id)) + half_rad = math.radians(half_deg) + # GW world Z is negative-up, so a positive lift is SUBTRACTED to raise the fan. + apex = (px, py, pz - z_lift) + + blocked_count = 0 + ter_hits = prop_hits = 0 + hit_prop_ids = set() + nearest = None + in_cone = visible = probed = 0 + ov = Overlay() + began = False + try: + ov.BeginDraw() + began = True + for end in _cone_ray_endpoints(apex, facing, half_rad, rng, n_rays): + blocked, pt, dist, source, hit_prop_id = _segment_block(apex, end, ter_on, prop_on) + if blocked and pt is not None: + blocked_count += 1 + if source == "terrain": + ter_hits += 1 + elif source == "props": + prop_hits += 1 + if hit_prop_id >= 0: + hit_prop_ids.add(hit_prop_id) + nearest = dist if nearest is None else min(nearest, dist) + ov.DrawLine3D(apex[0], apex[1], apex[2], pt[0], pt[1], pt[2], + COLOR_CONE_BLOCKED, CONE_RAY_THICKNESS) + else: + ov.DrawLine3D(apex[0], apex[1], apex[2], end[0], end[1], end[2], + COLOR_CONE_CLEAR, CONE_RAY_THICKNESS) + if mark_enemies: + in_cone, visible, probed = _mark_enemies_in_cone( + ov, apex, facing, half_rad, rng, z_lift, ter_on, prop_on) + finally: + if began: + ov.EndDraw() + + # draw the collision mesh of each distinct prop a cone ray hit (own BeginDraw/EndDraw) + if draw_mesh and hit_prop_ids: + for pid in list(hit_prop_ids)[:CONE_MAX_PROP_MESHES]: + _draw_prop_mesh(pid, COLOR_PROP_HILITE) + + lines = [("rays: %d/%d blocked%s" + % (blocked_count, n_rays, + (" nearest @%.0f" % nearest) if nearest is not None else ""), + (0.8, 0.7, 0.6, 1.0))] + lines.append((" by source: terrain %d, props %d%s%s" + % (ter_hits, prop_hits, + "" if ter_on else " (terrain off)", + "" if prop_on else " (props off)"), + (0.7, 0.75, 0.7, 1.0))) + if mark_enemies: + note = "enemies: %d in cone, %d with LoS" % (in_cone, visible) + if probed < in_cone: + note += " (nearest %d probed)" % probed + lines.append((note, (0.9, 0.6, 0.85, 1.0))) + _cone_status = { + "text": "cone: %d clear / %d blocked" % (n_rays - blocked_count, blocked_count), + "color": (0.3, 1.0, 0.3, 1.0) if blocked_count == 0 else (1.0, 0.7, 0.3, 1.0), + "lines": lines, + } + except Exception as e: + _cone_status = {"text": "cone error: %s" % e, "color": (1.0, 0.5, 0.0, 1.0), "lines": []} + + +def _draw_window(): + global INI_KEY, _status + if ImGui.Begin(INI_KEY, MODULE_NAME, flags=PyImGui.WindowFlags.AlwaysAutoResize): + enabled = bool(IniManager().get(INI_KEY, "enabled", True)) + new_enabled = PyImGui.checkbox("Enabled", enabled) + if new_enabled != enabled: + IniManager().set(INI_KEY, "enabled", new_enabled) + + draw_line = bool(IniManager().get(INI_KEY, "draw_line", True)) + new_draw_line = PyImGui.checkbox("Draw 3D line", draw_line) + if new_draw_line != draw_line: + IniManager().set(INI_KEY, "draw_line", new_draw_line) + + ter_on = bool(IniManager().get(INI_KEY, "ter_blocks", True)) + new_ter_on = PyImGui.checkbox("Terrain blocks LoS", ter_on) + if new_ter_on != ter_on: + IniManager().set(INI_KEY, "ter_blocks", new_ter_on) + + prop_on = bool(IniManager().get(INI_KEY, "prop_blocks", True)) + new_prop_on = PyImGui.checkbox("Props block LoS", prop_on) + if new_prop_on != prop_on: + IniManager().set(INI_KEY, "prop_blocks", new_prop_on) + + draw_props = bool(IniManager().get(INI_KEY, "draw_props", True)) + new_draw_props = PyImGui.checkbox("Draw props", draw_props) + if new_draw_props != draw_props: + IniManager().set(INI_KEY, "draw_props", new_draw_props) + + int_only = bool(IniManager().get(INI_KEY, "props_interactive_only", True)) + new_int_only = PyImGui.checkbox("Interactive props only", int_only) + if new_int_only != int_only: + IniManager().set(INI_KEY, "props_interactive_only", new_int_only) + + mesh_on = bool(IniManager().get(INI_KEY, "draw_prop_mesh", True)) + new_mesh_on = PyImGui.checkbox("Draw blocking prop mesh", mesh_on) + if new_mesh_on != mesh_on: + IniManager().set(INI_KEY, "draw_prop_mesh", new_mesh_on) + + z_lift = IniManager().getFloat(INI_KEY, "z_lift", Z_LIFT_DEFAULT) + new_z_lift = PyImGui.slider_float("Line Z lift", z_lift, 0.0, Z_LIFT_MAX) + if new_z_lift != z_lift: + IniManager().set(INI_KEY, "z_lift", new_z_lift) + z_lift = new_z_lift + # typeable field for exact values (drag the slider above, or type here) + typed_z = PyImGui.input_float("Line Z lift (type)", z_lift) + if typed_z != z_lift: + IniManager().set(INI_KEY, "z_lift", typed_z) + + PyImGui.separator() + cone_on = bool(IniManager().get(INI_KEY, "cone_enabled", False)) + new_cone_on = PyImGui.checkbox("Forward cone (facing dir)", cone_on) + if new_cone_on != cone_on: + IniManager().set(INI_KEY, "cone_enabled", new_cone_on) + if cone_on: + cone_ter = bool(IniManager().get(INI_KEY, "cone_terrain", True)) + new_cone_ter = PyImGui.checkbox("Cone: terrain blocks", cone_ter) + if new_cone_ter != cone_ter: + IniManager().set(INI_KEY, "cone_terrain", new_cone_ter) + + cone_prop = bool(IniManager().get(INI_KEY, "cone_props", True)) + new_cone_prop = PyImGui.checkbox("Cone: props block", cone_prop) + if new_cone_prop != cone_prop: + IniManager().set(INI_KEY, "cone_props", new_cone_prop) + + cone_mesh = bool(IniManager().get(INI_KEY, "cone_draw_prop_mesh", True)) + new_cone_mesh = PyImGui.checkbox("Cone: draw hit prop mesh", cone_mesh) + if new_cone_mesh != cone_mesh: + IniManager().set(INI_KEY, "cone_draw_prop_mesh", new_cone_mesh) + + half_deg = IniManager().getFloat(INI_KEY, "cone_half_angle", CONE_HALF_ANGLE_DEFAULT) + new_half = PyImGui.slider_float("Cone half-angle (deg)", half_deg, 5.0, CONE_HALF_ANGLE_MAX) + if new_half != half_deg: + IniManager().set(INI_KEY, "cone_half_angle", new_half) + + cone_range = IniManager().getFloat(INI_KEY, "cone_range", CONE_RANGE_DEFAULT) + new_range = PyImGui.slider_float("Cone range", cone_range, 100.0, CONE_RANGE_MAX) + if new_range != cone_range: + IniManager().set(INI_KEY, "cone_range", new_range) + + cone_rays = IniManager().getInt(INI_KEY, "cone_rays", CONE_RAYS_DEFAULT) + new_rays = PyImGui.slider_int("Cone rays", cone_rays, CONE_RAYS_MIN, CONE_RAYS_MAX) + if new_rays != cone_rays: + IniManager().set(INI_KEY, "cone_rays", new_rays) + + mark_enemies = bool(IniManager().get(INI_KEY, "cone_mark_enemies", True)) + new_mark = PyImGui.checkbox("Mark enemies in cone", mark_enemies) + if new_mark != mark_enemies: + IniManager().set(INI_KEY, "cone_mark_enemies", new_mark) + + PyImGui.text_colored(_cone_status["text"], _cone_status["color"]) + for _t, _c in _cone_status.get("lines", []): + PyImGui.text_colored(_t, _c) + + PyImGui.separator() + PyImGui.text("Player -> Target line of sight") + PyImGui.text_colored(_status["text"], _status["color"]) + PyImGui.text("backend: " + str(_status["backend"])) + for _t, _c in _status.get("lines", []): + PyImGui.text_colored(_t, _c) + ImGui.End(INI_KEY) + + +def draw(): + """Runs every frame (draw phase).""" + global initialized + if not initialized: + return + enabled = bool(IniManager().get(INI_KEY, "enabled", True)) + if enabled: + draw_line = bool(IniManager().get(INI_KEY, "draw_line", True)) + _do_ray(draw_line) + if bool(IniManager().get(INI_KEY, "draw_props", True)): + _draw_props() + if bool(IniManager().get(INI_KEY, "draw_prop_mesh", True)): + _draw_prop_mesh(_status.get("block_prop_id", -1), COLOR_PROP_HILITE) + if bool(IniManager().get(INI_KEY, "cone_enabled", False)): + _do_cone() + else: + _status.update({"text": "disabled", "backend": "-", "color": (0.6, 0.6, 0.6, 1.0), "lines": [], "block_prop_id": -1}) + _draw_window() + + +def tooltip(): + PyImGui.begin_tooltip() + title_color = Color(255, 200, 100, 255) + ImGui.push_font("Regular", 20) + PyImGui.text_colored(MODULE_NAME, title_color.to_tuple_normalized()) + ImGui.pop_font() + PyImGui.separator() + PyImGui.text("Reference HasLos tester (terrain + props):") + PyImGui.bullet_text("Green = clear, Red = blocked (terrain OR prop geometry)") + PyImGui.bullet_text("Markers: orange=terrain block, blue=prop block") + PyImGui.bullet_text("Draw props: cyan=collision prop, yellow=no mesh, magenta=blocking") + PyImGui.bullet_text("Blocking prop is drawn as a full magenta wireframe mesh") + PyImGui.bullet_text("Raise Line Z lift to chest height so the ray clears the ground") + PyImGui.end_tooltip() + + +def main(): + """Runs once to initialize.""" + global INI_KEY, initialized + if initialized: + return + if not Routines.Checks.Map.MapValid(): + return + if not INI_KEY: + INI_KEY = IniManager().ensure_key(INI_PATH, INI_FILENAME) + if not INI_KEY: + return + _add_config_vars() + IniManager().load_once(INI_KEY) + initialized = True + + +if __name__ == "__main__": + main() diff --git a/stubs/PyMap.pyi b/stubs/PyMap.pyi new file mode 100644 index 000000000..79f98d93d --- /dev/null +++ b/stubs/PyMap.pyi @@ -0,0 +1,93 @@ +"""Type stub for the native PyMap module (added by py_map.cpp in Py4GW.dll). + +RAW collision-raycast bridge: these functions return the engine's raw numbers +verbatim and do NO derived math. Normalize, segment length, the blocked decision, +hit-point reconstruction and the prop-mesh v*M transform all live on the Python +side (Py4GWCoreLib.Map.Raycast). Prefer the ergonomic Map.Raycast.* API over +calling these directly. +""" +from typing import List, Tuple + + +def RayCast(start: Tuple[float, float, float], + unit_dir: Tuple[float, float, float]) -> Tuple[bool, float, float, float, int]: + """RAW combined terrain + walkable-prop raycast (MapCliQueryIntersection). + + The engine primitive inherently writes a hit POINT (not a fraction), so this is + the one function that returns a point. `unit_dir` is the caller-normalized unit + direction; no distance bound is applied here. + + Returns (has_hit, hit_x, hit_y, hit_z, prop_layer): + * has_hit -> the engine returned a contact (within ~25000 units). + * hit_* -> the raw contact point (0.0 when has_hit is False). + * prop_layer -> 0 when the nearest hit was terrain (or no hit); != 0 when the + nearest hit was a prop MESH (PathGetMap-packed layer<<16|index). + """ + ... + + +def RayCastTerrain(start: Tuple[float, float, float], + end: Tuple[float, float, float]) -> Tuple[bool, float]: + """RAW terrain-only raycast (TerrainQueryIntersection) from start to end. + + Returns (has_hit, frac): + * has_hit -> the engine terrain contact flag. + * frac -> parametric hit position in [0,1] along start->end (NOT world units -- + the bound terrain kernel writes a fraction, not the rescaled distance). Only + meaningful when has_hit. The Python side (Map.Raycast.CastTerrain) multiplies + frac by seg_len to get the world distance, reconstructs the hit point and + decides blocked. + """ + ... + + +def RayCastInteractive(start: Tuple[float, float, float], + unit_dir: Tuple[float, float, float], + max_range: float) -> Tuple[bool, float, int, int]: + """RAW interactive-object mesh probe over interactive-prop geosets. + + Tests start + t*unit_dir (t in [0, max_range]) against the collision geosets of + props that carry an interactive_model (doors, gates, gadgets) -- which the + walkable-prop raycast (RayCast) does not always cover. Calls the GrModel geoset + intersect resolved by a stable assertion anchor. + + Returns (has_hit, dist, prop_id, n_scanned): + * has_hit -> an interactive prop's mesh was hit within max_range. + * dist -> nearest hit distance along unit_dir (only meaningful when has_hit). + * prop_id -> propArray index of that prop (-1 if none). + * n_scanned -> interactive props tested when the probe ran (0 => the map has no + interactive-prop meshes, a legitimate clear). -1 => the probe could NOT run + (unresolved symbol / no map-props context / non-unit dir) -- distinct from a + clear result so callers do not read it as line of sight. + """ + ... + + +def GetProps() -> List[Tuple[int, float, float, float, bool, int]]: + """Enumerate all map props for overlay debugging ("draw the props"). + + Returns a list of (prop_id, x, y, z, is_interactive, rec_count) per prop: + * prop_id -> propArray index (matches RayCastInteractive's prop_id). + * x, y, z -> prop world position. + * is_interactive -> prop carries an interactive_model (the set + RayCastInteractive scans). + * rec_count -> collision geoset count; >0 means it has collision meshes. + """ + ... + + +def GetPropGeometry(prop_id: int) -> List[Tuple[ + Tuple[float, float, float, float, float, float, + float, float, float, float, float, float], + List[Tuple[float, float, float, float, float, float, float, float, float]]]]: + """RAW collision MESH of one prop (the same geosets RayCastInteractive tests). + + Returns a list of (matrix12, tris_local) per submodel: + * matrix12 -> 12 floats, a row-major 3x4 affine world matrix for the submodel. + * tris_local -> list of (lx1,ly1,lz1, lx2,ly2,lz2, lx3,ly3,lz3) LOCAL-space + triangles. + Empty if the prop has no collision geometry. The per-vertex v*M world transform is + applied in Python (Map.Raycast.GetPropGeometry), which yields world-space triangles + for wireframe drawing via Overlay.DrawTriangle3D. + """ + ...