- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
188 lines
6.9 KiB
GDScript
188 lines
6.9 KiB
GDScript
# npc_shop_system.gd —— NPC 商店库存配置与买卖经济闭环(40250 官方 1:1 对齐)
|
|
# 对齐源码:
|
|
# - 40250 server/game/src/shop.cpp:450-580 (Buy, Sell, ITEM_ANTIFLAG_SELL, dwPrice /= 5)
|
|
# - 40250 root/uishop.py (ShopDialog, SetItemData, Buy, Sell)
|
|
# - 40250 uiscript/shopdialog.py (40 格商店货架)
|
|
class_name NpcShopSystem
|
|
extends RefCounted
|
|
|
|
const SHOP_HOST_ITEM_MAX_NUM := 40
|
|
const ITEM_ANTIFLAG_SELL := 1 << 8
|
|
const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3
|
|
|
|
# NPC Vnum 常量
|
|
const NPC_WEAPON_SHOP := 9001 # 武器商人
|
|
const NPC_ARMOR_SHOP := 9002 # 防具商人
|
|
const NPC_GENERAL_STORE := 9003 # 杂货商人
|
|
const NPC_FISHERMAN := 9009 # 渔夫
|
|
const NPC_DEONKBAE := 20015 # 矿工德安克贝
|
|
|
|
# 40250 官方标准商店货架预设表
|
|
const NPC_SHOPS := {
|
|
NPC_GENERAL_STORE: [
|
|
{"pos": 0, "vnum": 27001, "count": 200, "price": 1200, "name": "小型红药水"},
|
|
{"pos": 1, "vnum": 27002, "count": 200, "price": 4800, "name": "中型红药水"},
|
|
{"pos": 2, "vnum": 27003, "count": 200, "price": 16000, "name": "大型红药水"},
|
|
{"pos": 3, "vnum": 27004, "count": 200, "price": 2400, "name": "小型蓝药水"},
|
|
{"pos": 4, "vnum": 27005, "count": 200, "price": 9600, "name": "中型蓝药水"},
|
|
{"pos": 5, "vnum": 27006, "count": 200, "price": 32000, "name": "大型蓝药水"},
|
|
{"pos": 6, "vnum": 22001, "count": 1, "price": 10000, "name": "回城卷轴"},
|
|
{"pos": 7, "vnum": 22010, "count": 1, "price": 12000, "name": "地标传送卷轴"},
|
|
{"pos": 8, "vnum": 27800, "count": 50, "price": 2000, "name": "鱼饵(蚯蚓)"},
|
|
{"pos": 9, "vnum": 27400, "count": 1, "price": 50000, "name": "钓竿+0"},
|
|
{"pos": 10, "vnum": 29101, "count": 1, "price": 80000, "name": "矿镐+0"},
|
|
],
|
|
NPC_WEAPON_SHOP: [
|
|
{"pos": 0, "vnum": 10, "count": 1, "price": 1000, "name": "长剑+0"},
|
|
{"pos": 1, "vnum": 1000, "count": 1, "price": 1000, "name": "匕首+0"},
|
|
{"pos": 2, "vnum": 2000, "count": 1, "price": 1000, "name": "短弓+0"},
|
|
{"pos": 3, "vnum": 3000, "count": 1, "price": 1000, "name": "长戟+0"},
|
|
{"pos": 4, "vnum": 5000, "count": 1, "price": 1000, "name": "古铜铃+0"},
|
|
{"pos": 5, "vnum": 7000, "count": 1, "price": 1000, "name": "折扇+0"},
|
|
{"pos": 6, "vnum": 8000, "count": 200, "price": 100, "name": "木箭"},
|
|
],
|
|
NPC_ARMOR_SHOP: [
|
|
{"pos": 0, "vnum": 11200, "count": 1, "price": 1000, "name": "武士甲+0"},
|
|
{"pos": 1, "vnum": 11400, "count": 1, "price": 1000, "name": "刺客服+0"},
|
|
{"pos": 2, "vnum": 11600, "count": 1, "price": 1000, "name": "幽灵袍+0"},
|
|
{"pos": 3, "vnum": 11800, "count": 1, "price": 1000, "name": "萨满裙+0"},
|
|
{"pos": 4, "vnum": 12200, "count": 1, "price": 1000, "name": "铁盔+0"},
|
|
{"pos": 5, "vnum": 13000, "count": 1, "price": 1000, "name": "战斗盾+0"},
|
|
]
|
|
}
|
|
|
|
## 获取指定 NPC 的商店商品列表
|
|
static func get_shop_items(npc_vnum: int) -> Array:
|
|
return NPC_SHOPS.get(npc_vnum, []).duplicate(true)
|
|
|
|
## 检查 NPC 是否拥有商店
|
|
static func has_shop(npc_vnum: int) -> bool:
|
|
return NPC_SHOPS.has(npc_vnum)
|
|
|
|
## 计算物品出售折价 (40250 shop.cpp:520: dwPrice /= 5)
|
|
## 官方折价逻辑:一般物品回收价为买入价的 1/5(向上取整保底 1 金币)
|
|
static func calculate_sell_price(item_proto: Dictionary, count := 1) -> int:
|
|
var anti_flag := int(item_proto.get("anti_flags", item_proto.get("anti_flag", 0)))
|
|
if anti_flag & ITEM_ANTIFLAG_SELL != 0:
|
|
return 0 # 禁售物品不可回收
|
|
|
|
var buy_price := int(item_proto.get("shop_buy_price", item_proto.get("gold", 100)))
|
|
var flag := int(item_proto.get("flags", item_proto.get("flag", 0)))
|
|
var unit_price: int = 1
|
|
|
|
if flag & ITEM_FLAG_COUNT_PER_1GOLD != 0:
|
|
unit_price = 1
|
|
else:
|
|
unit_price = maxi(1, buy_price / 5)
|
|
|
|
return unit_price * maxi(1, count)
|
|
|
|
## 执行商店购买事务 (40250 shop.cpp Buy)
|
|
## player_ctx: {"gold": int, "inventory": Array}
|
|
static func buy_item(shop_pos: int, count: int, npc_vnum: int, player_ctx: Dictionary) -> Dictionary:
|
|
var items := get_shop_items(npc_vnum)
|
|
var target_good: Dictionary = {}
|
|
for it in items:
|
|
if int(it.get("pos", -1)) == shop_pos:
|
|
target_good = it
|
|
break
|
|
|
|
if target_good.is_empty():
|
|
return {"ok": false, "code": "SHOP_INVALID_POS", "msg": "该商品位置无效!"}
|
|
|
|
var buy_count := maxi(1, count)
|
|
var price_per_pack := int(target_good.get("price", 0))
|
|
var pack_count := int(target_good.get("count", 1))
|
|
var total_cost: int = price_per_pack * buy_count
|
|
|
|
var current_gold := int(player_ctx.get("gold", 0))
|
|
if current_gold < total_cost:
|
|
return {
|
|
"ok": false,
|
|
"code": "NOT_ENOUGH_GOLD",
|
|
"msg": "金币不足,无法购买!(需要 %d 金币,当前拥有 %d)" % [total_cost, current_gold]
|
|
}
|
|
|
|
# 检查背包剩余空位
|
|
var inv: Array = player_ctx.get("inventory", [])
|
|
var free_cell := -1
|
|
for cell in 90: # 0..89 背包槽
|
|
var occupied := false
|
|
for inv_item in inv:
|
|
if int(inv_item.get("cell", -1)) == cell:
|
|
occupied = true
|
|
break
|
|
if not occupied:
|
|
free_cell = cell
|
|
break
|
|
|
|
if free_cell == -1:
|
|
return {"ok": false, "code": "INVENTORY_FULL", "msg": "背包空间不足,无法放入商品!"}
|
|
|
|
# 扣款并给物品
|
|
player_ctx["gold"] = current_gold - total_cost
|
|
var new_item := {
|
|
"cell": free_cell,
|
|
"vnum": int(target_good.get("vnum", 0)),
|
|
"count": pack_count * buy_count,
|
|
"sockets": [0, 0, 0],
|
|
"attrs": []
|
|
}
|
|
inv.append(new_item)
|
|
player_ctx["inventory"] = inv
|
|
|
|
return {
|
|
"ok": true,
|
|
"code": "SUCCESS",
|
|
"cost": total_cost,
|
|
"cell": free_cell,
|
|
"item": new_item,
|
|
"msg": "购买成功!花费了 %d 金币。" % total_cost
|
|
}
|
|
|
|
## 执行商店出售事务 (40250 shop.cpp Sell)
|
|
## inv_cell: 背包槽位
|
|
static func sell_item(inv_cell: int, count: int, player_ctx: Dictionary, proto_getter: Callable) -> Dictionary:
|
|
var inv: Array = player_ctx.get("inventory", [])
|
|
var target_idx := -1
|
|
var target_item: Dictionary = {}
|
|
|
|
for i in inv.size():
|
|
if int(inv[i].get("cell", -1)) == inv_cell:
|
|
target_idx = i
|
|
target_item = inv[i]
|
|
break
|
|
|
|
if target_item.is_empty():
|
|
return {"ok": false, "code": "NO_ITEM", "msg": "背包该格没有物品!"}
|
|
|
|
var vnum := int(target_item.get("vnum", 0))
|
|
var proto: Dictionary = proto_getter.call(vnum) if proto_getter.is_valid() else {}
|
|
|
|
# 校验是否禁售
|
|
var anti_flag := int(proto.get("anti_flags", proto.get("anti_flag", 0)))
|
|
if anti_flag & ITEM_ANTIFLAG_SELL != 0:
|
|
return {"ok": false, "code": "ITEM_ANTIFLAG_SELL", "msg": "该物品为非卖品,无法向商人出售!"}
|
|
|
|
var cur_count := int(target_item.get("count", 1))
|
|
var sell_count := mini(cur_count, maxi(1, count))
|
|
var earn_gold := calculate_sell_price(proto, sell_count)
|
|
|
|
# 扣减物品
|
|
if cur_count <= sell_count:
|
|
inv.remove_at(target_idx)
|
|
else:
|
|
target_item["count"] = cur_count - sell_count
|
|
|
|
player_ctx["inventory"] = inv
|
|
player_ctx["gold"] = int(player_ctx.get("gold", 0)) + earn_gold
|
|
|
|
return {
|
|
"ok": true,
|
|
"code": "SUCCESS",
|
|
"gold_earned": earn_gold,
|
|
"sold_vnum": vnum,
|
|
"sold_count": sell_count,
|
|
"msg": "出售成功!获得了 %d 金币。" % earn_gold
|
|
}
|