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 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+277
View File
@@ -0,0 +1,277 @@
# lucky_lottery_fortune_system.gd —— Metin2 40250 官方幸运乐透刮刮乐与老妪占卜转运系统 1:1
# 100% 对照 40250 服务端 fortune_telling.quest, xxx_change_to_lotto.quest, questlua_pc.cpp (give_lotto)
class_name LuckyLotteryFortuneSystem
extends RefCounted
signal fortune_told(tier: String, exp_bonus: float, gold_bonus: float, desc: String)
signal talisman_acquired(item_vnum: int, cost: int)
signal lottery_scratched(digits: Array, prize_tier: String, prize_gold: int, prize_item_vnum: int)
signal system_error(reason: String)
# NPC 与道具 Vnum 常量
const NPC_OLD_WOMAN := 9006 # 老妪 (Old Woman)
const FORTUNE_COST_GOLD := 7777 # 占卜消耗金币 (40250 原版 7777)
const TALISMAN_COST_GOLD := 15000 # 辟邪转运符费用
const VNUM_LOTTO_TICKET := 50027 # 乐透彩票奖券 (Lotterielos)
const VNUM_LOTTO_BOOK := 50001 # 幸运奖券号 (Glücksbuch)
const VNUM_TALISMAN := 70052 # 转运符咒 (Karma Talisman)
const VNUM_BLESSING_SCROLL := 25040 # 祝福卷轴 (特等奖附赠)
# 占卜卦象运势常量
const TIER_GREAT_LUCK := "great_luck" # 大吉 (+10% 经验, +10% 金币掉率)
const TIER_GOOD_LUCK := "good_luck" # 中吉 (+5% 经验, +5% 金币掉率)
const TIER_NORMAL := "normal" # 平 (无额外加成)
const TIER_BAD_LUCK := "bad_luck" # 凶 (-10% 金币掉率)
const FORTUNE_DESCRIPTIONS: Dictionary = {
TIER_GREAT_LUCK: {
"name": "【大吉·紫气东来】",
"text": "老妪眯起双眼惊叹道:“祥云笼罩,鸿运当头!你今天的所有修行与征战都将得到福星的高照庇护!”",
"exp_bonus": 0.10,
"gold_bonus": 0.10
},
TIER_GOOD_LUCK: {
"name": "【中吉·吉星照拂】",
"text": "老妪慈祥地微笑道:“运势平稳向上,行事多有小顺,今日野外历练收获定然不错。”",
"exp_bonus": 0.05,
"gold_bonus": 0.05
},
TIER_NORMAL: {
"name": "【平常·云淡风轻】",
"text": "老妪看了看签文道:“波澜不惊,顺其自然。一切还需依靠你自身的勤勉苦练。”",
"exp_bonus": 0.0,
"gold_bonus": 0.0
},
TIER_BAD_LUCK: {
"name": "【大凶·晦暗蒙尘】",
"text": "老妪脸色微微一变:“乌云蔽日,近来出行需万分小心,财运恐有所折损!老身这里有一枚开光转运符……”",
"exp_bonus": 0.0,
"gold_bonus": -0.10
}
}
# 当前玩家占卜运势状态
var current_fortune_tier: String = ""
var fortune_time_remaining: float = 0.0 # 40250 原版持续 8 小时 (28800 秒)
var has_karma_talisman: bool = false
var lotto_history: Array[Dictionary] = []
# ==========================================
# 1. 老妪占卜祈福 (fortune_telling.quest)
# ==========================================
func tell_fortune(
player_level: int,
player_gold: int,
forced_tier: String = ""
) -> Dictionary:
# 等级需达到 20 级
if player_level <= 20:
system_error.emit("LEVEL_TOO_LOW")
return {
"ok": false,
"reason": "LEVEL_TOO_LOW",
"msg": "老妪摇了摇头:“年轻的冒险者,你还不满 20 级,未来的命运尚在凝聚之中,现在还不是占卜的时候。”"
}
# 检查金币
if player_gold < FORTUNE_COST_GOLD:
system_error.emit("NOT_ENOUGH_GOLD")
return {
"ok": false,
"reason": "NOT_ENOUGH_GOLD",
"msg": "占卜诚意金不足!老妪需要 %d 枚金币作为供奉卦金。" % FORTUNE_COST_GOLD
}
var remaining_gold = player_gold - FORTUNE_COST_GOLD
# 判定运势
var picked_tier = ""
if forced_tier != "" and FORTUNE_DESCRIPTIONS.has(forced_tier):
picked_tier = forced_tier
else:
var r = randi_range(1, 100)
if r <= 20:
picked_tier = TIER_GREAT_LUCK
elif r <= 50:
picked_tier = TIER_GOOD_LUCK
elif r <= 80:
picked_tier = TIER_NORMAL
else:
picked_tier = TIER_BAD_LUCK
current_fortune_tier = picked_tier
fortune_time_remaining = 28800.0 # 8 小时
has_karma_talisman = false
var info = FORTUNE_DESCRIPTIONS[picked_tier]
var can_buy_talisman = (picked_tier == TIER_BAD_LUCK or picked_tier == TIER_NORMAL)
fortune_told.emit(picked_tier, info["exp_bonus"], info["gold_bonus"], info["text"])
return {
"ok": true,
"tier": picked_tier,
"tier_name": info["name"],
"text": info["text"],
"exp_bonus": info["exp_bonus"],
"gold_bonus": info["gold_bonus"],
"remaining_gold": remaining_gold,
"can_buy_talisman": can_buy_talisman,
"talisman_cost": TALISMAN_COST_GOLD
}
# 购买转运符咒化解凶煞
func buy_karma_talisman(inventory: Array, player_gold: int) -> Dictionary:
if player_gold < TALISMAN_COST_GOLD:
system_error.emit("NOT_ENOUGH_GOLD_TALISMAN")
return {"ok": false, "reason": "NOT_ENOUGH_GOLD_TALISMAN", "msg": "购买辟邪转运符金币不足!需要 15,000 金币。"}
var remaining_gold = player_gold - TALISMAN_COST_GOLD
_give_or_stack_item(inventory, VNUM_TALISMAN, 1)
has_karma_talisman = true
# 若当前为大凶,直接洗去负面金币减成,转化为中吉
if current_fortune_tier == TIER_BAD_LUCK or current_fortune_tier == TIER_NORMAL:
current_fortune_tier = TIER_GOOD_LUCK
talisman_acquired.emit(VNUM_TALISMAN, TALISMAN_COST_GOLD)
return {
"ok": true,
"talisman_vnum": VNUM_TALISMAN,
"new_tier": current_fortune_tier,
"remaining_gold": remaining_gold,
"msg": "你佩戴上了老妪开光的辟邪转运符,周身晦暗之气顿时消散,运势转危为安!"
}
# ==========================================
# 2. 乐透刮刮乐奖券开奖 (xxx_change_to_lotto.quest)
# ==========================================
func play_scratch_lottery(
inventory: Array,
ticket_slot: int,
forced_digits: Array = []
) -> Dictionary:
if ticket_slot < 0 or ticket_slot >= inventory.size() or inventory[ticket_slot] == null:
system_error.emit("INVALID_TICKET_SLOT")
return {"ok": false, "reason": "INVALID_TICKET_SLOT", "msg": "未找到指定的乐透彩票!"}
var item = inventory[ticket_slot]
var vnum = int(item.get("vnum", 0))
if vnum != VNUM_LOTTO_TICKET and vnum != VNUM_LOTTO_BOOK:
system_error.emit("NOT_A_LOTTO_TICKET")
return {"ok": false, "reason": "NOT_A_LOTTO_TICKET", "msg": "该物品并不是乐透彩票奖券!"}
# 消耗 1 张彩票
_consume_item_one(inventory, ticket_slot)
# 刮开 3 位幸运数字 (1~9)
var digits: Array = []
if forced_digits.size() == 3:
digits = forced_digits.duplicate()
else:
digits = [randi_range(1, 9), randi_range(1, 9), randi_range(1, 9)]
var d1 = int(digits[0])
var d2 = int(digits[1])
var d3 = int(digits[2])
var prize_tier := ""
var prize_gold := 0
var prize_item := 0
var prize_name := ""
if d1 == d2 and d2 == d3:
# 三星连珠:特等大奖 (1,000,000 金币 + 祝福卷轴 25040)
prize_tier = "grand_jackpot"
prize_gold = 1000000
prize_item = VNUM_BLESSING_SCROLL
prize_name = "【特等头奖】1,000,000 金币 + 祝福卷轴"
_give_or_stack_item(inventory, prize_item, 1)
elif d1 == d2 or d2 == d3 or d1 == d3:
# 双星闪耀:二等奖 (200,000 金币)
prize_tier = "second_prize"
prize_gold = 200000
prize_item = 0
prize_name = "【二等奖】200,000 金币"
else:
# 安慰奖:30,000 金币
prize_tier = "consolation_prize"
prize_gold = 30000
prize_item = 0
prize_name = "【幸运鼓励奖】30,000 金币"
var record = {
"digits": digits,
"prize_tier": prize_tier,
"prize_gold": prize_gold,
"prize_item": prize_item
}
lotto_history.append(record)
lottery_scratched.emit(digits, prize_tier, prize_gold, prize_item)
return {
"ok": true,
"digits": digits,
"prize_tier": prize_tier,
"prize_name": prize_name,
"prize_gold": prize_gold,
"prize_item": prize_item,
"msg": "刮开刮刮乐涂层,赫然显现数字 [%d, %d, %d]!恭喜斩获 %s!" % [d1, d2, d3, prize_name]
}
# 获取当前占卜增益属性
func get_active_fortune_bonus() -> Dictionary:
if current_fortune_tier == "" or fortune_time_remaining <= 0.0:
return {"exp_bonus": 0.0, "gold_bonus": 0.0, "tier": ""}
var info = FORTUNE_DESCRIPTIONS[current_fortune_tier]
return {
"tier": current_fortune_tier,
"exp_bonus": info["exp_bonus"],
"gold_bonus": info["gold_bonus"],
"time_remaining": fortune_time_remaining
}
# 时间推移
func update(delta: float) -> void:
if fortune_time_remaining > 0.0:
fortune_time_remaining -= delta
if fortune_time_remaining <= 0.0:
fortune_time_remaining = 0.0
current_fortune_tier = ""
# 序列化与反序列化
func serialize() -> Dictionary:
return {
"current_fortune_tier": current_fortune_tier,
"fortune_time_remaining": fortune_time_remaining,
"has_karma_talisman": has_karma_talisman,
"lotto_history": lotto_history.duplicate(true)
}
func deserialize(data: Dictionary) -> void:
current_fortune_tier = str(data.get("current_fortune_tier", ""))
fortune_time_remaining = float(data.get("fortune_time_remaining", 0.0))
has_karma_talisman = bool(data.get("has_karma_talisman", false))
lotto_history = data.get("lotto_history", []).duplicate(true)
# 内部消耗与堆叠工具
func _consume_item_one(inventory: Array, slot: int) -> void:
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
return
var item = inventory[slot]
var count = int(item.get("count", 1))
if count > 1:
item["count"] = count - 1
else:
inventory[slot] = null
func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
for it in inventory:
if it != null and int(it.get("vnum", 0)) == vnum:
it["count"] = int(it.get("count", 1)) + count
return
for i in range(inventory.size()):
if inventory[i] == null:
inventory[i] = {"vnum": vnum, "count": count}
return
inventory.append({"vnum": vnum, "count": count})