- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
312 lines
10 KiB
GDScript
312 lines
10 KiB
GDScript
# offline_bazaar_market_system.gd —— Metin2 40250 单人单机广场离线摆摊与虚拟交易市场 1:1
|
|
# 对照 40250 服务端 char_shop.cpp, shop.cpp, shop_manager.cpp
|
|
class_name OfflineBazaarMarketSystem
|
|
extends RefCounted
|
|
|
|
signal stall_browsed(stall_id: int, stall_name: String)
|
|
signal item_bought_from_stall(stall_id: int, item_name: String, count: int, total_price: int)
|
|
signal player_stall_opened(title: String, item_count: int)
|
|
signal player_stall_item_sold(item_name: String, count: int, price: int)
|
|
signal player_stall_closed(revenue_collected: int)
|
|
|
|
const ITEM_BUNDLE_VNUM := 50200 # 摆摊包袱 (Bundle)
|
|
|
|
# 广场内置 3 大经典 NPC/虚拟玩家货摊
|
|
var npc_stalls: Dictionary = {
|
|
1: {
|
|
"id": 1,
|
|
"owner": "阿隆",
|
|
"title": "【稀缺材料专营店】",
|
|
"pos": Vector3(12.0, 0.0, 8.0),
|
|
"items": [
|
|
{"vnum": 27993, "name": "珍珠", "price": 500000, "stock": 5},
|
|
{"vnum": 27992, "name": "白石", "price": 300000, "stock": 10},
|
|
{"vnum": 30040, "name": "蜘蛛眼", "price": 50000, "stock": 20},
|
|
{"vnum": 50513, "name": "灵魂之石", "price": 1200000, "stock": 3}
|
|
]
|
|
},
|
|
2: {
|
|
"id": 2,
|
|
"owner": "铁峰",
|
|
"title": "【+4 强化灵石阁】",
|
|
"pos": Vector3(18.0, 0.0, 8.0),
|
|
"items": [
|
|
{"vnum": 28430, "name": "战士灵石+4", "price": 800000, "stock": 4},
|
|
{"vnum": 28431, "name": "忍者灵石+4", "price": 800000, "stock": 4},
|
|
{"vnum": 28432, "name": "修罗灵石+4", "price": 800000, "stock": 4},
|
|
{"vnum": 28435, "name": "魔石灵石+4", "price": 1500000, "stock": 2}
|
|
]
|
|
},
|
|
3: {
|
|
"id": 3,
|
|
"owner": "云游仙商",
|
|
"title": "【珍品洗炼附魔阁】",
|
|
"pos": Vector3(15.0, 0.0, 14.0),
|
|
"items": [
|
|
{"vnum": 71084, "name": "属性改变秘籍", "price": 2000000, "stock": 5},
|
|
{"vnum": 71085, "name": "添加属性秘籍", "price": 1500000, "stock": 5},
|
|
{"vnum": 70024, "name": "祝福宝珠", "price": 3000000, "stock": 2},
|
|
{"vnum": 71003, "name": "祝福卷轴", "price": 600000, "stock": 15}
|
|
]
|
|
}
|
|
}
|
|
|
|
# 玩家自己开设的离线个人商摊
|
|
var player_stall: Dictionary = {
|
|
"is_open": false,
|
|
"title": "",
|
|
"items": [], # Array of { "vnum", "name", "count", "price" }
|
|
"revenue": 0,
|
|
"total_sales_count": 0
|
|
}
|
|
|
|
# 获取所有广场摊位列表
|
|
func get_all_stalls() -> Array:
|
|
var list: Array = []
|
|
for sid in npc_stalls.keys():
|
|
var s = npc_stalls[sid]
|
|
list.append({
|
|
"id": s["id"],
|
|
"owner": s["owner"],
|
|
"title": s["title"],
|
|
"pos": s["pos"],
|
|
"item_count": s["items"].size()
|
|
})
|
|
if player_stall["is_open"]:
|
|
list.append({
|
|
"id": 999,
|
|
"owner": "玩家自己",
|
|
"title": player_stall["title"],
|
|
"pos": Vector3(0.0, 0.0, 0.0),
|
|
"item_count": player_stall["items"].size()
|
|
})
|
|
return list
|
|
|
|
# 浏览特定摊位商品
|
|
func browse_stall(stall_id: int) -> Dictionary:
|
|
if not npc_stalls.has(stall_id):
|
|
if stall_id == 999 and player_stall["is_open"]:
|
|
stall_browsed.emit(999, player_stall["title"])
|
|
return {"ok": true, "stall": player_stall}
|
|
return {"ok": false, "reason": "STALL_NOT_FOUND", "msg": "未找到该摊位!"}
|
|
|
|
var stall = npc_stalls[stall_id]
|
|
stall_browsed.emit(stall_id, stall["title"])
|
|
return {
|
|
"ok": true,
|
|
"id": stall_id,
|
|
"owner": stall["owner"],
|
|
"title": stall["title"],
|
|
"items": stall["items"]
|
|
}
|
|
|
|
# 从 NPC/虚拟摊位购买物品
|
|
func buy_from_stall(stall_id: int, item_idx: int, count: int, player_data: Dictionary, inventory: Array) -> Dictionary:
|
|
if not npc_stalls.has(stall_id):
|
|
return {"ok": false, "reason": "STALL_NOT_FOUND", "msg": "未找到该摊位!"}
|
|
|
|
var stall = npc_stalls[stall_id]
|
|
var items: Array = stall["items"]
|
|
if item_idx < 0 or item_idx >= items.size():
|
|
return {"ok": false, "reason": "ITEM_INDEX_INVALID", "msg": "无效的商品索引!"}
|
|
|
|
var target_goods = items[item_idx]
|
|
var available_stock: int = int(target_goods.get("stock", 0))
|
|
if available_stock < count:
|
|
return {"ok": false, "reason": "OUT_OF_STOCK", "msg": "库存不足!剩余: %d" % available_stock}
|
|
|
|
var total_cost: int = int(target_goods["price"]) * count
|
|
var current_gold: int = int(player_data.get("gold", 0))
|
|
if current_gold < total_cost:
|
|
return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足!需要: %d, 当前: %d" % [total_cost, current_gold]}
|
|
|
|
# 检查背包空位
|
|
var empty_slot = -1
|
|
for i in range(inventory.size()):
|
|
if inventory[i] == null:
|
|
empty_slot = i
|
|
break
|
|
|
|
if empty_slot == -1:
|
|
return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳新商品!"}
|
|
|
|
# 扣费
|
|
player_data["gold"] = current_gold - total_cost
|
|
target_goods["stock"] = available_stock - count
|
|
|
|
# 入包
|
|
var new_item: Dictionary = {
|
|
"vnum": target_goods["vnum"],
|
|
"name": target_goods["name"],
|
|
"count": count
|
|
}
|
|
inventory[empty_slot] = new_item
|
|
|
|
item_bought_from_stall.emit(stall_id, target_goods["name"], count, total_cost)
|
|
|
|
return {
|
|
"ok": true,
|
|
"item": new_item,
|
|
"slot": empty_slot,
|
|
"cost": total_cost,
|
|
"remaining_gold": player_data["gold"],
|
|
"msg": "成功购买【%s】x%d,花费 %d 金币!" % [target_goods["name"], count, total_cost]
|
|
}
|
|
|
|
# 玩家开设自己的离线摆摊
|
|
# items_to_sell: Array of { "slot": int, "price": int, "count": int }
|
|
func open_player_stall(title: String, items_to_sell: Array, inventory: Array, require_bundle: bool = true) -> Dictionary:
|
|
if player_stall["is_open"]:
|
|
return {"ok": false, "reason": "ALREADY_HAS_STALL", "msg": "你已开设了一个摊位!"}
|
|
|
|
if items_to_sell.is_empty():
|
|
return {"ok": false, "reason": "NO_ITEMS_TO_SELL", "msg": "请至少上架一件物品!"}
|
|
|
|
# 摆摊包袱校验 (50200)
|
|
var bundle_slot = -1
|
|
if require_bundle:
|
|
for i in range(inventory.size()):
|
|
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == ITEM_BUNDLE_VNUM:
|
|
bundle_slot = i
|
|
break
|
|
if bundle_slot == -1:
|
|
return {"ok": false, "reason": "NEED_BUNDLE_ITEM", "msg": "需要【摆摊包袱】(50200) 才能在广场开设个人货摊!"}
|
|
|
|
# 收集上架物品并从背包移出
|
|
var stall_items: Array = []
|
|
for spec in items_to_sell:
|
|
var slot_id: int = int(spec["slot"])
|
|
var price: int = int(spec["price"])
|
|
var sell_count: int = int(spec.get("count", 1))
|
|
|
|
if slot_id < 0 or slot_id >= inventory.size() or inventory[slot_id] == null:
|
|
continue
|
|
|
|
var inv_item: Dictionary = inventory[slot_id]
|
|
var cur_count: int = int(inv_item.get("count", 1))
|
|
|
|
if sell_count >= cur_count:
|
|
# 全量上架
|
|
var listed_item = inv_item.duplicate(true)
|
|
listed_item["price"] = price
|
|
stall_items.append(listed_item)
|
|
inventory[slot_id] = null
|
|
else:
|
|
# 部分上架
|
|
var listed_item = inv_item.duplicate(true)
|
|
listed_item["count"] = sell_count
|
|
listed_item["price"] = price
|
|
stall_items.append(listed_item)
|
|
inv_item["count"] = cur_count - sell_count
|
|
|
|
if stall_items.is_empty():
|
|
return {"ok": false, "reason": "NO_VALID_ITEMS", "msg": "没有有效可上架的物品!"}
|
|
|
|
# 消耗摆摊包袱
|
|
if bundle_slot != -1:
|
|
var b_item = inventory[bundle_slot]
|
|
var b_cnt = int(b_item.get("count", 1))
|
|
if b_cnt > 1:
|
|
b_item["count"] = b_cnt - 1
|
|
else:
|
|
inventory[bundle_slot] = null
|
|
|
|
player_stall["is_open"] = true
|
|
player_stall["title"] = title if not title.strip_edges().is_empty() else "【离线便民杂货店】"
|
|
player_stall["items"] = stall_items
|
|
player_stall["revenue"] = 0
|
|
player_stall["total_sales_count"] = 0
|
|
|
|
player_stall_opened.emit(player_stall["title"], stall_items.size())
|
|
|
|
return {
|
|
"ok": true,
|
|
"title": player_stall["title"],
|
|
"items_count": stall_items.size(),
|
|
"msg": "个人摊位【%s】开设成功!上架了 %d 件珍品。" % [player_stall["title"], stall_items.size()]
|
|
}
|
|
|
|
# 模拟单机挂机离线交易成交
|
|
func simulate_offline_sales(elapsed_minutes: float) -> Dictionary:
|
|
if not player_stall["is_open"] or player_stall["items"].is_empty():
|
|
return {"sold_count": 0, "earned_gold": 0}
|
|
|
|
var sold_items: Array = []
|
|
var earned: int = 0
|
|
var remaining_items: Array = []
|
|
|
|
# 根据挂机时长计算可能成交的件数
|
|
var max_potential_sales = int(elapsed_minutes / 5.0) + 1
|
|
var actual_sales = 0
|
|
|
|
# 随离线时长提升成交几率(满30分钟保底成交)
|
|
var sell_prob = clampf(0.5 + (elapsed_minutes / 30.0) * 0.5, 0.0, 1.0)
|
|
for item in player_stall["items"]:
|
|
if actual_sales < max_potential_sales and randf() <= sell_prob:
|
|
actual_sales += 1
|
|
var item_rev = int(item["price"]) * int(item.get("count", 1))
|
|
earned += item_rev
|
|
player_stall["revenue"] += item_rev
|
|
player_stall["total_sales_count"] += 1
|
|
sold_items.append(item)
|
|
player_stall_item_sold.emit(item["name"], int(item.get("count", 1)), item_rev)
|
|
else:
|
|
remaining_items.append(item)
|
|
|
|
player_stall["items"] = remaining_items
|
|
|
|
return {
|
|
"sold_count": sold_items.size(),
|
|
"earned_gold": earned,
|
|
"remaining_items": remaining_items.size()
|
|
}
|
|
|
|
# 领取摊位赚取的金币
|
|
func collect_stall_revenue(player_data: Dictionary) -> Dictionary:
|
|
if not player_stall["is_open"]:
|
|
return {"ok": false, "reason": "NO_ACTIVE_STALL"}
|
|
|
|
var rev: int = player_stall["revenue"]
|
|
if rev <= 0:
|
|
return {"ok": false, "reason": "NO_REVENUE", "msg": "当前没有可领取的摊位营收!"}
|
|
|
|
player_data["gold"] = int(player_data.get("gold", 0)) + rev
|
|
player_stall["revenue"] = 0
|
|
|
|
return {
|
|
"ok": true,
|
|
"collected_gold": rev,
|
|
"current_gold": player_data["gold"],
|
|
"msg": "成功领取摊位营业收入 %d 金币!" % rev
|
|
}
|
|
|
|
# 收摊并退回未售出物品
|
|
func close_player_stall(inventory: Array, player_data: Dictionary) -> Dictionary:
|
|
if not player_stall["is_open"]:
|
|
return {"ok": false, "reason": "NO_ACTIVE_STALL"}
|
|
|
|
var rev: int = player_stall["revenue"]
|
|
if rev > 0:
|
|
player_data["gold"] = int(player_data.get("gold", 0)) + rev
|
|
player_stall["revenue"] = 0
|
|
|
|
var returned_count: int = 0
|
|
for item in player_stall["items"]:
|
|
# 寻空位退回
|
|
for i in range(inventory.size()):
|
|
if inventory[i] == null:
|
|
inventory[i] = item
|
|
returned_count += 1
|
|
break
|
|
|
|
player_stall["is_open"] = false
|
|
player_stall["items"] = []
|
|
player_stall_closed.emit(rev)
|
|
|
|
return {
|
|
"ok": true,
|
|
"revenue_collected": rev,
|
|
"returned_items": returned_count,
|
|
"msg": "已顺利收摊!营业额 %d 金币已汇入背包,%d 件未售物品已退回。" % [rev, returned_count]
|
|
}
|