Files
mtgodot-poc/project/testing/playable_probe.gd
T

248 lines
10 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)