class_name PlayableProbe extends Node ## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入, ## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。 ## ## 每条观察都带 source: ## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调) ## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据 ## ## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次 ## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch ## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。 signal observed(kind: String, payload: Dictionary) const SERVER_SIGNALS := { "entered_game": "_on_entered_game", "disconnected": "_on_disconnected", "login_failed": "_on_login_failed", "char_list": "_on_char_list", "entity_main_set": "_on_entity_main_set", "entity_spawned": "_on_entity_spawned", "entity_despawned": "_on_entity_despawned", "entity_moved": "_on_entity_moved", "entity_dead": "_on_entity_dead", "damage": "_on_damage", "motion": "_on_motion", "vitals_changed": "_on_vitals", "points_changed": "_on_points_changed", "target_info": "_on_target_info", "ground_item_added": "_on_ground_item_added", "ground_item_removed": "_on_ground_item_removed", "item_picked_up": "_on_item_picked_up", "inventory_changed": "_on_inventory_changed", "affect_added": "_on_affect_added", "effect_cue": "_on_effect_cue", "fly_cue": "_on_fly_cue", "fly_targeting": "_on_fly_targeting", } var client: Object var flow: Object var connection_epoch := 1 var _entered_in_epoch := false var _connections: Array[Dictionary] = [] var _last_sp := -1 func setup(app_flow: Object, m2client: Object) -> void: flow = app_flow client = m2client if client == null: return for signal_name in SERVER_SIGNALS: _bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name])) ## Subscribe to a local presentation object (quickbar / skill fx). Its events ## are tagged source=local and never satisfy a server-evidence requirement. func watch_local(source: Object, signal_name: String, kind: String) -> void: if source == null or not source.has_signal(signal_name): return var callback := func(a: Variant = null, b: Variant = null, c: Variant = null, d: Variant = null, e: Variant = null) -> void: _emit_local(kind, [a, b, c, d, e]) _bind(source, signal_name, callback) func snapshot() -> Dictionary: var alive := client != null and is_instance_valid(client) var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game() var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0 var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0 var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0 var flow_alive := flow != null and is_instance_valid(flow) var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1 return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count, "ground_item_count": ground_count, "app_state": state} func disconnect_all() -> void: for entry in _connections: var source: Object = entry.source.get_ref() var signal_name: String = entry.signal var callback: Callable = entry.callback if source != null and is_instance_valid(source) and source.has_signal(signal_name) \ and source.is_connected(signal_name, callback): source.disconnect(signal_name, callback) _connections.clear() func bound_count() -> int: _prune_connections() return _connections.size() func _prune_connections() -> void: for i in range(_connections.size() - 1, -1, -1): if _connections[i].source.get_ref() == null: _connections.remove_at(i) func _exit_tree() -> void: disconnect_all() func _bind(source: Object, signal_name: String, callback: Callable) -> void: # Registries are RefCounted and own parsed effect caches. Observing a scene # must not keep it alive after reconnect; discard dead bindings on rebind. _prune_connections() if source.has_signal(signal_name) and not source.is_connected(signal_name, callback): source.connect(signal_name, callback) _connections.append({"source": weakref(source), "signal": signal_name, "callback": callback}) func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void: var data: Dictionary = payload.duplicate(true) data["source"] = "server" data["connection_epoch"] = connection_epoch data["actor_vid"] = actor_vid data["target_vid"] = target_vid data["received_us"] = Time.get_ticks_usec() observed.emit(kind, data) func _emit_local(kind: String, args: Array) -> void: var data := {"source": "local", "connection_epoch": connection_epoch, "actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()} match kind: "cast_started": data["skill_id"] = int(args[0]) if args[0] != null else 0 data["target_vid"] = int(args[1]) if args[1] != null else 0 "skill_rejected": data["skill_id"] = int(args[0]) if args[0] != null else 0 data["code"] = String(args[1]) if args[1] != null else "" "fx_spawned", "fx_finished": # EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]). data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else "" data["fx_id"] = int(args[1]) if args[1] != null else 0 data["lifetime_ms"] = int(args[2]) if args[2] != null else -1 if kind == "fx_finished": data["elapsed_ms"] = int(args[3]) if args[3] != null else -1 data["reason"] = String(args[4]) if args[4] != null else "" _: if args[0] is int: data["skill_id"] = args[0] observed.emit(kind, data) func _on_entered_game() -> void: if _entered_in_epoch: connection_epoch += 1 _entered_in_epoch = true _last_sp = -1 _emit("entered_game") func _on_disconnected(reason: String) -> void: _emit("disconnected", {"reason": reason.left(120)}) connection_epoch += 1 _entered_in_epoch = false _last_sp = -1 func _on_login_failed(reason: String) -> void: _emit("login_failed", {"reason": reason.left(120)}) func _on_char_list(chars: Array) -> void: var slots: Array = [] for character in chars: if character is Dictionary: slots.append(int(character.get("index", -1))) _emit("char_list", {"count": chars.size(), "slots": slots}) func _on_entity_main_set(vid: int) -> void: _emit("entity_main_set", {}, vid) func _on_entity_spawned(entity: Dictionary) -> void: _emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)), "dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)}, int(entity.get("vid", 0))) func _on_entity_despawned(vid: int) -> void: _emit("entity_despawned", {}, vid) func _on_entity_moved(vid: int) -> void: var pos: Variant = Vector3.ZERO if client != null and client.has_method("get_entity"): pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO) _emit("entity_moved", {"pos_cm": pos}, vid) func _on_entity_dead(vid: int) -> void: _emit("entity_dead", {}, 0, vid) func _on_damage(vid: int, amount: int, flag: int) -> void: _emit("damage", {"amount": amount, "flag": flag}, 0, vid) func _on_motion(vid: int, victim_vid: int, motion: int) -> void: _emit("server_motion", {"motion": motion}, vid, victim_vid) func _on_vitals(vid: int) -> void: var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {} _emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)), "dead": bool(entity.get("dead", false))}, vid) func _on_points_changed(points: Dictionary) -> void: # points_changed carries the main character's point table. Only a change in # SP is interesting as skill-resource evidence. var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1 if sp >= 0 and _last_sp >= 0 and sp != _last_sp: _emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp}) if sp >= 0: _last_sp = sp func _on_target_info(vid: int, hp_percent: int) -> void: _emit("target_info", {"hp_percent": hp_percent}, 0, vid) func _on_ground_item_added(item: Dictionary) -> void: _emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)}) func _on_ground_item_removed(vid: int) -> void: _emit("ground_item_removed", {"iid": vid}) func _on_item_picked_up(vnum: int, count: int, source: String) -> void: # `source` may be a player name; keep only whether it was attributed at all. _emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()}) func _on_inventory_changed(window: int, cell: int) -> void: _emit("inventory_changed", {"window": window, "cell": cell}) func _on_affect_added(affect: Dictionary) -> void: _emit("affect_added", {"type": int(affect.get("type", 0))}) func _on_effect_cue(vid: int, name: String, special: int) -> void: _emit("effect_cue", {"effect": name.get_file(), "special": special}, vid) func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void: _emit("fly_cue", {"type": kind}, start_vid, end_vid) func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void: _emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid) ## Ownership is evaluated in-process so the owner's name never reaches a report: ## an empty owner is free-for-all, otherwise it must equal the main character. func _owner_matches(owner: String) -> bool: if owner.is_empty(): return true if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): return false var main: Dictionary = client.get_entity(int(client.get_main_vid())) return String(main.get("name", "")) == owner func _ground_pos_cm(item: Dictionary) -> Vector2: var pos: Variant = item.get("pos", null) if pos is Vector3: # M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm. return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0)) return Vector2(-1, -1)