Files
mtgodot-poc/project/net_world.gd
T
shenandClaude Sonnet 5 47baf6c0c6 Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
2026-08-31 20:02:12 +09:00

518 lines
18 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.
# NetWorld —— 把 M2Client 的网络实体镜像成 Godot 场景节点。
#
# 消费 M2Client 的 entity_spawned / entity_despawned / entity_moved /
# entity_main_set / chat 信号:每个 vid 一个子节点,位置向 client.get_entity(vid).pos
# 平滑逼近,rotation.y 取自 angle_deg,动画状态取自 funcFUNC_*)。
#
# 默认生成「占位胶囊 + 名字牌」(快、无依赖,先跑通同步/插值/状态循环)。
# 要换成真模型:set_model_factory(func(entity_dict) -> Node3D),返回的节点若有
# set_anim_state(name:String) 方法就会被驱动。
#
# 用法:
# var nw := preload("res://net_world.gd").new()
# add_child(nw)
# nw.setup(m2client, self) # self = 实体挂载父节点(Node3D)
# nw.world = metin2_world # 可选:贴地
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
extends Node
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
signal main_entity_ready(node: Node3D, vid: int)
signal chat_line(type: int, vid: int, text: String)
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
signal fishing_event(vid: int, subheader: int, dir: int)
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
# CInstanceBase::FUNC_*
const FUNC_WAIT := 0
const FUNC_MOVE := 1
const FUNC_ATTACK := 2
const FUNC_COMBO := 3
const FUNC_MOB_SKILL := 4
const FUNC_EMOTION := 5
const FUNC_SKILL := 0x80
# EDamageFlag
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
var client: Node # M2Client
var parent: Node3D # 实体挂这下面
var world: Node # Metin2World(可空,用 sample_height 贴地)
var follow_lerp := 14.0 # 位置逼近速度
var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳)
var _by_vid := {} # vid:int -> Node3D
var _main_vid := 0
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
var _model_factory: Callable # func(Dictionary) -> Node3D
var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
func setup(m2client: Node, mount: Node3D) -> void:
client = m2client
parent = mount
client.entity_spawned.connect(_on_spawn)
client.entity_despawned.connect(_on_despawn)
client.entity_main_set.connect(_on_main_set)
if client.has_signal("entity_info"):
client.entity_info.connect(_on_info)
client.chat.connect(func(t, v, s):
chat_line.emit(t, v, s)
if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡
_bubble(int(v), String(s)))
# entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。
client.entity_moved.connect(_on_moved)
client.vitals_changed.connect(_on_vitals)
client.entity_dead.connect(_on_dead)
client.damage.connect(_on_damage)
if client.has_signal("dig_motion"):
client.dig_motion.connect(_on_dig_motion)
if client.has_signal("fly_cue"):
client.fly_cue.connect(_on_fly)
if client.has_signal("fly_targeting"):
client.fly_targeting.connect(_on_fly_targeting)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
if client.has_signal("pvp_changed"):
client.pvp_changed.connect(_on_pvp_changed)
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
func catch_up() -> void:
if client == null or not client.has_method("get_entities"):
return
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid != 0 and not _by_vid.has(vid) and vid != _local_vid:
_on_spawn(e)
if client.has_method("get_pvp_relations"):
for relation in client.get_pvp_relations():
_on_pvp_changed(int(relation.get("src_vid", 0)), int(relation.get("dst_vid", 0)),
int(relation.get("mode", 0)))
func set_model_factory(f: Callable) -> void:
_model_factory = f
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
func set_local_vid(vid: int) -> void:
_local_vid = vid
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
n.queue_free()
func node_for(vid: int) -> Node3D:
return _by_vid.get(vid, null)
func main_node() -> Node3D:
return _by_vid.get(_main_vid, null)
# ---------------------------------------------------------------------------
func _on_spawn(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0 or _by_vid.has(vid) or vid == _local_vid:
return
var node: Node3D = null
if _model_factory.is_valid():
node = _model_factory.call(d)
if node == null:
node = _make_placeholder(d)
elif not node.has_node("Label3D"):
_attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条
node.name = "e_%d" % vid
parent.add_child(node)
node.position = _grounded(d.get("pos", Vector3.ZERO))
node.set_meta("vid", vid)
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
_by_vid[vid] = node
_refresh_pvp_tag(vid)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
func _on_info(vid: int, d: Dictionary) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
var nm := _display_name(d)
if nm != "":
if n.has_node("Label3D"):
(n.get_node("Label3D") as Label3D).text = nm
elif n.has_method("set_display_name"):
n.set_display_name(nm)
func _display_name(d: Dictionary) -> String:
if name_resolver.is_valid():
var r: String = name_resolver.call(d)
if r != "":
return r
return str(d.get("name", ""))
func _on_despawn(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
n.queue_free()
entity_removed.emit(vid)
func _on_main_set(vid: int) -> void:
_main_vid = vid
var n: Node3D = _by_vid.get(vid, null)
if n:
main_entity_ready.emit(n, vid)
func _on_moved(_vid: int) -> void:
pass # 插值在 _process 里统一做
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
if src_vid == 0 or dst_vid == 0:
return
var key := _pvp_key(src_vid, dst_vid)
if mode == 0:
_pvp_relations.erase(key)
else:
_pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode}
_refresh_pvp_tag(src_vid)
_refresh_pvp_tag(dst_vid)
func _pvp_key(a: int, b: int) -> String:
return "%d:%d" % [min(a, b), max(a, b)]
func _pvp_mode_for(vid: int) -> int:
for relation in _pvp_relations.values():
if int(relation.get("src_vid", 0)) == vid or int(relation.get("dst_vid", 0)) == vid:
return int(relation.get("mode", 0))
return 0
func _refresh_pvp_tag(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
var mode := _pvp_mode_for(vid)
var tag := n.get_node_or_null("PvpTag") as Label3D
if mode == 0:
if tag:
tag.queue_free()
return
if tag == null:
tag = Label3D.new()
tag.name = "PvpTag"
tag.position.y = 2.7
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.0045
n.add_child(tag)
match mode:
1:
tag.text = "挑战"
tag.modulate = Color(1.0, 0.85, 0.25)
2:
tag.text = "PVP"
tag.modulate = Color(1.0, 0.25, 0.25)
3:
tag.text = "复仇"
tag.modulate = Color(1.0, 0.45, 0.1)
_:
tag.text = "PVP"
tag.modulate = Color.WHITE
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。
signal main_bubble(text: String)
func _bubble(vid: int, text: String) -> void:
if vid == _local_vid or vid == 0:
main_bubble.emit(text)
return
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
var old := n.get_node_or_null("bubble")
if old:
old.queue_free()
var lbl := Label3D.new()
lbl.name = "bubble"
lbl.text = text
lbl.position.y = 2.35
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.0055
lbl.modulate = Color(1, 1, 1)
lbl.outline_size = 6
n.add_child(lbl)
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
tw.tween_callback(lbl.queue_free)
func _on_vitals(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var hp := int(e.get("hp", 0))
var max_hp := int(e.get("max_hp", 0))
var dead := bool(e.get("dead", false))
vitals.emit(vid, hp, max_hp, dead)
if n and n.has_node("HpBar") and max_hp > 0:
var bar := n.get_node("HpBar") as Sprite3D
bar.scale.x = clampf(float(hp) / float(max_hp), 0.0, 1.0)
bar.modulate = Color(0.9, 0.2, 0.2) if not dead else Color(0.3, 0.3, 0.3)
func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除)
var t := create_tween()
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
if n.has_method("set_anim_state"):
n.call("set_anim_state", "dead")
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以
# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
var a: Vector3 = _entity_pos(start_vid)
var b: Vector3 = _entity_pos(end_vid)
var shooter: Node3D = _by_vid.get(start_vid, null)
if b == Vector3.INF and shooter:
var target: Dictionary = {}
var queued: Variant = shooter.get_meta("fly_target_queue", [])
if queued is Array and not queued.is_empty():
target = queued.pop_front()
shooter.set_meta("fly_target_queue", queued)
var target_vid := int(target.get("vid", 0))
if target_vid != 0:
b = _entity_pos(target_vid)
else:
var target_cm: Variant = target.get("cm", null)
if target_cm is Vector2:
b = _server_cm_to_world(target_cm)
if a == Vector3.INF or b == Vector3.INF or parent == null:
return
var proj := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 0.08
sm.height = 0.16
proj.mesh = sm
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.9, 0.4)
mat.emission_enabled = true
mat.emission = Color(0.9, 0.7, 0.2)
proj.material_override = mat
parent.add_child(proj)
proj.global_position = a + Vector3(0, 1.0, 0)
var dur := maxf(0.15, a.distance_to(b) / 40.0)
var tw := create_tween()
tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur)
tw.tween_callback(proj.queue_free)
# GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on
# the shooter node so a later projectile cue can use it even before a target
# entity exists (target VID 0 carries an explicit coordinate).
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
var shooter: Node3D = _by_vid.get(shooter_vid, null)
if shooter:
var queued: Array = []
var previous: Variant = shooter.get_meta("fly_target_queue", [])
if append and previous is Array:
queued = previous
queued.append({"vid": target_vid, "cm": target_cm})
shooter.set_meta("fly_target_queue", queued)
shooter.set_meta("fly_target_vid", target_vid)
shooter.set_meta("fly_target_cm", target_cm)
shooter.set_meta("fly_target_append", append)
fly_targeting.emit(shooter_vid, target_vid, target_cm, append)
func _server_cm_to_world(cm: Vector2) -> Vector3:
var p := MapCoord.to_world(Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01))
if world and world.has_method("sample_height"):
p.y = float(world.call("sample_height", p.x, p.z)) + 0.6
else:
p.y = 0.6
return p
# 实体世界坐标(Godot 米);找不到返回 Vector3.INF
func _entity_pos(vid: int) -> Vector3:
var e: Dictionary = client.get_entity(vid) if client else {}
if not e.is_empty() and e.get("pos", null) is Vector3:
return MapCoord.to_world(e["pos"])
var n = _by_vid.get(vid, null)
if is_instance_valid(n):
return (n as Node3D).global_position
return Vector3.INF
func _on_dig_motion(vid: int, target_vid: int, count: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
n.set_meta("dig_count", count)
n.set_meta("dig_target_vid", target_vid)
var target: Node3D = _by_vid.get(target_vid, null)
if target and target != n:
var flat := target.global_position
flat.y = n.global_position.y
if n.global_position.distance_squared_to(flat) > 0.001:
n.look_at(flat, Vector3.UP)
if n.has_method("set_anim_state"):
n.call("set_anim_state", "dig")
# GC_FISHING: `info` is the fisher VID for motion subheaders. Keep the
# authoritative direction and state on the entity; real PlayerView/MobView
# assets may provide matching fishing animations, while placeholders still
# expose the event to gameplay/UI code.
func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
# FISH carries an item vnum in `info`, not a fisher VID; leave it to the
# fishing UI / inventory event consumer.
if info == 0 or subheader == 5:
return
var n: Node3D = _by_vid.get(info, null)
if n == null:
return
n.set_meta("fishing_subheader", subheader)
n.set_meta("fishing_dir", dir)
var state := "fishing"
match subheader:
1: state = "wait" # STOP
2: state = "fishing_react" # REACT
3: state = "fishing_catch" # SUCCESS
4: state = "fishing_fail" # FAIL
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
fishing_event.emit(info, subheader, dir)
func _on_damage(vid: int, amount: int, flag: int) -> void:
var crit := (flag & DAMAGE_CRITICAL) != 0
var dodge := (flag & DAMAGE_DODGE) != 0
damage_number.emit(vid, amount, crit, dodge)
var n: Node3D = _by_vid.get(vid, null)
if n == null or parent == null:
return
var lbl := Label3D.new()
lbl.text = "MISS" if dodge else str(amount)
lbl.modulate = Color(1.0, 0.85, 0.2) if crit else Color(1, 1, 1)
lbl.font_size = 48 if crit else 32
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.007
parent.add_child(lbl)
lbl.global_position = n.global_position + Vector3(randf_range(-0.3, 0.3), 1.9, 0)
var tw := create_tween()
tw.set_parallel(true)
tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8)
tw.tween_property(lbl, "modulate:a", 0.0, 0.8).set_delay(0.3)
tw.chain().tween_callback(lbl.queue_free)
func _process(dt: float) -> void:
if client == null or parent == null:
return
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
_by_vid.erase(vid)
continue
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
continue
var want := _grounded(e.get("pos", n.position))
if n.position.distance_to(want) > snap_dist:
n.position = want
else:
n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", 1))
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
or walk_mode != int(n.get_meta("walk_mode", -1)):
n.set_meta("func", f)
n.set_meta("moving", moving)
n.set_meta("walk_mode", walk_mode)
_apply_anim(n, f, moving, walk_mode)
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
var state := "wait"
if f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == 0 else "run"
elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
state = "skill"
elif f == FUNC_EMOTION:
state = "emotion"
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
elif n.has_node("Label3D"):
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
func _state_tint(s: String) -> Color:
match s:
"walk": return Color(0.7, 0.9, 1.0)
"run": return Color(0.6, 1.0, 0.6)
"attack": return Color(1.0, 0.5, 0.4)
"skill": return Color(1.0, 0.8, 0.3)
_: return Color(1, 1, 1)
func _grounded(p: Variant) -> Vector3:
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO
if world and world.has_method("sample_height"):
v.y = float(world.call("sample_height", v.x, v.z))
return v
func _make_placeholder(d: Dictionary) -> Node3D:
var root := Node3D.new()
var mesh := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.35
cap.height = 1.8
mesh.mesh = cap
mesh.position.y = 0.9
var mat := StandardMaterial3D.new()
var is_main := bool(d.get("is_main", false))
mat.albedo_color = Color(0.35, 0.6, 1.0) if is_main else Color(0.85, 0.4, 0.35)
mesh.material_override = mat
root.add_child(mesh)
_attach_nameplate(root, d)
return root
# 头顶名字 Label3D + HP 条 Sprite3D(占位胶囊和真模型共用)。
func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
var tag := Label3D.new()
tag.name = "Label3D"
var nm := _display_name(d)
tag.text = nm if nm != "" else "vid %d" % int(d.get("vid", 0))
tag.position.y = 2.1
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.006
root.add_child(tag)
var bar := Sprite3D.new()
bar.name = "HpBar"
bar.texture = _white_1px()
bar.pixel_size = 0.01 # 100px -> 1m
bar.position.y = 1.95
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
bar.no_depth_test = true
bar.modulate = Color(0.9, 0.2, 0.2)
bar.scale.x = 1.0
root.add_child(bar)
static var _white_tex: Texture2D
static func _white_1px() -> Texture2D:
if _white_tex == null:
var img := Image.create(100, 6, false, Image.FORMAT_RGBA8)
img.fill(Color.WHITE)
_white_tex = ImageTexture.create_from_image(img)
return _white_tex