- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
260 lines
8.1 KiB
GDScript
260 lines
8.1 KiB
GDScript
# mysterious_merchant_blackmarket_system.gd —— Metin2 40250 野外随机神秘游商与黑市折扣市场系统 1:1
|
||
# 对照 40250 服务端 shop.cpp, regen.cpp, char_item.cpp
|
||
class_name MysteriousMerchantBlackmarketSystem
|
||
extends RefCounted
|
||
|
||
signal merchant_appeared(map_name: String, pos: Vector2, duration_seconds: float)
|
||
signal merchant_departed()
|
||
signal item_bought(item_name: String, cost: int)
|
||
signal item_sold(item_name: String, earned: int)
|
||
signal stock_refreshed(stock_list: Array)
|
||
|
||
const NPC_VNUM := 20095 # 野外神秘游商 (Wandering Merchant)
|
||
const DEFAULT_DURATION := 1800.0 # 停留 30 分钟 (1800 秒)
|
||
const HIGH_PRICE_BUYBACK_MULTIPLIER := 1.3 # 高价回收 +30% 黄金溢价 (1.30x)
|
||
const ITEM_ANTIFLAG_SELL := 1 << 8 # 256 禁售标志位
|
||
|
||
# 3 大野外候选刷新点 (沙漠绿洲、雪山孤峰、烈焰核心)
|
||
const SPAWN_LOCATIONS: Array = [
|
||
{"map": "Desert", "name": "沙漠绿洲", "pos": Vector2(120, 350)},
|
||
{"map": "Mount_Sohan", "name": "雪山孤峰", "pos": Vector2(450, 680)},
|
||
{"map": "Doyyumhwan", "name": "烈焰核心", "pos": Vector2(600, 210)}
|
||
]
|
||
|
||
# 黑市半价珍品底库预设 (40250 官方核心珍宝)
|
||
const DEFAULT_BLACK_MARKET_POOL: Array = [
|
||
{
|
||
"vnum": 51501,
|
||
"name": "龙石炼金原石(巨龙之心)",
|
||
"base_price": 2000000,
|
||
"price": 1000000, # 5折半价
|
||
"stock": 5,
|
||
"count": 1,
|
||
"type": 16 # ITEM_METIN / 炼金
|
||
},
|
||
{
|
||
"vnum": 28430,
|
||
"name": "+4 灵石礼包",
|
||
"base_price": 5000000,
|
||
"price": 2500000, # 5折半价
|
||
"stock": 3,
|
||
"count": 1,
|
||
"type": 10 # ITEM_STONE
|
||
},
|
||
{
|
||
"vnum": 50300,
|
||
"name": "古代进阶技能秘籍",
|
||
"base_price": 3000000,
|
||
"price": 1500000, # 5折半价
|
||
"stock": 3,
|
||
"count": 1,
|
||
"type": 17 # ITEM_SKILLBOOK
|
||
},
|
||
{
|
||
"vnum": 25040,
|
||
"name": "洗炼祝福包(双份)",
|
||
"base_price": 1000000,
|
||
"price": 500000, # 5折半价
|
||
"stock": 10,
|
||
"count": 2,
|
||
"type": 3 # ITEM_USE
|
||
},
|
||
{
|
||
"vnum": 71084,
|
||
"name": "传说洗练神丹",
|
||
"base_price": 4000000,
|
||
"price": 2000000, # 5折半价
|
||
"stock": 2,
|
||
"count": 1,
|
||
"type": 3 # ITEM_USE
|
||
}
|
||
]
|
||
|
||
# 当前游商运行状态
|
||
var is_active: bool = false
|
||
var current_map: String = ""
|
||
var current_pos: Vector2 = Vector2.ZERO
|
||
var time_remaining: float = 0.0
|
||
var total_duration: float = DEFAULT_DURATION
|
||
var current_stock: Array = []
|
||
|
||
# 随机刷出野外神秘游商
|
||
func spawn_random_merchant(duration: float = DEFAULT_DURATION, loc_index: int = -1) -> Dictionary:
|
||
var loc_idx: int = loc_index
|
||
if loc_idx < 0 or loc_idx >= SPAWN_LOCATIONS.size():
|
||
loc_idx = randi() % SPAWN_LOCATIONS.size()
|
||
|
||
var chosen_loc: Dictionary = SPAWN_LOCATIONS[loc_idx]
|
||
return spawn_merchant(chosen_loc["map"], chosen_loc["pos"], duration)
|
||
|
||
# 指定地点召唤/刷出游商
|
||
func spawn_merchant(map_name: String, pos: Vector2, duration: float = DEFAULT_DURATION) -> Dictionary:
|
||
is_active = true
|
||
current_map = map_name
|
||
current_pos = pos
|
||
time_remaining = duration
|
||
total_duration = duration
|
||
|
||
refresh_stock()
|
||
|
||
merchant_appeared.emit(current_map, current_pos, duration)
|
||
return {
|
||
"ok": true,
|
||
"map": current_map,
|
||
"pos": current_pos,
|
||
"duration": duration,
|
||
"npc_vnum": NPC_VNUM
|
||
}
|
||
|
||
# 游商离去或收摊
|
||
func despawn_merchant() -> void:
|
||
if not is_active:
|
||
return
|
||
is_active = false
|
||
current_map = ""
|
||
current_pos = Vector2.ZERO
|
||
time_remaining = 0.0
|
||
current_stock.clear()
|
||
merchant_departed.emit()
|
||
|
||
# 物理帧或逻辑心跳更新
|
||
func update(delta: float) -> void:
|
||
if not is_active:
|
||
return
|
||
time_remaining -= delta
|
||
if time_remaining <= 0.0:
|
||
despawn_merchant()
|
||
|
||
# 重新生成黑市货架 (重置折扣和限量库存)
|
||
func refresh_stock() -> void:
|
||
current_stock.clear()
|
||
for item_template in DEFAULT_BLACK_MARKET_POOL:
|
||
var good: Dictionary = item_template.duplicate(true)
|
||
current_stock.append(good)
|
||
stock_refreshed.emit(current_stock)
|
||
|
||
# 获取当前商品清单
|
||
func get_stock() -> Array:
|
||
return current_stock.duplicate(true)
|
||
|
||
# 购买黑市商品
|
||
func buy_item(stock_idx: int, player_data: Dictionary, inventory: Array) -> Dictionary:
|
||
if not is_active:
|
||
return {"ok": false, "reason": "MERCHANT_NOT_ACTIVE", "msg": "神秘游商已离去或尚未现身!"}
|
||
|
||
if stock_idx < 0 or stock_idx >= current_stock.size():
|
||
return {"ok": false, "reason": "INVALID_STOCK_INDEX", "msg": "商品编号无效!"}
|
||
|
||
var good: Dictionary = current_stock[stock_idx]
|
||
var current_qty := int(good.get("stock", 0))
|
||
if current_qty <= 0:
|
||
return {"ok": false, "reason": "OUT_OF_STOCK", "msg": "该珍品黑市配额已售罄!"}
|
||
|
||
var price := int(good.get("price", 0))
|
||
var player_gold := int(player_data.get("gold", 0))
|
||
if player_gold < price:
|
||
return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足,无法购买黑市珍品!"}
|
||
|
||
# 寻找背包空槽
|
||
var target_slot := -1
|
||
for i in range(inventory.size()):
|
||
if inventory[i] == null or (inventory[i] is Dictionary and inventory[i].is_empty()):
|
||
target_slot = i
|
||
break
|
||
|
||
if target_slot == -1:
|
||
return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,请清理后再购买!"}
|
||
|
||
# 扣费与发货
|
||
player_data["gold"] = player_gold - price
|
||
good["stock"] = current_qty - 1
|
||
|
||
var bought_item: Dictionary = {
|
||
"vnum": good["vnum"],
|
||
"name": good["name"],
|
||
"count": good.get("count", 1),
|
||
"type": good.get("type", 0)
|
||
}
|
||
inventory[target_slot] = bought_item
|
||
|
||
item_bought.emit(good["name"], price)
|
||
|
||
return {
|
||
"ok": true,
|
||
"slot": target_slot,
|
||
"item": bought_item,
|
||
"cost": price,
|
||
"remaining_stock": good["stock"],
|
||
"msg": "成功以黑市特惠价购入 %s!" % good["name"]
|
||
}
|
||
|
||
# 计算高价回收金额 (+30% 溢价)
|
||
func calculate_sell_price(item: Dictionary) -> int:
|
||
var anti_flags := int(item.get("anti_flags", item.get("anti_flag", 0)))
|
||
if anti_flags & ITEM_ANTIFLAG_SELL != 0:
|
||
return 0 # 禁售品
|
||
|
||
var base_price := int(item.get("shop_buy_price", item.get("gold", item.get("price", 1000))))
|
||
var count := maxi(1, int(item.get("count", 1)))
|
||
|
||
# 40250 官方标准回收价为买入价的 1/5 (20%)
|
||
var standard_unit_sell: int = maxi(1, int(base_price / 5.0))
|
||
|
||
# 神秘游商提供 +30% 溢价 (1.30 倍)
|
||
var blackmarket_unit_sell: int = int(round(standard_unit_sell * HIGH_PRICE_BUYBACK_MULTIPLIER))
|
||
|
||
return maxi(1, blackmarket_unit_sell * count)
|
||
|
||
# 向神秘游商高价出售闲置珍品
|
||
func sell_item(inv_slot: int, player_data: Dictionary, inventory: Array) -> Dictionary:
|
||
if not is_active:
|
||
return {"ok": false, "reason": "MERCHANT_NOT_ACTIVE", "msg": "神秘游商已离去,无法交易!"}
|
||
|
||
if inv_slot < 0 or inv_slot >= inventory.size() or inventory[inv_slot] == null:
|
||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "背包槽位为空或无效!"}
|
||
|
||
var item: Dictionary = inventory[inv_slot]
|
||
var anti_flags := int(item.get("anti_flags", item.get("anti_flag", 0)))
|
||
if anti_flags & ITEM_ANTIFLAG_SELL != 0:
|
||
return {"ok": false, "reason": "ITEM_ANTIFLAG_SELL", "msg": "该物品属于非卖品,无法出售!"}
|
||
|
||
var earn_gold := calculate_sell_price(item)
|
||
if earn_gold <= 0:
|
||
return {"ok": false, "reason": "VALUATION_ZERO", "msg": "该物品估价为 0,游商不予回收!"}
|
||
|
||
var item_name: String = str(item.get("name", "珍宝"))
|
||
|
||
# 结算金币与移除物品
|
||
player_data["gold"] = int(player_data.get("gold", 0)) + earn_gold
|
||
inventory[inv_slot] = null
|
||
|
||
item_sold.emit(item_name, earn_gold)
|
||
|
||
return {
|
||
"ok": true,
|
||
"earned": earn_gold,
|
||
"item_name": item_name,
|
||
"msg": "神秘游商高价收购了【%s】,支付了 %d 金币(+30%% 溢价)!" % [item_name, earn_gold]
|
||
}
|
||
|
||
# 存档序列化
|
||
func serialize() -> Dictionary:
|
||
return {
|
||
"is_active": is_active,
|
||
"current_map": current_map,
|
||
"pos_x": current_pos.x,
|
||
"pos_y": current_pos.y,
|
||
"time_remaining": time_remaining,
|
||
"total_duration": total_duration,
|
||
"stock": current_stock.duplicate(true)
|
||
}
|
||
|
||
# 读档反序列化
|
||
func deserialize(data: Dictionary) -> void:
|
||
is_active = bool(data.get("is_active", false))
|
||
current_map = str(data.get("current_map", ""))
|
||
current_pos = Vector2(float(data.get("pos_x", 0.0)), float(data.get("pos_y", 0.0)))
|
||
time_remaining = float(data.get("time_remaining", 0.0))
|
||
total_duration = float(data.get("total_duration", DEFAULT_DURATION))
|
||
current_stock = data.get("stock", []).duplicate(true)
|