extends SceneTree const MAP_SCENES := { "whale_port": "res://scenes/Maps/square.tscn", "work_zone": "res://scenes/Maps/work_zone.tscn", "whale_cafe": "res://scenes/Maps/cafe_interior.tscn", } const MAX_NPC_FOOTPRINT := Vector2(60, 28) const ROUTE_SAMPLE_DISTANCE := 8.0 var _failures: Array[String] = [] func _initialize() -> void: call_deferred("_run") func _run() -> void: var arguments := OS.get_cmdline_user_args() assert(arguments.size() == 1, "pass the exported world NPC graph JSON path after --") var graph := JSON.parse_string(FileAccess.get_file_as_string(arguments[0])) as Dictionary assert(not graph.is_empty(), "world NPC graph JSON is invalid") var locationsById := {} for locationValue in graph.get("locations", []): var location := locationValue as Dictionary locationsById[str(location.get("id", ""))] = location for mapId in MAP_SCENES: await _validate_map(str(mapId), str(MAP_SCENES[mapId]), graph, locationsById) if not _failures.is_empty(): for failure in _failures: push_error(failure) quit(1) return print("WORLD_NPC_NAVIGATION_OK") quit() func _validate_map(mapId: String, scenePath: String, graph: Dictionary, locationsById: Dictionary) -> void: var mapScene := (load(scenePath) as PackedScene).instantiate() as Node2D for uiName in ["ChatUI", "WorldBulletinPanel"]: var uiNode := mapScene.find_child(uiName, true, false) if uiNode != null: uiNode.free() root.add_child(mapScene) await process_frame for locationValue in graph.get("locations", []): var location := locationValue as Dictionary if str(location.get("mapId", "")) != mapId: continue for slotIndex in range((location.get("slots", []) as Array).size()): var slot := (location.get("slots", []) as Array)[slotIndex] as Dictionary _validate_clear_point( mapScene, Vector2(float(slot.get("x", 0)), float(slot.get("y", 0))), "%s slot %d" % [location.get("id", ""), slotIndex], ) for edgeValue in graph.get("edges", []): var edge := edgeValue as Dictionary if str(edge.get("kind", "")) != "walk": continue var from := locationsById.get(str(edge.get("from", "")), {}) as Dictionary var to := locationsById.get(str(edge.get("to", "")), {}) as Dictionary if str(from.get("mapId", "")) != mapId or str(to.get("mapId", "")) != mapId: continue var fromCenter := Vector2(float(from.get("x", 0)), float(from.get("y", 0))) var toCenter := Vector2(float(to.get("x", 0)), float(to.get("y", 0))) var fromPoints: Array[Vector2] = [fromCenter] var toPoints: Array[Vector2] = [toCenter] for slotValue in from.get("slots", []): var slot := slotValue as Dictionary fromPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0)))) for slotValue in to.get("slots", []): var slot := slotValue as Dictionary toPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0)))) for fromPoint in fromPoints: _validate_clear_segment(mapScene, fromPoint, toCenter, "%s -> %s" % [from.get("id", ""), to.get("id", "")]) for toPoint in toPoints: _validate_clear_segment(mapScene, fromCenter, toPoint, "%s -> %s" % [from.get("id", ""), to.get("id", "")]) mapScene.queue_free() await process_frame func _validate_clear_segment(mapScene: Node2D, from: Vector2, to: Vector2, label: String) -> void: var samples := maxi(1, int(ceil(from.distance_to(to) / ROUTE_SAMPLE_DISTANCE))) for sampleIndex in range(samples + 1): var point := from.lerp(to, float(sampleIndex) / float(samples)) if not _static_colliders_at(mapScene, point).is_empty(): _failures.append("route intersects static collision: %s at %s" % [label, point]) return func _validate_clear_point(mapScene: Node2D, point: Vector2, label: String) -> void: var colliders := _static_colliders_at(mapScene, point) if not colliders.is_empty(): _failures.append("slot intersects static collision: %s at %s (%s)" % [label, point, ", ".join(colliders)]) func _static_colliders_at(mapScene: Node2D, point: Vector2) -> Array[String]: var shape := RectangleShape2D.new() shape.size = MAX_NPC_FOOTPRINT var query := PhysicsShapeQueryParameters2D.new() query.shape = shape query.transform = Transform2D(0.0, point) query.collision_mask = 1 query.collide_with_areas = false query.collide_with_bodies = true var colliders: Array[String] = [] for hit in mapScene.get_world_2d().direct_space_state.intersect_shape(query, 64): var collider := hit.get("collider") as CollisionObject2D if collider is StaticBody2D and not colliders.has(str(collider.get_path())): colliders.append(str(collider.get_path())) return colliders