Files
mtgodot-poc/project/mysterious_merchant_blackmarket_system.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

260 lines
8.1 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.
# 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)