- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
195 lines
6.2 KiB
GDScript
195 lines
6.2 KiB
GDScript
extends SceneTree
|
|
|
|
const NetWorld = preload("res://net_world.gd")
|
|
const GroundItems = preload("res://ui/ground_items.gd")
|
|
const PlayerController = preload("res://player_controller.gd")
|
|
|
|
class MockClient extends Node:
|
|
signal entity_spawned(e: Dictionary)
|
|
signal entity_despawned(vid: int)
|
|
signal entity_dead(vid: int)
|
|
signal entity_moved(vid: int)
|
|
signal entity_main_set(vid: int)
|
|
signal vitals_updated(vid: int)
|
|
signal vitals_changed(vid: int)
|
|
signal warp_requested(x: float, y: float)
|
|
signal ground_item_added(item: Dictionary)
|
|
signal ground_item_removed(vid: int)
|
|
signal chat(mode: int, text: String)
|
|
signal chat_bubble(vid: int, text: String)
|
|
signal damage(target_vid: int, attacker_vid: int, damage_val: int, flag: int)
|
|
signal whisper_received(author: String, text: String)
|
|
|
|
var _main_vid := 1
|
|
var picked_vids: Array[int] = []
|
|
|
|
func get_main_vid() -> int:
|
|
return _main_vid
|
|
|
|
func get_entity(vid: int) -> Dictionary:
|
|
return {"vid": vid, "pos": Vector3.ZERO, "race": 101, "name": "Wild Dog", "hp": 100, "max_hp": 100}
|
|
|
|
func get_ground_items() -> Array:
|
|
return []
|
|
|
|
func pickup_item(vid: int) -> bool:
|
|
picked_vids.append(vid)
|
|
return true
|
|
|
|
class MockWorld extends Node:
|
|
func sample_height(x: float, z: float) -> float:
|
|
return 5.5
|
|
|
|
func _init() -> void:
|
|
print("--- Running mob_death_drop_test ---")
|
|
test_mob_death_and_despawn()
|
|
test_player_controller_ignores_dead()
|
|
test_ground_items_coordinate_mapping()
|
|
print("mob_death_drop_test passed with 0 failures!")
|
|
quit(0)
|
|
|
|
func test_mob_death_and_despawn() -> void:
|
|
var client = MockClient.new()
|
|
var mount = Node3D.new()
|
|
root.add_child(mount)
|
|
|
|
var net_world = NetWorld.new()
|
|
root.add_child(net_world)
|
|
net_world.setup(client, mount)
|
|
|
|
# Spawn a mob with tags
|
|
var mob_vid := 100
|
|
var mob_node := Node3D.new()
|
|
mob_node.name = "entity_%d" % mob_vid
|
|
var name_tag := Label3D.new()
|
|
name_tag.name = "NameTag"
|
|
name_tag.text = "Wild Dog"
|
|
mob_node.add_child(name_tag)
|
|
var shadow := Decal.new()
|
|
shadow.name = "Shadow"
|
|
mob_node.add_child(shadow)
|
|
mount.add_child(mob_node)
|
|
|
|
net_world._by_vid[mob_vid] = mob_node
|
|
net_world._make_hp_bar(mob_vid, {"hp": 100, "max_hp": 100, "name": "Wild Dog"})
|
|
net_world.set_target_vid(mob_vid)
|
|
net_world.set_hover_vid(mob_vid)
|
|
|
|
assert(net_world._hp_bars.has(mob_vid), "HP bar should exist initially")
|
|
assert(net_world._target_vid == mob_vid, "Target should be mob_vid")
|
|
assert(net_world._hover_vid == mob_vid, "Hover should be mob_vid")
|
|
assert(name_tag.visible == true, "NameTag should be visible initially")
|
|
|
|
# Trigger death
|
|
net_world._on_dead(mob_vid)
|
|
|
|
assert(not net_world._hp_bars.has(mob_vid), "HP bar should be removed immediately on death")
|
|
assert(name_tag.visible == false, "NameTag should be detached/hidden on death")
|
|
assert(shadow.visible == false, "Shadow should be hidden on death")
|
|
assert(bool(mob_node.get_meta("dead", false)) == true, "Mob should have dead meta")
|
|
assert(net_world._target_vid == 0, "Target should be cleared on death")
|
|
assert(net_world._hover_vid == 0, "Hover should be cleared on death")
|
|
|
|
# Trigger despawn
|
|
net_world._on_despawn(mob_vid)
|
|
assert(not net_world._by_vid.has(mob_vid), "Mob should be erased from _by_vid on despawn")
|
|
print("OK: Mob death detach & despawn lifecycle verified")
|
|
|
|
net_world.queue_free()
|
|
mount.queue_free()
|
|
client.queue_free()
|
|
|
|
func test_player_controller_ignores_dead() -> void:
|
|
var pc = PlayerController.new()
|
|
var player = Node3D.new()
|
|
player.position = Vector3(0, 0, 0)
|
|
root.add_child(player)
|
|
pc.player = player
|
|
|
|
var dead_mob = Node3D.new()
|
|
dead_mob.position = Vector3(0, 0, 2)
|
|
dead_mob.set_meta("dead", true)
|
|
root.add_child(dead_mob)
|
|
|
|
pc.pickables.clear()
|
|
pc.pickables.append(dead_mob)
|
|
|
|
# Test collision check (line 317)
|
|
var blocked: bool = pc._actor_blocked(Vector3(0, 0, 1.8))
|
|
assert(not blocked, "Dead mob should not block player movement")
|
|
|
|
print("OK: PlayerController ignores dead mobs for collision and picking")
|
|
pc.queue_free()
|
|
player.queue_free()
|
|
dead_mob.queue_free()
|
|
|
|
func test_ground_items_coordinate_mapping() -> void:
|
|
# Set map base position (e.g. Map 1: 400000 cm X, 300000 cm Y)
|
|
MapCoord.set_base(Vector2(400000, 300000))
|
|
|
|
var client = MockClient.new()
|
|
var mount = Node3D.new()
|
|
root.add_child(mount)
|
|
|
|
var world = MockWorld.new()
|
|
root.add_child(world)
|
|
|
|
var player = Node3D.new()
|
|
player.position = Vector3(100, 5.5, 200) # Player is at world (100, 5.5, 200)
|
|
root.add_child(player)
|
|
|
|
var gi = GroundItems.new()
|
|
root.add_child(gi)
|
|
|
|
gi.setup(client, mount, func() -> Node: return player, null, null,
|
|
Callable(), "", func() -> Node: return world)
|
|
|
|
# Net position from server:
|
|
# Metin2 global: X = 410000 cm, Y = 320000 cm (local world should be: X = 100m, Z = 200m)
|
|
# M2Client position_to_godot produces:
|
|
# net.x = 4100.0, net.y = 0.0, net.z = -3200.0
|
|
var net_drop_pos := Vector3(4100.0, 0.0, -3200.0)
|
|
var drop_vid := 9999
|
|
var drop_dict := {
|
|
"vid": drop_vid,
|
|
"vnum": 1, # Yang / Gold
|
|
"owner": "Hero",
|
|
"pos": net_drop_pos
|
|
}
|
|
|
|
gi._on_added(drop_dict)
|
|
|
|
assert(gi._by_vid.has(drop_vid), "Ground item should be registered in _by_vid")
|
|
var drop_node: Node3D = gi._by_vid[drop_vid]
|
|
assert(drop_node != null, "Drop node should exist")
|
|
|
|
# Check world coordinate translation
|
|
assert(is_equal_approx(drop_node.position.x, 100.0), "X should be translated to 100m")
|
|
assert(is_equal_approx(drop_node.position.z, 200.0), "Z should be translated to 200m")
|
|
# Check terrain height snapping (+0.1m above 5.5m = 5.6m)
|
|
assert(is_equal_approx(drop_node.position.y, 5.6), "Y should be snapped to terrain height + 0.1m")
|
|
|
|
# Check item name for vnum 1
|
|
var tag := drop_node.get_node("tag") as Label3D
|
|
assert(tag != null and tag.text == "Yang", "Vnum 1 name should be Yang")
|
|
|
|
# Test pickup with player near (distance = 0.1m < PICKUP_RANGE 3.0m)
|
|
var picked_vid := gi.try_pickup()
|
|
assert(picked_vid == drop_vid, "try_pickup should pick nearby item")
|
|
assert(client.picked_vids.has(drop_vid), "client.pickup_item should be called")
|
|
|
|
# Test remove
|
|
gi._on_removed(drop_vid)
|
|
assert(not gi._by_vid.has(drop_vid), "Item should be removed from _by_vid")
|
|
|
|
print("OK: Ground items coordinate mapping, height snapping, Yang naming, and pickup verified")
|
|
|
|
# Reset MapCoord base
|
|
MapCoord.set_base(Vector2.ZERO)
|
|
|
|
gi.queue_free()
|
|
world.queue_free()
|
|
player.queue_free()
|
|
mount.queue_free()
|
|
client.queue_free()
|