extends RefCounted ## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints..local.json)。 ## ## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense ## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED, ## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。 const Config = preload("res://testing/playable_config.gd") const SCHEMA_VERSION := 1 const KINDS := ["flat", "slope", "dense", "warp"] const AUTO_KINDS := ["flat", "slope", "dense"] const STATUSES := ["confirmed", "unconfirmed"] const MAP_CELL_CM := 25600.0 const DEFAULT_YAW_DEG := 45.0 ## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。 const AUTO_EDGE_MARGIN_M := 12.0 const AUTO_SEPARATION_M := 20.0 const AUTO_TREE_CLEARANCE_M := 3.0 const AUTO_DENSITY_RADIUS_M := 12.0 const AUTO_MAX_SLOPE_RATIO := 0.6 static func load_file(path: String) -> Dictionary: var result := {"ok": false, "data": {}, "errors": []} if path.strip_edges().is_empty(): result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败 return result if not FileAccess.file_exists(path): result.errors.append("viewpoint file does not exist: %s" % path) return result var file := FileAccess.open(path, FileAccess.READ) var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null if not (parsed is Dictionary): result.errors.append("viewpoint file root must be a JSON object") return result result.data = parsed result.ok = true return result ## bounds = {"base": Vector2 cm, "size": Vector2i tiles}(GameScene._map_bounds 的结果)。 ## 返回 {errors, viewpoints, missing_kinds, races};viewpoints 带 local_m(地图本地米)。 static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary: var errors: Array[String] = [] var viewpoints: Array = [] var races: Array = [] var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races} if not data.is_empty(): if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION: errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION) if not (data.get("maps", null) is Dictionary): errors.append("viewpoint maps must be an object keyed by map_key") Config._reject_secrets(data, "", errors) var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {} var entry: Variant = maps.get(map_key, {}) if not (entry is Dictionary): errors.append("maps.%s must be an object" % map_key) entry = {} var seen := {} var listed: Variant = entry.get("viewpoints", []) if not (listed is Array): errors.append("maps.%s.viewpoints must be an array" % map_key) listed = [] for i in listed.size(): var where := "maps.%s.viewpoints[%d]" % [map_key, i] var item: Variant = listed[i] if not (item is Dictionary): errors.append("%s must be an object" % where) continue var id := String(item.get("id", "")).strip_edges() if id.is_empty() or not id.is_valid_filename() or " " in id: errors.append("%s.id must be a non-empty file-safe name" % where) elif seen.has(id): errors.append("%s.id duplicates %s" % [where, id]) seen[id] = true var kind := String(item.get("kind", "")) if not (kind in KINDS): errors.append("%s.kind must be one of %s" % [where, KINDS]) var status := String(item.get("status", "")) var confirmed_by := String(item.get("confirmed_by", "")).strip_edges() if not (status in STATUSES): errors.append("%s.status must be confirmed or unconfirmed" % where) elif status == "confirmed" and confirmed_by.is_empty(): errors.append("%s is confirmed but confirmed_by (role) is empty" % where) elif status == "unconfirmed" and not confirmed_by.is_empty(): errors.append("%s is unconfirmed but carries confirmed_by" % where) var cm: Variant = item.get("server_cm", null) var server_cm := Vector2(-1, -1) if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \ or int(cm[0]) < 0 or int(cm[1]) < 0: errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where) else: server_cm = Vector2(int(cm[0]), int(cm[1])) if not contains_server_cm(bounds, server_cm): errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key]) var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG) if not (yaw is float or yaw is int): errors.append("%s.camera_yaw_deg must be a number" % where) yaw = DEFAULT_YAW_DEG viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config", "confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm, "local_m": to_local_m(bounds, server_cm)}) var race_list: Variant = entry.get("races", []) if not (race_list is Array): errors.append("maps.%s.races must be an array of mob vnums" % map_key) else: for value in race_list: if not _is_int(value) or int(value) <= 0: errors.append("maps.%s.races must contain positive integers" % map_key) break races.append(int(value)) for kind in KINDS: if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind): out.missing_kinds.append(kind) return out static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool: if bounds.is_empty(): return false var base: Vector2 = bounds.base var size: Vector2i = bounds.size return server_cm.x >= base.x and server_cm.y >= base.y \ and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM ## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。 static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2: var base: Vector2 = bounds.get("base", Vector2.ZERO) return (server_cm - base) * 0.01 static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2: var base: Vector2 = bounds.get("base", Vector2.ZERO) return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y)) ## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}] ## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。 static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array: var usable: Array = [] for s: Dictionary in samples: var p: Vector2 = s.p if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \ or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M: continue var nearest := INF var density := 0 for t in trees: var d := t.distance_to(p) nearest = minf(nearest, d) if d <= AUTO_DENSITY_RADIUS_M: density += 1 if nearest < AUTO_TREE_CLEARANCE_M: continue var slope := float(s.range_m) / maxf(float(s.span_m), 0.001) usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density}) var chosen: Array = [] var centre := size_m * 0.5 for kind in ["flat", "dense", "slope"]: if not (kind in kinds): continue var best: Dictionary = {} for u: Dictionary in usable: if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M): continue if best.is_empty() or _better(kind, u, best, centre): best = u if best.is_empty(): continue if kind == "slope" and float(best.slope_ratio) <= 0.0: continue if kind == "dense" and int(best.density) == 0: continue var c := best.duplicate() c["kind"] = kind chosen.append(c) return chosen static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool: match kind: "flat": if not is_equal_approx(float(a.range_m), float(b.range_m)): return float(a.range_m) < float(b.range_m) return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre) "slope": var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO if a_ok != b_ok: return a_ok return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio) "dense": if int(a.density) != int(b.density): return int(a.density) > int(b.density) return float(a.range_m) < float(b.range_m) return false static func _is_int(value: Variant) -> bool: return value is int or (value is float and is_equal_approx(value, roundf(value)))