chore: delete 106 unreachable *_system.gd and their parity tests
These files were loaded only by their own test_*_parity.gd and never by the runtime (transitively checked, incl. client_phase_integration_system). Removes 76 manifest implementation/evidence references across 18 contracts; contract docs get a note that conclusions based on them are void. Live systems kept: chest, consumable, item_attr, metin_socket, whisper_chat. Full headless suite: no new failures (7 failing + 5 headless-hanging render tests are identical before the deletion). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,187 +0,0 @@
|
||||
# alignment_pk_system.gd —— Metin2 40250 善恶值与名望头衔奖惩系统 1:1
|
||||
# 对照 40250 服务端 char.cpp:1190-1205, char_battle.cpp:UpdateAlignment, root/localeinfo.py:GetAlignmentTitleName
|
||||
class_name AlignmentPkSystem
|
||||
extends RefCounted
|
||||
|
||||
signal alignment_changed(old_val: int, new_val: int, title: String)
|
||||
signal title_changed(old_title: String, new_title: String)
|
||||
signal items_dropped_on_death(dropped_items: Array)
|
||||
signal zen_bean_consumed(cleared_amount: int, current_alignment: int)
|
||||
|
||||
# 40250 官方善恶值极值 (内部数值 -200000 ~ +200000, 显示数值 -20000 ~ +20000)
|
||||
const MIN_REAL_ALIGNMENT := -200000
|
||||
const MAX_REAL_ALIGNMENT := 200000
|
||||
|
||||
const VNUM_ZEN_BEAN := 70102 # 禅豆 (消减罪孽值)
|
||||
const ZEN_BEAN_CLEAR_POINTS := 500 # 每次消除 500 显示点数 (5000 内部点数)
|
||||
|
||||
# 9 大官方经典头衔段位表 (40250 localeinfo.py / locale_game.txt)
|
||||
const TITLES: Array = [
|
||||
{"threshold": 12000, "name": "崇高骑士", "en_name": "Chivalric", "is_evil": false},
|
||||
{"threshold": 8000, "name": "尊贵贵族", "en_name": "Noble", "is_evil": false},
|
||||
{"threshold": 4000, "name": "正义良民", "en_name": "Good", "is_evil": false},
|
||||
{"threshold": 1000, "name": "友好伙伴", "en_name": "Friendly", "is_evil": false},
|
||||
{"threshold": 0, "name": "中立行者", "en_name": "Neutral", "is_evil": false},
|
||||
{"threshold": -3999, "name": "好斗狂徒", "en_name": "Aggressive","is_evil": true},
|
||||
{"threshold": -7999, "name": "狡诈恶徒", "en_name": "Fraudulent","is_evil": true},
|
||||
{"threshold": -11999,"name": "险恶凶手", "en_name": "Malicious", "is_evil": true},
|
||||
{"threshold": -20000,"name": "残忍魔头", "en_name": "Cruel", "is_evil": true}
|
||||
]
|
||||
|
||||
var real_alignment: int = 0 # 内部实际值 (-200000 ~ +200000)
|
||||
|
||||
# 获取玩家当前对外显示的善恶值 (40250: sAlignment = m_iAlignment / 10)
|
||||
func get_alignment() -> int:
|
||||
return int(real_alignment / 10)
|
||||
|
||||
# 根据善恶值获取官方 1:1 头衔名称 (40250 localeinfo.py:GetAlignmentTitleName)
|
||||
static func get_title_name(alignment: int) -> String:
|
||||
if alignment >= 12000:
|
||||
return "崇高骑士"
|
||||
elif alignment >= 8000:
|
||||
return "尊贵贵族"
|
||||
elif alignment >= 4000:
|
||||
return "正义良民"
|
||||
elif alignment >= 1000:
|
||||
return "友好伙伴"
|
||||
elif alignment >= 0:
|
||||
return "中立行者"
|
||||
elif alignment > -4000:
|
||||
return "好斗狂徒"
|
||||
elif alignment > -8000:
|
||||
return "狡诈恶徒"
|
||||
elif alignment > -12000:
|
||||
return "险恶凶手"
|
||||
else:
|
||||
return "残忍魔头"
|
||||
|
||||
# 检查当前是否为红名/恶名
|
||||
func is_evil() -> bool:
|
||||
return get_alignment() < 0
|
||||
|
||||
# 更新善恶值 (40250 char_battle.cpp:UpdateAlignment)
|
||||
# amount 为内部值加减 (如传入 100 表示内部 +100)
|
||||
func update_real_alignment(amount: int) -> void:
|
||||
var old_display = get_alignment()
|
||||
var old_title = get_title_name(old_display)
|
||||
|
||||
real_alignment = clampi(real_alignment + amount, MIN_REAL_ALIGNMENT, MAX_REAL_ALIGNMENT)
|
||||
|
||||
var new_display = get_alignment()
|
||||
var new_title = get_title_name(new_display)
|
||||
|
||||
if old_display != new_display:
|
||||
alignment_changed.emit(old_display, new_display, new_title)
|
||||
|
||||
if old_title != new_title:
|
||||
title_changed.emit(old_title, new_title)
|
||||
|
||||
# 按显示点数直接调整善恶值 (如传入 +500 相当于内部 +5000)
|
||||
func update_alignment_points(points: int) -> void:
|
||||
update_real_alignment(points * 10)
|
||||
|
||||
# 在线时间自然恢复善恶值 (40250 char.cpp:1194-1203)
|
||||
# minutes: 挂机分钟数
|
||||
func recover_alignment_by_time(minutes: int, has_fast_recovery_item: bool = false) -> void:
|
||||
if minutes <= 0:
|
||||
return
|
||||
|
||||
if real_alignment < 0:
|
||||
var rate_per_min = 120 if has_fast_recovery_item else 60
|
||||
update_alignment_points(rate_per_min * minutes)
|
||||
else:
|
||||
var rate_per_min = 5
|
||||
update_alignment_points(rate_per_min * minutes)
|
||||
|
||||
# 使用禅豆消减罪孽 (40250 Zen Bean 70102)
|
||||
func use_zen_bean(inventory: Array, slot_index: int) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
if int(item.get("vnum", 0)) != VNUM_ZEN_BEAN:
|
||||
return {"ok": false, "reason": "NOT_A_ZEN_BEAN", "msg": "此物品不是禅豆!"}
|
||||
|
||||
var cur_align := get_alignment()
|
||||
if cur_align >= 0:
|
||||
return {"ok": false, "reason": "ALIGNMENT_ALREADY_GOOD", "msg": "当前心境平和,无需使用禅豆消业!"}
|
||||
|
||||
# 消耗 1 颗禅豆
|
||||
var cnt := int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 恢复 500 点善恶值 (若剩余负值不足 500 则刚好回满到 0)
|
||||
var clear_amount: int = mini(ZEN_BEAN_CLEAR_POINTS, -cur_align)
|
||||
update_alignment_points(clear_amount)
|
||||
|
||||
zen_bean_consumed.emit(clear_amount, get_alignment())
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"cleared": clear_amount,
|
||||
"current_alignment": get_alignment(),
|
||||
"title": get_title_name(get_alignment()),
|
||||
"msg": "服用禅豆,消除 %d 点罪业!当前善恶值:%d (%s)" % [clear_amount, get_alignment(), get_title_name(get_alignment())]
|
||||
}
|
||||
|
||||
# 击杀和平角色 (恶名惩罚)
|
||||
func record_pk_kill() -> void:
|
||||
update_alignment_points(-2000)
|
||||
|
||||
# 击破魔石或野外怪物 (正向善名积累)
|
||||
func record_monster_kill(is_metin: bool = false) -> void:
|
||||
var bonus = 50 if is_metin else 10
|
||||
update_alignment_points(bonus)
|
||||
|
||||
# 死亡惩罚计算 (40250 红名野外死亡爆装机制)
|
||||
func handle_death_drop_penalty(inventory: Array) -> Array:
|
||||
var cur_align := get_alignment()
|
||||
if cur_align >= 0:
|
||||
return [] # 白名/良民免死掉落
|
||||
|
||||
var drop_chance := 0.10
|
||||
var max_drop_count := 1
|
||||
|
||||
if cur_align <= -12000: # 残忍魔头
|
||||
drop_chance = 0.80
|
||||
max_drop_count = 3
|
||||
elif cur_align <= -8000: # 险恶凶手
|
||||
drop_chance = 0.50
|
||||
max_drop_count = 2
|
||||
elif cur_align <= -4000: # 狡诈恶徒
|
||||
drop_chance = 0.25
|
||||
max_drop_count = 1
|
||||
|
||||
var dropped_items: Array = []
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
continue
|
||||
var item: Dictionary = inventory[i]
|
||||
# 灵魂绑定锁定的装备免于死亡掉落
|
||||
if item.get("is_soulbound", false):
|
||||
continue
|
||||
|
||||
# 判定概率爆出
|
||||
if randf() <= drop_chance:
|
||||
dropped_items.append(item.duplicate(true))
|
||||
inventory[i] = null
|
||||
if dropped_items.size() >= max_drop_count:
|
||||
break
|
||||
|
||||
if not dropped_items.is_empty():
|
||||
items_dropped_on_death.emit(dropped_items)
|
||||
|
||||
return dropped_items
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"real_alignment": real_alignment
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
real_alignment = int(data.get("real_alignment", 0))
|
||||
@@ -1 +0,0 @@
|
||||
uid://cvfimpfsp5t6b
|
||||
@@ -1,128 +0,0 @@
|
||||
# alignment_system.gd —— 人物善恶道德与红名系统(40250 官方 1:1 对齐)
|
||||
# 对齐源码:
|
||||
# - 40250 server/game/src/char.cpp:1190-1205 (UpdateAlignment, 自然恢复公式)
|
||||
# - 40250 server/game/src/char.cpp:3300 (UpdateAlignment)
|
||||
# - 40250 ClientVS22/source/UserInterface/InstanceBaseEffect.cpp:609 (GetTitleColor)
|
||||
# - 40250 ClientVS22/source/UserInterface/InstanceBase.h:262 (TITLE_NUM=9, TITLE_NONE=4)
|
||||
# - 40250 text_tail.gd (title_color, title_name_key)
|
||||
class_name AlignmentSystem
|
||||
extends RefCounted
|
||||
|
||||
const MIN_ALIGNMENT := -20000
|
||||
const MAX_ALIGNMENT := 20000
|
||||
|
||||
# 9 档善恶称号常量定义
|
||||
const TITLE_HEROIC := 0 # 英雄 (Heroic, >= 12000)
|
||||
const TITLE_NOBLE := 1 # 尊者 (Noble, 8000..11999)
|
||||
const TITLE_GOOD := 2 # 善良 (Good, 4000..7999)
|
||||
const TITLE_FRIENDLY := 3 # 友好 (Friendly, 1000..3999)
|
||||
const TITLE_NEUTRAL := 4 # 普通 (Neutral, 0..999)
|
||||
const TITLE_AGGRESSIVE := 5 # 无赖 (Aggressive, -1..-3999)
|
||||
const TITLE_FRAUDULENT := 6 # 欺诈 (Fraudulent, -4000..-7999)
|
||||
const TITLE_MALICIOUS := 7 # 邪恶 (Malicious, -8000..-11999)
|
||||
const TITLE_CRUEL := 8 # 魔头 (Cruel, <= -12000)
|
||||
|
||||
const VNUM_ZEN_BEAN := 70102 # 禅定豆 / 清恶豆
|
||||
|
||||
const GRADE_NAMES := {
|
||||
TITLE_HEROIC: "英雄",
|
||||
TITLE_NOBLE: "尊者",
|
||||
TITLE_GOOD: "善良",
|
||||
TITLE_FRIENDLY: "友好",
|
||||
TITLE_NEUTRAL: "普通",
|
||||
TITLE_AGGRESSIVE: "无赖",
|
||||
TITLE_FRAUDULENT: "欺诈",
|
||||
TITLE_MALICIOUS: "邪恶",
|
||||
TITLE_CRUEL: "魔头",
|
||||
}
|
||||
|
||||
const GRADE_COLORS := {
|
||||
TITLE_HEROIC: Color8(0, 204, 255),
|
||||
TITLE_NOBLE: Color8(0, 144, 255),
|
||||
TITLE_GOOD: Color8(92, 110, 255),
|
||||
TITLE_FRIENDLY: Color8(155, 155, 255),
|
||||
TITLE_NEUTRAL: Color8(255, 255, 255),
|
||||
TITLE_AGGRESSIVE: Color8(207, 117, 0),
|
||||
TITLE_FRAUDULENT: Color8(235, 83, 0),
|
||||
TITLE_MALICIOUS: Color8(227, 0, 0),
|
||||
TITLE_CRUEL: Color8(255, 0, 0),
|
||||
}
|
||||
|
||||
## 获取善恶值对应的 40250 称号等级 (0..8)
|
||||
static func get_alignment_grade(alignment: int) -> int:
|
||||
if alignment >= 12000:
|
||||
return TITLE_HEROIC
|
||||
elif alignment >= 8000:
|
||||
return TITLE_NOBLE
|
||||
elif alignment >= 4000:
|
||||
return TITLE_GOOD
|
||||
elif alignment >= 1000:
|
||||
return TITLE_FRIENDLY
|
||||
elif alignment >= 0:
|
||||
return TITLE_NEUTRAL
|
||||
elif alignment > -4000:
|
||||
return TITLE_AGGRESSIVE
|
||||
elif alignment > -8000:
|
||||
return TITLE_FRAUDULENT
|
||||
elif alignment > -12000:
|
||||
return TITLE_MALICIOUS
|
||||
else:
|
||||
return TITLE_CRUEL
|
||||
|
||||
## 获取善恶称号中文名
|
||||
static func get_title_name(grade: int) -> String:
|
||||
return GRADE_NAMES.get(grade, "普通")
|
||||
|
||||
## 获取善恶称号颜色
|
||||
static func get_title_color(grade: int) -> Color:
|
||||
return GRADE_COLORS.get(grade, Color.WHITE)
|
||||
|
||||
## 检查是否为红名(罪恶状态)
|
||||
static func is_red_name(alignment: int) -> bool:
|
||||
return alignment < 0
|
||||
|
||||
## 检查是否为蓝名(正义状态)
|
||||
static func is_blue_name(alignment: int) -> bool:
|
||||
return alignment >= 1000
|
||||
|
||||
## 40250 char.cpp:1197 在线自然恢复善恶计算
|
||||
## minutes: 在线分钟数;has_fast_item: 是否佩戴禅定香囊等加速道具
|
||||
static func calculate_time_recovery(cur_alignment: int, minutes: int, has_fast_item := false) -> int:
|
||||
if minutes <= 0:
|
||||
return cur_alignment
|
||||
var updated := cur_alignment
|
||||
if cur_alignment < 0:
|
||||
# 恶名状态:基础每分钟 +60 点,带香囊每分钟 +120 点
|
||||
var rate: int = 120 if has_fast_item else 60
|
||||
updated = mini(0, cur_alignment + rate * minutes)
|
||||
else:
|
||||
# 善良状态:每分钟 +5 点
|
||||
updated = mini(MAX_ALIGNMENT, cur_alignment + 5 * minutes)
|
||||
return updated
|
||||
|
||||
## 击杀怪物增加善恶值 (40250 char_battle.cpp: UpdateAlignment)
|
||||
## 击杀野外怪物根据怪物等级恢复善恶(红名时恢复极快)
|
||||
static func on_kill_monster(cur_alignment: int, mob_level: int, player_level: int) -> int:
|
||||
var delta := maxi(1, mob_level - player_level / 2)
|
||||
if cur_alignment < 0:
|
||||
delta *= 2 # 红名击杀怪物恢复加倍
|
||||
return mini(MAX_ALIGNMENT, cur_alignment + delta)
|
||||
|
||||
## 使用禅定豆 (Zen Bean 70102)
|
||||
## 40250 原生逻辑:仅在红名 (alignment < 0) 时可使用,一次清除 500 点罪恶
|
||||
static func use_zen_bean(cur_alignment: int) -> Dictionary:
|
||||
if cur_alignment >= 0:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": "CANNOT_USE_NOT_EVIL",
|
||||
"msg": "你未处于恶名状态,无需使用禅定豆。"
|
||||
}
|
||||
var new_align: int = mini(0, cur_alignment + 500)
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "SUCCESS",
|
||||
"old_alignment": cur_alignment,
|
||||
"new_alignment": new_align,
|
||||
"consumed": true,
|
||||
"msg": "使用了禅定豆,洗清了 500 点罪恶值!(当前善恶: %d)" % new_align
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://vg3mkqiy31mq
|
||||
@@ -1,132 +0,0 @@
|
||||
# atlas_navigation_system.gd —— 40250 Atlas M 键全景大地图与 NPC 寻路导航光标系统 1:1
|
||||
# 严格对照:
|
||||
# Client/Eternexus/root/uiminimap.py (AtlasWindow, MiniMap)
|
||||
# ClientVS22/source/UserInterface/PythonMiniMap.cpp, PythonMiniMap.h (AddSignalPoint, ClearAllSignalPoint)
|
||||
# Client/Eternexus/root/atlasinfo.txt
|
||||
extends RefCounted
|
||||
|
||||
# 地图手绘全景 Atlas 纹理与尺寸映射
|
||||
const MAP_ATLAS_REGISTRY := {
|
||||
"metin2_map_a1": { "name": "赤蛇国主城 (Yongan)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
|
||||
"metin2_map_b1": { "name": "白虎国主城 (Joan)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
|
||||
"metin2_map_c1": { "name": "苍龙国主城 (Pyungmoo)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
|
||||
"metin2_map_milgyo": { "name": "幽冥神殿 (Dark Temple)", "size": Vector2(256, 256), "world_size": Vector2(2000, 2000) },
|
||||
"metin2_map_deviltower": { "name": "恶魔之塔 (Demon Tower)", "size": Vector2(256, 256), "world_size": Vector2(1000, 1000) },
|
||||
}
|
||||
|
||||
# 官方预设标准城镇 NPC 索引目录 (NPC VID -> 数据)
|
||||
const PRESET_TOWN_NPCS := [
|
||||
{ "vid": 20016, "name": "铁匠 (Blacksmith)", "category": "Smith", "pos": Vector2(580, 620) },
|
||||
{ "vid": 9001, "name": "武器商人 (Weapon Dealer)", "category": "Shop", "pos": Vector2(610, 630) },
|
||||
{ "vid": 9002, "name": "防具商人 (Armor Dealer)", "category": "Shop", "pos": Vector2(590, 650) },
|
||||
{ "vid": 9003, "name": "杂货商人 (General Store)", "category": "Shop", "pos": Vector2(630, 600) },
|
||||
{ "vid": 9005, "name": "仓库保管员 (Storekeeper)", "category": "Safebox", "pos": Vector2(550, 580) },
|
||||
{ "vid": 20084, "name": "生物学家采吉拉布 (Biologist)", "category": "Quest", "pos": Vector2(680, 520) },
|
||||
{ "vid": 20092, "name": "老行商 (Old Wanderer)", "category": "Special", "pos": Vector2(450, 710) },
|
||||
{ "vid": 20008, "name": "城门守卫 (City Guard)", "category": "Guard", "pos": Vector2(800, 800) },
|
||||
{ "vid": 20001, "name": "药店掌柜 (Alchemist)", "category": "Craft", "pos": Vector2(520, 640) },
|
||||
{ "vid": 20091, "name": "修仙道长 (Taoist)", "category": "Skill", "pos": Vector2(640, 750) },
|
||||
]
|
||||
|
||||
# 状态变量
|
||||
var is_atlas_visible: bool = false
|
||||
var current_map_id: String = "metin2_map_a1"
|
||||
var zoom_level: float = 1.0 # 滚轮缩放倍率 (0.5x ~ 3.0x)
|
||||
var pan_offset: Vector2 = Vector2.ZERO # 拖拽平移偏移
|
||||
|
||||
# 动态信标点列表 (1:1 CPythonMiniMap::m_SignalPointVector)
|
||||
var signal_points: Array = []
|
||||
|
||||
func _init(map_id: String = "metin2_map_a1") -> void:
|
||||
current_map_id = map_id
|
||||
is_atlas_visible = false
|
||||
zoom_level = 1.0
|
||||
pan_offset = Vector2.ZERO
|
||||
signal_points.clear()
|
||||
|
||||
# 1. M 键快捷切换全景大地图 (1:1 ToggleAtlasWindow)
|
||||
func toggle_atlas() -> bool:
|
||||
is_atlas_visible = !is_atlas_visible
|
||||
return is_atlas_visible
|
||||
|
||||
func show_atlas() -> void:
|
||||
is_atlas_visible = true
|
||||
|
||||
func hide_atlas() -> void:
|
||||
is_atlas_visible = false
|
||||
|
||||
# 2. 切换当前地图与底图载入 (SetMapName)
|
||||
func set_map(map_id: String) -> bool:
|
||||
if not MAP_ATLAS_REGISTRY.has(map_id):
|
||||
return false
|
||||
current_map_id = map_id
|
||||
clear_all_signals()
|
||||
pan_offset = Vector2.ZERO
|
||||
zoom_level = 1.0
|
||||
return true
|
||||
|
||||
func get_current_map_info() -> Dictionary:
|
||||
return MAP_ATLAS_REGISTRY.get(current_map_id, {})
|
||||
|
||||
# 3. 缩放与平移控制
|
||||
func set_zoom(zoom: float) -> void:
|
||||
zoom_level = clamp(zoom, 0.5, 3.0)
|
||||
|
||||
func apply_pan(delta_offset: Vector2) -> void:
|
||||
pan_offset += delta_offset
|
||||
|
||||
# 4. NPC 目录检索与过滤
|
||||
func get_npcs(category_filter: String = "") -> Array:
|
||||
if category_filter.is_empty():
|
||||
return PRESET_TOWN_NPCS
|
||||
var filtered: Array = []
|
||||
for npc in PRESET_TOWN_NPCS:
|
||||
if npc.get("category") == category_filter:
|
||||
filtered.append(npc)
|
||||
return filtered
|
||||
|
||||
func find_npc_by_vid(vid: int) -> Dictionary:
|
||||
for npc in PRESET_TOWN_NPCS:
|
||||
if npc.get("vid") == vid:
|
||||
return npc
|
||||
return {}
|
||||
|
||||
# 5. 信标寻路导航点机制 (1:1 CPythonMiniMap::AddSignalPoint / ClearAllSignalPoint)
|
||||
func add_signal_point(target_x: float, target_y: float) -> Dictionary:
|
||||
var pt := {
|
||||
"x": target_x,
|
||||
"y": target_y,
|
||||
"active": true
|
||||
}
|
||||
signal_points.append(pt)
|
||||
return pt
|
||||
|
||||
func clear_all_signals() -> void:
|
||||
signal_points.clear()
|
||||
|
||||
# 6. 指南针方位角与距离计算 (角色与目标间的连线向量)
|
||||
func calculate_navigation_vector(player_pos: Vector2, target_pos: Vector2) -> Dictionary:
|
||||
var diff := target_pos - player_pos
|
||||
var dist := diff.length()
|
||||
var angle_rad := diff.angle()
|
||||
var angle_deg := rad_to_deg(angle_rad)
|
||||
|
||||
return {
|
||||
"distance": dist,
|
||||
"direction_vector": diff.normalized() if dist > 0.001 else Vector2.ZERO,
|
||||
"angle_rad": angle_rad,
|
||||
"angle_deg": angle_deg,
|
||||
"arrived": (dist <= 30.0) # 30 码内视为到达目标
|
||||
}
|
||||
|
||||
# 7. 世界坐标至大地图 UI 局部像素坐标投影转换
|
||||
func world_to_atlas_coord(world_pos: Vector2) -> Vector2:
|
||||
var map_info := get_current_map_info()
|
||||
var world_size: Vector2 = map_info.get("world_size", Vector2(4000, 4000))
|
||||
var atlas_size: Vector2 = map_info.get("size", Vector2(256, 256))
|
||||
|
||||
var ratio_x := atlas_size.x / world_size.x
|
||||
var ratio_y := atlas_size.y / world_size.y
|
||||
|
||||
var projected := Vector2(world_pos.x * ratio_x, world_pos.y * ratio_y)
|
||||
return (projected * zoom_level) + pan_offset
|
||||
@@ -1 +0,0 @@
|
||||
uid://dras8n6cjudtn
|
||||
@@ -1,144 +0,0 @@
|
||||
# attach_metin_dialog_system.gd
|
||||
# 40250 官方 1:1 装备幽魂石/灵魂石镶嵌与双重属性对比预览系统
|
||||
# 对照: uiattachmetin.py, attachstonedialog.py, char_item.cpp
|
||||
class_name AttachMetinDialogSystem
|
||||
extends RefCounted
|
||||
|
||||
const METIN_SOCKET_MAX_NUM: int = 3
|
||||
|
||||
const METIN_NORMAL: int = 0
|
||||
const METIN_GOLD: int = 1
|
||||
|
||||
const METIN_SOCKET_TYPE_NONE: int = 0
|
||||
const METIN_SOCKET_TYPE_SILVER: int = 1
|
||||
const METIN_SOCKET_TYPE_GOLD: int = 2
|
||||
|
||||
const ITEM_BROKEN_METIN_VNUM: int = 28960
|
||||
|
||||
# 经典灵魂石属性对照表 (40250 canonical metin stones 28000~28443)
|
||||
const METIN_STONES: Dictionary = {
|
||||
28030: {"name": "战神之石+0", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 5},
|
||||
28130: {"name": "战神之石+1", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 8},
|
||||
28230: {"name": "战神之石+2", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 12},
|
||||
28330: {"name": "战神之石+3", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 17},
|
||||
28430: {"name": "战神之石+4", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 25},
|
||||
28434: {"name": "穿透之石+4", "bonus_type": "POINT_PENETRATE_PCT", "bonus_value": 8},
|
||||
28435: {"name": "致命之石+4", "bonus_type": "POINT_CRITICAL_PCT", "bonus_value": 8},
|
||||
28438: {"name": "防御之石+4", "bonus_type": "POINT_DEF_GRADE_BONUS", "bonus_value": 15},
|
||||
28442: {"name": "加速之石+4", "bonus_type": "POINT_MOV_SPEED", "bonus_value": 30},
|
||||
28960: {"name": "破碎的石头碎块", "bonus_type": "NONE", "bonus_value": 0}
|
||||
}
|
||||
|
||||
var is_open: bool = false
|
||||
var metin_pos: int = -1
|
||||
var target_pos: int = -1
|
||||
|
||||
var metin_vnum: int = 0
|
||||
var metin_subtype: int = METIN_NORMAL
|
||||
|
||||
var target_item_vnum: int = 0
|
||||
var current_sockets: Array = [0, 0, 0]
|
||||
var preview_sockets: Array = [0, 0, 0]
|
||||
var target_socket_idx: int = -1
|
||||
|
||||
var last_packet_sent: Dictionary = {}
|
||||
var last_sound_played: String = ""
|
||||
|
||||
signal dialog_opened(metin_pos: int, target_pos: int, preview_sockets: Array)
|
||||
signal dialog_closed()
|
||||
signal metin_attached(metin_pos: int, target_pos: int, target_slot_idx: int)
|
||||
signal attach_failed(reason: String)
|
||||
|
||||
func can_attach_metin(slot_type: int, metin_type: int) -> bool:
|
||||
if metin_type == METIN_NORMAL:
|
||||
if slot_type == METIN_SOCKET_TYPE_SILVER or slot_type == METIN_SOCKET_TYPE_GOLD:
|
||||
return true
|
||||
elif metin_type == METIN_GOLD:
|
||||
if slot_type == METIN_SOCKET_TYPE_GOLD:
|
||||
return true
|
||||
return false
|
||||
|
||||
func open_dialog(p_metin_pos: int, p_target_pos: int, p_metin_vnum: int, p_metin_subtype: int, p_target_vnum: int, p_sockets: Array) -> bool:
|
||||
metin_pos = p_metin_pos
|
||||
target_pos = p_target_pos
|
||||
metin_vnum = p_metin_vnum
|
||||
metin_subtype = p_metin_subtype
|
||||
target_item_vnum = p_target_vnum
|
||||
|
||||
current_sockets = [0, 0, 0]
|
||||
preview_sockets = [0, 0, 0]
|
||||
target_socket_idx = -1
|
||||
|
||||
for i in range(mini(p_sockets.size(), METIN_SOCKET_MAX_NUM)):
|
||||
current_sockets[i] = p_sockets[i]
|
||||
preview_sockets[i] = p_sockets[i]
|
||||
|
||||
# 寻找首个符合条件的开放槽位
|
||||
var found_slot: bool = false
|
||||
for i in range(METIN_SOCKET_MAX_NUM):
|
||||
var slot_state: int = current_sockets[i]
|
||||
if can_attach_metin(slot_state, metin_subtype):
|
||||
preview_sockets[i] = metin_vnum
|
||||
target_socket_idx = i
|
||||
found_slot = true
|
||||
break
|
||||
|
||||
if not found_slot:
|
||||
attach_failed.emit("NO_AVAILABLE_SOCKET")
|
||||
return false
|
||||
|
||||
is_open = true
|
||||
dialog_opened.emit(metin_pos, target_pos, preview_sockets)
|
||||
return true
|
||||
|
||||
func accept() -> bool:
|
||||
if not is_open:
|
||||
return false
|
||||
if target_socket_idx < 0:
|
||||
attach_failed.emit("INVALID_SOCKET")
|
||||
return false
|
||||
|
||||
# 40250 发送 net.SendItemUseToItemPacket(self.metinItemPos, self.targetItemPos)
|
||||
last_packet_sent = {
|
||||
"header": "HEADER_CG_ITEM_USE_TO_ITEM",
|
||||
"metin_pos": metin_pos,
|
||||
"target_pos": target_pos,
|
||||
"target_socket_idx": target_socket_idx,
|
||||
"metin_vnum": metin_vnum
|
||||
}
|
||||
|
||||
# 官方原版音效
|
||||
last_sound_played = "sound/ui/metinstone_insert.wav"
|
||||
|
||||
metin_attached.emit(metin_pos, target_pos, target_socket_idx)
|
||||
close_dialog()
|
||||
return true
|
||||
|
||||
func close_dialog() -> void:
|
||||
is_open = false
|
||||
metin_pos = -1
|
||||
target_pos = -1
|
||||
dialog_closed.emit()
|
||||
|
||||
# 模拟服务端结算镶嵌 (支持成功率判定: 成功直接嵌入,失败产生破碎石块 28960)
|
||||
func simulate_server_process(target_sockets: Array, socket_idx: int, attached_metin_vnum: int, success: bool) -> Array:
|
||||
var result_sockets: Array = target_sockets.duplicate()
|
||||
if socket_idx >= 0 and socket_idx < result_sockets.size():
|
||||
if success:
|
||||
result_sockets[socket_idx] = attached_metin_vnum
|
||||
else:
|
||||
result_sockets[socket_idx] = ITEM_BROKEN_METIN_VNUM
|
||||
return result_sockets
|
||||
|
||||
# 模拟卷轴清理破碎石头 (vnum 71083 / Scroll of Cleansing)
|
||||
func clean_broken_socket(target_sockets: Array, socket_idx: int) -> Array:
|
||||
var result_sockets: Array = target_sockets.duplicate()
|
||||
if socket_idx >= 0 and socket_idx < result_sockets.size():
|
||||
if result_sockets[socket_idx] == ITEM_BROKEN_METIN_VNUM:
|
||||
result_sockets[socket_idx] = METIN_SOCKET_TYPE_SILVER
|
||||
return result_sockets
|
||||
|
||||
func get_metin_info(vnum: int) -> Dictionary:
|
||||
if METIN_STONES.has(vnum):
|
||||
return METIN_STONES[vnum]
|
||||
return {"name": "未知幽魂石", "bonus_type": "UNKNOWN", "bonus_value": 0}
|
||||
@@ -1 +0,0 @@
|
||||
uid://bkw5vjkd4yqgq
|
||||
@@ -1,175 +0,0 @@
|
||||
# auto_combat_bot_system.gd —— Metin2 40250 自动战斗猎杀挂机助手 1:1
|
||||
# 对照 40250 服务端 char_battle.cpp, bot.cpp, item_manager.cpp
|
||||
class_name AutoCombatBotSystem
|
||||
extends RefCounted
|
||||
|
||||
signal bot_toggled(enabled: bool)
|
||||
signal potion_used(potion_vnum: int, ptype: String)
|
||||
signal target_locked(target_id: String)
|
||||
signal auto_attack_fired(target_id: String, damage: int)
|
||||
signal auto_skill_fired(skill_vnum: int, target_id: String)
|
||||
signal auto_loot_collected(item_name: String, slot: int)
|
||||
|
||||
# 红药与蓝药 VNUM
|
||||
const HP_POTION_VNUMS: Array = [27001, 27002, 27003] # 小/中/大红
|
||||
const SP_POTION_VNUMS: Array = [27004, 27005, 27006] # 小/中/大蓝
|
||||
|
||||
var is_enabled: bool = false
|
||||
var auto_potion_enabled: bool = true
|
||||
var hp_threshold: float = 0.50 # 50% HP 触发喝红
|
||||
var sp_threshold: float = 0.40 # 40% SP 触发喝蓝
|
||||
|
||||
var auto_attack_enabled: bool = true
|
||||
var scan_radius: float = 15.0
|
||||
var attack_range: float = 3.0
|
||||
|
||||
var auto_skill_enabled: bool = true
|
||||
var assigned_skills: Array = []
|
||||
var skill_cooldowns: Dictionary = {}
|
||||
|
||||
var auto_loot_enabled: bool = true
|
||||
var loot_radius: float = 8.0
|
||||
|
||||
var current_target_id: String = ""
|
||||
var potion_cooldown: float = 0.0
|
||||
|
||||
# 开启/关闭挂机内挂
|
||||
func toggle_bot(enable: bool) -> Dictionary:
|
||||
is_enabled = enable
|
||||
if not is_enabled:
|
||||
current_target_id = ""
|
||||
bot_toggled.emit(is_enabled)
|
||||
return {"ok": true, "enabled": is_enabled, "msg": "自动战斗猎杀助手已%s!" % ("开启" if is_enabled else "关闭")}
|
||||
|
||||
# 帧心跳驱动挂机逻辑
|
||||
func update_bot(delta: float, player_data: Dictionary, inventory: Array, nearby_mobs: Array, nearby_drops: Array) -> Dictionary:
|
||||
if not is_enabled:
|
||||
return {"status": "disabled"}
|
||||
|
||||
if potion_cooldown > 0.0:
|
||||
potion_cooldown = max(0.0, potion_cooldown - delta)
|
||||
|
||||
for sk in skill_cooldowns.keys():
|
||||
if skill_cooldowns[sk] > 0.0:
|
||||
skill_cooldowns[sk] = max(0.0, skill_cooldowns[sk] - delta)
|
||||
|
||||
var p_pos: Vector3 = player_data.get("pos", Vector3.ZERO)
|
||||
var p_hp: int = int(player_data.get("hp", 100))
|
||||
var p_max_hp: int = max(1, int(player_data.get("max_hp", 100)))
|
||||
var p_sp: int = int(player_data.get("sp", 100))
|
||||
var p_max_sp: int = max(1, int(player_data.get("max_sp", 100)))
|
||||
|
||||
# 1. 自动智能喝药判定
|
||||
if auto_potion_enabled and potion_cooldown <= 0.0:
|
||||
if float(p_hp) / float(p_max_hp) <= hp_threshold:
|
||||
if _use_potion(HP_POTION_VNUMS, inventory, player_data, "hp"):
|
||||
potion_cooldown = 1.0 # 喝药公共冷却 1 秒
|
||||
|
||||
if float(p_sp) / float(p_max_sp) <= sp_threshold:
|
||||
if _use_potion(SP_POTION_VNUMS, inventory, player_data, "sp"):
|
||||
potion_cooldown = 1.0
|
||||
|
||||
# 2. 自动拾取掉落物
|
||||
if auto_loot_enabled:
|
||||
_process_auto_loot(p_pos, nearby_drops, inventory)
|
||||
|
||||
# 3. 自动搜敌与战斗打击
|
||||
if auto_attack_enabled:
|
||||
var act = _process_auto_combat(delta, p_pos, player_data, nearby_mobs)
|
||||
return act
|
||||
|
||||
return {"status": "idle"}
|
||||
|
||||
# 自动喝药
|
||||
func _use_potion(potion_vnums: Array, inventory: Array, player_data: Dictionary, ptype: String) -> bool:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) in potion_vnums:
|
||||
var pot = inventory[i]
|
||||
var pvnum = int(pot["vnum"])
|
||||
var cnt = int(pot.get("count", 1))
|
||||
if cnt > 1:
|
||||
pot["count"] = cnt - 1
|
||||
else:
|
||||
inventory[i] = null
|
||||
|
||||
var rec = 1500 if pvnum in [27003, 27006] else 800
|
||||
if ptype == "hp":
|
||||
player_data["hp"] = min(int(player_data["max_hp"]), int(player_data["hp"]) + rec)
|
||||
else:
|
||||
player_data["sp"] = min(int(player_data["max_sp"]), int(player_data["sp"]) + rec)
|
||||
|
||||
potion_used.emit(pvnum, ptype)
|
||||
return true
|
||||
return false
|
||||
|
||||
# 自动拾取
|
||||
func _process_auto_loot(player_pos: Vector3, drops: Array, inventory: Array) -> void:
|
||||
for d in drops:
|
||||
if d.get("looted", false):
|
||||
continue
|
||||
var d_pos: Vector3 = d.get("pos", Vector3.ZERO)
|
||||
if player_pos.distance_to(d_pos) <= loot_radius:
|
||||
# 放入背包
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = {
|
||||
"vnum": d.get("vnum", 1),
|
||||
"name": d.get("name", "战利品"),
|
||||
"count": int(d.get("count", 1))
|
||||
}
|
||||
d["looted"] = true
|
||||
auto_loot_collected.emit(d["name"], i)
|
||||
break
|
||||
|
||||
# 自动锁敌与攻击
|
||||
func _process_auto_combat(delta: float, player_pos: Vector3, player_data: Dictionary, mobs: Array) -> Dictionary:
|
||||
var closest_mob = null
|
||||
var min_dist = scan_radius + 1.0
|
||||
|
||||
for m in mobs:
|
||||
if not m.get("alive", true):
|
||||
continue
|
||||
var m_pos: Vector3 = m.get("pos", Vector3.ZERO)
|
||||
var dist = player_pos.distance_to(m_pos)
|
||||
if dist <= scan_radius and dist < min_dist:
|
||||
min_dist = dist
|
||||
closest_mob = m
|
||||
|
||||
if closest_mob == null:
|
||||
current_target_id = ""
|
||||
return {"status": "no_targets"}
|
||||
|
||||
var target_id = str(closest_mob.get("id", "mob"))
|
||||
if current_target_id != target_id:
|
||||
current_target_id = target_id
|
||||
target_locked.emit(target_id)
|
||||
|
||||
var target_pos: Vector3 = closest_mob.get("pos", Vector3.ZERO)
|
||||
var dist_to_target = player_pos.distance_to(target_pos)
|
||||
|
||||
# 走位靠拢
|
||||
if dist_to_target > attack_range:
|
||||
var new_pos = player_pos.move_toward(target_pos, 5.0 * delta)
|
||||
player_data["pos"] = new_pos
|
||||
return {"status": "moving_to_target", "target": target_id, "dist": dist_to_target}
|
||||
|
||||
# 尝试释放技能
|
||||
if auto_skill_enabled:
|
||||
for sk in assigned_skills:
|
||||
if skill_cooldowns.get(sk, 0.0) <= 0.0:
|
||||
skill_cooldowns[sk] = 8.0 # 默认技能 8 秒冷却
|
||||
auto_skill_fired.emit(sk, target_id)
|
||||
var sk_dmg = int(float(player_data.get("atk", 500)) * 2.0)
|
||||
closest_mob["hp"] = max(0, int(closest_mob.get("hp", 1000)) - sk_dmg)
|
||||
if closest_mob["hp"] <= 0:
|
||||
closest_mob["alive"] = false
|
||||
return {"status": "skill_attack", "skill": sk, "damage": sk_dmg}
|
||||
|
||||
# 普通挥砍攻击
|
||||
var atk_dmg = int(player_data.get("atk", 500))
|
||||
closest_mob["hp"] = max(0, int(closest_mob.get("hp", 1000)) - atk_dmg)
|
||||
if closest_mob["hp"] <= 0:
|
||||
closest_mob["alive"] = false
|
||||
|
||||
auto_attack_fired.emit(target_id, atk_dmg)
|
||||
return {"status": "basic_attack", "target": target_id, "damage": atk_dmg}
|
||||
@@ -1 +0,0 @@
|
||||
uid://d2i1ud7k3o0l5
|
||||
@@ -1,218 +0,0 @@
|
||||
# battle_arena_duel_system.gd —— Metin2 40250 官方武斗竞技场切磋与裁判规则 1:1
|
||||
# 对照 40250 服务端 arena.cpp, arena.h, BattleArena.cpp, questlua_arena.cpp
|
||||
class_name BattleArenaDuelSystem
|
||||
extends RefCounted
|
||||
|
||||
signal duel_started(opponent_name: String, set_count: int, time_limit: float)
|
||||
signal round_started(current_round: int)
|
||||
signal round_ended(winner_side: String, score_a: int, score_b: int)
|
||||
signal duel_finished(winner_side: String, final_score_a: int, final_score_b: int, reward: Dictionary)
|
||||
signal rule_violation_intercepted(reason: String)
|
||||
|
||||
const NPC_REFEREE := 20017 # 比武场裁判 (Arena Referee)
|
||||
const ARENA_MAP_INDEX := 112
|
||||
|
||||
# 擂台对立出生点 (Point A 玩家, Point B 对手)
|
||||
const START_POINT_A := Vector2(100.0, 100.0)
|
||||
const START_POINT_B := Vector2(200.0, 200.0)
|
||||
const ARENA_BOUNDS := Rect2(80.0, 80.0, 140.0, 140.0) # 擂台活动合法矩形区域
|
||||
|
||||
# 决斗状态枚举
|
||||
const STATE_IDLE := "IDLE"
|
||||
const STATE_COUNTDOWN := "COUNTDOWN"
|
||||
const STATE_FIGHTING := "FIGHTING"
|
||||
const STATE_ROUND_OVER:= "ROUND_OVER"
|
||||
const STATE_FINISHED := "FINISHED"
|
||||
|
||||
# 单机官方 4 大门派守擂试炼 NPC 导师
|
||||
const OPPONENT_PRESETS: Dictionary = {
|
||||
"warrior": {
|
||||
"id": "warrior",
|
||||
"name": "战神武者教头",
|
||||
"job": "warrior",
|
||||
"max_hp": 12000,
|
||||
"attack_power": 450,
|
||||
"defense": 200,
|
||||
"skills": ["三连斩", "剑气破空"]
|
||||
},
|
||||
"assassin": {
|
||||
"id": "assassin",
|
||||
"name": "暗夜游侠神箭手",
|
||||
"job": "assassin",
|
||||
"max_hp": 9000,
|
||||
"attack_power": 520,
|
||||
"defense": 150,
|
||||
"skills": ["连环毒矢", "疾影步"]
|
||||
},
|
||||
"sura": {
|
||||
"id": "sura",
|
||||
"name": "冥界暗影修罗",
|
||||
"job": "sura",
|
||||
"max_hp": 11000,
|
||||
"attack_power": 480,
|
||||
"defense": 220,
|
||||
"skills": ["幽冥炎刃", "魔灵护体"]
|
||||
},
|
||||
"shaman": {
|
||||
"id": "shaman",
|
||||
"name": "雷鸣灵术萨满",
|
||||
"job": "shaman",
|
||||
"max_hp": 10000,
|
||||
"attack_power": 400,
|
||||
"defense": 180,
|
||||
"skills": ["天顶奔雷", "金钟守护"]
|
||||
}
|
||||
}
|
||||
|
||||
# 比赛运行时状态
|
||||
var match_state: String = STATE_IDLE
|
||||
var current_round: int = 1
|
||||
var max_sets: int = 3 # 3 局 2 胜
|
||||
var sets_to_win: int = 2
|
||||
var time_limit_per_round: float = 300.0 # 5 分钟
|
||||
var round_timer: float = 300.0
|
||||
var countdown_timer: float = 3.0
|
||||
|
||||
var score_a: int = 0 # 玩家得分
|
||||
var score_b: int = 0 # 对手得分
|
||||
|
||||
var player_pos: Vector2 = START_POINT_A
|
||||
var opponent_pos: Vector2 = START_POINT_B
|
||||
|
||||
var active_opponent: Dictionary = {}
|
||||
var opponent_current_hp: int = 0
|
||||
|
||||
# 启动武斗切磋
|
||||
func start_duel(opponent_id: String, sets: int = 3, time_limit: float = 300.0) -> Dictionary:
|
||||
if not OPPONENT_PRESETS.has(opponent_id):
|
||||
return {"ok": false, "reason": "INVALID_OPPONENT", "msg": "未知的守擂武者!"}
|
||||
|
||||
active_opponent = OPPONENT_PRESETS[opponent_id].duplicate(true)
|
||||
max_sets = sets
|
||||
sets_to_win = int(ceil(float(max_sets) / 2.0))
|
||||
time_limit_per_round = time_limit
|
||||
|
||||
score_a = 0
|
||||
score_b = 0
|
||||
current_round = 1
|
||||
|
||||
_prepare_round()
|
||||
|
||||
duel_started.emit(active_opponent["name"], max_sets, time_limit_per_round)
|
||||
return {
|
||||
"ok": true,
|
||||
"opponent": active_opponent["name"],
|
||||
"max_sets": max_sets,
|
||||
"sets_to_win": sets_to_win,
|
||||
"pos_a": player_pos,
|
||||
"pos_b": opponent_pos
|
||||
}
|
||||
|
||||
# 准备新回合
|
||||
func _prepare_round() -> void:
|
||||
match_state = STATE_COUNTDOWN
|
||||
countdown_timer = 3.0
|
||||
round_timer = time_limit_per_round
|
||||
|
||||
player_pos = START_POINT_A
|
||||
opponent_pos = START_POINT_B
|
||||
opponent_current_hp = int(active_opponent.get("max_hp", 10000))
|
||||
|
||||
# 心跳逻辑推进
|
||||
func update(delta: float) -> void:
|
||||
if match_state == STATE_COUNTDOWN:
|
||||
countdown_timer -= delta
|
||||
if countdown_timer <= 0.0:
|
||||
match_state = STATE_FIGHTING
|
||||
round_started.emit(current_round)
|
||||
elif match_state == STATE_FIGHTING:
|
||||
round_timer -= delta
|
||||
if round_timer <= 0.0:
|
||||
# 回合超时:双方均未击倒,判定为平局或根据剩余生命决出
|
||||
_handle_round_over("timeout")
|
||||
|
||||
# 40250 禁药规则拦截
|
||||
func use_potion_check(item_vnum: int) -> bool:
|
||||
if match_state == STATE_FIGHTING or match_state == STATE_COUNTDOWN:
|
||||
# 27001~27006 为红蓝药水
|
||||
if item_vnum >= 27001 and item_vnum <= 27006:
|
||||
rule_violation_intercepted.emit("POTION_FORBIDDEN_IN_ARENA")
|
||||
return false
|
||||
return true
|
||||
|
||||
# 40250 禁马规则拦截
|
||||
func mount_check() -> bool:
|
||||
if match_state == STATE_FIGHTING or match_state == STATE_COUNTDOWN:
|
||||
rule_violation_intercepted.emit("MOUNT_FORBIDDEN_IN_ARENA")
|
||||
return false
|
||||
return true
|
||||
|
||||
# 40250 擂台边界巡检
|
||||
func check_boundary(pos: Vector2) -> bool:
|
||||
if not ARENA_BOUNDS.has_point(pos):
|
||||
rule_violation_intercepted.emit("OUT_OF_ARENA_BOUNDS")
|
||||
return false
|
||||
return true
|
||||
|
||||
# 击倒判定处理
|
||||
func record_defeat(defeated_side: String) -> Dictionary:
|
||||
if match_state != STATE_FIGHTING:
|
||||
return {"ok": false, "reason": "NOT_IN_FIGHTING_STATE"}
|
||||
|
||||
var round_winner := ""
|
||||
if defeated_side == "B": # 对手倒下,玩家得分
|
||||
score_a += 1
|
||||
round_winner = "A"
|
||||
elif defeated_side == "A": # 玩家倒下,对手得分
|
||||
score_b += 1
|
||||
round_winner = "B"
|
||||
|
||||
return _handle_round_over(round_winner)
|
||||
|
||||
# 回合结束与大局胜负判定
|
||||
func _handle_round_over(winner_side: String) -> Dictionary:
|
||||
match_state = STATE_ROUND_OVER
|
||||
round_ended.emit(winner_side, score_a, score_b)
|
||||
|
||||
# 检查大局是否已有胜者
|
||||
if score_a >= sets_to_win or score_b >= sets_to_win or current_round >= max_sets:
|
||||
match_state = STATE_FINISHED
|
||||
var grand_winner := "A" if score_a > score_b else ("B" if score_b > score_a else "DRAW")
|
||||
|
||||
var reward: Dictionary = {}
|
||||
if grand_winner == "A":
|
||||
reward = {
|
||||
"arena_points": 100 * sets_to_win,
|
||||
"gold": 500000,
|
||||
"title": "【竞技场常胜军】"
|
||||
}
|
||||
|
||||
duel_finished.emit(grand_winner, score_a, score_b, reward)
|
||||
return {
|
||||
"ok": true,
|
||||
"match_finished": true,
|
||||
"winner": grand_winner,
|
||||
"score_a": score_a,
|
||||
"score_b": score_b,
|
||||
"reward": reward
|
||||
}
|
||||
|
||||
# 进入下一回合
|
||||
current_round += 1
|
||||
_prepare_round()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"match_finished": false,
|
||||
"current_round": current_round,
|
||||
"score_a": score_a,
|
||||
"score_b": score_b
|
||||
}
|
||||
|
||||
# 重置武斗场状态
|
||||
func reset_arena() -> void:
|
||||
match_state = STATE_IDLE
|
||||
current_round = 1
|
||||
score_a = 0
|
||||
score_b = 0
|
||||
active_opponent.clear()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bsbmx3e0l0jgs
|
||||
@@ -1,161 +0,0 @@
|
||||
# belt_system.gd —— Metin2 40250 腰带系统与专属药水背包 1:1
|
||||
# 对照 40250 服务端 belt.cpp, char_item.cpp, uiinventory.py
|
||||
class_name BeltSystem
|
||||
extends RefCounted
|
||||
|
||||
signal belt_equipped(belt_name: String, slots_unlocked: int)
|
||||
signal belt_unequipped()
|
||||
signal belt_item_stored(slot: int, item_name: String)
|
||||
signal belt_item_removed(slot: int)
|
||||
|
||||
const MAX_BELT_SLOTS := 16 # 最大 4x4 空间
|
||||
|
||||
# 允许存入腰带的消耗品/药水类型
|
||||
const ALLOWED_CONSUMABLE_TYPES: Array = ["potion", "consumable", "dew", "elixir"]
|
||||
|
||||
# 已装备的腰带
|
||||
var equipped_belt: Dictionary = {}
|
||||
var belt_inventory: Array = [] # 16 个格子
|
||||
|
||||
func _init() -> void:
|
||||
belt_inventory.resize(MAX_BELT_SLOTS)
|
||||
for i in range(MAX_BELT_SLOTS):
|
||||
belt_inventory[i] = null
|
||||
|
||||
# 根据腰带强化等级计算开放的药水槽位数量
|
||||
static func calculate_unlocked_slots(refine_level: int) -> int:
|
||||
if refine_level >= 9:
|
||||
return 16 # 4x4
|
||||
elif refine_level >= 7:
|
||||
return 12 # 3x4
|
||||
elif refine_level >= 5:
|
||||
return 10
|
||||
elif refine_level >= 3:
|
||||
return 8 # 2x4
|
||||
elif refine_level >= 1:
|
||||
return 6
|
||||
else:
|
||||
return 4 # 1x4
|
||||
|
||||
# 获取当前开放的药水格子数
|
||||
func get_unlocked_slot_count() -> int:
|
||||
if equipped_belt.is_empty():
|
||||
return 0
|
||||
var refine: int = int(equipped_belt.get("refine_level", 0))
|
||||
return calculate_unlocked_slots(refine)
|
||||
|
||||
# 佩戴腰带
|
||||
func equip_belt(belt_item: Dictionary) -> Dictionary:
|
||||
if belt_item.is_empty():
|
||||
return {"ok": false, "reason": "EMPTY_BELT", "msg": "腰带数据为空!"}
|
||||
|
||||
equipped_belt = belt_item.duplicate(true)
|
||||
var unlocked := get_unlocked_slot_count()
|
||||
var bname: String = equipped_belt.get("name", "腰带")
|
||||
|
||||
belt_equipped.emit(bname, unlocked)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"belt": equipped_belt,
|
||||
"unlocked_slots": unlocked,
|
||||
"msg": "已佩戴【%s】,解锁了 %d 格专属药水空间!" % [bname, unlocked]
|
||||
}
|
||||
|
||||
# 卸下腰带 (需确保腰带内物品能够全部安全移出或为空)
|
||||
func unequip_belt(main_inventory: Array) -> Dictionary:
|
||||
if equipped_belt.is_empty():
|
||||
return {"ok": false, "reason": "NO_BELT_EQUIPPED", "msg": "当前未佩戴腰带!"}
|
||||
|
||||
# 检查腰带内是否有物品
|
||||
var items_in_belt: Array = []
|
||||
for i in range(MAX_BELT_SLOTS):
|
||||
if belt_inventory[i] != null:
|
||||
items_in_belt.append({"belt_slot": i, "item": belt_inventory[i]})
|
||||
|
||||
if not items_in_belt.is_empty():
|
||||
# 统计主背包空位
|
||||
var empty_slots: Array = []
|
||||
for j in range(main_inventory.size()):
|
||||
if main_inventory[j] == null:
|
||||
empty_slots.append(j)
|
||||
|
||||
if empty_slots.size() < items_in_belt.size():
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "BELT_NOT_EMPTY",
|
||||
"items_count": items_in_belt.size(),
|
||||
"empty_slots": empty_slots.size(),
|
||||
"msg": "主背包空间不足以容纳腰带内的药水,无法卸下腰带!"
|
||||
}
|
||||
|
||||
# 移入主背包
|
||||
for k in range(items_in_belt.size()):
|
||||
var m_slot: int = empty_slots[k]
|
||||
var it = items_in_belt[k]["item"]
|
||||
var b_slot: int = items_in_belt[k]["belt_slot"]
|
||||
main_inventory[m_slot] = it
|
||||
belt_inventory[b_slot] = null
|
||||
|
||||
var old_belt := equipped_belt.duplicate(true)
|
||||
equipped_belt.clear()
|
||||
belt_unequipped.emit()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"belt": old_belt,
|
||||
"msg": "腰带已卸下!内部药水已全部归还至主背包。"
|
||||
}
|
||||
|
||||
# 存放药水至腰带专属背包
|
||||
func store_item_in_belt(slot_idx: int, item: Dictionary) -> Dictionary:
|
||||
if equipped_belt.is_empty():
|
||||
return {"ok": false, "reason": "NO_BELT_EQUIPPED", "msg": "未佩戴腰带,无法使用专属药水栏!"}
|
||||
|
||||
var unlocked := get_unlocked_slot_count()
|
||||
if slot_idx < 0 or slot_idx >= unlocked:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "SLOT_LOCKED_OR_INVALID",
|
||||
"unlocked_slots": unlocked,
|
||||
"msg": "该腰带槽位尚未解锁或无效!当前腰带只开放了前 %d 格。" % unlocked
|
||||
}
|
||||
|
||||
# 药品类型校验
|
||||
var it_type: String = item.get("item_type", "")
|
||||
var is_potion: bool = ALLOWED_CONSUMABLE_TYPES.has(it_type) or item.get("is_potion", false)
|
||||
if not is_potion:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "NOT_A_POTION",
|
||||
"msg": "腰带栏位专用于补给品,只能存放药水、灵药与神圣露水!"
|
||||
}
|
||||
|
||||
belt_inventory[slot_idx] = item.duplicate(true)
|
||||
var iname: String = item.get("name", "药水")
|
||||
belt_item_stored.emit(slot_idx, iname)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"slot_idx": slot_idx,
|
||||
"item": belt_inventory[slot_idx],
|
||||
"msg": "已将【%s】置入腰带快捷栏!" % iname
|
||||
}
|
||||
|
||||
# 从腰带栏取出药水
|
||||
func take_item_from_belt(slot_idx: int) -> Dictionary:
|
||||
if slot_idx < 0 or slot_idx >= MAX_BELT_SLOTS:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item = belt_inventory[slot_idx]
|
||||
if item == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT"}
|
||||
|
||||
belt_inventory[slot_idx] = null
|
||||
belt_item_removed.emit(slot_idx)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"slot_idx": slot_idx,
|
||||
"item": item
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://nde6dfrdg7vg
|
||||
@@ -1,101 +0,0 @@
|
||||
# bgm_jukebox_system.gd —— 40250 客户端 BGM 点唱机与地图音乐配置系统 1:1
|
||||
# 严格对照:
|
||||
# Client/Eternexus/root/uiselectmusic.py (SelectMusicWindow)
|
||||
# Client/Eternexus/root/musicinfo.py
|
||||
# ClientVS22/source/UserInterface/PythonSoundManagerModule.cpp
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_THEME_TRACK := "enter_the_east.mp3"
|
||||
|
||||
# 40250 官方标准曲目清单 (musicinfo.py)
|
||||
const CANONICAL_TRACKS := [
|
||||
"enter_the_east.mp3",
|
||||
"open_the_gate.mp3",
|
||||
"characterselect.mp3",
|
||||
"wedding.mp3",
|
||||
"desert.mp3",
|
||||
"mountain.mp3",
|
||||
"temple.mp3",
|
||||
"monkey_dungeon.mp3",
|
||||
"flame_dungeon.mp3",
|
||||
"catacomb.mp3",
|
||||
"save_the_world.mp3",
|
||||
"peaceful_valley.mp3",
|
||||
"wonderland.mp3"
|
||||
]
|
||||
|
||||
# 各大地图官方默认 BGM 映射表
|
||||
const DEFAULT_MAP_BGM_MAP := {
|
||||
"intro_login": "enter_the_east.mp3",
|
||||
"intro_select": "characterselect.mp3",
|
||||
"metin2_map_a1": "open_the_gate.mp3",
|
||||
"metin2_map_b1": "open_the_gate.mp3",
|
||||
"metin2_map_c1": "open_the_gate.mp3",
|
||||
"metin2_map_n_desert_01": "desert.mp3",
|
||||
"metin2_map_n_snowm_01": "mountain.mp3",
|
||||
"metin2_map_milgyo": "temple.mp3",
|
||||
"metin2_map_deviltower": "catacomb.mp3",
|
||||
"metin2_map_wedding_01": "wedding.mp3",
|
||||
}
|
||||
|
||||
# 播放器运行时状态
|
||||
var current_track: String = ""
|
||||
var is_playing: bool = false
|
||||
var loop_enabled: bool = true
|
||||
var volume: float = 0.8 # 0.0 ~ 1.0 (默认 80%)
|
||||
|
||||
# 自定义地图偏好覆写: map_id -> track_name
|
||||
var custom_map_overrides: Dictionary = {}
|
||||
|
||||
func _init() -> void:
|
||||
current_track = ""
|
||||
is_playing = false
|
||||
loop_enabled = true
|
||||
volume = 0.8
|
||||
custom_map_overrides.clear()
|
||||
|
||||
# 1. 播放指定曲目 (1:1 snd.PlayBGM)
|
||||
func play_bgm(track_name: String, loop: bool = true) -> bool:
|
||||
if not CANONICAL_TRACKS.has(track_name):
|
||||
return false
|
||||
current_track = track_name
|
||||
is_playing = true
|
||||
loop_enabled = loop
|
||||
return true
|
||||
|
||||
# 2. 停止背景音乐 (1:1 snd.StopBGM)
|
||||
func stop_bgm() -> void:
|
||||
is_playing = false
|
||||
current_track = ""
|
||||
|
||||
# 3. 调节音量 (1:1 snd.SetBGMVolume)
|
||||
func set_volume(vol_pct: float) -> void:
|
||||
volume = clamp(vol_pct, 0.0, 1.0)
|
||||
|
||||
# 4. 获取指定地图应当播放的背景音乐 (自定义优先,其次默认)
|
||||
func get_effective_map_bgm(map_id: String) -> String:
|
||||
if custom_map_overrides.has(map_id):
|
||||
return custom_map_overrides[map_id]
|
||||
return DEFAULT_MAP_BGM_MAP.get(map_id, DEFAULT_THEME_TRACK)
|
||||
|
||||
# 5. 进入地图自动切换音乐
|
||||
func on_enter_map(map_id: String) -> String:
|
||||
var target_track = get_effective_map_bgm(map_id)
|
||||
if target_track != current_track or not is_playing:
|
||||
play_bgm(target_track, true)
|
||||
return target_track
|
||||
|
||||
# 6. 点唱机自定义地图专属曲目 (uiselectmusic.py: SelectMusic)
|
||||
func set_custom_map_bgm(map_id: String, track_name: String) -> bool:
|
||||
if not CANONICAL_TRACKS.has(track_name):
|
||||
return false
|
||||
custom_map_overrides[map_id] = track_name
|
||||
return true
|
||||
|
||||
# 7. 恢复地图默认曲目
|
||||
func reset_custom_map_bgm(map_id: String) -> void:
|
||||
if custom_map_overrides.has(map_id):
|
||||
custom_map_overrides.erase(map_id)
|
||||
|
||||
func clear_all_custom_overrides() -> void:
|
||||
custom_map_overrides.clear()
|
||||
@@ -1 +0,0 @@
|
||||
uid://co4sc1bl3n0sj
|
||||
@@ -1,270 +0,0 @@
|
||||
# biologist_chaegirab_research_system.gd —— Metin2 40250 官方生物学者车齿木全套调查研究链与永久属性嘉奖系统 1:1
|
||||
# 对照 40250 服务端 collect_quest_lv30.quest ~ collect_quest_lv94.quest, char.cpp
|
||||
class_name BiologistChaegirabResearchSystem
|
||||
extends RefCounted
|
||||
|
||||
signal sample_submitted(item_name: String, success: bool, current: int, target: int)
|
||||
signal capstone_submitted(stone_name: String, stage_name: String)
|
||||
signal stage_completed(stage_name: String, rewards: Dictionary)
|
||||
|
||||
const NPC_BIOLOGIST := 20084 # 生物学者车齿木 (Biologist Chaegirab)
|
||||
const VNUM_RESEARCH_ELIXIR := 71035 # 贤者之药 (提高提交成功率至 90%)
|
||||
|
||||
# 40250 官方 8 大里程碑阶段全量研究任务链
|
||||
const RESEARCH_STAGES: Array = [
|
||||
{
|
||||
"stage_id": 1,
|
||||
"title": "【Lv 30 研究】兽人牙齿调查",
|
||||
"min_level": 30,
|
||||
"sample_vnum": 30006, # 兽人牙齿
|
||||
"sample_name": "兽人牙齿",
|
||||
"target_count": 10,
|
||||
"soul_stone_vnum": 30220, # 兽人灵魂石
|
||||
"soul_stone_name": "兽人灵魂石",
|
||||
"reward_stats": {"move_speed": 10},
|
||||
"desc": "永久移动速度 +10"
|
||||
},
|
||||
{
|
||||
"stage_id": 2,
|
||||
"title": "【Lv 40 研究】诅咒之书调查",
|
||||
"min_level": 40,
|
||||
"sample_vnum": 30047, # 诅咒之书
|
||||
"sample_name": "诅咒之书",
|
||||
"target_count": 15,
|
||||
"soul_stone_vnum": 30221, # 诅咒灵魂石
|
||||
"soul_stone_name": "诅咒灵魂石",
|
||||
"reward_stats": {"cast_speed_pct": 5},
|
||||
"desc": "永久施法速度 +5%"
|
||||
},
|
||||
{
|
||||
"stage_id": 3,
|
||||
"title": "【Lv 50 研究】恶魔纪念物调查",
|
||||
"min_level": 50,
|
||||
"sample_vnum": 30015, # 恶魔纪念物
|
||||
"sample_name": "恶魔纪念物",
|
||||
"target_count": 15,
|
||||
"soul_stone_vnum": 30222, # 恶魔灵魂石
|
||||
"soul_stone_name": "恶魔灵魂石",
|
||||
"reward_stats": {"defense": 60},
|
||||
"desc": "永久防御力 +60"
|
||||
},
|
||||
{
|
||||
"stage_id": 4,
|
||||
"title": "【Lv 60 研究】冰之碎片调查",
|
||||
"min_level": 60,
|
||||
"sample_vnum": 30050, # 冰之碎片
|
||||
"sample_name": "冰之碎片",
|
||||
"target_count": 20,
|
||||
"soul_stone_vnum": 30223, # 冰霜灵魂石
|
||||
"soul_stone_name": "冰霜灵魂石",
|
||||
"reward_stats": {"attack_power": 50},
|
||||
"desc": "永久物理攻击力 +50"
|
||||
},
|
||||
{
|
||||
"stage_id": 5,
|
||||
"title": "【Lv 70 研究】幽灵树枝调查",
|
||||
"min_level": 70,
|
||||
"sample_vnum": 30165, # 幽灵树枝
|
||||
"sample_name": "幽灵树枝",
|
||||
"target_count": 25,
|
||||
"soul_stone_vnum": 30224, # 森林灵魂石
|
||||
"soul_stone_name": "森林灵魂石",
|
||||
"reward_stats": {"move_speed": 11, "damage_reduction_pct": 10},
|
||||
"desc": "永久移动速度 +11, 伤害抗性 +10%"
|
||||
},
|
||||
{
|
||||
"stage_id": 6,
|
||||
"title": "【Lv 80 研究】巨灵石碑调查",
|
||||
"min_level": 80,
|
||||
"sample_vnum": 30166, # 巨灵石碑
|
||||
"sample_name": "巨灵石碑",
|
||||
"target_count": 30,
|
||||
"soul_stone_vnum": 30225, # 巨灵灵魂石
|
||||
"soul_stone_name": "巨灵灵魂石",
|
||||
"reward_stats": {"attack_speed": 6, "attack_pct": 10},
|
||||
"desc": "永久攻击速度 +6, 物理伤害 +10%"
|
||||
},
|
||||
{
|
||||
"stage_id": 7,
|
||||
"title": "【Lv 85 研究】红木灵枝调查",
|
||||
"min_level": 85,
|
||||
"sample_vnum": 30167, # 红木灵枝
|
||||
"sample_name": "红木灵枝",
|
||||
"target_count": 40,
|
||||
"soul_stone_vnum": 30226, # 灵魂之石
|
||||
"soul_stone_name": "灵魂之石",
|
||||
"reward_stats": {"all_resistance_pct": 10},
|
||||
"desc": "永久全属性抗性 +10%"
|
||||
},
|
||||
{
|
||||
"stage_id": 8,
|
||||
"title": "【Lv 90 研究】首领领主证书调查",
|
||||
"min_level": 90,
|
||||
"sample_vnum": 30168, # 领主证书
|
||||
"sample_name": "首领领主证书",
|
||||
"target_count": 50,
|
||||
"soul_stone_vnum": 30227, # 首领灵魂石
|
||||
"soul_stone_name": "首领灵魂石",
|
||||
"reward_stats": {"boss_damage_pct": 10},
|
||||
"desc": "永久对野外首领伤害 +10%"
|
||||
}
|
||||
]
|
||||
|
||||
var current_stage_index: int = 0
|
||||
var current_samples: int = 0
|
||||
var waiting_for_soul_stone: bool = false
|
||||
var completed_stages: Array = []
|
||||
var permanent_bonuses: Dictionary = {}
|
||||
|
||||
# 获取当前正在进行的研究任务配置
|
||||
func get_current_stage() -> Dictionary:
|
||||
if current_stage_index >= RESEARCH_STAGES.size():
|
||||
return {} # 已全部完成
|
||||
return RESEARCH_STAGES[current_stage_index].duplicate(true)
|
||||
|
||||
# 交付样本材料
|
||||
func submit_sample(inventory: Array, slot_index: int, player_level: int, force_success: bool = false, use_elixir: bool = false) -> Dictionary:
|
||||
var stage = get_current_stage()
|
||||
if stage.is_empty():
|
||||
return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED", "msg": "所有生物学者调查研究均已登峰造极!"}
|
||||
|
||||
if player_level < int(stage["min_level"]):
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "PLAYER_LEVEL_TOO_LOW",
|
||||
"msg": "等级不足!开启该阶段研究需达到 %d 级。" % int(stage["min_level"])
|
||||
}
|
||||
|
||||
if waiting_for_soul_stone:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "WAITING_FOR_SOUL_STONE",
|
||||
"msg": "样本已全部收集齐!请交付【%s】以完成终极认证。" % stage["soul_stone_name"]
|
||||
}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
var item_vnum := int(item.get("vnum", 0))
|
||||
if item_vnum != int(stage["sample_vnum"]):
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "WRONG_SAMPLE_ITEM",
|
||||
"msg": "车齿木摇了摇头:当前阶段需要研究的是【%s】!" % stage["sample_name"]
|
||||
}
|
||||
|
||||
# 消耗 1 件样本
|
||||
var cnt := int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 判定成功率 (基础 60%,使用贤者之药 90%)
|
||||
var success_chance := 0.90 if use_elixir else 0.60
|
||||
var is_success := force_success or (randf() <= success_chance)
|
||||
|
||||
if is_success:
|
||||
current_samples += 1
|
||||
sample_submitted.emit(stage["sample_name"], true, current_samples, stage["target_count"])
|
||||
|
||||
# 检查样本是否集齐
|
||||
if current_samples >= int(stage["target_count"]):
|
||||
waiting_for_soul_stone = true
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"current": current_samples,
|
||||
"target": stage["target_count"],
|
||||
"need_soul_stone": true,
|
||||
"soul_stone_name": stage["soul_stone_name"],
|
||||
"msg": "样本品质极高!%d 件样本已全部收集完毕,请寻找【%s】交付!" % [current_samples, stage["soul_stone_name"]]
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"current": current_samples,
|
||||
"target": stage["target_count"],
|
||||
"need_soul_stone": false,
|
||||
"msg": "样本验证成功!研究进度:(%d / %d)。" % [current_samples, stage["target_count"]]
|
||||
}
|
||||
else:
|
||||
sample_submitted.emit(stage["sample_name"], false, current_samples, stage["target_count"])
|
||||
return {
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"current": current_samples,
|
||||
"target": stage["target_count"],
|
||||
"need_soul_stone": false,
|
||||
"msg": "学者车齿木叹息:这件样本受损严重,无法用于精炼分析。"
|
||||
}
|
||||
|
||||
# 交付关底灵魂石完成该阶段
|
||||
func submit_soul_stone(inventory: Array, slot_index: int, player_stats: Dictionary) -> Dictionary:
|
||||
var stage = get_current_stage()
|
||||
if stage.is_empty():
|
||||
return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED"}
|
||||
|
||||
if not waiting_for_soul_stone:
|
||||
return {"ok": false, "reason": "SAMPLES_NOT_COMPLETED", "msg": "样本尚未收集齐,无法交付灵魂石!"}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
if int(item.get("vnum", 0)) != int(stage["soul_stone_vnum"]):
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "WRONG_SOUL_STONE",
|
||||
"msg": "需要交付的是专属的【%s】!" % stage["soul_stone_name"]
|
||||
}
|
||||
|
||||
# 消耗灵魂石
|
||||
var cnt := int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 注入永久属性加成
|
||||
var rewards: Dictionary = stage.get("reward_stats", {})
|
||||
for k in rewards.keys():
|
||||
var val = int(rewards[k])
|
||||
player_stats[k] = int(player_stats.get(k, 0)) + val
|
||||
permanent_bonuses[k] = int(permanent_bonuses.get(k, 0)) + val
|
||||
|
||||
completed_stages.append(stage["stage_id"])
|
||||
current_stage_index += 1
|
||||
current_samples = 0
|
||||
waiting_for_soul_stone = false
|
||||
|
||||
capstone_submitted.emit(stage["soul_stone_name"], stage["title"])
|
||||
stage_completed.emit(stage["title"], rewards)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"completed_stage": stage["title"],
|
||||
"rewards": rewards,
|
||||
"permanent_bonuses": permanent_bonuses,
|
||||
"msg": "【%s】圆满达成!永久获得属性加成:%s!" % [stage["title"], stage["desc"]]
|
||||
}
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"current_stage_index": current_stage_index,
|
||||
"current_samples": current_samples,
|
||||
"waiting_for_soul_stone": waiting_for_soul_stone,
|
||||
"completed_stages": completed_stages.duplicate(true),
|
||||
"permanent_bonuses": permanent_bonuses.duplicate(true)
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
current_stage_index = int(data.get("current_stage_index", 0))
|
||||
current_samples = int(data.get("current_samples", 0))
|
||||
waiting_for_soul_stone = bool(data.get("waiting_for_soul_stone", false))
|
||||
completed_stages = data.get("completed_stages", []).duplicate(true)
|
||||
permanent_bonuses = data.get("permanent_bonuses", {}).duplicate(true)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dvues2wv3bqb1
|
||||
@@ -1,263 +0,0 @@
|
||||
# biologist_quest_system.gd —— Metin2 40250 生物学研究家任务链与永久属性加成系统 1:1
|
||||
# 对照 40250 服务端 collect_quest_lv30.quest ~ collect_quest_lv80.quest 及 char.cpp:ComputePoints
|
||||
class_name BiologistQuestSystem
|
||||
extends RefCounted
|
||||
|
||||
const NPC_CHAEGIRAB := 20084
|
||||
const ITEM_SPIRAL_POTION := 71035 # 灵魂秘药 / 研究家的秘药
|
||||
|
||||
const STAGES: Array[Dictionary] = [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Lv30 兽人的牙齿研究",
|
||||
"required_level": 30,
|
||||
"collect_vnum": 30006, # 兽人齿
|
||||
"collect_name": "兽人齿",
|
||||
"collect_count": 10,
|
||||
"soul_stone_vnum": 30220, # 兽人灵魂石
|
||||
"soul_stone_name": "金龙之灵魂石",
|
||||
"reward_type": "move_speed",
|
||||
"reward_value": 10,
|
||||
"reward_title": "永久移动速度 +10"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Lv40 诅咒之书研究",
|
||||
"required_level": 40,
|
||||
"collect_vnum": 30047, # 诅咒书
|
||||
"collect_name": "诅咒之书",
|
||||
"collect_count": 15,
|
||||
"soul_stone_vnum": 30221, # 诅咒灵魂石
|
||||
"soul_stone_name": "神殿之灵魂石",
|
||||
"reward_type": "att_speed",
|
||||
"reward_value": 5,
|
||||
"reward_title": "永久攻击速度 +5"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Lv50 恶魔纪念物研究",
|
||||
"required_level": 50,
|
||||
"collect_vnum": 30015, # 恶魔纪念物
|
||||
"collect_name": "恶魔纪念物",
|
||||
"collect_count": 15,
|
||||
"soul_stone_vnum": 30222, # 恶魔灵魂石
|
||||
"soul_stone_name": "恶魔之灵魂石",
|
||||
"reward_type": "def_grade",
|
||||
"reward_value": 60,
|
||||
"reward_title": "永久防御力 +60"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Lv60 冰球研究",
|
||||
"required_level": 60,
|
||||
"collect_vnum": 30050, # 冰球
|
||||
"collect_name": "冰球",
|
||||
"collect_count": 20,
|
||||
"soul_stone_vnum": 30223, # 冰球灵魂石
|
||||
"soul_stone_name": "雪山之灵魂石",
|
||||
"reward_type": "att_grade",
|
||||
"reward_value": 50,
|
||||
"reward_title": "永久攻击力 +50"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Lv70 幽灵树枝研究",
|
||||
"required_level": 70,
|
||||
"collect_vnum": 30165, # 幽灵树枝
|
||||
"collect_name": "幽灵树枝",
|
||||
"collect_count": 25,
|
||||
"soul_stone_vnum": 30224, # 幽灵灵魂石
|
||||
"soul_stone_name": "鬼树之灵魂石",
|
||||
"reward_type": "damage_reduction",
|
||||
"reward_value": 10,
|
||||
"reward_title": "受到伤害永久减少 10%"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Lv80 图基石板研究",
|
||||
"required_level": 80,
|
||||
"collect_vnum": 30166, # 图基石板
|
||||
"collect_name": "图基石板",
|
||||
"collect_count": 30,
|
||||
"soul_stone_vnum": 30225, # 图基灵魂石
|
||||
"soul_stone_name": "图基之灵魂石",
|
||||
"reward_type": "att_grade_pct",
|
||||
"reward_value": 10,
|
||||
"reward_title": "物理攻击力永久提升 10%"
|
||||
}
|
||||
]
|
||||
|
||||
var player_level: int = 1
|
||||
var current_stage_idx: int = -1 # -1: 未开始
|
||||
var delivered_count: int = 0
|
||||
var soul_stone_phase: bool = false
|
||||
var completed_stages: Array[int] = []
|
||||
var cooldown_remaining: float = 0.0
|
||||
|
||||
var permanent_bonuses: Dictionary = {
|
||||
"move_speed": 0,
|
||||
"att_speed": 0,
|
||||
"def_grade": 0,
|
||||
"att_grade": 0,
|
||||
"damage_reduction": 0,
|
||||
"att_grade_pct": 0
|
||||
}
|
||||
|
||||
func init_for_player(level: int) -> void:
|
||||
player_level = level
|
||||
_check_available_stage()
|
||||
|
||||
func _check_available_stage() -> void:
|
||||
if current_stage_idx >= 0 and current_stage_idx < STAGES.size():
|
||||
return # 正在进行中
|
||||
|
||||
for i in range(STAGES.size()):
|
||||
if i not in completed_stages:
|
||||
var stage: Dictionary = STAGES[i]
|
||||
if player_level >= int(stage["required_level"]):
|
||||
current_stage_idx = i
|
||||
delivered_count = 0
|
||||
soul_stone_phase = false
|
||||
break
|
||||
|
||||
func get_current_stage() -> Dictionary:
|
||||
if current_stage_idx >= 0 and current_stage_idx < STAGES.size():
|
||||
return STAGES[current_stage_idx]
|
||||
return {}
|
||||
|
||||
func can_submit() -> bool:
|
||||
return current_stage_idx >= 0 and not soul_stone_phase and cooldown_remaining <= 0.0
|
||||
|
||||
func submit_item(inventory: Array, use_potion: bool = false, force_success: bool = false) -> Dictionary:
|
||||
if current_stage_idx < 0 or current_stage_idx >= STAGES.size():
|
||||
return {"ok": false, "reason": "NO_ACTIVE_QUEST"}
|
||||
if soul_stone_phase:
|
||||
return {"ok": false, "reason": "NEED_SOUL_STONE"}
|
||||
if cooldown_remaining > 0.0 and not use_potion:
|
||||
return {"ok": false, "reason": "COOLDOWN_ACTIVE", "remaining": cooldown_remaining}
|
||||
|
||||
var stage: Dictionary = STAGES[current_stage_idx]
|
||||
var req_vnum: int = int(stage["collect_vnum"])
|
||||
|
||||
# 查找收集物槽位
|
||||
var item_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var it = inventory[i]
|
||||
if it != null and int(it.get("vnum", 0)) == req_vnum:
|
||||
item_slot = i
|
||||
break
|
||||
|
||||
if item_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_ITEM", "vnum": req_vnum}
|
||||
|
||||
# 检查秘药
|
||||
var potion_consumed := false
|
||||
if use_potion:
|
||||
for i in range(inventory.size()):
|
||||
var it = inventory[i]
|
||||
if it != null and int(it.get("vnum", 0)) == ITEM_SPIRAL_POTION:
|
||||
inventory[i] = null
|
||||
potion_consumed = true
|
||||
cooldown_remaining = 0.0
|
||||
break
|
||||
|
||||
# 扣除收集物
|
||||
inventory[item_slot] = null
|
||||
|
||||
# 成功判定 (默认 60%,秘药 90%)
|
||||
var success_chance: float = 0.90 if potion_consumed else 0.60
|
||||
var roll: float = 0.0 if force_success else randf()
|
||||
var success := roll < success_chance
|
||||
|
||||
if not success:
|
||||
cooldown_remaining = 0.0 if potion_consumed else 3600.0 # 1 小时
|
||||
return {
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"delivered": delivered_count,
|
||||
"target": stage["collect_count"],
|
||||
"msg": "样本已损坏,未能提取有效数据!"
|
||||
}
|
||||
|
||||
# 成功交付
|
||||
delivered_count += 1
|
||||
var target_count: int = int(stage["collect_count"])
|
||||
if delivered_count >= target_count:
|
||||
soul_stone_phase = true
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"delivered": delivered_count,
|
||||
"target": target_count,
|
||||
"stage_completed": true,
|
||||
"msg": "研究样本收集完毕!接下来请获取专属灵魂石交付!"
|
||||
}
|
||||
else:
|
||||
cooldown_remaining = 0.0 if potion_consumed else 3600.0
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"delivered": delivered_count,
|
||||
"target": target_count,
|
||||
"stage_completed": false,
|
||||
"msg": "样本质量极佳!已记录样本数据。"
|
||||
}
|
||||
|
||||
func submit_soul_stone(inventory: Array) -> Dictionary:
|
||||
if current_stage_idx < 0 or current_stage_idx >= STAGES.size():
|
||||
return {"ok": false, "reason": "NO_ACTIVE_QUEST"}
|
||||
if not soul_stone_phase:
|
||||
return {"ok": false, "reason": "NOT_IN_SOUL_STONE_PHASE"}
|
||||
|
||||
var stage: Dictionary = STAGES[current_stage_idx]
|
||||
var soul_vnum: int = int(stage["soul_stone_vnum"])
|
||||
|
||||
var found_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var it = inventory[i]
|
||||
if it != null and int(it.get("vnum", 0)) == soul_vnum:
|
||||
found_slot = i
|
||||
break
|
||||
|
||||
if found_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_SOUL_STONE", "vnum": soul_vnum}
|
||||
|
||||
# 消耗灵魂石
|
||||
inventory[found_slot] = null
|
||||
|
||||
# 结算阶段奖励
|
||||
var reward_type: String = String(stage["reward_type"])
|
||||
var reward_val: int = int(stage["reward_value"])
|
||||
permanent_bonuses[reward_type] = int(permanent_bonuses.get(reward_type, 0)) + reward_val
|
||||
completed_stages.append(current_stage_idx)
|
||||
|
||||
var finished_stage_id: int = current_stage_idx
|
||||
current_stage_idx = -1
|
||||
delivered_count = 0
|
||||
soul_stone_phase = false
|
||||
cooldown_remaining = 0.0
|
||||
|
||||
# 检查下一阶段
|
||||
_check_available_stage()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"completed_stage": finished_stage_id,
|
||||
"reward_title": stage["reward_title"],
|
||||
"reward_type": reward_type,
|
||||
"reward_value": reward_val,
|
||||
"all_bonuses": permanent_bonuses.duplicate()
|
||||
}
|
||||
|
||||
func get_quest_status() -> Dictionary:
|
||||
return {
|
||||
"player_level": player_level,
|
||||
"stage_idx": current_stage_idx,
|
||||
"stage_info": get_current_stage(),
|
||||
"delivered": delivered_count,
|
||||
"target": int(get_current_stage().get("collect_count", 0)),
|
||||
"soul_stone_phase": soul_stone_phase,
|
||||
"cooldown_remaining": cooldown_remaining,
|
||||
"completed_stages": completed_stages,
|
||||
"permanent_bonuses": permanent_bonuses
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://cqcq40dpplogo
|
||||
@@ -1,312 +0,0 @@
|
||||
# boss_summon_scroll_system.gd —— Metin2 40250 官方首领降临与魔石/矿脉召唤卷轴系统 1:1
|
||||
# 100% 对照 40250 服务端 special_item_group.txt, char_item.cpp (ITEM_GIFTBOX), char_manager.cpp
|
||||
class_name BossSummonScrollSystem
|
||||
extends RefCounted
|
||||
|
||||
signal boss_summoned(mob_vnum: int, mob_name: String, spawn_pos: Vector2, is_aggressive: bool)
|
||||
signal metin_summoned(metin_vnum: int, metin_name: String, spawn_pos: Vector2, level: int)
|
||||
signal ore_vein_summoned(vein_vnum: int, vein_name: String, spawn_pos: Vector2)
|
||||
signal summon_failed(reason: String)
|
||||
|
||||
# ==========================================
|
||||
# 40250 官方首领召唤卷轴 (Boss Summon Scrolls)
|
||||
# 对应 special_item_group.txt Vnum 71036 ~ 71043
|
||||
# ==========================================
|
||||
const BOSS_SCROLLS: Dictionary = {
|
||||
71036: {
|
||||
"name": "兽人首领召唤卷轴",
|
||||
"tr_name": "Kağıt-Ork Şefi",
|
||||
"mob_vnum": 691,
|
||||
"mob_name": "兽人首领 (Orc Chief)",
|
||||
"level": 50,
|
||||
"rank": 4, # BOSS
|
||||
"is_aggressive": true
|
||||
},
|
||||
71037: {
|
||||
"name": "密教首领召唤卷轴",
|
||||
"tr_name": "Kağıt-Karanlık Lider",
|
||||
"mob_vnum": 791,
|
||||
"mob_name": "黑风教主/密教首领 (Dark Leader)",
|
||||
"level": 54,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71038: {
|
||||
"name": "蜘蛛女王召唤卷轴",
|
||||
"tr_name": "Kağıt-Kraliçe Örümcek",
|
||||
"mob_vnum": 2091,
|
||||
"mob_name": "蜘蛛女王 (Spider Queen)",
|
||||
"level": 60,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71039: {
|
||||
"name": "沙漠巨龟召唤卷轴",
|
||||
"tr_name": "Kağıt-Kaplumbağa",
|
||||
"mob_vnum": 2191,
|
||||
"mob_name": "沙漠巨龟 (Giant Desert Tortoise)",
|
||||
"level": 67,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71040: {
|
||||
"name": "烈焰魔王召唤卷轴",
|
||||
"tr_name": "Kağıt-Alev Kral",
|
||||
"mob_vnum": 2206,
|
||||
"mob_name": "烈焰魔王 (Flame King)",
|
||||
"level": 73,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71041: {
|
||||
"name": "九尾狐召唤卷轴",
|
||||
"tr_name": "Kağıt-Dokuz Kuyruk",
|
||||
"mob_vnum": 1901,
|
||||
"mob_name": "九尾狐仙 (Nine Tails)",
|
||||
"level": 72,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71042: {
|
||||
"name": "恶魔之王召唤卷轴",
|
||||
"tr_name": "Kağıt-Şeytan Kral",
|
||||
"mob_vnum": 1091,
|
||||
"mob_name": "恶魔之王 (Demon King)",
|
||||
"level": 75,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
},
|
||||
71043: {
|
||||
"name": "虎灵妖兽召唤卷轴",
|
||||
"tr_name": "Sarı Kaplan H. Kağıdı",
|
||||
"mob_vnum": 1304,
|
||||
"mob_name": "黄虎幽灵 (Tiger Ghost)",
|
||||
"level": 75,
|
||||
"rank": 4,
|
||||
"is_aggressive": true
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 40250 官方魔石召唤卷轴 (Metin Summon Scrolls)
|
||||
# 对应 special_item_group.txt Vnum 71080 ~ 71082
|
||||
# ==========================================
|
||||
const METIN_SCROLLS: Dictionary = {
|
||||
71080: {
|
||||
"name": "初级魔石召唤卷",
|
||||
"tr_name": "Metin Taşı Çağırma Kağıdı (K)",
|
||||
"pool": [
|
||||
{"vnum": 8001, "name": "悲哀之石 (Metin of Sorrow)", "level": 5},
|
||||
{"vnum": 8002, "name": "战斗之石 (Metin of Combat)", "level": 10},
|
||||
{"vnum": 8003, "name": "会战之石 (Metin of Battle)", "level": 15},
|
||||
{"vnum": 8004, "name": "贪婪之石 (Metin of Greed)", "level": 20},
|
||||
{"vnum": 8005, "name": "漆黑之石 (Metin of Black)", "level": 25}
|
||||
]
|
||||
},
|
||||
71081: {
|
||||
"name": "中级魔石召唤卷",
|
||||
"tr_name": "Metin Taşı Çağırma Kağıdı (O)",
|
||||
"pool": [
|
||||
{"vnum": 8006, "name": "黑暗之石 (Metin of Darkness)", "level": 30},
|
||||
{"vnum": 8007, "name": "嫉妒之石 (Metin of Jealousy)", "level": 35},
|
||||
{"vnum": 8008, "name": "灵魂之石 (Metin of Soul)", "level": 40},
|
||||
{"vnum": 8009, "name": "阴影之石 (Metin of Shadow)", "level": 45},
|
||||
{"vnum": 8010, "name": "强韧之石 (Metin of Toughness)", "level": 50}
|
||||
]
|
||||
},
|
||||
71082: {
|
||||
"name": "高级魔石召唤卷",
|
||||
"tr_name": "Metin Taşı Çağırma Kağıdı (Z)",
|
||||
"pool": [
|
||||
{"vnum": 8011, "name": "恶魔之石 (Metin of Devil)", "level": 55},
|
||||
{"vnum": 8012, "name": "陨落之石 (Metin of Fall)", "level": 60},
|
||||
{"vnum": 8013, "name": "死者之石 (Metin of Death)", "level": 65},
|
||||
{"vnum": 8014, "name": "屠杀之石 (Metin of Murder)", "level": 70},
|
||||
{"vnum": 8024, "name": "狂风之石 (Metin of Pung-Ma)", "level": 75},
|
||||
{"vnum": 8025, "name": "魔岩之石 (Metin of Ma-An)", "level": 80},
|
||||
{"vnum": 8026, "name": "斗击之石 (Metin of Tu-Gyuk)", "level": 85},
|
||||
{"vnum": 8027, "name": "绝望之石 (Metin of Jeon-Un)", "level": 90}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 40250 官方矿脉召唤卷轴 (Ore Vein Scrolls)
|
||||
# 对应 special_item_group.txt Vnum 71057 ~ 71067
|
||||
# ==========================================
|
||||
const ORE_VEIN_SCROLLS: Dictionary = {
|
||||
71057: {"mob_vnum": 20049, "name": "钻石矿脉 (Diamond Vein)"},
|
||||
71058: {"mob_vnum": 20050, "name": "琥珀矿脉 (Amber Vein)"},
|
||||
71059: {"mob_vnum": 20051, "name": "木化石矿脉 (Fossil Wood Vein)"},
|
||||
71060: {"mob_vnum": 20052, "name": "铜矿脉 (Copper Vein)"},
|
||||
71061: {"mob_vnum": 20053, "name": "银矿脉 (Silver Vein)"},
|
||||
71062: {"mob_vnum": 20054, "name": "金矿脉 (Gold Vein)"},
|
||||
71063: {"mob_vnum": 20055, "name": "翡翠矿脉 (Jade Vein)"},
|
||||
71064: {"mob_vnum": 20056, "name": "黑檀木矿脉 (Ebony Vein)"},
|
||||
71065: {"mob_vnum": 20057, "name": "珍珠矿脉 (Pearl Vein)"},
|
||||
71066: {"mob_vnum": 20058, "name": "白金矿脉 (White Gold Vein)"},
|
||||
71067: {"mob_vnum": 20059, "name": "水晶矿脉 (Crystal Vein)"}
|
||||
}
|
||||
|
||||
# 当前已召唤生成的实体记录列表
|
||||
var _active_summoned_entities: Array[Dictionary] = []
|
||||
|
||||
# 判断是否是 40250 原版召唤卷轴
|
||||
func is_summon_scroll(vnum: int) -> bool:
|
||||
return BOSS_SCROLLS.has(vnum) or METIN_SCROLLS.has(vnum) or ORE_VEIN_SCROLLS.has(vnum)
|
||||
|
||||
# 获取召唤卷轴类型类别
|
||||
func get_scroll_category(vnum: int) -> String:
|
||||
if BOSS_SCROLLS.has(vnum):
|
||||
return "boss"
|
||||
elif METIN_SCROLLS.has(vnum):
|
||||
return "metin"
|
||||
elif ORE_VEIN_SCROLLS.has(vnum):
|
||||
return "ore_vein"
|
||||
return ""
|
||||
|
||||
# 使用召唤卷轴
|
||||
func use_summon_scroll(
|
||||
inventory: Array,
|
||||
slot_index: int,
|
||||
player_pos: Vector2,
|
||||
is_in_safe_zone: bool,
|
||||
forced_choice_idx: int = -1
|
||||
) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size():
|
||||
summon_failed.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包物品格位!"}
|
||||
|
||||
var item = inventory[slot_index]
|
||||
if item == null:
|
||||
summon_failed.emit("EMPTY_SLOT")
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "指定格位没有任何道具!"}
|
||||
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
var category = get_scroll_category(vnum)
|
||||
if category == "":
|
||||
summon_failed.emit("NOT_A_SUMMON_SCROLL")
|
||||
return {"ok": false, "reason": "NOT_A_SUMMON_SCROLL", "msg": "此物品并非怪物或魔石召唤卷轴!"}
|
||||
|
||||
# 40250 原版规则:安全区内禁止召唤具有攻击性的首领与魔石
|
||||
if is_in_safe_zone and category != "ore_vein":
|
||||
summon_failed.emit("CANNOT_SPAWN_IN_SAFEZONE")
|
||||
return {"ok": false, "reason": "CANNOT_SPAWN_IN_SAFEZONE", "msg": "在安全区内无法召唤怪物!请移至野外危险区域。"}
|
||||
|
||||
# 扣除卷轴消耗 (40250 item->SetCount(item->GetCount()-1))
|
||||
var current_count = int(item.get("count", 1))
|
||||
if current_count > 1:
|
||||
item["count"] = current_count - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 生成随机偏移位置 (类似 40250 GetX() + number(-500, 500))
|
||||
var offset_x = randf_range(-120.0, 120.0)
|
||||
var offset_y = randf_range(-120.0, 120.0)
|
||||
# 确保不会生成在绝对重合点
|
||||
if abs(offset_x) < 30.0:
|
||||
offset_x = 50.0 if offset_x >= 0.0 else -50.0
|
||||
if abs(offset_y) < 30.0:
|
||||
offset_y = 50.0 if offset_y >= 0.0 else -50.0
|
||||
|
||||
var spawn_pos = Vector2(player_pos.x + offset_x, player_pos.y + offset_y)
|
||||
var summoned_entity: Dictionary = {}
|
||||
|
||||
match category:
|
||||
"boss":
|
||||
var boss_info = BOSS_SCROLLS[vnum]
|
||||
summoned_entity = {
|
||||
"vnum": int(boss_info["mob_vnum"]),
|
||||
"name": str(boss_info["mob_name"]),
|
||||
"level": int(boss_info["level"]),
|
||||
"rank": int(boss_info["rank"]),
|
||||
"pos": spawn_pos,
|
||||
"is_aggressive": true,
|
||||
"category": "boss",
|
||||
"summoner_pos": player_pos
|
||||
}
|
||||
_active_summoned_entities.append(summoned_entity)
|
||||
boss_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos, true)
|
||||
|
||||
"metin":
|
||||
var metin_scroll = METIN_SCROLLS[vnum]
|
||||
var pool: Array = metin_scroll["pool"]
|
||||
var picked_idx = 0
|
||||
if forced_choice_idx >= 0 and forced_choice_idx < pool.size():
|
||||
picked_idx = forced_choice_idx
|
||||
else:
|
||||
picked_idx = randi() % pool.size()
|
||||
|
||||
var picked_metin = pool[picked_idx]
|
||||
summoned_entity = {
|
||||
"vnum": int(picked_metin["vnum"]),
|
||||
"name": str(picked_metin["name"]),
|
||||
"level": int(picked_metin["level"]),
|
||||
"rank": 5, # STONE
|
||||
"pos": spawn_pos,
|
||||
"is_aggressive": false,
|
||||
"category": "metin",
|
||||
"summoner_pos": player_pos
|
||||
}
|
||||
_active_summoned_entities.append(summoned_entity)
|
||||
metin_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos, summoned_entity["level"])
|
||||
|
||||
"ore_vein":
|
||||
var vein_info = ORE_VEIN_SCROLLS[vnum]
|
||||
summoned_entity = {
|
||||
"vnum": int(vein_info["mob_vnum"]),
|
||||
"name": str(vein_info["name"]),
|
||||
"pos": spawn_pos,
|
||||
"is_aggressive": false,
|
||||
"category": "ore_vein",
|
||||
"summoner_pos": player_pos
|
||||
}
|
||||
_active_summoned_entities.append(summoned_entity)
|
||||
ore_vein_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"category": category,
|
||||
"entity": summoned_entity,
|
||||
"msg": "卷轴散发出一道耀眼的光芒,强大的封印能量破空而出!"
|
||||
}
|
||||
|
||||
# 获取当前已召唤的所有活跃实体
|
||||
func get_active_summoned_entities() -> Array[Dictionary]:
|
||||
return _active_summoned_entities
|
||||
|
||||
# 清空/移除已击败或离开的实体
|
||||
func remove_summoned_entity(index: int) -> bool:
|
||||
if index >= 0 and index < _active_summoned_entities.size():
|
||||
_active_summoned_entities.remove_at(index)
|
||||
return true
|
||||
return false
|
||||
|
||||
func clear_summoned_entities() -> void:
|
||||
_active_summoned_entities.clear()
|
||||
|
||||
# 存档与读档支持
|
||||
func serialize() -> Dictionary:
|
||||
var list_copy: Array = []
|
||||
for e in _active_summoned_entities:
|
||||
var pos_dict = {"x": e["pos"].x, "y": e["pos"].y}
|
||||
var copy_e = e.duplicate()
|
||||
copy_e["pos"] = pos_dict
|
||||
if copy_e.has("summoner_pos"):
|
||||
copy_e["summoner_pos"] = {"x": e["summoner_pos"].x, "y": e["summoner_pos"].y}
|
||||
list_copy.append(copy_e)
|
||||
return {
|
||||
"active_entities": list_copy
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
_active_summoned_entities.clear()
|
||||
var raw_list = data.get("active_entities", [])
|
||||
for item in raw_list:
|
||||
var pos_data = item.get("pos", {"x": 0.0, "y": 0.0})
|
||||
var ent = item.duplicate()
|
||||
ent["pos"] = Vector2(pos_data.get("x", 0.0), pos_data.get("y", 0.0))
|
||||
if ent.has("summoner_pos"):
|
||||
var sp_data = ent.get("summoner_pos", {"x": 0.0, "y": 0.0})
|
||||
ent["summoner_pos"] = Vector2(sp_data.get("x", 0.0), sp_data.get("y", 0.0))
|
||||
_active_summoned_entities.append(ent)
|
||||
@@ -1 +0,0 @@
|
||||
uid://xjrr61n0rkui
|
||||
@@ -1,147 +0,0 @@
|
||||
# chat_hyperlink_dice_system.gd —— 40250 聊天物品超链接与 /dice 掷骰系统 1:1
|
||||
# 严格对照:
|
||||
# ClientVS22/source/EterLib/TextTag.cpp:32 (Hyperlink |Hitem:vnum:flags:socket:attr|h[%s]|h)
|
||||
# Client/Eternexus/root/uichat.py & uitooltip.py (ItemHyperlink, SetHyperlinkItem)
|
||||
# metin2/src/server/game/src/cmd_general.cpp:2392 (do_dice)
|
||||
extends RefCounted
|
||||
|
||||
const LINK_COLOR := "ffffc700" # 官方物品链接专用亮金黄色
|
||||
|
||||
# 1. 将物品数据编码为 40250 官方超链接格式
|
||||
# 格式: |cffffc700|Hitem:vnum:flags:s0:s1:s2:a0_t:a0_v:a1_t:a1_v:...|h[物品名]|h|r
|
||||
static func encode_item_hyperlink(item_data: Dictionary) -> String:
|
||||
var vnum: int = item_data.get("vnum", 0)
|
||||
var name: String = item_data.get("name", "Unknown Item")
|
||||
var flags: int = item_data.get("flags", 0)
|
||||
|
||||
var sockets: Array = item_data.get("sockets", [0, 0, 0])
|
||||
while sockets.size() < 3:
|
||||
sockets.append(0)
|
||||
|
||||
var attrs: Array = item_data.get("attributes", [])
|
||||
var attr_parts: Array = []
|
||||
for a in attrs:
|
||||
attr_parts.append("%d:%d" % [a.get("type", 0), a.get("value", 0)])
|
||||
|
||||
var payload_parts := [
|
||||
str(vnum),
|
||||
str(flags),
|
||||
str(sockets[0]),
|
||||
str(sockets[1]),
|
||||
str(sockets[2])
|
||||
]
|
||||
if not attr_parts.is_empty():
|
||||
payload_parts.append((":").join(attr_parts))
|
||||
|
||||
var payload := (":").join(payload_parts)
|
||||
return "|c%s|Hitem:%s|h[%s]|h|r" % [LINK_COLOR, payload, name]
|
||||
|
||||
# 2. 从超链接中解析提取物品完整数据结构 (1:1 TextTag::FindHyperlink)
|
||||
static func decode_item_hyperlink(hyperlink_text: String) -> Dictionary:
|
||||
var res := {
|
||||
"valid": false,
|
||||
"vnum": 0,
|
||||
"name": "",
|
||||
"flags": 0,
|
||||
"sockets": [0, 0, 0],
|
||||
"attributes": []
|
||||
}
|
||||
|
||||
# 正则或字符串匹配提取 |Hitem:(.+?)\|h\[(.*?)\]\|h
|
||||
var h_start := hyperlink_text.find("|Hitem:")
|
||||
if h_start == -1:
|
||||
return res
|
||||
|
||||
var payload_start := h_start + 7 # len("|Hitem:")
|
||||
var payload_end := hyperlink_text.find("|h[", payload_start)
|
||||
if payload_end == -1:
|
||||
return res
|
||||
|
||||
var payload := hyperlink_text.substr(payload_start, payload_end - payload_start)
|
||||
|
||||
var name_start := payload_end + 3 # len("|h[")
|
||||
var name_end := hyperlink_text.find("]|h", name_start)
|
||||
if name_end == -1:
|
||||
return res
|
||||
|
||||
var display_name := hyperlink_text.substr(name_start, name_end - name_start)
|
||||
res["name"] = display_name
|
||||
|
||||
var tokens := payload.split(":")
|
||||
if tokens.size() < 5:
|
||||
return res
|
||||
|
||||
res["vnum"] = tokens[0].to_int()
|
||||
res["flags"] = tokens[1].to_int()
|
||||
res["sockets"] = [tokens[2].to_int(), tokens[3].to_int(), tokens[4].to_int()]
|
||||
|
||||
# 解析属性对 (type:value)
|
||||
var attrs: Array = []
|
||||
var idx := 5
|
||||
while idx + 1 < tokens.size():
|
||||
var at_type := tokens[idx].to_int()
|
||||
var at_val := tokens[idx + 1].to_int()
|
||||
attrs.append({ "type": at_type, "value": at_val })
|
||||
idx += 2
|
||||
|
||||
res["attributes"] = attrs
|
||||
res["valid"] = true
|
||||
return res
|
||||
|
||||
# 3. 模拟 Tooltip 生成(悬停时渲染内容)
|
||||
static func build_tooltip_text(item_dict: Dictionary) -> String:
|
||||
if not item_dict.get("valid", false):
|
||||
return "无效的物品链接"
|
||||
|
||||
var lines: Array = []
|
||||
lines.append("=== %s (Vnum: %d) ===" % [item_dict["name"], item_dict["vnum"]])
|
||||
|
||||
# 宝石孔位信息
|
||||
var sockets: Array = item_dict.get("sockets", [])
|
||||
for i in range(sockets.size()):
|
||||
var s: int = sockets[i]
|
||||
if s > 0:
|
||||
lines.append("孔位 %d: 镶嵌宝石 [Vnum %d]" % [i + 1, s])
|
||||
elif s == 1: # 官方孔位开孔标记
|
||||
lines.append("孔位 %d: 空孔" % [i + 1])
|
||||
|
||||
# 附加属性信息 (1~7 条)
|
||||
var attrs: Array = item_dict.get("attributes", [])
|
||||
for a in attrs:
|
||||
lines.append("附加属性: 类型 %d +%d" % [a["type"], a["value"]])
|
||||
|
||||
return ("\n").join(lines)
|
||||
|
||||
# 4. 40250 官方 /dice 掷骰系统 (1:1 ACMD(do_dice))
|
||||
static func execute_dice(player_name: String, args: Array = [], roll_override: int = -1) -> Dictionary:
|
||||
var start_val := 1
|
||||
var end_val := 100
|
||||
|
||||
if args.size() >= 2:
|
||||
start_val = int(args[0])
|
||||
end_val = int(args[1])
|
||||
elif args.size() == 1:
|
||||
start_val = 1
|
||||
end_val = int(args[0])
|
||||
|
||||
# 40250 规则: end = MAX(start, end); start = MIN(start, end);
|
||||
var real_start := mini(start_val, end_val)
|
||||
var real_end := maxi(start_val, end_val)
|
||||
|
||||
var roll_result: int = 0
|
||||
if roll_override >= 0:
|
||||
roll_result = clamp(roll_override, real_start, real_end)
|
||||
else:
|
||||
roll_result = randi() % (real_end - real_start + 1) + real_start
|
||||
|
||||
var broadcast_msg := "%s 投掷了骰子,点数为 %d (%d-%d)" % [
|
||||
player_name, roll_result, real_start, real_end
|
||||
]
|
||||
|
||||
return {
|
||||
"player": player_name,
|
||||
"roll": roll_result,
|
||||
"start": real_start,
|
||||
"end": real_end,
|
||||
"message": broadcast_msg
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://bnktthkdod33w
|
||||
@@ -1,233 +0,0 @@
|
||||
# class_specialization_system.gd —— Metin2 40250 角色 30 级双流派转职与无损洗髓系统 1:1
|
||||
# 对照 40250 服务端 quest/skill_group.quest, char_skill.cpp, cmd_general.cpp
|
||||
class_name ClassSpecializationSystem
|
||||
extends RefCounted
|
||||
|
||||
signal specialization_chosen(job: int, spec_id: int, spec_name: String)
|
||||
signal skill_point_allocated(skill_vnum: int, new_level: int, remaining_points: int)
|
||||
signal specialization_reset(refunded_points: int)
|
||||
|
||||
const VNUM_RESET_SCROLL := 71100 # 洗髓卷轴 / 重新分配技能书 (Scroll of Redistribution)
|
||||
const REQUIRED_LEVEL := 30 # 转职等级门槛
|
||||
|
||||
const SPEC_TREE: Dictionary = {
|
||||
0: { # 战士
|
||||
1: {
|
||||
"name": "战狂流派 (Arahan)",
|
||||
"desc": "极度偏向狂暴输出与高速连斩,兼具霸体与破坏力",
|
||||
"skills": {
|
||||
1: "三连斩",
|
||||
2: "旋风斩",
|
||||
3: "狂战士",
|
||||
4: "剑气",
|
||||
5: "冲锋"
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"name": "圣战流派 (Partisan)",
|
||||
"desc": "坚若磐石的钢铁之躯,以强身术与精神打击粉碎强敌",
|
||||
"skills": {
|
||||
16: "强身术",
|
||||
17: "精神打击",
|
||||
18: "宝剑斩",
|
||||
19: "震地波",
|
||||
20: "剑气斩"
|
||||
}
|
||||
}
|
||||
},
|
||||
1: { # 刺客
|
||||
1: {
|
||||
"name": "近战刺客流派 (Dagger)",
|
||||
"desc": "隐身于阴影之中,擅长淬毒与致命近身背刺",
|
||||
"skills": {
|
||||
31: "伏击",
|
||||
32: "绝杀",
|
||||
33: "匕首旋风",
|
||||
34: "隐身术",
|
||||
35: "毒刃"
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"name": "远程弓箭流派 (Archer)",
|
||||
"desc": "千里之外取敌首级,漫天箭雨与火炎穿透射击",
|
||||
"skills": {
|
||||
46: "连射",
|
||||
47: "箭雨",
|
||||
48: "火炎箭",
|
||||
49: "疾风步",
|
||||
50: "毒箭"
|
||||
}
|
||||
}
|
||||
},
|
||||
2: { # 修罗
|
||||
1: {
|
||||
"name": "魔剑修罗流派 (Weapon)",
|
||||
"desc": "附魔之刃附体,吸血噬魂与破魔剑气并存",
|
||||
"skills": {
|
||||
61: "附魔之刃",
|
||||
62: "附魔之甲",
|
||||
63: "惧意",
|
||||
64: "龙卷风",
|
||||
65: "手指戳击"
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"name": "黑魔法修罗流派 (Black Magic)",
|
||||
"desc": "掌控幽冥暗焰,幽暗之盾抵消伤害,黑暗之球远程轰杀",
|
||||
"skills": {
|
||||
76: "幽冥一击",
|
||||
77: "烈焰一击",
|
||||
78: "黑暗之球",
|
||||
79: "幽暗之盾",
|
||||
80: "鬼影步"
|
||||
}
|
||||
}
|
||||
},
|
||||
3: { # 萨满
|
||||
1: {
|
||||
"name": "巨龙神圣辅助流派 (Dragon)",
|
||||
"desc": "掌控巨龙图腾之力,为团队提供巨龙之赐与龙之协助光环",
|
||||
"skills": {
|
||||
91: "飞龙射击",
|
||||
92: "巨龙射击",
|
||||
93: "巨龙咆哮",
|
||||
94: "巨龙之赐",
|
||||
95: "龙之协助"
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"name": "雷电神圣治疗流派 (Lightning)",
|
||||
"desc": "引动九天雷电轰杀群敌,施展神圣治愈术与疾风迅捷",
|
||||
"skills": {
|
||||
106: "雷电箭",
|
||||
107: "召唤雷电",
|
||||
108: "雷电之爪",
|
||||
109: "治愈术",
|
||||
110: "迅捷步"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 获取职业可选流派
|
||||
func get_available_specializations(job: int) -> Dictionary:
|
||||
return SPEC_TREE.get(job, {})
|
||||
|
||||
# 进行 30 级转职选定流派
|
||||
func choose_specialization(spec_id: int, player_data: Dictionary) -> Dictionary:
|
||||
var job: int = int(player_data.get("job", 0))
|
||||
var lvl: int = int(player_data.get("level", 1))
|
||||
|
||||
if lvl < REQUIRED_LEVEL:
|
||||
return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "转职需要角色等级达到 30 级!当前等级: %d" % lvl}
|
||||
|
||||
var cur_group: int = int(player_data.get("skill_group", 0))
|
||||
if cur_group != 0:
|
||||
return {"ok": false, "reason": "ALREADY_SPECIALIZED", "msg": "你已选定了流派!若需变更,请先使用【洗髓卷轴】。"}
|
||||
|
||||
if not SPEC_TREE.has(job) or not SPEC_TREE[job].has(spec_id):
|
||||
return {"ok": false, "reason": "INVALID_SPEC_ID", "msg": "无效的流派代码!"}
|
||||
|
||||
var spec = SPEC_TREE[job][spec_id]
|
||||
player_data["skill_group"] = spec_id
|
||||
|
||||
# 初始化该流派专属 5 大技能
|
||||
if not player_data.has("skills"):
|
||||
player_data["skills"] = {}
|
||||
|
||||
for svnum in spec["skills"].keys():
|
||||
player_data["skills"][svnum] = {
|
||||
"name": spec["skills"][svnum],
|
||||
"level": 0,
|
||||
"master_type": 0
|
||||
}
|
||||
|
||||
specialization_chosen.emit(job, spec_id, spec["name"])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"job": job,
|
||||
"spec_id": spec_id,
|
||||
"spec_name": spec["name"],
|
||||
"skills_count": spec["skills"].size(),
|
||||
"msg": "恭喜!成功完成 30 级转职,踏入【%s】流派之道!" % spec["name"]
|
||||
}
|
||||
|
||||
# 学习与加点技能
|
||||
func allocate_skill_point(skill_vnum: int, player_data: Dictionary) -> Dictionary:
|
||||
var cur_group: int = int(player_data.get("skill_group", 0))
|
||||
if cur_group == 0:
|
||||
return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "尚未进行 30 级转职,无法修习流派绝学!"}
|
||||
|
||||
var pts: int = int(player_data.get("skill_points", 0))
|
||||
if pts <= 0:
|
||||
return {"ok": false, "reason": "NO_SKILL_POINTS", "msg": "技能点数不足!"}
|
||||
|
||||
var skills: Dictionary = player_data.get("skills", {})
|
||||
if not skills.has(skill_vnum):
|
||||
return {"ok": false, "reason": "SKILL_NOT_AVAILABLE", "msg": "此技能不属于当前专精流派!"}
|
||||
|
||||
var sk = skills[skill_vnum]
|
||||
var cur_lvl: int = int(sk.get("level", 0))
|
||||
if cur_lvl >= 20:
|
||||
return {"ok": false, "reason": "MAX_NORMAL_LEVEL", "msg": "该技能基础等级已满 (20),进阶需使用技能秘籍!"}
|
||||
|
||||
sk["level"] = cur_lvl + 1
|
||||
player_data["skill_points"] = pts - 1
|
||||
|
||||
skill_point_allocated.emit(skill_vnum, sk["level"], player_data["skill_points"])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"skill_vnum": skill_vnum,
|
||||
"new_level": sk["level"],
|
||||
"remaining_points": player_data["skill_points"],
|
||||
"msg": "【%s】提升至等级 %d!" % [sk.get("name", "技能"), sk["level"]]
|
||||
}
|
||||
|
||||
# 使用洗髓卷轴 (71100) 无损重置流派与返还技能点
|
||||
func reset_specialization(inventory: Array, player_data: Dictionary, free_reset: bool = false) -> Dictionary:
|
||||
var cur_group: int = int(player_data.get("skill_group", 0))
|
||||
if cur_group == 0:
|
||||
return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "当前尚未选定任何专精流派,无需洗髓!"}
|
||||
|
||||
# 卷轴消耗检查
|
||||
var scroll_slot = -1
|
||||
if not free_reset:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_RESET_SCROLL:
|
||||
scroll_slot = i
|
||||
break
|
||||
if scroll_slot == -1:
|
||||
return {"ok": false, "reason": "NEED_RESET_SCROLL", "msg": "需要【洗髓卷轴】(71100) 才能重置专精流派!"}
|
||||
|
||||
# 统计已投入的所有技能点数
|
||||
var skills: Dictionary = player_data.get("skills", {})
|
||||
var total_invested: int = 0
|
||||
for svnum in skills.keys():
|
||||
var sk = skills[svnum]
|
||||
total_invested += int(sk.get("level", 0))
|
||||
|
||||
# 消耗卷轴
|
||||
if scroll_slot != -1:
|
||||
var sc_item = inventory[scroll_slot]
|
||||
var sc_cnt = int(sc_item.get("count", 1))
|
||||
if sc_cnt > 1:
|
||||
sc_item["count"] = sc_cnt - 1
|
||||
else:
|
||||
inventory[scroll_slot] = null
|
||||
|
||||
# 全额返还技能点
|
||||
var cur_pts: int = int(player_data.get("skill_points", 0))
|
||||
player_data["skill_points"] = cur_pts + total_invested
|
||||
player_data["skill_group"] = 0
|
||||
player_data["skills"] = {}
|
||||
|
||||
specialization_reset.emit(total_invested)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"refunded_points": total_invested,
|
||||
"total_skill_points": player_data["skill_points"],
|
||||
"msg": "洗髓成功!经脉重铸,%d 点技能点数全额返还,可重新选定双分支流派!" % total_invested
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://cxlbtf4m6ejc4
|
||||
@@ -1,203 +0,0 @@
|
||||
# classic_pet_system.gd —— Metin2 40250 经典封印宠物跟随与守护系统 1:1
|
||||
# 对照 40250 服务端 PetSystem.cpp, PetSystem.h, questlua_pet.cpp
|
||||
class_name ClassicPetSystem
|
||||
extends RefCounted
|
||||
|
||||
signal pet_summoned(mob_vnum: int, pet_name: String, buffs: Dictionary)
|
||||
signal pet_unsummoned(mob_vnum: int, pet_name: String)
|
||||
signal pet_moved(current_pos: Vector2, is_teleport: bool)
|
||||
|
||||
# 40250 官方经典宠物召唤 Mob Vnum
|
||||
const MOB_BABY_BOAR := 34001 # 幼野猪
|
||||
const MOB_BABY_WOLF := 34002 # 幼狼
|
||||
const MOB_BABY_TIGER := 34003 # 幼虎
|
||||
const MOB_BABY_LION := 34004 # 幼狮
|
||||
const MOB_BABY_FIREDRAKE := 34005 # 幼火龙
|
||||
const MOB_ICE_PHOENIX := 34006 # 冰凤凰
|
||||
const MOB_BABY_AZRAEL := 34009 # 幼小阿兹瑞尔
|
||||
|
||||
# 40250 官方经典封印物品 Vnum
|
||||
const SEAL_BABY_BOAR := 53001 # 幼野猪封印
|
||||
const SEAL_BABY_FIREDRAKE := 53002 # 幼火龙封印
|
||||
const SEAL_ICE_PHOENIX := 53003 # 冰凤凰封印
|
||||
const SEAL_BABY_LION := 53004 # 幼狮封印
|
||||
const SEAL_BABY_AZRAEL := 53005 # 幼小阿兹瑞尔封印
|
||||
|
||||
# 40250 AI 跟随与瞬移距离参数
|
||||
const MIN_FOLLOW_DIST := 2.0 # 最小跟随停步距离 (米)
|
||||
const MAX_TELEPORT_DIST := 15.0 # 最大跟随超距,超过直接瞬移到身边 (米)
|
||||
const FOLLOW_SPEED := 6.0 # 跟随移动速度
|
||||
|
||||
# 40250 宠物封印配置表
|
||||
const PET_SEAL_CONFIGS: Dictionary = {
|
||||
SEAL_BABY_BOAR: {
|
||||
"mob_vnum": MOB_BABY_BOAR,
|
||||
"name": "幼野猪",
|
||||
"buffs": {"monster_damage_pct": 15},
|
||||
"dungeon_only": false
|
||||
},
|
||||
SEAL_BABY_FIREDRAKE: {
|
||||
"mob_vnum": MOB_BABY_FIREDRAKE,
|
||||
"name": "幼火龙",
|
||||
"buffs": {"max_hp": 1500, "attack_pct": 15},
|
||||
"dungeon_only": false
|
||||
},
|
||||
SEAL_ICE_PHOENIX: {
|
||||
"mob_vnum": MOB_ICE_PHOENIX,
|
||||
"name": "冰凤凰",
|
||||
"buffs": {"max_hp": 1500, "defense_pct": 15},
|
||||
"dungeon_only": false
|
||||
},
|
||||
SEAL_BABY_LION: {
|
||||
"mob_vnum": MOB_BABY_LION,
|
||||
"name": "幼狮",
|
||||
"buffs": {"crit_pct": 10},
|
||||
"dungeon_only": false
|
||||
},
|
||||
SEAL_BABY_AZRAEL: {
|
||||
"mob_vnum": MOB_BABY_AZRAEL,
|
||||
"name": "小阿兹瑞尔",
|
||||
"buffs": {"dungeon_attack_pct": 20, "max_hp": 1000},
|
||||
"dungeon_only": true # 40250 源码判定:若不在地牢中,专属地牢增益不生效
|
||||
}
|
||||
}
|
||||
|
||||
# 当前宠物出战状态
|
||||
var is_summoned: bool = false
|
||||
var active_mob_vnum: int = 0
|
||||
var active_seal_vnum: int = 0
|
||||
var active_pet_name: String = ""
|
||||
var active_buffs: Dictionary = {}
|
||||
var pet_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
# 检查物品是否为 40250 宠物封印
|
||||
static func is_pet_seal(vnum: int) -> bool:
|
||||
return PET_SEAL_CONFIGS.has(vnum)
|
||||
|
||||
# 获取宠物封印配置
|
||||
static func get_pet_config_by_seal(seal_vnum: int) -> Dictionary:
|
||||
return PET_SEAL_CONFIGS.get(seal_vnum, {}).duplicate(true)
|
||||
|
||||
# 召唤经典封印宠物 (40250 CPetSystem::Summon)
|
||||
func summon_pet(seal_item: Dictionary, player_stats: Dictionary, is_in_dungeon: bool = false, owner_pos: Vector2 = Vector2.ZERO) -> Dictionary:
|
||||
var seal_vnum := int(seal_item.get("vnum", 0))
|
||||
if not PET_SEAL_CONFIGS.has(seal_vnum):
|
||||
return {"ok": false, "reason": "NOT_A_PET_SEAL", "msg": "该物品不是有效的宠物封印!"}
|
||||
|
||||
# 若已有宠物出战,先解散旧宠物 (40250 规则:同时只出战一只)
|
||||
if is_summoned:
|
||||
unsummon_pet(player_stats)
|
||||
|
||||
var conf: Dictionary = PET_SEAL_CONFIGS[seal_vnum]
|
||||
active_mob_vnum = conf["mob_vnum"]
|
||||
active_seal_vnum = seal_vnum
|
||||
active_pet_name = conf["name"]
|
||||
pet_pos = owner_pos + Vector2(1.5, 0.0) # 出生在身侧
|
||||
|
||||
# 计算注入的 Buff (40250 GiveBuff 规则)
|
||||
var buffs_to_apply: Dictionary = {}
|
||||
var base_buffs: Dictionary = conf.get("buffs", {})
|
||||
var is_dungeon_only: bool = bool(conf.get("dungeon_only", false))
|
||||
|
||||
for stat_key in base_buffs.keys():
|
||||
var val = base_buffs[stat_key]
|
||||
# 40250 特殊判定:小阿兹瑞尔等特定宠物仅在地牢中激活特定地牢 Buff
|
||||
if stat_key == "dungeon_attack_pct":
|
||||
if is_in_dungeon:
|
||||
buffs_to_apply[stat_key] = val
|
||||
else:
|
||||
buffs_to_apply[stat_key] = val
|
||||
|
||||
# 应用属性加成至玩家
|
||||
_apply_buffs_to_player(buffs_to_apply, player_stats, 1)
|
||||
active_buffs = buffs_to_apply
|
||||
is_summoned = true
|
||||
|
||||
pet_summoned.emit(active_mob_vnum, active_pet_name, active_buffs)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"mob_vnum": active_mob_vnum,
|
||||
"pet_name": active_pet_name,
|
||||
"buffs": active_buffs,
|
||||
"pos": pet_pos,
|
||||
"msg": "成功召唤宠物【%s】!守护属性已激活。" % active_pet_name
|
||||
}
|
||||
|
||||
# 召回/解散宠物 (40250 CPetSystem::Unsummon / ClearBuff)
|
||||
func unsummon_pet(player_stats: Dictionary) -> Dictionary:
|
||||
if not is_summoned:
|
||||
return {"ok": false, "reason": "NO_PET_SUMMONED", "msg": "当前没有出战的宠物!"}
|
||||
|
||||
# 清除施加在玩家身上的属性
|
||||
_apply_buffs_to_player(active_buffs, player_stats, -1)
|
||||
|
||||
var old_mob := active_mob_vnum
|
||||
var old_name := active_pet_name
|
||||
|
||||
is_summoned = false
|
||||
active_mob_vnum = 0
|
||||
active_seal_vnum = 0
|
||||
active_pet_name = ""
|
||||
active_buffs.clear()
|
||||
pet_pos = Vector2.ZERO
|
||||
|
||||
pet_unsummoned.emit(old_mob, old_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"mob_vnum": old_mob,
|
||||
"pet_name": old_name,
|
||||
"msg": "宠物【%s】已召回,守护属性已解除。" % old_name
|
||||
}
|
||||
|
||||
# 跟随 AI 逻辑步进 (40250 CPetActor::_UpdateFollowAI)
|
||||
func update_follow_ai(owner_pos: Vector2, delta: float) -> Dictionary:
|
||||
if not is_summoned:
|
||||
return {"action": "none"}
|
||||
|
||||
var dist := pet_pos.distance_to(owner_pos)
|
||||
|
||||
# 超过最大瞬移距离,直接瞬移回主人身边
|
||||
if dist > MAX_TELEPORT_DIST:
|
||||
pet_pos = owner_pos + Vector2(1.5, 0.0)
|
||||
pet_moved.emit(pet_pos, true)
|
||||
return {"action": "teleport", "pos": pet_pos, "dist": dist}
|
||||
|
||||
# 超过最小跟随距离,平滑朝主人位移
|
||||
if dist > MIN_FOLLOW_DIST:
|
||||
var move_dist: float = minf(FOLLOW_SPEED * delta, dist - MIN_FOLLOW_DIST)
|
||||
pet_pos = pet_pos.move_toward(owner_pos, move_dist)
|
||||
pet_moved.emit(pet_pos, false)
|
||||
return {"action": "follow", "pos": pet_pos, "dist": pet_pos.distance_to(owner_pos)}
|
||||
|
||||
# 处于舒适距离,保持待机
|
||||
return {"action": "idle", "pos": pet_pos, "dist": dist}
|
||||
|
||||
# 属性加成注入或反向扣减
|
||||
func _apply_buffs_to_player(buffs: Dictionary, player_stats: Dictionary, factor: int) -> void:
|
||||
for stat_key in buffs.keys():
|
||||
var val = int(buffs[stat_key]) * factor
|
||||
var cur = int(player_stats.get(stat_key, 0))
|
||||
player_stats[stat_key] = cur + val
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"is_summoned": is_summoned,
|
||||
"active_mob_vnum": active_mob_vnum,
|
||||
"active_seal_vnum": active_seal_vnum,
|
||||
"active_pet_name": active_pet_name,
|
||||
"active_buffs": active_buffs.duplicate(true),
|
||||
"pos_x": pet_pos.x,
|
||||
"pos_y": pet_pos.y
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
is_summoned = bool(data.get("is_summoned", false))
|
||||
active_mob_vnum = int(data.get("active_mob_vnum", 0))
|
||||
active_seal_vnum = int(data.get("active_seal_vnum", 0))
|
||||
active_pet_name = str(data.get("active_pet_name", ""))
|
||||
active_buffs = data.get("active_buffs", {}).duplicate(true)
|
||||
pet_pos = Vector2(float(data.get("pos_x", 0.0)), float(data.get("pos_y", 0.0)))
|
||||
@@ -1 +0,0 @@
|
||||
uid://brl1ecflh4ugi
|
||||
@@ -1,192 +0,0 @@
|
||||
# client_phase_integration_system.gd —— 40250 客户端全状态机与全流程一体化集成系统 1:1
|
||||
# 严格对照:
|
||||
# Client/Eternexus/root/networkmodule.py, interfacemodule.py, game.py
|
||||
# ClientVS22/source/UserInterface/PythonNetworkStream.h (PHASE_*)
|
||||
extends RefCounted
|
||||
|
||||
const IntroLoginPhase = preload("res://intro_login_phase.gd")
|
||||
const IntroEmpirePhase = preload("res://intro_empire_phase.gd")
|
||||
const IntroSelectPhase = preload("res://intro_select_phase.gd")
|
||||
const IntroCreatePhase = preload("res://intro_create_phase.gd")
|
||||
const IntroLoadingPhase = preload("res://intro_loading_phase.gd")
|
||||
|
||||
const MessengerSystem = preload("res://messenger_system.gd")
|
||||
const WhisperChatSystem = preload("res://whisper_chat_system.gd")
|
||||
const AtlasNavigationSystem = preload("res://atlas_navigation_system.gd")
|
||||
const EquipmentInspectSystem = preload("res://equipment_inspect_system.gd")
|
||||
const BgmJukeboxSystem = preload("res://bgm_jukebox_system.gd")
|
||||
|
||||
# 官方 Phase 枚举 (PythonNetworkStream.h)
|
||||
enum ClientPhase {
|
||||
PHASE_OFFLINE,
|
||||
PHASE_LOGIN,
|
||||
PHASE_EMPIRE,
|
||||
PHASE_SELECT,
|
||||
PHASE_CREATE,
|
||||
PHASE_LOADING,
|
||||
PHASE_GAME
|
||||
}
|
||||
|
||||
var current_phase: int = ClientPhase.PHASE_LOGIN
|
||||
|
||||
# 阶段子模块实例
|
||||
var login_phase: RefCounted = null
|
||||
var empire_phase: RefCounted = null
|
||||
var select_phase: RefCounted = null
|
||||
var create_phase: RefCounted = null
|
||||
var loading_phase: RefCounted = null
|
||||
|
||||
# 游戏内核心交互模块实例
|
||||
var messenger: RefCounted = null
|
||||
var whisper: RefCounted = null
|
||||
var atlas: RefCounted = null
|
||||
var equip_inspect: RefCounted = null
|
||||
var jukebox: RefCounted = null
|
||||
|
||||
# 活跃角色数据
|
||||
var active_character: Dictionary = {}
|
||||
var active_account: String = ""
|
||||
|
||||
# 热键弹窗焦点栈 (用于 Escape 键自顶向下关闭)
|
||||
var dialog_focus_stack: Array = []
|
||||
|
||||
func _init() -> void:
|
||||
current_phase = ClientPhase.PHASE_LOGIN
|
||||
active_character = {}
|
||||
active_account = ""
|
||||
dialog_focus_stack.clear()
|
||||
|
||||
# 初始化各阶段系统
|
||||
login_phase = IntroLoginPhase.new()
|
||||
empire_phase = IntroEmpirePhase.new()
|
||||
select_phase = IntroSelectPhase.new()
|
||||
create_phase = IntroCreatePhase.new()
|
||||
loading_phase = IntroLoadingPhase.new()
|
||||
|
||||
# 初始化游戏内交互系统
|
||||
messenger = MessengerSystem.new()
|
||||
whisper = WhisperChatSystem.new()
|
||||
atlas = AtlasNavigationSystem.new()
|
||||
equip_inspect = EquipmentInspectSystem.new()
|
||||
jukebox = BgmJukeboxSystem.new()
|
||||
|
||||
# 1. 登录跳转流 (Login -> Select / Empire)
|
||||
func process_login(account: String, password_input: String) -> Dictionary:
|
||||
var r = login_phase.attempt_login(account, password_input)
|
||||
if not r["success"]:
|
||||
return r
|
||||
|
||||
active_account = account
|
||||
if r.get("has_characters", false):
|
||||
current_phase = ClientPhase.PHASE_SELECT
|
||||
r["next_phase"] = ClientPhase.PHASE_SELECT
|
||||
else:
|
||||
current_phase = ClientPhase.PHASE_EMPIRE
|
||||
r["next_phase"] = ClientPhase.PHASE_EMPIRE
|
||||
|
||||
return r
|
||||
|
||||
# 2. 阵营抉择流 (Empire -> Create)
|
||||
func process_empire_select(empire_id: int) -> Dictionary:
|
||||
if current_phase != ClientPhase.PHASE_EMPIRE:
|
||||
return { "success": false, "error": "NOT_IN_EMPIRE_PHASE" }
|
||||
|
||||
empire_phase.select_empire(empire_id)
|
||||
var conf = empire_phase.confirm_empire_selection()
|
||||
current_phase = ClientPhase.PHASE_CREATE
|
||||
conf["next_phase"] = ClientPhase.PHASE_CREATE
|
||||
return conf
|
||||
|
||||
# 3. 角色塑形流 (Create -> Select)
|
||||
func process_character_create(name: String, empire_id: int = 1) -> Dictionary:
|
||||
if current_phase != ClientPhase.PHASE_CREATE:
|
||||
return { "success": false, "error": "NOT_IN_CREATE_PHASE" }
|
||||
|
||||
var r = create_phase.create_character(name, empire_id)
|
||||
if not r["success"]:
|
||||
return r
|
||||
|
||||
# 存入角色选择大厅首个空槽位
|
||||
select_phase.set_character_slot(0, r["character"])
|
||||
current_phase = ClientPhase.PHASE_SELECT
|
||||
r["next_phase"] = ClientPhase.PHASE_SELECT
|
||||
return r
|
||||
|
||||
# 4. 选择角色出战流 (Select -> Loading -> Game)
|
||||
func process_character_start(slot_idx: int) -> Dictionary:
|
||||
if current_phase != ClientPhase.PHASE_SELECT:
|
||||
return { "success": false, "error": "NOT_IN_SELECT_PHASE" }
|
||||
|
||||
select_phase.select_slot(slot_idx)
|
||||
var r = select_phase.start_game()
|
||||
if not r["success"]:
|
||||
return r
|
||||
|
||||
active_character = r["character"]
|
||||
|
||||
# 启动过场加载
|
||||
loading_phase.start_loading()
|
||||
current_phase = ClientPhase.PHASE_LOADING
|
||||
|
||||
# 完成 4 阶段加载
|
||||
for i in range(5):
|
||||
loading_phase.advance_loading_step()
|
||||
|
||||
if loading_phase.is_completed():
|
||||
current_phase = ClientPhase.PHASE_GAME
|
||||
jukebox.on_enter_map("metin2_map_a1")
|
||||
messenger.main_player_name = active_character.get("name", "Hero")
|
||||
whisper.main_player_name = active_character.get("name", "Hero")
|
||||
r["next_phase"] = ClientPhase.PHASE_GAME
|
||||
|
||||
return r
|
||||
|
||||
# 5. 全局热键路由与窗口调度 (1:1 interfacemodule.py)
|
||||
func handle_input_hotkey(key_name: String) -> Dictionary:
|
||||
if current_phase != ClientPhase.PHASE_GAME:
|
||||
return { "handled": false, "phase": current_phase }
|
||||
|
||||
match key_name:
|
||||
"Alt+M": # 好友面板
|
||||
var is_showing: bool = (dialog_focus_stack.has("MESSENGER"))
|
||||
if is_showing:
|
||||
dialog_focus_stack.erase("MESSENGER")
|
||||
return { "handled": true, "action": "CLOSE_MESSENGER" }
|
||||
else:
|
||||
dialog_focus_stack.append("MESSENGER")
|
||||
return { "handled": true, "action": "OPEN_MESSENGER" }
|
||||
|
||||
"M": # Atlas 全景大地图
|
||||
var visible = atlas.toggle_atlas()
|
||||
if visible:
|
||||
dialog_focus_stack.append("ATLAS")
|
||||
return { "handled": true, "action": "OPEN_ATLAS" }
|
||||
else:
|
||||
dialog_focus_stack.erase("ATLAS")
|
||||
return { "handled": true, "action": "CLOSE_ATLAS" }
|
||||
|
||||
"Escape": # 自顶向下关闭弹窗或呼出系统菜单
|
||||
if not dialog_focus_stack.is_empty():
|
||||
var top_dialog = dialog_focus_stack.pop_back()
|
||||
if top_dialog == "ATLAS":
|
||||
atlas.hide_atlas()
|
||||
return { "handled": true, "action": "CLOSED_TOP_DIALOG", "dialog": top_dialog }
|
||||
else:
|
||||
return { "handled": true, "action": "OPEN_SYSTEM_MENU" }
|
||||
|
||||
return { "handled": false }
|
||||
|
||||
# 6. 登出游戏 (Game -> Select / Login)
|
||||
func logout_to_select() -> void:
|
||||
current_phase = ClientPhase.PHASE_SELECT
|
||||
active_character = {}
|
||||
dialog_focus_stack.clear()
|
||||
atlas.hide_atlas()
|
||||
jukebox.on_enter_map("intro_select")
|
||||
|
||||
func logout_to_login() -> void:
|
||||
current_phase = ClientPhase.PHASE_LOGIN
|
||||
active_character = {}
|
||||
active_account = ""
|
||||
dialog_focus_stack.clear()
|
||||
jukebox.on_enter_map("intro_login")
|
||||
@@ -1 +0,0 @@
|
||||
uid://n55fydu6qdrj
|
||||
@@ -1,170 +0,0 @@
|
||||
# combat_replay_snapshot_system.gd —— Metin2 40250 战斗高光战报与首领击杀瞬间快照 1:1
|
||||
# 对照 40250 服务端 battle.cpp, packet.h, char_battle.cpp
|
||||
class_name CombatReplaySnapshotSystem
|
||||
extends RefCounted
|
||||
|
||||
signal combat_session_started(target_name: String)
|
||||
signal combat_highlight_generated(card: Dictionary)
|
||||
signal new_personal_record(target_name: String, record_type: String, new_val: int)
|
||||
|
||||
const MAX_STORED_CARDS := 20
|
||||
|
||||
var is_in_session: bool = false
|
||||
var session_id: int = 1
|
||||
var target_vnum: int = 0
|
||||
var target_name: String = ""
|
||||
var target_max_hp: int = 0
|
||||
|
||||
var session_start_time: float = 0.0
|
||||
var elapsed_time: float = 0.0
|
||||
|
||||
var total_damage_dealt: int = 0
|
||||
var total_damage_taken: int = 0
|
||||
var total_attacks: int = 0
|
||||
var total_skills_used: int = 0
|
||||
var max_single_hit: int = 0
|
||||
var critical_hits_count: int = 0
|
||||
|
||||
# 历史高光战报归档
|
||||
var highlight_cards: Array = []
|
||||
# 个人最高纪录:target_name -> { "best_dps": int, "fastest_time": float, "highest_hit": int }
|
||||
var personal_bests: Dictionary = {}
|
||||
|
||||
# 开始一场战斗记录
|
||||
func start_combat_session(vnum: int, tname: String, max_hp: int) -> Dictionary:
|
||||
is_in_session = true
|
||||
target_vnum = vnum
|
||||
target_name = tname
|
||||
target_max_hp = max_hp
|
||||
|
||||
session_start_time = Time.get_unix_time_from_system()
|
||||
elapsed_time = 0.0
|
||||
|
||||
total_damage_dealt = 0
|
||||
total_damage_taken = 0
|
||||
total_attacks = 0
|
||||
total_skills_used = 0
|
||||
max_single_hit = 0
|
||||
critical_hits_count = 0
|
||||
|
||||
combat_session_started.emit(target_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"session_id": session_id,
|
||||
"target_name": target_name,
|
||||
"target_hp": target_max_hp
|
||||
}
|
||||
|
||||
# 记录一次攻击命中
|
||||
func record_hit(dmg: int, is_crit: bool, skill_name: String = "") -> void:
|
||||
if not is_in_session:
|
||||
return
|
||||
|
||||
total_damage_dealt += dmg
|
||||
total_attacks += 1
|
||||
|
||||
if is_crit:
|
||||
critical_hits_count += 1
|
||||
|
||||
if not skill_name.is_empty():
|
||||
total_skills_used += 1
|
||||
|
||||
if dmg > max_single_hit:
|
||||
max_single_hit = dmg
|
||||
|
||||
# 记录受到的伤害
|
||||
func record_damage_taken(dmg: int) -> void:
|
||||
if not is_in_session:
|
||||
return
|
||||
total_damage_taken += dmg
|
||||
|
||||
# 帧心跳更新战斗时长
|
||||
func update(delta: float) -> void:
|
||||
if is_in_session:
|
||||
elapsed_time += delta
|
||||
|
||||
# 结束战斗并生成结构化高光快照战报
|
||||
func end_combat_session(is_kill: bool, drops: Array = []) -> Dictionary:
|
||||
if not is_in_session:
|
||||
return {"ok": false, "reason": "NO_ACTIVE_SESSION"}
|
||||
|
||||
is_in_session = false
|
||||
var duration = max(0.5, elapsed_time)
|
||||
var dps = int(float(total_damage_dealt) / duration)
|
||||
|
||||
# 战斗评级评估 (S / A / B / C)
|
||||
var rank = "C"
|
||||
if is_kill:
|
||||
if dps >= 4000:
|
||||
rank = "S"
|
||||
elif dps >= 2500:
|
||||
rank = "A"
|
||||
elif dps >= 1200:
|
||||
rank = "B"
|
||||
else:
|
||||
rank = "C"
|
||||
|
||||
var card: Dictionary = {
|
||||
"session_id": session_id,
|
||||
"target_vnum": target_vnum,
|
||||
"target_name": target_name,
|
||||
"is_kill": is_kill,
|
||||
"rank": rank,
|
||||
"duration": duration,
|
||||
"total_damage": total_damage_dealt,
|
||||
"dps": dps,
|
||||
"max_single_hit": max_single_hit,
|
||||
"crit_count": critical_hits_count,
|
||||
"damage_taken": total_damage_taken,
|
||||
"skills_used": total_skills_used,
|
||||
"drops": drops.duplicate(true),
|
||||
"datetime": Time.get_datetime_string_from_system()
|
||||
}
|
||||
|
||||
session_id += 1
|
||||
highlight_cards.push_front(card)
|
||||
if highlight_cards.size() > MAX_STORED_CARDS:
|
||||
highlight_cards.pop_back()
|
||||
|
||||
# 刷新个人纪录判定
|
||||
_update_personal_bests(card)
|
||||
|
||||
combat_highlight_generated.emit(card)
|
||||
return card
|
||||
|
||||
# 检查并更新个人生涯纪录
|
||||
func _update_personal_bests(card: Dictionary) -> void:
|
||||
if not card["is_kill"]:
|
||||
return
|
||||
|
||||
var tname: String = card["target_name"]
|
||||
if not personal_bests.has(tname):
|
||||
personal_bests[tname] = {
|
||||
"best_dps": card["dps"],
|
||||
"fastest_time": card["duration"],
|
||||
"highest_hit": card["max_single_hit"]
|
||||
}
|
||||
new_personal_record.emit(tname, "FIRST_KILL", card["dps"])
|
||||
return
|
||||
|
||||
var pb = personal_bests[tname]
|
||||
if card["dps"] > pb["best_dps"]:
|
||||
pb["best_dps"] = card["dps"]
|
||||
new_personal_record.emit(tname, "BEST_DPS", card["dps"])
|
||||
|
||||
if card["duration"] < pb["fastest_time"]:
|
||||
pb["fastest_time"] = card["duration"]
|
||||
new_personal_record.emit(tname, "FASTEST_TIME", int(card["duration"]))
|
||||
|
||||
if card["max_single_hit"] > pb["highest_hit"]:
|
||||
pb["highest_hit"] = card["max_single_hit"]
|
||||
new_personal_record.emit(tname, "HIGHEST_HIT", card["max_single_hit"])
|
||||
|
||||
# 查询最近战报
|
||||
func get_recent_highlights() -> Array:
|
||||
return highlight_cards
|
||||
|
||||
# 查询特定目标的最佳战报纪录
|
||||
func get_personal_best(tname: String) -> Dictionary:
|
||||
return personal_bests.get(tname, {})
|
||||
@@ -1 +0,0 @@
|
||||
uid://cxu88rt8qo1o5
|
||||
@@ -1,249 +0,0 @@
|
||||
# combat_status_effect_system.gd —— Metin2 40250 官方战斗状态异常与五大负面减益结算系统 1:1
|
||||
# 100% 对照 40250 服务端 battle.cpp, char_resist.cpp, affect.h, char.h (POINT_POISON_PCT, IMMUNE_STUN, IMMUNE_SLOW)
|
||||
class_name CombatStatusEffectSystem
|
||||
extends RefCounted
|
||||
|
||||
signal status_applied(target_id: int, effect_type: String, duration: float)
|
||||
signal status_resisted(target_id: int, effect_type: String, reason: String)
|
||||
signal status_ticked(target_id: int, effect_type: String, damage: int, remaining_ticks: int)
|
||||
signal status_cleared(target_id: int, effect_type: String)
|
||||
|
||||
# 40250 官方核心状态异常类型
|
||||
const EFFECT_POISON := "poison" # 中毒 (AFF_POISON)
|
||||
const EFFECT_STUN := "stun" # 眩晕 (AFF_STUN)
|
||||
const EFFECT_SLOW := "slow" # 减速 (AFF_SLOW)
|
||||
const EFFECT_FIRE := "fire" # 烈焰灼烧 (AFF_FIRE)
|
||||
const EFFECT_BLEEDING := "bleeding" # 流血虚弱 (AFF_BLEEDING)
|
||||
|
||||
# 等级压制中毒调整表 (40250 char_resist.cpp: poison_level_adjust)
|
||||
const POISON_LEVEL_ADJUST: Array[int] = [100, 90, 80, 70, 60, 50, 40, 30, 20]
|
||||
|
||||
# 状态记录表: target_id -> { effect_type -> { "duration": float, "timer": float, "tick_interval": float, "ticks_left": int, "param": int } }
|
||||
var _target_effects: Dictionary = {}
|
||||
|
||||
# ==========================================
|
||||
# 1. 状态异常施加与判定 (battle.cpp: NormalAttackAffect)
|
||||
# ==========================================
|
||||
|
||||
# 尝试施加攻击异常效果 (通用结算)
|
||||
func try_apply_normal_attack_effects(
|
||||
attacker: Dictionary,
|
||||
victim: Dictionary,
|
||||
forced_rolls: Dictionary = {}
|
||||
) -> Dictionary:
|
||||
var victim_id = int(victim.get("id", 0))
|
||||
var applied: Array[String] = []
|
||||
var resisted: Array[String] = []
|
||||
|
||||
var is_attacker_pc = bool(attacker.get("is_pc", true))
|
||||
var is_victim_pc = bool(victim.get("is_pc", true))
|
||||
var attacker_level = int(attacker.get("level", 1))
|
||||
var victim_level = int(victim.get("level", 1))
|
||||
|
||||
# 1. 中毒判定 (POINT_POISON_PCT)
|
||||
var poison_pct = int(attacker.get("poison_pct", 0))
|
||||
if poison_pct > 0 and not has_effect(victim_id, EFFECT_POISON):
|
||||
# 怪物只中毒一次检查 (char_resist.cpp: m_bHasPoisoned && !IsPC())
|
||||
var already_poisoned_once = bool(victim.get("has_been_poisoned", false))
|
||||
if not is_victim_pc and already_poisoned_once:
|
||||
resisted.append("poison_already_poisoned_once")
|
||||
else:
|
||||
var roll = int(forced_rolls.get("poison_pct_roll", randi_range(1, 100)))
|
||||
if roll <= poison_pct:
|
||||
# 等级差距惩罚检查
|
||||
var can_poison := true
|
||||
if attacker_level < victim_level:
|
||||
var delta = min(victim_level - attacker_level, 8)
|
||||
var level_roll = int(forced_rolls.get("poison_level_roll", randi_range(1, 100)))
|
||||
if level_roll > POISON_LEVEL_ADJUST[delta]:
|
||||
can_poison = false
|
||||
resisted.append("poison_level_penalty")
|
||||
|
||||
if can_poison:
|
||||
apply_poison(victim, attacker)
|
||||
applied.append(EFFECT_POISON)
|
||||
|
||||
# 2. 眩晕判定 (POINT_STUN_PCT)
|
||||
var stun_pct = int(attacker.get("stun_pct", 0))
|
||||
if stun_pct > 0 and not has_effect(victim_id, EFFECT_STUN):
|
||||
# 免疫眩晕判定 (IMMUNE_STUN)
|
||||
if bool(victim.get("immune_stun", false)):
|
||||
status_resisted.emit(victim_id, EFFECT_STUN, "IMMUNE_STUN")
|
||||
resisted.append("immune_stun")
|
||||
else:
|
||||
var roll = int(forced_rolls.get("stun_pct_roll", randi_range(1, 100)))
|
||||
if roll <= stun_pct:
|
||||
var stun_dur = 4.0 if (is_attacker_pc and not is_victim_pc) else 2.0
|
||||
apply_stun(victim_id, stun_dur)
|
||||
applied.append(EFFECT_STUN)
|
||||
|
||||
# 3. 减速判定 (POINT_SLOW_PCT)
|
||||
var slow_pct = int(attacker.get("slow_pct", 0))
|
||||
if slow_pct > 0 and not has_effect(victim_id, EFFECT_SLOW):
|
||||
# 免疫减速判定 (IMMUNE_SLOW)
|
||||
if bool(victim.get("immune_slow", false)):
|
||||
status_resisted.emit(victim_id, EFFECT_SLOW, "IMMUNE_SLOW")
|
||||
resisted.append("immune_slow")
|
||||
else:
|
||||
var roll = int(forced_rolls.get("slow_pct_roll", randi_range(1, 100)))
|
||||
if roll <= slow_pct:
|
||||
apply_slow(victim_id, 20.0, 30) # 40250 官方减速 20 秒,移速 -30
|
||||
applied.append(EFFECT_SLOW)
|
||||
|
||||
return {
|
||||
"applied": applied,
|
||||
"resisted": resisted
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 2. 状态具体应用 (char_resist.cpp)
|
||||
# ==========================================
|
||||
|
||||
# 施加中毒: 10 次跳伤害,每次间隔 3 秒,总持续 30 秒 (POISON_LENGTH + 1)
|
||||
func apply_poison(victim: Dictionary, attacker: Dictionary = {}) -> void:
|
||||
var victim_id = int(victim.get("id", 0))
|
||||
victim["has_been_poisoned"] = true
|
||||
|
||||
_add_raw_effect(victim_id, EFFECT_POISON, {
|
||||
"duration": 30.0,
|
||||
"timer": 3.0,
|
||||
"tick_interval": 3.0,
|
||||
"ticks_left": 10,
|
||||
"param": int(victim.get("poison_reduce", 0)), # 抗毒减免比例 (0~100)
|
||||
"max_hp": int(victim.get("max_hp", 1000)),
|
||||
"attacker_id": int(attacker.get("id", 0))
|
||||
})
|
||||
status_applied.emit(victim_id, EFFECT_POISON, 30.0)
|
||||
|
||||
# 施加眩晕: 期间无法移动和释放技能
|
||||
func apply_stun(victim_id: int, duration: float = 2.0) -> void:
|
||||
_add_raw_effect(victim_id, EFFECT_STUN, {
|
||||
"duration": duration,
|
||||
"timer": duration,
|
||||
"tick_interval": 0.0,
|
||||
"ticks_left": 0,
|
||||
"param": 0
|
||||
})
|
||||
status_applied.emit(victim_id, EFFECT_STUN, duration)
|
||||
|
||||
# 施加减速: 移动速度扣除 speed_reduction 点
|
||||
func apply_slow(victim_id: int, duration: float = 20.0, speed_reduction: int = 30) -> void:
|
||||
_add_raw_effect(victim_id, EFFECT_SLOW, {
|
||||
"duration": duration,
|
||||
"timer": duration,
|
||||
"tick_interval": 0.0,
|
||||
"ticks_left": 0,
|
||||
"param": speed_reduction
|
||||
})
|
||||
status_applied.emit(victim_id, EFFECT_SLOW, duration)
|
||||
|
||||
# 施加烈焰灼烧: 固定火伤每 3 秒跳一次
|
||||
func apply_fire(victim_id: int, ticks: int = 5, damage_per_tick: int = 50) -> void:
|
||||
_add_raw_effect(victim_id, EFFECT_FIRE, {
|
||||
"duration": float(ticks * 3),
|
||||
"timer": 3.0,
|
||||
"tick_interval": 3.0,
|
||||
"ticks_left": ticks,
|
||||
"param": damage_per_tick
|
||||
})
|
||||
status_applied.emit(victim_id, EFFECT_FIRE, float(ticks * 3))
|
||||
|
||||
# 解除特定状态 (例如解毒药水 RemovePoison)
|
||||
func clear_effect(victim_id: int, effect_type: String) -> bool:
|
||||
if not _target_effects.has(victim_id):
|
||||
return false
|
||||
if _target_effects[victim_id].has(effect_type):
|
||||
_target_effects[victim_id].erase(effect_type)
|
||||
status_cleared.emit(victim_id, effect_type)
|
||||
return true
|
||||
return false
|
||||
|
||||
# 检查目标是否具有特定异常状态
|
||||
func has_effect(victim_id: int, effect_type: String) -> bool:
|
||||
if not _target_effects.has(victim_id):
|
||||
return false
|
||||
return _target_effects[victim_id].has(effect_type)
|
||||
|
||||
# 检查目标是否处于无法行动状态 (眩晕)
|
||||
func is_stunned(victim_id: int) -> bool:
|
||||
return has_effect(victim_id, EFFECT_STUN)
|
||||
|
||||
# 获取减速移速惩罚值
|
||||
func get_slow_reduction(victim_id: int) -> int:
|
||||
if not has_effect(victim_id, EFFECT_SLOW):
|
||||
return 0
|
||||
return int(_target_effects[victim_id][EFFECT_SLOW].get("param", 30))
|
||||
|
||||
# 检查自然回血是否被阻断 (40250 char.cpp:2453 中毒状态禁止自然回血)
|
||||
func is_natural_hp_regen_blocked(victim_id: int) -> bool:
|
||||
return has_effect(victim_id, EFFECT_POISON)
|
||||
|
||||
# ==========================================
|
||||
# 3. 状态心跳更新与跳伤害结算 (char_resist.cpp: poison_event, fire_event)
|
||||
# ==========================================
|
||||
func update(delta: float, targets_map: Dictionary = {}) -> void:
|
||||
for vid in _target_effects.keys().duplicate():
|
||||
var effects: Dictionary = _target_effects[vid]
|
||||
var to_remove: Array[String] = []
|
||||
|
||||
for eff_type in effects.keys():
|
||||
var data = effects[eff_type]
|
||||
data["duration"] -= delta
|
||||
|
||||
if data["tick_interval"] > 0.0:
|
||||
# 周期跳伤害逻辑 (中毒/灼烧)
|
||||
data["timer"] -= delta
|
||||
while data["timer"] <= 0.0 and data["ticks_left"] > 0:
|
||||
data["timer"] += data["tick_interval"]
|
||||
data["ticks_left"] -= 1
|
||||
|
||||
var dmg := 0
|
||||
if eff_type == EFFECT_POISON:
|
||||
# 40250 官方: GetMaxHP() * 5 / 100 * (100 - poison_reduce) / 100
|
||||
var max_hp = int(data.get("max_hp", 1000))
|
||||
var reduce = int(data.get("param", 0))
|
||||
var base_dmg = int(floor(max_hp * 0.05))
|
||||
dmg = max(1, int(floor(base_dmg * (100 - reduce) / 100.0)))
|
||||
elif eff_type == EFFECT_FIRE:
|
||||
dmg = int(data.get("param", 50))
|
||||
|
||||
# 扣减目标生命 (不可致死,留 1 点 HP)
|
||||
if targets_map.has(vid):
|
||||
var tgt = targets_map[vid]
|
||||
var cur_hp = int(tgt.get("hp", 100))
|
||||
tgt["hp"] = max(1, cur_hp - dmg)
|
||||
|
||||
status_ticked.emit(vid, eff_type, dmg, data["ticks_left"])
|
||||
|
||||
if data["ticks_left"] <= 0:
|
||||
to_remove.append(eff_type)
|
||||
break
|
||||
else:
|
||||
# 持续时间型 (眩晕/减速)
|
||||
if data["duration"] <= 0.0:
|
||||
to_remove.append(eff_type)
|
||||
|
||||
for eff_type in to_remove:
|
||||
effects.erase(eff_type)
|
||||
status_cleared.emit(vid, eff_type)
|
||||
|
||||
if effects.is_empty():
|
||||
_target_effects.erase(vid)
|
||||
|
||||
func _add_raw_effect(victim_id: int, effect_type: String, data: Dictionary) -> void:
|
||||
if not _target_effects.has(victim_id):
|
||||
_target_effects[victim_id] = {}
|
||||
_target_effects[victim_id][effect_type] = data
|
||||
|
||||
# 获取目标全部当前状态
|
||||
func get_target_effects(victim_id: int) -> Dictionary:
|
||||
return _target_effects.get(victim_id, {}).duplicate(true)
|
||||
|
||||
# 序列化与反序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"target_effects": _target_effects.duplicate(true)
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
_target_effects = data.get("target_effects", {}).duplicate(true)
|
||||
@@ -1 +0,0 @@
|
||||
uid://bo2ad4sxgu07r
|
||||
@@ -1,192 +0,0 @@
|
||||
# compass_treasure_hunt_system.gd —— Metin2 40250 官方魔石地脉探测罗盘与寻宝系统 1:1
|
||||
# 对照 40250 服务端 questlua_item.cpp, locale_en/itemdesc.txt:27989
|
||||
class_name CompassTreasureHuntSystem
|
||||
extends RefCounted
|
||||
|
||||
signal compass_scanned(target_name: String, distance: float, angle_deg: float, proximity: String, charges_left: int)
|
||||
signal treasure_excavated(chest_vnum: int, chest_name: String)
|
||||
signal treasure_opened(chest_name: String, rewards: Array)
|
||||
|
||||
# 40250 官方核心道具 Vnum
|
||||
const VNUM_COMPASS_OF_METIN := 27989 # 魔石探测罗盘 (Compass for Metin Stones, 6次充能)
|
||||
const VNUM_GOLD_CHEST := 50005 # 黄金宝箱
|
||||
const VNUM_SILVER_CHEST := 50006 # 白银宝箱
|
||||
const VNUM_GOLD_KEY := 50007 # 黄金钥匙
|
||||
const VNUM_SILVER_KEY := 50008 # 白银钥匙
|
||||
|
||||
const MAX_CHARGES := 6
|
||||
|
||||
# 探测距离等级
|
||||
const PROXIMITY_FAR := "FAR" # > 100m ("微弱微光 - 遥远")
|
||||
const PROXIMITY_MEDIUM := "MEDIUM" # 50m ~ 100m ("泛起银光 - 渐近")
|
||||
const PROXIMITY_NEAR := "NEAR" # 20m ~ 50m ("强烈明光 - 附近")
|
||||
const PROXIMITY_VERY_CLOSE := "VERY_CLOSE" # < 20m ("璀璨耀光 - 极近")
|
||||
|
||||
# 激活魔石探测罗盘扫描最近的魔石或地脉宝藏
|
||||
func scan_nearest_metin(player_pos: Vector2, target_list: Array, inventory: Array, slot_index: int) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var compass: Dictionary = inventory[slot_index]
|
||||
if int(compass.get("vnum", 0)) != VNUM_COMPASS_OF_METIN:
|
||||
return {"ok": false, "reason": "NOT_A_COMPASS", "msg": "该物品不是魔石探测罗盘!"}
|
||||
|
||||
var charges: int = int(compass.get("charges", MAX_CHARGES))
|
||||
if charges <= 0:
|
||||
inventory[slot_index] = null
|
||||
return {"ok": false, "reason": "COMPASS_EXHAUSTED", "msg": "罗盘灵力已耗尽,化作飞灰!"}
|
||||
|
||||
if target_list.is_empty():
|
||||
return {"ok": false, "reason": "NO_TARGETS_FOUND", "msg": "当前地图未探测到任何魔石波动!"}
|
||||
|
||||
# 寻找最近的目标
|
||||
var nearest_target: Dictionary = {}
|
||||
var min_dist := INF
|
||||
for t in target_list:
|
||||
var pos: Vector2 = t.get("pos", Vector2.ZERO)
|
||||
var d := player_pos.distance_to(pos)
|
||||
if d < min_dist:
|
||||
min_dist = d
|
||||
nearest_target = t
|
||||
|
||||
# 扣除 1 次充能计数 (40250 官方 6 次限制)
|
||||
charges -= 1
|
||||
compass["charges"] = charges
|
||||
if charges <= 0:
|
||||
inventory[slot_index] = null # 充能用完销毁
|
||||
|
||||
# 计算方位角与距离等级
|
||||
var target_pos: Vector2 = nearest_target.get("pos", Vector2.ZERO)
|
||||
var dir_vec: Vector2 = (target_pos - player_pos).normalized()
|
||||
var angle := rad_to_deg(dir_vec.angle())
|
||||
|
||||
var proximity := PROXIMITY_FAR
|
||||
var desc := "微弱微光(遥远)"
|
||||
if min_dist < 20.0:
|
||||
proximity = PROXIMITY_VERY_CLOSE
|
||||
desc = "璀璨耀光(极近)"
|
||||
elif min_dist < 50.0:
|
||||
proximity = PROXIMITY_NEAR
|
||||
desc = "强烈明光(附近)"
|
||||
elif min_dist < 100.0:
|
||||
proximity = PROXIMITY_MEDIUM
|
||||
desc = "泛起银光(渐近)"
|
||||
|
||||
var target_name: String = str(nearest_target.get("name", "魔石"))
|
||||
compass_scanned.emit(target_name, min_dist, angle, proximity, charges)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"target_name": target_name,
|
||||
"target_pos": nearest_target.get("pos", Vector2.ZERO),
|
||||
"distance": min_dist,
|
||||
"angle_deg": angle,
|
||||
"proximity": proximity,
|
||||
"proximity_desc": desc,
|
||||
"charges_left": charges,
|
||||
"msg": "罗盘指针震颤!锁定【%s】,距离 %.1f 米,光芒等级:%s,剩余使用次数:%d。" % [target_name, min_dist, desc, charges]
|
||||
}
|
||||
|
||||
# 在地脉极近坐标处挖掘秘宝
|
||||
func excavate_treasure(player_pos: Vector2, treasure_nodes: Array, inventory: Array) -> Dictionary:
|
||||
var target_idx := -1
|
||||
for i in range(treasure_nodes.size()):
|
||||
var node_pos: Vector2 = treasure_nodes[i].get("pos", Vector2.ZERO)
|
||||
if player_pos.distance_to(node_pos) <= 5.0: # 5米范围内可挖掘
|
||||
target_idx = i
|
||||
break
|
||||
|
||||
if target_idx == -1:
|
||||
return {"ok": false, "reason": "NO_TREASURE_NEARBY", "msg": "周围 5 米内未探测到地脉埋藏的宝箱!"}
|
||||
|
||||
var node: Dictionary = treasure_nodes[target_idx]
|
||||
var is_gold: bool = bool(node.get("is_gold", false))
|
||||
var chest_vnum = VNUM_GOLD_CHEST if is_gold else VNUM_SILVER_CHEST
|
||||
var chest_name = "黄金宝箱" if is_gold else "白银宝箱"
|
||||
|
||||
# 寻找背包空格放入宝箱
|
||||
var free_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
free_slot = i
|
||||
break
|
||||
|
||||
if free_slot == -1:
|
||||
return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳挖出的宝箱!"}
|
||||
|
||||
inventory[free_slot] = {
|
||||
"vnum": chest_vnum,
|
||||
"name": chest_name,
|
||||
"count": 1
|
||||
}
|
||||
|
||||
treasure_nodes.remove_at(target_idx)
|
||||
treasure_excavated.emit(chest_vnum, chest_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"chest_vnum": chest_vnum,
|
||||
"chest_name": chest_name,
|
||||
"slot": free_slot,
|
||||
"msg": "破土而出!在地脉深处成功发掘出【%s】!" % chest_name
|
||||
}
|
||||
|
||||
# 使用对应的黄金钥匙或白银钥匙开启宝箱
|
||||
func open_treasure_chest(chest_slot: int, key_slot: int, inventory: Array) -> Dictionary:
|
||||
if chest_slot < 0 or chest_slot >= inventory.size() or inventory[chest_slot] == null:
|
||||
return {"ok": false, "reason": "INVALID_CHEST_SLOT"}
|
||||
if key_slot < 0 or key_slot >= inventory.size() or inventory[key_slot] == null:
|
||||
return {"ok": false, "reason": "INVALID_KEY_SLOT"}
|
||||
|
||||
var chest: Dictionary = inventory[chest_slot]
|
||||
var key: Dictionary = inventory[key_slot]
|
||||
|
||||
var chest_vnum := int(chest.get("vnum", 0))
|
||||
var key_vnum := int(key.get("vnum", 0))
|
||||
|
||||
var match_ok := false
|
||||
if chest_vnum == VNUM_GOLD_CHEST and key_vnum == VNUM_GOLD_KEY:
|
||||
match_ok = true
|
||||
elif chest_vnum == VNUM_SILVER_CHEST and key_vnum == VNUM_SILVER_KEY:
|
||||
match_ok = true
|
||||
|
||||
if not match_ok:
|
||||
return {"ok": false, "reason": "KEY_MISMATCH", "msg": "钥匙与宝箱类型不匹配!黄金宝箱需用黄金钥匙开启。"}
|
||||
|
||||
# 消耗钥匙与宝箱
|
||||
inventory[chest_slot] = null
|
||||
var key_cnt := int(key.get("count", 1))
|
||||
if key_cnt > 1:
|
||||
key["count"] = key_cnt - 1
|
||||
else:
|
||||
inventory[key_slot] = null
|
||||
|
||||
# 生成高额宝物奖励
|
||||
var rewards: Array = []
|
||||
if chest_vnum == VNUM_GOLD_CHEST:
|
||||
rewards = [
|
||||
{"vnum": 25040, "name": "祝福卷轴", "count": 3},
|
||||
{"vnum": 51501, "name": "龙石原石", "count": 2},
|
||||
{"vnum": 28430, "name": "+4 灵石", "count": 1}
|
||||
]
|
||||
else:
|
||||
rewards = [
|
||||
{"vnum": 25040, "name": "祝福卷轴", "count": 1},
|
||||
{"vnum": 50300, "name": "技能书", "count": 1}
|
||||
]
|
||||
|
||||
# 放入背包
|
||||
for r in rewards:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = r.duplicate(true)
|
||||
break
|
||||
|
||||
treasure_opened.emit(chest.get("name", "宝箱"), rewards)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"chest_name": chest.get("name", "宝箱"),
|
||||
"rewards": rewards,
|
||||
"msg": "清脆的开锁声响起!开启宝箱,获得珍稀秘宝!"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://bau7tkdeg2823
|
||||
@@ -1,244 +0,0 @@
|
||||
# costume_attr_transfer_system.gd —— Metin2 40250 官方时装属性转移与增益附魔系统 1:1
|
||||
# 100% 对照 40250 服务端 char_item.cpp (ITEM_COSTUME, 70063, 70064), item_proto.txt
|
||||
class_name CostumeAttrTransferSystem
|
||||
extends RefCounted
|
||||
|
||||
signal costume_transformed(costume_vnum: int, new_attrs: Array)
|
||||
signal costume_enchanted(costume_vnum: int, new_attrs: Array)
|
||||
signal costume_attributes_transferred(src_vnum: int, dst_vnum: int, attrs: Array)
|
||||
signal costume_error(reason: String)
|
||||
|
||||
# 40250 官方时装重铸与附魔道具 Vnum
|
||||
const VNUM_COSTUME_TRANSFORM := 70063 # 时装重铸卷轴 (Kostümü dönüştür - 随机 1~3 条新属性)
|
||||
const VNUM_COSTUME_ENCHANT := 70064 # 时装附魔石 (Kostümü efsunla - 保留条数重洗属性)
|
||||
const VNUM_ATTR_TRANSFER := 72325 # 时装属性转移符
|
||||
|
||||
# 时装最大词条数
|
||||
const MAX_COSTUME_ATTRS := 3
|
||||
|
||||
# 40250 官方时装属性词条候选池
|
||||
const COSTUME_ATTR_POOL: Array[Dictionary] = [
|
||||
{"type": "max_hp", "name": "生命上限", "min": 500, "max": 2000},
|
||||
{"type": "max_sp", "name": "法力上限", "min": 200, "max": 1000},
|
||||
{"type": "crit_pct", "name": "暴击几率 (%)", "min": 5, "max": 10},
|
||||
{"type": "penetrate_pct", "name": "穿刺伤害几率 (%)", "min": 5, "max": 10},
|
||||
{"type": "att_speed", "name": "攻击速度", "min": 5, "max": 10},
|
||||
{"type": "mov_speed", "name": "移动速度", "min": 5, "max": 10},
|
||||
{"type": "cast_speed", "name": "施法速度", "min": 5, "max": 10},
|
||||
{"type": "hp_regen", "name": "生命自然恢复 (%)", "min": 10, "max": 30}
|
||||
]
|
||||
|
||||
# 校验是否为时装物品
|
||||
func is_costume_item(item: Dictionary) -> bool:
|
||||
if item == null:
|
||||
return false
|
||||
var subtype = str(item.get("costume_subtype", ""))
|
||||
var is_costume = bool(item.get("is_costume", false))
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
# 40250 官方区间:时装铠甲 (41001~44000), 时装发型 (45001~46000), 时装武器 (40101~)
|
||||
if is_costume or subtype != "":
|
||||
return true
|
||||
if (vnum >= 41001 and vnum <= 46000) or (vnum >= 40101 and vnum <= 40999):
|
||||
return true
|
||||
return false
|
||||
|
||||
# ==========================================
|
||||
# 1. 时装重铸 (70063 Kostümü dönüştür)
|
||||
# ==========================================
|
||||
# 移除所有既有属性,随机赋予 1~3 条全新的时装属性
|
||||
func transform_costume(
|
||||
inventory: Array,
|
||||
scroll_slot: int,
|
||||
costume_slot: int,
|
||||
forced_count: int = -1,
|
||||
forced_attrs: Array = []
|
||||
) -> Dictionary:
|
||||
if not _validate_slots(inventory, scroll_slot, costume_slot):
|
||||
costume_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "格位无效或道具为空!"}
|
||||
|
||||
var scroll = inventory[scroll_slot]
|
||||
var costume = inventory[costume_slot]
|
||||
|
||||
if int(scroll.get("vnum", 0)) != VNUM_COSTUME_TRANSFORM:
|
||||
costume_error.emit("NOT_TRANSFORM_SCROLL")
|
||||
return {"ok": false, "reason": "NOT_TRANSFORM_SCROLL", "msg": "该道具不是时装重铸卷轴!"}
|
||||
|
||||
if not is_costume_item(costume):
|
||||
costume_error.emit("NOT_COSTUME")
|
||||
return {"ok": false, "reason": "NOT_COSTUME", "msg": "只有时装物品可以使用时装重铸卷轴!"}
|
||||
|
||||
if bool(costume.get("is_equipped", false)):
|
||||
costume_error.emit("EQUIPPED_ITEM")
|
||||
return {"ok": false, "reason": "EQUIPPED_ITEM", "msg": "无法对穿戴中的时装进行重铸,请先卸下!"}
|
||||
|
||||
# 消耗 1 个重铸卷轴
|
||||
_consume_item_one(inventory, scroll_slot)
|
||||
|
||||
# 决定新属性条数 (1~3 条)
|
||||
var count = forced_count if forced_count in [1, 2, 3] else randi_range(1, 3)
|
||||
var new_attrs: Array = []
|
||||
|
||||
if not forced_attrs.is_empty():
|
||||
new_attrs = forced_attrs.duplicate(true)
|
||||
else:
|
||||
new_attrs = _roll_random_attributes(count)
|
||||
|
||||
costume["attrs"] = new_attrs
|
||||
costume_transformed.emit(int(costume.get("vnum", 0)), new_attrs)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"attrs_count": new_attrs.size(),
|
||||
"attrs": new_attrs,
|
||||
"msg": "时装周身流光溢彩!重铸成功,获得了 %d 条全新的时装属性。" % new_attrs.size()
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 2. 时装附魔 (70064 Kostümü efsunla)
|
||||
# ==========================================
|
||||
# 保留当前属性条数,重新随机洗练词条类型与数值
|
||||
func enchant_costume(
|
||||
inventory: Array,
|
||||
scroll_slot: int,
|
||||
costume_slot: int,
|
||||
forced_attrs: Array = []
|
||||
) -> Dictionary:
|
||||
if not _validate_slots(inventory, scroll_slot, costume_slot):
|
||||
costume_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "格位无效或道具为空!"}
|
||||
|
||||
var scroll = inventory[scroll_slot]
|
||||
var costume = inventory[costume_slot]
|
||||
|
||||
if int(scroll.get("vnum", 0)) != VNUM_COSTUME_ENCHANT:
|
||||
costume_error.emit("NOT_ENCHANT_SCROLL")
|
||||
return {"ok": false, "reason": "NOT_ENCHANT_SCROLL", "msg": "该道具不是时装附魔石!"}
|
||||
|
||||
if not is_costume_item(costume):
|
||||
costume_error.emit("NOT_COSTUME")
|
||||
return {"ok": false, "reason": "NOT_COSTUME", "msg": "只有时装物品可以使用时装附魔石!"}
|
||||
|
||||
if bool(costume.get("is_equipped", false)):
|
||||
costume_error.emit("EQUIPPED_ITEM")
|
||||
return {"ok": false, "reason": "EQUIPPED_ITEM", "msg": "无法对穿戴中的时装进行附魔,请先卸下!"}
|
||||
|
||||
var existing_attrs: Array = costume.get("attrs", [])
|
||||
if existing_attrs.is_empty():
|
||||
costume_error.emit("NO_ATTRS_TO_ENCHANT")
|
||||
return {"ok": false, "reason": "NO_ATTRS_TO_ENCHANT", "msg": "此时装没有任何附加属性!请先使用时装重铸卷轴赋予属性。"}
|
||||
|
||||
# 消耗 1 个附魔石
|
||||
_consume_item_one(inventory, scroll_slot)
|
||||
|
||||
var count = existing_attrs.size()
|
||||
var new_attrs: Array = []
|
||||
if not forced_attrs.is_empty():
|
||||
new_attrs = forced_attrs.duplicate(true)
|
||||
else:
|
||||
new_attrs = _roll_random_attributes(count)
|
||||
|
||||
costume["attrs"] = new_attrs
|
||||
costume_enchanted.emit(int(costume.get("vnum", 0)), new_attrs)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"attrs_count": new_attrs.size(),
|
||||
"attrs": new_attrs,
|
||||
"msg": "附魔灵石融入织物!保留原有 %d 条属性槽位,洗练出了全新的强力词条。" % count
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 3. 时装属性转移 (Costume Attribute Transfer)
|
||||
# ==========================================
|
||||
func transfer_costume_attributes(
|
||||
inventory: Array,
|
||||
scroll_slot: int,
|
||||
src_slot: int,
|
||||
dst_slot: int
|
||||
) -> Dictionary:
|
||||
if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
|
||||
costume_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "转移卷轴格位无效!"}
|
||||
|
||||
if src_slot < 0 or src_slot >= inventory.size() or inventory[src_slot] == null:
|
||||
costume_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "源时装格位无效!"}
|
||||
|
||||
if dst_slot < 0 or dst_slot >= inventory.size() or inventory[dst_slot] == null:
|
||||
costume_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "目标时装格位无效!"}
|
||||
|
||||
var scroll = inventory[scroll_slot]
|
||||
var src_item = inventory[src_slot]
|
||||
var dst_item = inventory[dst_slot]
|
||||
|
||||
if int(scroll.get("vnum", 0)) != VNUM_ATTR_TRANSFER:
|
||||
costume_error.emit("NOT_TRANSFER_SCROLL")
|
||||
return {"ok": false, "reason": "NOT_TRANSFER_SCROLL", "msg": "该物品不是时装属性转移符!"}
|
||||
|
||||
if not is_costume_item(src_item) or not is_costume_item(dst_item):
|
||||
costume_error.emit("NOT_COSTUME")
|
||||
return {"ok": false, "reason": "NOT_COSTUME", "msg": "源道具与目标道具都必须是时装!"}
|
||||
|
||||
var src_type = str(src_item.get("costume_subtype", "body"))
|
||||
var dst_type = str(dst_item.get("costume_subtype", "body"))
|
||||
if src_type != dst_type:
|
||||
costume_error.emit("TYPE_MISMATCH")
|
||||
return {"ok": false, "reason": "TYPE_MISMATCH", "msg": "时装类型不匹配!时装铠甲只能转移至时装铠甲,发型只能转移至发型。"}
|
||||
|
||||
var src_attrs: Array = src_item.get("attrs", [])
|
||||
if src_attrs.is_empty():
|
||||
costume_error.emit("NO_ATTRS_TO_TRANSFER")
|
||||
return {"ok": false, "reason": "NO_ATTRS_TO_TRANSFER", "msg": "源时装没有任何可转移的属性!"}
|
||||
|
||||
# 消耗 1 张转移符
|
||||
_consume_item_one(inventory, scroll_slot)
|
||||
|
||||
# 执行无损属性迁移
|
||||
var transferred = src_attrs.duplicate(true)
|
||||
dst_item["attrs"] = transferred
|
||||
src_item["attrs"] = [] # 源时装词条清空
|
||||
|
||||
costume_attributes_transferred.emit(int(src_item.get("vnum", 0)), int(dst_item.get("vnum", 0)), transferred)
|
||||
return {
|
||||
"ok": true,
|
||||
"transferred_attrs": transferred,
|
||||
"msg": "乾坤倒转!成功将 %d 条极品属性从旧时装完整迁移至新时装。" % transferred.size()
|
||||
}
|
||||
|
||||
# 内部随机属性抽取工具
|
||||
func _roll_random_attributes(count: int) -> Array:
|
||||
var result: Array = []
|
||||
var pool_indices: Array[int] = []
|
||||
for i in range(COSTUME_ATTR_POOL.size()):
|
||||
pool_indices.append(i)
|
||||
pool_indices.shuffle()
|
||||
|
||||
var actual_count = min(count, pool_indices.size())
|
||||
for i in range(actual_count):
|
||||
var def = COSTUME_ATTR_POOL[pool_indices[i]]
|
||||
var val = randi_range(int(def["min"]), int(def["max"]))
|
||||
result.append({
|
||||
"type": def["type"],
|
||||
"name": def["name"],
|
||||
"value": val
|
||||
})
|
||||
return result
|
||||
|
||||
func _validate_slots(inventory: Array, s1: int, s2: int) -> bool:
|
||||
if s1 < 0 or s1 >= inventory.size() or inventory[s1] == null:
|
||||
return false
|
||||
if s2 < 0 or s2 >= inventory.size() or inventory[s2] == null:
|
||||
return false
|
||||
return 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
|
||||
@@ -1 +0,0 @@
|
||||
uid://cd5g767aqt3ui
|
||||
@@ -1,126 +0,0 @@
|
||||
# costume_system.gd —— Metin2 40250 独立时装发型与武器衣柜系统 1:1
|
||||
# 对照 40250 服务端 costume.cpp, char_item.cpp, uiinventory.py, item_data.h
|
||||
class_name CostumeSystem
|
||||
extends RefCounted
|
||||
|
||||
signal costume_equipped(slot_type: int, item: Dictionary)
|
||||
signal costume_unequipped(slot_type: int)
|
||||
signal costume_expired(slot_type: int, item_name: String)
|
||||
|
||||
enum CostumeSlot {
|
||||
COSTUME_BODY = 0, # 时装衣服
|
||||
COSTUME_HAIR = 1, # 时装发型
|
||||
COSTUME_WEAPON = 2 # 时装武器外观
|
||||
}
|
||||
|
||||
const DEFAULT_COSTUME_DURATION := 604800.0 # 默认 7 天 (7 * 86400)
|
||||
|
||||
var equipped_costumes: Dictionary = {
|
||||
CostumeSlot.COSTUME_BODY: null,
|
||||
CostumeSlot.COSTUME_HAIR: null,
|
||||
CostumeSlot.COSTUME_WEAPON: null
|
||||
}
|
||||
|
||||
# 穿戴时装
|
||||
func equip_costume(slot_type: int, costume_item: Dictionary) -> Dictionary:
|
||||
if not equipped_costumes.has(slot_type):
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的时装栏位!"}
|
||||
|
||||
if costume_item.is_empty():
|
||||
return {"ok": false, "reason": "EMPTY_ITEM", "msg": "时装物品数据为空!"}
|
||||
|
||||
# 校验时装部位匹配
|
||||
var c_type: String = costume_item.get("costume_type", "")
|
||||
match slot_type:
|
||||
CostumeSlot.COSTUME_BODY:
|
||||
if c_type != "body":
|
||||
return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装衣服!"}
|
||||
CostumeSlot.COSTUME_HAIR:
|
||||
if c_type != "hair":
|
||||
return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装发型!"}
|
||||
CostumeSlot.COSTUME_WEAPON:
|
||||
if c_type != "weapon":
|
||||
return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装武器外观!"}
|
||||
|
||||
var old_item = equipped_costumes[slot_type]
|
||||
var item_to_equip = costume_item.duplicate(true)
|
||||
|
||||
if not item_to_equip.has("remaining_time"):
|
||||
item_to_equip["remaining_time"] = DEFAULT_COSTUME_DURATION
|
||||
|
||||
equipped_costumes[slot_type] = item_to_equip
|
||||
|
||||
costume_equipped.emit(slot_type, item_to_equip)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"slot_type": slot_type,
|
||||
"item": item_to_equip,
|
||||
"old_item": old_item,
|
||||
"msg": "时装【%s】已成功穿戴!" % item_to_equip.get("name", "时装")
|
||||
}
|
||||
|
||||
# 卸下时装
|
||||
func unequip_costume(slot_type: int) -> Dictionary:
|
||||
if not equipped_costumes.has(slot_type):
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的时装栏位!"}
|
||||
|
||||
var item = equipped_costumes[slot_type]
|
||||
if item == null:
|
||||
return {"ok": false, "reason": "SLOT_EMPTY", "msg": "该时装栏位未穿戴时装!"}
|
||||
|
||||
equipped_costumes[slot_type] = null
|
||||
costume_unequipped.emit(slot_type)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"slot_type": slot_type,
|
||||
"item": item,
|
||||
"msg": "已卸下时装【%s】。" % item.get("name", "时装")
|
||||
}
|
||||
|
||||
# 汇总所有已穿戴时装的战力属性
|
||||
func get_costume_bonuses() -> Dictionary:
|
||||
var total_bonuses: Dictionary = {}
|
||||
for slot in equipped_costumes.keys():
|
||||
var it = equipped_costumes[slot]
|
||||
if it == null:
|
||||
continue
|
||||
var bonuses: Dictionary = it.get("bonuses", {})
|
||||
for b_key in bonuses.keys():
|
||||
var val: int = int(bonuses[b_key])
|
||||
total_bonuses[b_key] = total_bonuses.get(b_key, 0) + val
|
||||
return total_bonuses
|
||||
|
||||
# 获取 3D 外观覆盖模型 ID
|
||||
func get_appearance_overrides() -> Dictionary:
|
||||
var overrides: Dictionary = {
|
||||
"body_model": 0,
|
||||
"hair_model": 0,
|
||||
"weapon_model": 0
|
||||
}
|
||||
if equipped_costumes[CostumeSlot.COSTUME_BODY] != null:
|
||||
overrides["body_model"] = int(equipped_costumes[CostumeSlot.COSTUME_BODY].get("model_id", 0))
|
||||
if equipped_costumes[CostumeSlot.COSTUME_HAIR] != null:
|
||||
overrides["hair_model"] = int(equipped_costumes[CostumeSlot.COSTUME_HAIR].get("model_id", 0))
|
||||
if equipped_costumes[CostumeSlot.COSTUME_WEAPON] != null:
|
||||
overrides["weapon_model"] = int(equipped_costumes[CostumeSlot.COSTUME_WEAPON].get("model_id", 0))
|
||||
return overrides
|
||||
|
||||
# 逐帧时效衰减更新
|
||||
func update(dt: float) -> Array:
|
||||
var expired_list: Array = []
|
||||
for slot in equipped_costumes.keys():
|
||||
var it = equipped_costumes[slot]
|
||||
if it == null:
|
||||
continue
|
||||
|
||||
it["remaining_time"] = maxf(0.0, float(it.get("remaining_time", 0.0)) - dt)
|
||||
if it["remaining_time"] <= 0.0:
|
||||
var iname: String = it.get("name", "时装")
|
||||
expired_list.append({"slot": slot, "item": it})
|
||||
equipped_costumes[slot] = null
|
||||
costume_expired.emit(slot, iname)
|
||||
costume_unequipped.emit(slot)
|
||||
|
||||
return expired_list
|
||||
@@ -1 +0,0 @@
|
||||
uid://bas2h6u0g5rsg
|
||||
@@ -1,293 +0,0 @@
|
||||
# cube_crafting_system.gd —— Metin2 40250 官方魔方炼金合成台系统 1:1
|
||||
# 100% 对照 40250 服务端 cube.cpp, cube.h, cube.txt, quest/cube.quest
|
||||
class_name CubeCraftingSystem
|
||||
extends RefCounted
|
||||
|
||||
signal cube_opened(npc_vnum: int, npc_name: String)
|
||||
signal cube_closed()
|
||||
signal craft_succeeded(reward_vnum: int, count: int, reward_name: String)
|
||||
signal craft_failed(recipe_name: String)
|
||||
signal cube_error(reason: String)
|
||||
|
||||
# 40250 官方四大魔方炼金 NPC
|
||||
const NPC_BAEK_GO := 20018 # 药剂师 白高 (草药汁提炼)
|
||||
const NPC_YU_HWAN := 20017 # 游侠助手 刘焕 (中级药水配制)
|
||||
const NPC_HUAHN_SO := 20022 # 隐者 幻素 (高级露水炼制)
|
||||
const NPC_JAE_SEON_KIM:= 20383 # 顶级工匠 金在善 (神武/神甲熔铸)
|
||||
|
||||
const NPC_NAMES: Dictionary = {
|
||||
20018: "药剂师 白高 (Baek-Go)",
|
||||
20017: "游侠助手 刘焕 (Yu-Hwan)",
|
||||
20022: "隐者 幻素 (Huahn-So)",
|
||||
20383: "神级工匠 金在善 (Jae-Seon Kim)"
|
||||
}
|
||||
|
||||
# 40250 经典 cube.txt 配方定义表
|
||||
const RECIPES: Array[Dictionary] = [
|
||||
# --- 药剂师 白高 (20018) & 刘焕 (20017) 基础草药汁 ---
|
||||
{
|
||||
"id": "herb_peach_flower",
|
||||
"name": "桃花汁提炼",
|
||||
"npcs": [20018, 20017],
|
||||
"materials": [{"vnum": 50721, "count": 1}], # 桃花
|
||||
"reward": {"vnum": 50801, "count": 1, "name": "桃花汁 (Peach Flower Juice)"},
|
||||
"gold": 0,
|
||||
"percent": 100
|
||||
},
|
||||
{
|
||||
"id": "herb_campanula",
|
||||
"name": "风铃草汁提炼",
|
||||
"npcs": [20018, 20017],
|
||||
"materials": [{"vnum": 50722, "count": 1}], # 风铃草
|
||||
"reward": {"vnum": 50802, "count": 1, "name": "风铃草汁 (Campanula Juice)"},
|
||||
"gold": 0,
|
||||
"percent": 100
|
||||
},
|
||||
{
|
||||
"id": "herb_lilac",
|
||||
"name": "紫丁香汁批量提炼",
|
||||
"npcs": [20018, 20017],
|
||||
"materials": [{"vnum": 50723, "count": 1}], # 紫丁香
|
||||
"reward": {"vnum": 50803, "count": 100, "name": "紫丁香汁 x100"},
|
||||
"gold": 0,
|
||||
"percent": 100
|
||||
},
|
||||
{
|
||||
"id": "herb_persimmon",
|
||||
"name": "柿子花汁批量提炼",
|
||||
"npcs": [20018, 20017],
|
||||
"materials": [{"vnum": 50724, "count": 1}], # 柿子花
|
||||
"reward": {"vnum": 50804, "count": 100, "name": "柿子花汁 x100"},
|
||||
"gold": 0,
|
||||
"percent": 100
|
||||
},
|
||||
|
||||
# --- 隐者 幻素 (20022) 露水药水炼制 (Dew Crafting) ---
|
||||
{
|
||||
"id": "dew_peach_flower",
|
||||
"name": "桃花露水炼制",
|
||||
"npcs": [20022],
|
||||
"materials": [
|
||||
{"vnum": 50801, "count": 10}, # 桃花汁 x10
|
||||
{"vnum": 70031, "count": 10} # 空瓶 (Empty Bottle) x10
|
||||
],
|
||||
"reward": {"vnum": 50821, "count": 1, "name": "纯净桃花露 (Peach Flower Dew)"},
|
||||
"gold": 10000,
|
||||
"percent": 100
|
||||
},
|
||||
{
|
||||
"id": "dew_campanula",
|
||||
"name": "风铃草露水炼制",
|
||||
"npcs": [20022],
|
||||
"materials": [
|
||||
{"vnum": 50802, "count": 10}, # 风铃草汁 x10
|
||||
{"vnum": 70031, "count": 10} # 空瓶 x10
|
||||
],
|
||||
"reward": {"vnum": 50822, "count": 1, "name": "纯净风铃露 (Campanula Dew)"},
|
||||
"gold": 10000,
|
||||
"percent": 100
|
||||
},
|
||||
|
||||
# --- 工匠 金在善 (20383) 顶级神兵海神剑 (Triton Sword) 熔铸 ---
|
||||
{
|
||||
"id": "craft_triton_low",
|
||||
"name": "海神剑试炼熔铸 (低阶+5)",
|
||||
"npcs": [20383],
|
||||
"materials": [
|
||||
{"vnum": 145, "count": 1}, # 战神之剑+5
|
||||
{"vnum": 30500, "count": 10},# 能量晶石 x10
|
||||
{"vnum": 30505, "count": 10} # 熔铸秘石 x10
|
||||
],
|
||||
"reward": {"vnum": 460, "count": 1, "name": "海神之剑+0 (Triton Sword)"},
|
||||
"gold": 100000,
|
||||
"percent": 5 # 40250 原版 5% 概率
|
||||
},
|
||||
{
|
||||
"id": "craft_triton_master",
|
||||
"name": "海神剑巅峰熔铸 (满阶+9)",
|
||||
"npcs": [20383],
|
||||
"materials": [
|
||||
{"vnum": 149, "count": 1}, # 战神之剑+9
|
||||
{"vnum": 30500, "count": 10},# 能量晶石 x10
|
||||
{"vnum": 30505, "count": 10} # 熔铸秘石 x10
|
||||
],
|
||||
"reward": {"vnum": 460, "count": 1, "name": "海神之剑+0 (Triton Sword)"},
|
||||
"gold": 100000,
|
||||
"percent": 100 # 40250 原版 +9 保底 100%
|
||||
}
|
||||
]
|
||||
|
||||
# 当前魔方状态
|
||||
var current_active_npc: int = 0
|
||||
var is_cube_open: bool = false
|
||||
var crafting_history: Array[Dictionary] = []
|
||||
|
||||
# 打开魔方窗口
|
||||
func open_cube(npc_vnum: int) -> bool:
|
||||
if not NPC_NAMES.has(npc_vnum):
|
||||
cube_error.emit("INVALID_NPC")
|
||||
return false
|
||||
current_active_npc = npc_vnum
|
||||
is_cube_open = true
|
||||
cube_opened.emit(npc_vnum, NPC_NAMES[npc_vnum])
|
||||
return true
|
||||
|
||||
# 关闭魔方窗口
|
||||
func close_cube() -> void:
|
||||
current_active_npc = 0
|
||||
is_cube_open = false
|
||||
cube_closed.emit()
|
||||
|
||||
# 根据放入槽位的材料与当前 NPC 匹配配方 (对照 cube.cpp: FN_find_cube)
|
||||
func find_matching_recipe(cube_slots: Array, npc_vnum: int) -> Dictionary:
|
||||
# 统计放入槽位的物品 Vnum 与数量
|
||||
var inputs: Dictionary = {}
|
||||
for it in cube_slots:
|
||||
if it != null:
|
||||
var v = int(it.get("vnum", 0))
|
||||
var c = int(it.get("count", 1))
|
||||
inputs[v] = inputs.get(v, 0) + c
|
||||
|
||||
if inputs.is_empty():
|
||||
return {}
|
||||
|
||||
for rec in RECIPES:
|
||||
var npcs: Array = rec["npcs"]
|
||||
if not npcs.has(npc_vnum):
|
||||
continue
|
||||
|
||||
var req_materials: Array = rec["materials"]
|
||||
if req_materials.size() != inputs.size():
|
||||
continue
|
||||
|
||||
var matched := true
|
||||
for mat in req_materials:
|
||||
var mv = int(mat["vnum"])
|
||||
var mc = int(mat["count"])
|
||||
if inputs.get(mv, -1) != mc:
|
||||
matched = false
|
||||
break
|
||||
|
||||
if matched:
|
||||
return rec
|
||||
|
||||
return {}
|
||||
|
||||
# 执行魔方制造 (对照 cube.cpp: Cube_make)
|
||||
func craft_item(
|
||||
cube_slots: Array,
|
||||
inventory: Array,
|
||||
player_gold: int,
|
||||
forced_roll: int = -1
|
||||
) -> Dictionary:
|
||||
if not is_cube_open:
|
||||
cube_error.emit("CUBE_NOT_OPEN")
|
||||
return {"ok": false, "reason": "CUBE_NOT_OPEN", "msg": "制造窗口尚未打开!"}
|
||||
|
||||
var recipe = find_matching_recipe(cube_slots, current_active_npc)
|
||||
if recipe.is_empty():
|
||||
cube_error.emit("NO_MATCHING_RECIPE")
|
||||
return {"ok": false, "reason": "NO_MATCHING_RECIPE", "msg": "材料不足或配方不存在!"}
|
||||
|
||||
var fee = int(recipe.get("gold", 0))
|
||||
if player_gold < fee:
|
||||
cube_error.emit("NOT_ENOUGH_GOLD")
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "NOT_ENOUGH_GOLD",
|
||||
"msg": "金币不足!本次合成需要 %d 金币,你只有 %d 金币。" % [fee, player_gold]
|
||||
}
|
||||
|
||||
# 扣除材料 (清空魔方放置槽)
|
||||
for i in range(cube_slots.size()):
|
||||
cube_slots[i] = null
|
||||
|
||||
# 扣除手续费金币
|
||||
var remaining_gold = player_gold - fee
|
||||
|
||||
# 判定成功概率
|
||||
var pct = int(recipe.get("percent", 100))
|
||||
var roll = forced_roll if forced_roll > 0 else randi_range(1, 100)
|
||||
|
||||
if roll <= pct:
|
||||
# 合成成功 (cube success)
|
||||
var rew = recipe["reward"]
|
||||
var reward_vnum = int(rew["vnum"])
|
||||
var reward_count = int(rew.get("count", 1))
|
||||
var reward_name = str(rew.get("name", "制造道具"))
|
||||
|
||||
_give_or_stack_item(inventory, reward_vnum, reward_count)
|
||||
|
||||
var record = {
|
||||
"recipe_id": recipe["id"],
|
||||
"success": true,
|
||||
"reward_vnum": reward_vnum,
|
||||
"reward_count": reward_count,
|
||||
"fee": fee
|
||||
}
|
||||
crafting_history.append(record)
|
||||
craft_succeeded.emit(reward_vnum, reward_count, reward_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"reward_vnum": reward_vnum,
|
||||
"reward_count": reward_count,
|
||||
"remaining_gold": remaining_gold,
|
||||
"msg": "叮!伴随着绚烂的灵光,你成功制造出了【%s】!" % reward_name
|
||||
}
|
||||
else:
|
||||
# 合成失败 (cube fail)
|
||||
var record = {
|
||||
"recipe_id": recipe["id"],
|
||||
"success": false,
|
||||
"fee": fee
|
||||
}
|
||||
crafting_history.append(record)
|
||||
craft_failed.emit(recipe["name"])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"remaining_gold": remaining_gold,
|
||||
"msg": "制造失败了!魔方中传出一阵焦黑的青烟,材料化为了灰烬。"
|
||||
}
|
||||
|
||||
# 获取当前 NPC 允许合成的所有配方清单
|
||||
func get_available_recipes(npc_vnum: int) -> Array[Dictionary]:
|
||||
var list: Array[Dictionary] = []
|
||||
for rec in RECIPES:
|
||||
if rec["npcs"].has(npc_vnum):
|
||||
list.append(rec)
|
||||
return list
|
||||
|
||||
# 历史记录
|
||||
func get_history() -> Array[Dictionary]:
|
||||
return crafting_history
|
||||
|
||||
func clear_history() -> void:
|
||||
crafting_history.clear()
|
||||
|
||||
# 内部发放道具堆叠
|
||||
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})
|
||||
|
||||
# 序列化与反序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"history": crafting_history.duplicate(true)
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
crafting_history.clear()
|
||||
var raw = data.get("history", [])
|
||||
for it in raw:
|
||||
crafting_history.append(it.duplicate(true))
|
||||
@@ -1 +0,0 @@
|
||||
uid://dxkngqnfky42a
|
||||
@@ -1,196 +0,0 @@
|
||||
# cursed_monolith_spire_system.gd —— Metin2 40250 野外魔石破除之【封印魔塔】限时防守挑战 1:1
|
||||
# 对照 40250 服务端 regen.cpp, dungeon.cpp, char_battle.cpp, item_manager.cpp
|
||||
class_name CursedMonolithSpireSystem
|
||||
extends RefCounted
|
||||
|
||||
signal spire_emerged(pos: Vector3, time_limit: float)
|
||||
signal wave_spawned(wave_index: int, wave_name: String, mob_count: int)
|
||||
signal trial_completed(success: bool, time_used: float)
|
||||
signal reward_chest_spawned(chest_vnum: int, pos: Vector3)
|
||||
|
||||
const TRIAL_TIME_LIMIT: float = 180.0 # 3 分钟倒计时
|
||||
const CHEST_VNUM: int = 50130 # 上古魔塔宝箱 (Ancient Spire Chest)
|
||||
|
||||
const WAVE_CONFIG: Dictionary = {
|
||||
1: {
|
||||
"name": "第 1 波:狂暴魔兵突袭",
|
||||
"mob_vnum": 701, # 恶魔士兵
|
||||
"count": 6
|
||||
},
|
||||
2: {
|
||||
"name": "第 2 波:双生魔将合围",
|
||||
"mob_vnum": 1092, # 恶魔大将
|
||||
"count": 2
|
||||
},
|
||||
3: {
|
||||
"name": "第 3 波:上古守墓魔王",
|
||||
"mob_vnum": 1093, # 恶魔死神幻影
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
|
||||
var is_active: bool = false
|
||||
var current_wave: int = 0
|
||||
var time_remaining: float = 0.0
|
||||
var spire_pos: Vector3 = Vector3.ZERO
|
||||
var difficulty: int = 50
|
||||
|
||||
var active_mobs: Dictionary = {} # mob_id -> { "vnum", "alive" }
|
||||
var next_mob_id: int = 1
|
||||
var is_victory: bool = false
|
||||
var chest_available: bool = false
|
||||
|
||||
# 启动封印魔塔挑战
|
||||
func start_trial(pos: Vector3, diff_level: int = 50) -> Dictionary:
|
||||
if is_active:
|
||||
return {"ok": false, "reason": "TRIAL_ALREADY_ACTIVE", "msg": "封印魔塔挑战正在进行中!"}
|
||||
|
||||
is_active = true
|
||||
is_victory = false
|
||||
chest_available = false
|
||||
spire_pos = pos
|
||||
difficulty = diff_level
|
||||
time_remaining = TRIAL_TIME_LIMIT
|
||||
current_wave = 0
|
||||
active_mobs.clear()
|
||||
next_mob_id = 1
|
||||
|
||||
spire_emerged.emit(pos, TRIAL_TIME_LIMIT)
|
||||
|
||||
# 立即召唤第 1 波
|
||||
_spawn_wave(1)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"pos": pos,
|
||||
"time_limit": TRIAL_TIME_LIMIT,
|
||||
"msg": "大地剧烈震颤!【诅咒的封印黑石魔塔】破土升起!请在 180 秒内清除所有魔潮!"
|
||||
}
|
||||
|
||||
# 产生指定阶段怪潮
|
||||
func _spawn_wave(wave_idx: int) -> void:
|
||||
current_wave = wave_idx
|
||||
var cfg = WAVE_CONFIG[wave_idx]
|
||||
var count: int = cfg["count"]
|
||||
var vnum: int = cfg["mob_vnum"]
|
||||
|
||||
active_mobs.clear()
|
||||
for i in range(count):
|
||||
var mid := next_mob_id
|
||||
next_mob_id += 1
|
||||
active_mobs[mid] = {
|
||||
"id": mid,
|
||||
"vnum": vnum,
|
||||
"alive": true
|
||||
}
|
||||
|
||||
wave_spawned.emit(wave_idx, cfg["name"], count)
|
||||
|
||||
# 击杀魔潮怪物
|
||||
func kill_mob(mob_id: int) -> Dictionary:
|
||||
if not is_active:
|
||||
return {"ok": false, "reason": "TRIAL_NOT_ACTIVE"}
|
||||
|
||||
if not active_mobs.has(mob_id) or not active_mobs[mob_id]["alive"]:
|
||||
return {"ok": false, "reason": "MOB_NOT_FOUND"}
|
||||
|
||||
active_mobs[mob_id]["alive"] = false
|
||||
active_mobs.erase(mob_id)
|
||||
|
||||
# 检查当前波次是否全清
|
||||
if active_mobs.is_empty():
|
||||
if current_wave < 3:
|
||||
# 进入下一波
|
||||
var next_w = current_wave + 1
|
||||
_spawn_wave(next_w)
|
||||
return {
|
||||
"ok": true,
|
||||
"wave_cleared": current_wave - 1,
|
||||
"next_wave": next_w,
|
||||
"msg": "当前波次肃清!更加凶险的魔物正在凝聚!"
|
||||
}
|
||||
else:
|
||||
# 第 3 波首领击破 -> 大获全胜!
|
||||
return _finish_trial(true)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"remaining_in_wave": active_mobs.size()
|
||||
}
|
||||
|
||||
# 帧心跳更新限时倒计时
|
||||
func update(delta: float) -> Dictionary:
|
||||
if not is_active:
|
||||
return {"status": "inactive"}
|
||||
|
||||
time_remaining -= delta
|
||||
if time_remaining <= 0.0:
|
||||
time_remaining = 0.0
|
||||
return _finish_trial(false)
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"wave": current_wave,
|
||||
"remaining_mobs": active_mobs.size(),
|
||||
"time_remaining": time_remaining
|
||||
}
|
||||
|
||||
# 结算挑战胜负
|
||||
func _finish_trial(success: bool) -> Dictionary:
|
||||
is_active = false
|
||||
is_victory = success
|
||||
var time_used = TRIAL_TIME_LIMIT - time_remaining
|
||||
|
||||
if success:
|
||||
chest_available = true
|
||||
reward_chest_spawned.emit(CHEST_VNUM, spire_pos)
|
||||
trial_completed.emit(true, time_used)
|
||||
return {
|
||||
"ok": true,
|
||||
"victory": true,
|
||||
"time_used": time_used,
|
||||
"msg": "【封印魔塔】封印彻底瓦解!降下【上古魔塔宝箱】!耗时: %.1f 秒" % time_used
|
||||
}
|
||||
else:
|
||||
active_mobs.clear()
|
||||
trial_completed.emit(false, TRIAL_TIME_LIMIT)
|
||||
return {
|
||||
"ok": true,
|
||||
"victory": false,
|
||||
"time_used": TRIAL_TIME_LIMIT,
|
||||
"msg": "时间耗尽!封印黑石魔塔重新沉入深渊地底,挑战失败!"
|
||||
}
|
||||
|
||||
# 开启上古宝箱获取掉落
|
||||
func open_reward_chest() -> Array:
|
||||
if not chest_available:
|
||||
return []
|
||||
|
||||
chest_available = false
|
||||
var drops: Array = [
|
||||
{"vnum": 71003, "name": "祝福卷轴", "count": 2},
|
||||
{"vnum": 50513, "name": "灵魂之石", "count": 1},
|
||||
{"vnum": 70024, "name": "祝福宝珠", "count": 1},
|
||||
{"vnum": 1, "name": "金币", "count": 3500000}
|
||||
]
|
||||
return drops
|
||||
|
||||
# 取消/中断挑战
|
||||
func cancel_trial() -> Dictionary:
|
||||
if not is_active:
|
||||
return {"ok": false, "reason": "NOT_ACTIVE"}
|
||||
|
||||
is_active = false
|
||||
active_mobs.clear()
|
||||
return {"ok": true, "msg": "挑战已提前终止。"}
|
||||
|
||||
# 获取状态
|
||||
func get_trial_state() -> Dictionary:
|
||||
return {
|
||||
"is_active": is_active,
|
||||
"is_victory": is_victory,
|
||||
"current_wave": current_wave,
|
||||
"remaining_mobs": active_mobs.size(),
|
||||
"time_remaining": time_remaining,
|
||||
"chest_available": chest_available
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://mklhwerocdlu
|
||||
@@ -1,225 +0,0 @@
|
||||
# demon_tower_system.gd —— 恶魔之塔 1~9 层单机爬塔挑战循环与塔顶免费铁匠(40250 官方 1:1 对齐)
|
||||
# 对齐源码:
|
||||
# - 40250 server/share/locale/english/quest/deviltower_zone.quest
|
||||
# - 40250 server/game/src/dungeon.cpp
|
||||
# - 40250 server/game/src/char_battle.cpp
|
||||
class_name DemonTowerSystem
|
||||
extends RefCounted
|
||||
|
||||
signal floor_changed(floor_num: int, floor_name: String)
|
||||
signal floor_completed(floor_num: int)
|
||||
signal tower_completed
|
||||
signal tower_failed(reason: String)
|
||||
signal blacksmith_spawned(blacksmith_vnum: int, blacksmith_name: String)
|
||||
|
||||
# 核心怪物与 NPC Vnum (40250 官方标准)
|
||||
const VNUM_METIN_FLOOR1 := 8015 # 坚韧之石 (1层入口封印)
|
||||
const VNUM_DEMON_KING := 1091 # 恶魔王 (3层 BOSS)
|
||||
const VNUM_METIN_FLOOR4 := 8016 # 恶魔封印石 (4层真伪封印石)
|
||||
const VNUM_ELITE_DEMON_KING := 1092 # 精英恶魔王 (6层 BOSS)
|
||||
const VNUM_METIN_DEATH := 8017 # 死亡之石 (7层)
|
||||
const VNUM_DEATH_REAPER := 1093 # 死神 (9层 最终 BOSS)
|
||||
|
||||
# 塔顶免费铁匠 NPC
|
||||
const NPC_TOWER_WEAPON_SMITH := 20074 # 恶魔之塔武器铁匠
|
||||
const NPC_TOWER_ARMOR_SMITH := 20075 # 恶魔之塔防具铁匠
|
||||
const NPC_TOWER_JEWELRY_SMITH := 20076 # 恶魔之塔首饰铁匠
|
||||
|
||||
# 核心任务道具
|
||||
const VNUM_ZIN_KEY_FLOOR5 := 50084 # 解除 5 层石碑钥匙
|
||||
const VNUM_ZIN_MAP_FLOOR7 := 30302 # 7 层恶魔封印地图
|
||||
const VNUM_ZIN_KEY_FLOOR8 := 30304 # 8 层通往塔顶钥匙
|
||||
const VNUM_DEATH_REAPER_CHEST := 50082 # 9 层死神宝箱
|
||||
|
||||
const TOTAL_FLOORS := 9
|
||||
const FLOOR_NAMES := {
|
||||
1: "第 1 层:恶魔入口",
|
||||
2: "第 2 层:恶魔集结所",
|
||||
3: "第 3 层:恶魔王大殿",
|
||||
4: "第 4 层:迷幻封印之阵",
|
||||
5: "第 5 层:五柱封印禁地",
|
||||
6: "第 6 层:恶魔之塔顶峰",
|
||||
7: "第 7 层:死神迷宫前厅",
|
||||
8: "第 8 层:死神结界禁地",
|
||||
9: "第 9 层:死神王座决战",
|
||||
}
|
||||
|
||||
# 运行期状态
|
||||
var current_floor := 1
|
||||
var is_active := false
|
||||
var floor_time_left := 1200.0 # 20 分钟单层时限
|
||||
var floor4_real_metin_idx := -1
|
||||
var floor5_unlocked_pillars := 0
|
||||
var floor6_blacksmith_vnum := 0
|
||||
var floor6_free_refine_used := false
|
||||
|
||||
func start_tower() -> void:
|
||||
current_floor = 1
|
||||
is_active = true
|
||||
floor_time_left = 1200.0
|
||||
floor4_real_metin_idx = -1
|
||||
floor5_unlocked_pillars = 0
|
||||
floor6_blacksmith_vnum = 0
|
||||
floor6_free_refine_used = false
|
||||
floor_changed.emit(1, FLOOR_NAMES[1])
|
||||
|
||||
func get_floor() -> int:
|
||||
return current_floor
|
||||
|
||||
func get_floor_name() -> String:
|
||||
return FLOOR_NAMES.get(current_floor, "未知层")
|
||||
|
||||
## 第 1 层判定:击碎封印石 8015 即可通关
|
||||
func on_floor1_metin_destroyed(vnum: int) -> bool:
|
||||
if current_floor != 1 or not is_active:
|
||||
return false
|
||||
if vnum == VNUM_METIN_FLOOR1:
|
||||
_advance_floor()
|
||||
return true
|
||||
return false
|
||||
|
||||
## 第 2 层判定:清理全图怪物
|
||||
func on_floor2_mobs_cleared(mob_count: int) -> bool:
|
||||
if current_floor != 2 or not is_active:
|
||||
return false
|
||||
if mob_count <= 0:
|
||||
_advance_floor()
|
||||
return true
|
||||
return false
|
||||
|
||||
## 第 3 层判定:击杀恶魔王 1091
|
||||
func on_floor3_boss_killed(boss_vnum: int) -> bool:
|
||||
if current_floor != 3 or not is_active:
|
||||
return false
|
||||
if boss_vnum == VNUM_DEMON_KING:
|
||||
_advance_floor()
|
||||
return true
|
||||
return false
|
||||
|
||||
## 第 4 层判定:初始化 7 座封印石并在打破真石时通关
|
||||
func setup_floor4_metins() -> void:
|
||||
# 7 座封印石 (索引 0..6),随机指定 1 座为真石
|
||||
floor4_real_metin_idx = randi() % 7
|
||||
|
||||
func on_floor4_metin_hit(stone_idx: int) -> Dictionary:
|
||||
if current_floor != 4 or not is_active:
|
||||
return {"ok": false, "msg": "当前不在第 4 层!"}
|
||||
if stone_idx == floor4_real_metin_idx:
|
||||
_advance_floor()
|
||||
return {"ok": true, "is_real": true, "msg": "找到了真实的恶魔封印石!通往第 5 层的传送门开启!"}
|
||||
else:
|
||||
return {"ok": true, "is_real": false, "msg": "这是一座虚假的封印石,迷雾尚未散开!"}
|
||||
|
||||
## 第 5 层判定:使用钥匙解除 5 座恶魔石柱
|
||||
func on_floor5_unlock_pillar(key_vnum: int) -> Dictionary:
|
||||
if current_floor != 5 or not is_active:
|
||||
return {"ok": false, "msg": "当前不在第 5 层!"}
|
||||
if key_vnum != VNUM_ZIN_KEY_FLOOR5:
|
||||
return {"ok": false, "msg": "钥匙不匹配!"}
|
||||
|
||||
floor5_unlocked_pillars += 1
|
||||
var remain := 5 - floor5_unlocked_pillars
|
||||
if floor5_unlocked_pillars >= 5:
|
||||
_advance_floor()
|
||||
return {"ok": true, "completed": true, "msg": "5 座恶魔石碑全部解除封印!传送至第 6 层!"}
|
||||
else:
|
||||
return {"ok": true, "completed": false, "remain": remain, "msg": "成功解除了一座石碑封印,还剩 %d 座!" % remain}
|
||||
|
||||
## 第 6 层判定:击杀精英恶魔王 1092 并召唤免费铁匠
|
||||
func on_floor6_boss_killed(boss_vnum: int) -> Dictionary:
|
||||
if current_floor != 6 or not is_active:
|
||||
return {"ok": false, "msg": "当前不在第 6 层!"}
|
||||
if boss_vnum == VNUM_ELITE_DEMON_KING:
|
||||
# 随机召唤三位塔顶铁匠之一
|
||||
var smiths := [
|
||||
{"vnum": NPC_TOWER_WEAPON_SMITH, "name": "恶魔之塔武器铁匠"},
|
||||
{"vnum": NPC_TOWER_ARMOR_SMITH, "name": "恶魔之塔防具铁匠"},
|
||||
{"vnum": NPC_TOWER_JEWELRY_SMITH, "name": "恶魔之塔首饰铁匠"}
|
||||
]
|
||||
var chosen: Dictionary = smiths[randi() % smiths.size()]
|
||||
floor6_blacksmith_vnum = chosen["vnum"]
|
||||
floor6_free_refine_used = false
|
||||
blacksmith_spawned.emit(chosen["vnum"], chosen["name"])
|
||||
return {
|
||||
"ok": true,
|
||||
"blacksmith_vnum": chosen["vnum"],
|
||||
"blacksmith_name": chosen["name"],
|
||||
"msg": "击杀了精英恶魔王!塔顶现身了一位【%s】!" % chosen["name"]
|
||||
}
|
||||
return {"ok": false, "msg": "目标不是精英恶魔王!"}
|
||||
|
||||
## 第 6 层塔顶免费铁匠强化 (无需材料,单人爬塔经典福利)
|
||||
func perform_tower_free_refine(item: Dictionary, item_type: int) -> Dictionary:
|
||||
if current_floor != 6 or floor6_blacksmith_vnum == 0:
|
||||
return {"ok": false, "code": "NO_BLACKSMITH", "msg": "塔顶铁匠尚未出现!"}
|
||||
if floor6_free_refine_used:
|
||||
return {"ok": false, "code": "ALREADY_USED", "msg": "恶魔之塔铁匠本次只能免费强化一次!"}
|
||||
|
||||
# 校验铁匠类型与物品类型匹配
|
||||
if floor6_blacksmith_vnum == NPC_TOWER_WEAPON_SMITH and item_type != 1:
|
||||
return {"ok": false, "code": "TYPE_MISMATCH", "msg": "武器铁匠只能强化武器!"}
|
||||
if floor6_blacksmith_vnum == NPC_TOWER_ARMOR_SMITH and item_type != 2:
|
||||
return {"ok": false, "code": "TYPE_MISMATCH", "msg": "防具铁匠只能强化防具!"}
|
||||
|
||||
floor6_free_refine_used = true
|
||||
var cur_vnum := int(item.get("vnum", 0))
|
||||
var new_vnum := cur_vnum + 1
|
||||
item["vnum"] = new_vnum
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "SUCCESS",
|
||||
"old_vnum": cur_vnum,
|
||||
"new_vnum": new_vnum,
|
||||
"msg": "【%s】为你施展了恶魔神工,免费强化成功!装备晋升为 #%d!" % [
|
||||
"武器铁匠" if floor6_blacksmith_vnum == NPC_TOWER_WEAPON_SMITH else "防具铁匠",
|
||||
new_vnum
|
||||
]
|
||||
}
|
||||
|
||||
## 从第 6 层前往第 7 层 (要求角色等级 >= 75)
|
||||
func advance_to_floor7(player_level: int) -> Dictionary:
|
||||
if current_floor != 6:
|
||||
return {"ok": false, "msg": "不在第 6 层!"}
|
||||
if player_level < 75:
|
||||
return {"ok": false, "code": "LEVEL_TOO_LOW", "msg": "只有等级达到 75 级的真正勇者,才能踏入恶魔之塔第 7 层禁地!"}
|
||||
_advance_floor()
|
||||
return {"ok": true, "msg": "踏入了死神禁地第 7 层!"}
|
||||
|
||||
## 第 7 层判定:使用恶魔地图 30302 通关
|
||||
func on_floor7_map_used(map_vnum: int) -> bool:
|
||||
if current_floor != 7 or not is_active:
|
||||
return false
|
||||
if map_vnum == VNUM_ZIN_MAP_FLOOR7:
|
||||
_advance_floor()
|
||||
return true
|
||||
return false
|
||||
|
||||
## 第 8 层判定:使用钥匙 30304 通关
|
||||
func on_floor8_key_used(key_vnum: int) -> bool:
|
||||
if current_floor != 8 or not is_active:
|
||||
return false
|
||||
if key_vnum == VNUM_ZIN_KEY_FLOOR8:
|
||||
_advance_floor()
|
||||
return true
|
||||
return false
|
||||
|
||||
## 第 9 层终极对决:击杀死神 1093
|
||||
func on_floor9_boss_killed(boss_vnum: int) -> Dictionary:
|
||||
if current_floor != 9 or not is_active:
|
||||
return {"ok": false, "msg": "当前不在第 9 层!"}
|
||||
if boss_vnum == VNUM_DEATH_REAPER:
|
||||
is_active = false
|
||||
tower_completed.emit()
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "TOWER_CLEAR",
|
||||
"drop_vnum": VNUM_DEATH_REAPER_CHEST,
|
||||
"msg": "壮举!你成功斩杀了死神,恶魔之塔被彻底征服!获得了【死神宝箱】!"
|
||||
}
|
||||
return {"ok": false, "msg": "目标不是死神!"}
|
||||
|
||||
func _advance_floor() -> void:
|
||||
floor_completed.emit(current_floor)
|
||||
current_floor += 1
|
||||
floor_time_left = 1200.0
|
||||
floor_changed.emit(current_floor, FLOOR_NAMES.get(current_floor, "第 %d 层" % current_floor))
|
||||
@@ -1 +0,0 @@
|
||||
uid://berjrd53seoek
|
||||
@@ -1,326 +0,0 @@
|
||||
# devils_catacomb_system.gd —— Metin2 40250 地下墓穴 7 层与冥王阿兹瑞尔挑战副本 1:1
|
||||
# 对照 40250 服务端 devils_catacomb.quest, dungeon.cpp, char_battle.cpp, mob 2598, 2597, 2596
|
||||
class_name DevilsCatacombSystem
|
||||
extends RefCounted
|
||||
|
||||
signal floor_advanced(floor: int)
|
||||
signal metin_destroyed(is_correct: bool)
|
||||
signal tartaros_killed(drop_item: Dictionary)
|
||||
signal obelisk_activated()
|
||||
signal charon_killed()
|
||||
signal azrael_hp_changed(current_hp: int, max_hp: int)
|
||||
signal catacomb_cleared(drops: Array)
|
||||
signal catacomb_failed(reason: String)
|
||||
|
||||
const NPC_CATACOMB_GUARD := 20367
|
||||
const ITEM_SOUL_CRYSTAL_KEY := 30311 # 灵魂水晶钥匙 (进入与 1 层解锁)
|
||||
const ITEM_GRIM_SOUL_STONE := 30312 # 冷酷之石 (5 层塔耳塔洛斯掉落)
|
||||
const MIN_LEVEL := 75
|
||||
const TIME_LIMIT := 3600.0 # 60 分钟限时
|
||||
|
||||
# 首领属性
|
||||
const MOB_TARTAROS := 2597 # 5 层守卫 塔耳塔洛斯
|
||||
const MOB_CHARON := 2596 # 6 层将军 卡隆
|
||||
const MOB_AZRAEL := 2598 # 7 层冥界之王 阿兹瑞尔
|
||||
|
||||
const AZRAEL_MAX_HP := 900000
|
||||
const AZRAEL_BASE_ATK := 2200
|
||||
const AZRAEL_BASE_DEF := 680
|
||||
|
||||
var is_active: bool = false
|
||||
var current_floor: int = 0
|
||||
var remaining_time: float = 0.0
|
||||
|
||||
# 3 层复仇魔石状态
|
||||
var correct_metin_id: int = 3
|
||||
var metins_broken: int = 0
|
||||
|
||||
# 5 层方尖碑状态
|
||||
var has_grim_stone: bool = false
|
||||
var is_obelisk_unlocked: bool = false
|
||||
|
||||
# 7 层阿兹瑞尔状态
|
||||
var azrael_hp: int = 0
|
||||
var azrael_max_hp: int = AZRAEL_MAX_HP
|
||||
var is_azrael_alive: bool = false
|
||||
var azrael_minions_summoned: bool = false
|
||||
|
||||
# 进入地下墓穴 (通过 NPC 20367)
|
||||
func enter_catacomb(player_level: int, inventory: Array) -> Dictionary:
|
||||
if player_level < MIN_LEVEL:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "LEVEL_TOO_LOW",
|
||||
"msg": "你的等级不足 75 级,无法承受地下墓穴的死灵死气!"
|
||||
}
|
||||
|
||||
# 检查 1 层必需的灵魂水晶钥匙 (30311)
|
||||
var key_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var item = inventory[i]
|
||||
if item != null and int(item.get("vnum", 0)) == ITEM_SOUL_CRYSTAL_KEY:
|
||||
key_slot = i
|
||||
break
|
||||
|
||||
if key_slot == -1:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "MISSING_SOUL_KEY",
|
||||
"msg": "进入地下墓穴需要持有【灵魂水晶钥匙】!"
|
||||
}
|
||||
|
||||
# 初始化副本状态进入 1 层
|
||||
is_active = true
|
||||
current_floor = 1
|
||||
remaining_time = TIME_LIMIT
|
||||
correct_metin_id = randi_range(1, 5)
|
||||
metins_broken = 0
|
||||
has_grim_stone = false
|
||||
is_obelisk_unlocked = false
|
||||
is_azrael_alive = false
|
||||
|
||||
floor_advanced.emit(1)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 1,
|
||||
"time_limit": remaining_time,
|
||||
"msg": "成功踏入地下墓穴一层!限时 60 分钟!"
|
||||
}
|
||||
|
||||
# 第 1 层:使用灵魂水晶钥匙开启石门封印进入 2 层
|
||||
func unlock_floor_1(inventory: Array) -> Dictionary:
|
||||
if not is_active or current_floor != 1:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
var key_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var item = inventory[i]
|
||||
if item != null and int(item.get("vnum", 0)) == ITEM_SOUL_CRYSTAL_KEY:
|
||||
key_slot = i
|
||||
break
|
||||
|
||||
if key_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_KEY", "msg": "背包中缺少灵魂水晶钥匙!"}
|
||||
|
||||
# 消耗钥匙
|
||||
inventory[key_slot] = null
|
||||
current_floor = 2
|
||||
floor_advanced.emit(2)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 2,
|
||||
"msg": "灵魂水晶钥匙已插入封印,石门轰然开启!进入地下墓穴二层!"
|
||||
}
|
||||
|
||||
# 第 2 层:突破恶灵迷宫进入 3 层
|
||||
func complete_floor_2() -> Dictionary:
|
||||
if not is_active or current_floor != 2:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
current_floor = 3
|
||||
floor_advanced.emit(3)
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 3,
|
||||
"msg": "已突破恶灵迷宫!进入地下墓穴三层!"
|
||||
}
|
||||
|
||||
# 第 3 层:击碎复仇魔石 (8035)
|
||||
func break_floor_3_metin(metin_idx: int) -> Dictionary:
|
||||
if not is_active or current_floor != 3:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
metins_broken += 1
|
||||
if metin_idx == correct_metin_id:
|
||||
current_floor = 4
|
||||
metin_destroyed.emit(true)
|
||||
floor_advanced.emit(4)
|
||||
return {
|
||||
"ok": true,
|
||||
"correct": true,
|
||||
"floor": 4,
|
||||
"msg": "击碎了真正的复仇魔石!封印瓦解,传送至地下墓穴四层!"
|
||||
}
|
||||
else:
|
||||
metin_destroyed.emit(false)
|
||||
return {
|
||||
"ok": true,
|
||||
"correct": false,
|
||||
"floor": 3,
|
||||
"msg": "这是虚假的伪装魔石!周围唤醒了冥界诅咒之魂!"
|
||||
}
|
||||
|
||||
# 第 4 层:玄武岩石柱迷宫寻路进入 5 层
|
||||
func complete_floor_4() -> Dictionary:
|
||||
if not is_active or current_floor != 4:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
current_floor = 5
|
||||
floor_advanced.emit(5)
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 5,
|
||||
"msg": "成功找到玄武岩迷宫真出口!进入地下墓穴五层!"
|
||||
}
|
||||
|
||||
# 第 5 层:击败塔耳塔洛斯 (2597) 并获得冷酷之石 (30312)
|
||||
func defeat_tartaros(inventory: Array) -> Dictionary:
|
||||
if not is_active or current_floor != 5:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
# 放入背包
|
||||
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": "背包已满,无法拾取冷酷之石!"}
|
||||
|
||||
var grim_stone = {"vnum": ITEM_GRIM_SOUL_STONE, "name": "冷酷之石", "count": 1}
|
||||
inventory[empty_slot] = grim_stone
|
||||
has_grim_stone = true
|
||||
|
||||
tartaros_killed.emit(grim_stone)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"drop": grim_stone,
|
||||
"msg": "击败了守卫【塔耳塔洛斯】,获得了【冷酷之石】!请前往方尖碑镶嵌!"
|
||||
}
|
||||
|
||||
# 第 5 层:将冷酷之石插入玄武岩方尖碑,开启 6 层通道
|
||||
func insert_grim_stone_to_obelisk(inventory: Array) -> Dictionary:
|
||||
if not is_active or current_floor != 5:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
var stone_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var item = inventory[i]
|
||||
if item != null and int(item.get("vnum", 0)) == ITEM_GRIM_SOUL_STONE:
|
||||
stone_slot = i
|
||||
break
|
||||
|
||||
if stone_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_STONE", "msg": "背包中缺少冷酷之石!"}
|
||||
|
||||
inventory[stone_slot] = null
|
||||
is_obelisk_unlocked = true
|
||||
current_floor = 6
|
||||
obelisk_activated.emit()
|
||||
floor_advanced.emit(6)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 6,
|
||||
"msg": "冷酷之石完美嵌入方尖碑!冥界封印开启,进入地下墓穴六层!"
|
||||
}
|
||||
|
||||
# 第 6 层:击败冥界将军卡隆 (2596) 进入 7 层阿兹瑞尔王座
|
||||
func defeat_charon() -> Dictionary:
|
||||
if not is_active or current_floor != 6:
|
||||
return {"ok": false, "reason": "WRONG_FLOOR"}
|
||||
|
||||
current_floor = 7
|
||||
# 初始化 7 层 Boss 阿兹瑞尔
|
||||
azrael_hp = AZRAEL_MAX_HP
|
||||
azrael_max_hp = AZRAEL_MAX_HP
|
||||
is_azrael_alive = true
|
||||
azrael_minions_summoned = false
|
||||
|
||||
charon_killed.emit()
|
||||
floor_advanced.emit(7)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"floor": 7,
|
||||
"boss_hp": azrael_hp,
|
||||
"msg": "冥界将军【卡隆】已被斩灭!传送至七层【冥王阿兹瑞尔王座】!"
|
||||
}
|
||||
|
||||
# 第 7 层:对冥王阿兹瑞尔造成伤害
|
||||
func attack_azrael(raw_dmg: int) -> Dictionary:
|
||||
if not is_active or current_floor != 7 or not is_azrael_alive:
|
||||
return {"ok": false, "reason": "BOSS_NOT_AVAILABLE"}
|
||||
|
||||
var net_dmg: int = max(1, raw_dmg - AZRAEL_BASE_DEF)
|
||||
azrael_hp = max(0, azrael_hp - net_dmg)
|
||||
|
||||
azrael_hp_changed.emit(azrael_hp, azrael_max_hp)
|
||||
|
||||
# 半血召唤死灵护卫
|
||||
var triggered_minions := false
|
||||
if azrael_hp <= int(AZRAEL_MAX_HP * 0.5) and not azrael_minions_summoned:
|
||||
azrael_minions_summoned = true
|
||||
triggered_minions = true
|
||||
|
||||
# 击败阿兹瑞尔
|
||||
if azrael_hp <= 0:
|
||||
is_azrael_alive = false
|
||||
is_active = false
|
||||
var drops: Array = _generate_azrael_drops()
|
||||
catacomb_cleared.emit(drops)
|
||||
return {
|
||||
"ok": true,
|
||||
"killed": true,
|
||||
"net_dmg": net_dmg,
|
||||
"remaining_hp": 0,
|
||||
"drops": drops,
|
||||
"msg": "【冥王阿兹瑞尔】轰然倒下!地下墓穴已被彻底征服!"
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"killed": false,
|
||||
"net_dmg": net_dmg,
|
||||
"remaining_hp": azrael_hp,
|
||||
"summoned_minions": triggered_minions
|
||||
}
|
||||
|
||||
# 逐帧倒计时
|
||||
func update(dt: float) -> void:
|
||||
if not is_active:
|
||||
return
|
||||
|
||||
remaining_time -= dt
|
||||
if remaining_time <= 0.0:
|
||||
is_active = false
|
||||
is_azrael_alive = false
|
||||
catacomb_failed.emit("TIME_OUT")
|
||||
|
||||
# 生成阿兹瑞尔掉落物
|
||||
func _generate_azrael_drops() -> Array:
|
||||
var drops: Array = []
|
||||
|
||||
# 1. 必掉阿兹瑞尔专属宝箱 (50187)
|
||||
drops.append({
|
||||
"vnum": 50187,
|
||||
"name": "阿兹瑞尔宝箱",
|
||||
"count": 1
|
||||
})
|
||||
|
||||
# 2. 必掉灵魂之石 (50513)
|
||||
drops.append({
|
||||
"vnum": 50513,
|
||||
"name": "灵魂之石",
|
||||
"count": 1
|
||||
})
|
||||
|
||||
# 3. 终极泰坦之盾 (13149) 概率掉落
|
||||
drops.append({
|
||||
"vnum": 13149,
|
||||
"name": "泰坦之盾+0",
|
||||
"count": 1
|
||||
})
|
||||
|
||||
# 4. 大额冥界金币
|
||||
drops.append({
|
||||
"vnum": 1,
|
||||
"name": "金币",
|
||||
"count": randi_range(200000, 400000)
|
||||
})
|
||||
|
||||
return drops
|
||||
@@ -1 +0,0 @@
|
||||
uid://dyqcl4bpdcalf
|
||||
@@ -1,257 +0,0 @@
|
||||
# dragon_lair_system.gd —— Metin2 40250 冰龙贝兰-塞陶挑战巢穴 1:1
|
||||
# 对照 40250 服务端 dragon_lair.quest, dungeon.cpp, char_battle.cpp, mob 2493, NPC 30121
|
||||
class_name DragonLairSystem
|
||||
extends RefCounted
|
||||
|
||||
signal dungeon_started(time_limit: float)
|
||||
signal metin_destroyed(metin_vnum: int, metin_name: String)
|
||||
signal minions_spawned(count: int, mob_vnum: int)
|
||||
signal dragon_hp_changed(current_hp: int, max_hp: int)
|
||||
signal dragon_defeated(drops: Array)
|
||||
signal dungeon_failed(reason: String)
|
||||
|
||||
const NPC_GHOST_OF_SURA := 30121 # 修罗鬼魂
|
||||
const ITEM_TWISTED_KEY := 30179 # 扭曲钥匙
|
||||
const KEY_REQUIRED_COUNT := 3
|
||||
const MIN_LEVEL := 75
|
||||
const TIME_LIMIT := 3600.0 # 60 分钟限时
|
||||
|
||||
const BOSS_VNUM := 2493 # 贝兰-塞陶 (Beran-Setaou)
|
||||
const BOSS_MAX_HP := 800000
|
||||
const BOSS_BASE_ATK := 1800
|
||||
const BOSS_BASE_DEF := 650
|
||||
|
||||
const METIN_TABLE: Dictionary = {
|
||||
8031: {"name": "傲慢之石", "desc": "提供 30% 物理抗性减免"},
|
||||
8032: {"name": "寂寞之石", "desc": "提供生命自然恢复"},
|
||||
8033: {"name": "平静之石", "desc": "提供 30% 魔法抗性减免"},
|
||||
8034: {"name": "复仇之石", "desc": "提供 40% 攻击力增幅"}
|
||||
}
|
||||
|
||||
var is_active: bool = false
|
||||
var remaining_time: float = 0.0
|
||||
var dragon_hp: int = 0
|
||||
var dragon_max_hp: int = BOSS_MAX_HP
|
||||
var is_dragon_alive: bool = false
|
||||
var active_metins: Dictionary = {} # vnum -> bool (true = alive)
|
||||
var active_minions: Array = []
|
||||
var regen_timer: float = 0.0
|
||||
|
||||
# 进入冰龙巢穴 (通过 NPC 30121 验证)
|
||||
func enter_lair(player_level: int, inventory: Array) -> Dictionary:
|
||||
if player_level < MIN_LEVEL:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "LEVEL_TOO_LOW",
|
||||
"msg": "你的等级不足 75 级,无法承受冰龙巢穴的严寒!"
|
||||
}
|
||||
|
||||
# 检查背包中的 3 把扭曲钥匙 (30179)
|
||||
var key_slots: Array = []
|
||||
var total_keys: int = 0
|
||||
for i in range(inventory.size()):
|
||||
var item = inventory[i]
|
||||
if item != null and int(item.get("vnum", 0)) == ITEM_TWISTED_KEY:
|
||||
var cnt: int = int(item.get("count", 1))
|
||||
total_keys += cnt
|
||||
key_slots.append({"slot": i, "count": cnt})
|
||||
|
||||
if total_keys < KEY_REQUIRED_COUNT:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "MISSING_KEYS",
|
||||
"current_keys": total_keys,
|
||||
"required_keys": KEY_REQUIRED_COUNT,
|
||||
"msg": "进入冰龙巢穴需要 3 把扭曲钥匙!你当前拥有 %d 把。" % total_keys
|
||||
}
|
||||
|
||||
# 扣除 3 把扭曲钥匙
|
||||
var needed: int = KEY_REQUIRED_COUNT
|
||||
for ks in key_slots:
|
||||
var slot_idx: int = ks["slot"]
|
||||
var available: int = ks["count"]
|
||||
if available <= needed:
|
||||
needed -= available
|
||||
inventory[slot_idx] = null
|
||||
else:
|
||||
inventory[slot_idx]["count"] = available - needed
|
||||
needed = 0
|
||||
if needed <= 0:
|
||||
break
|
||||
|
||||
# 初始化巢穴状态
|
||||
is_active = true
|
||||
remaining_time = TIME_LIMIT
|
||||
dragon_hp = BOSS_MAX_HP
|
||||
dragon_max_hp = BOSS_MAX_HP
|
||||
is_dragon_alive = true
|
||||
active_minions.clear()
|
||||
regen_timer = 0.0
|
||||
|
||||
# 刷新 4 块龙神魔石
|
||||
active_metins.clear()
|
||||
for vnum in METIN_TABLE.keys():
|
||||
active_metins[vnum] = true
|
||||
|
||||
dungeon_started.emit(remaining_time)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"time_limit": remaining_time,
|
||||
"dragon_hp": dragon_hp,
|
||||
"metins": active_metins.duplicate(),
|
||||
"msg": "已使用 3 把扭曲钥匙,成功开启冰龙巢穴!限时 60 分钟!"
|
||||
}
|
||||
|
||||
# 获取当前冰龙实际攻击力 (受 8034 复仇之石影响)
|
||||
func get_dragon_atk() -> int:
|
||||
var atk := BOSS_BASE_ATK
|
||||
if active_metins.get(8034, false):
|
||||
atk = int(atk * 1.4) # +40% ATK
|
||||
return atk
|
||||
|
||||
# 获取当前冰龙防御力
|
||||
func get_dragon_def() -> int:
|
||||
return BOSS_BASE_DEF
|
||||
|
||||
# 击破魔石机制
|
||||
func destroy_metin(metin_vnum: int) -> Dictionary:
|
||||
if not is_active:
|
||||
return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
|
||||
|
||||
if not active_metins.get(metin_vnum, false):
|
||||
return {"ok": false, "reason": "METIN_ALREADY_DESTROYED"}
|
||||
|
||||
active_metins[metin_vnum] = false
|
||||
var mname: String = METIN_TABLE[metin_vnum]["name"]
|
||||
|
||||
# 击破魔石唤醒 4 只塞陶龙宫护卫 (vnum: 2401)
|
||||
var spawn_count := 4
|
||||
for i in range(spawn_count):
|
||||
active_minions.append({"vnum": 2401, "hp": 30000})
|
||||
|
||||
metin_destroyed.emit(metin_vnum, mname)
|
||||
minions_spawned.emit(spawn_count, 2401)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"metin_vnum": metin_vnum,
|
||||
"metin_name": mname,
|
||||
"remaining_metins": get_alive_metin_count(),
|
||||
"spawned_minions": spawn_count,
|
||||
"msg": "【%s】已被击破!对应龙神加成已破除,但唤醒了龙宫护卫!" % mname
|
||||
}
|
||||
|
||||
# 获取存活魔石数量
|
||||
func get_alive_metin_count() -> int:
|
||||
var count := 0
|
||||
for v in active_metins.values():
|
||||
if v:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
# 对冰龙造成伤害
|
||||
func apply_damage(raw_dmg: int, is_magic: bool = false) -> Dictionary:
|
||||
if not is_active or not is_dragon_alive:
|
||||
return {"ok": false, "reason": "DRAGON_NOT_ALIVE"}
|
||||
|
||||
var def := get_dragon_def()
|
||||
var net_dmg: float = max(1, raw_dmg - def)
|
||||
|
||||
# 8031 傲慢之石减免 30% 物理伤害
|
||||
if not is_magic and active_metins.get(8031, false):
|
||||
net_dmg *= 0.70
|
||||
|
||||
# 8033 平静之石减免 30% 魔法伤害
|
||||
if is_magic and active_metins.get(8033, false):
|
||||
net_dmg *= 0.70
|
||||
|
||||
var final_dmg := int(round(net_dmg))
|
||||
dragon_hp = max(0, dragon_hp - final_dmg)
|
||||
|
||||
dragon_hp_changed.emit(dragon_hp, dragon_max_hp)
|
||||
|
||||
# 击杀冰龙判定
|
||||
if dragon_hp <= 0:
|
||||
is_dragon_alive = false
|
||||
is_active = false
|
||||
var drops := _generate_dragon_drops()
|
||||
dragon_defeated.emit(drops)
|
||||
return {
|
||||
"ok": true,
|
||||
"killed": true,
|
||||
"net_dmg": final_dmg,
|
||||
"remaining_hp": 0,
|
||||
"drops": drops,
|
||||
"msg": "【冰龙贝兰-塞陶】已被彻底击败!龙鳞与龙爪散落一地!"
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"killed": false,
|
||||
"net_dmg": final_dmg,
|
||||
"remaining_hp": dragon_hp,
|
||||
"hp_ratio": float(dragon_hp) / float(dragon_max_hp)
|
||||
}
|
||||
|
||||
# 逐帧更新 (计时与 8032 寂寞之石生命回复)
|
||||
func update(dt: float) -> void:
|
||||
if not is_active:
|
||||
return
|
||||
|
||||
remaining_time -= dt
|
||||
if remaining_time <= 0.0:
|
||||
is_active = false
|
||||
is_dragon_alive = false
|
||||
dungeon_failed.emit("TIME_OUT")
|
||||
return
|
||||
|
||||
# 8032 寂寞之石:每 5 秒为冰龙回复 2% 最大生命 (16000 HP)
|
||||
if is_dragon_alive and active_metins.get(8032, false):
|
||||
regen_timer += dt
|
||||
if regen_timer >= 5.0:
|
||||
regen_timer = 0.0
|
||||
var heal_amount := int(BOSS_MAX_HP * 0.02)
|
||||
dragon_hp = mini(BOSS_MAX_HP, dragon_hp + heal_amount)
|
||||
dragon_hp_changed.emit(dragon_hp, dragon_max_hp)
|
||||
|
||||
# 生成冰龙掉落物 (71123 龙鳞, 71129 龙爪, 50186 水龙宝箱)
|
||||
func _generate_dragon_drops() -> Array:
|
||||
var drops: Array = []
|
||||
|
||||
# 1. 必掉史诗升阶材料:龙鳞 (71123)
|
||||
drops.append({
|
||||
"vnum": 71123,
|
||||
"name": "龙鳞",
|
||||
"count": randi_range(1, 2)
|
||||
})
|
||||
|
||||
# 2. 必掉史诗升阶材料:龙爪 (71129)
|
||||
drops.append({
|
||||
"vnum": 71129,
|
||||
"name": "龙爪",
|
||||
"count": randi_range(1, 2)
|
||||
})
|
||||
|
||||
# 3. 必掉水龙专属宝箱 (50186)
|
||||
drops.append({
|
||||
"vnum": 50186,
|
||||
"name": "贝兰-塞陶宝箱",
|
||||
"count": 1
|
||||
})
|
||||
|
||||
# 4. 必掉灵魂之石 (50513)
|
||||
drops.append({
|
||||
"vnum": 50513,
|
||||
"name": "灵魂之石",
|
||||
"count": 1
|
||||
})
|
||||
|
||||
# 5. 大额金币掉落
|
||||
drops.append({
|
||||
"vnum": 1,
|
||||
"name": "金币",
|
||||
"count": randi_range(150000, 300000)
|
||||
})
|
||||
|
||||
return drops
|
||||
@@ -1 +0,0 @@
|
||||
uid://bp1qvx4apa2no
|
||||
@@ -1,215 +0,0 @@
|
||||
# dragon_soul_system.gd —— Metin2 40250 龙神炼金石系统 1:1
|
||||
# 对照 40250 服务端 DragonSoul.cpp, char_dragonsoul.cpp 及客户端 uidragonsoul.py
|
||||
class_name DragonSoulSystem
|
||||
extends RefCounted
|
||||
|
||||
signal deck_activated(deck_idx: int)
|
||||
signal deck_deactivated(deck_idx: int)
|
||||
|
||||
# 6 种官方龙神石类型 (DragonSoul.cpp:EDSStoneTypes)
|
||||
enum DragonStoneType {
|
||||
NONE = 0,
|
||||
DIAMOND = 1, # 钻石: INT, 魔法抗性
|
||||
RUBY = 2, # 红宝石: STR, 物理攻击力, 防御力
|
||||
JADE = 3, # 玉石: Max HP, HP 恢复
|
||||
SAPPHIRE = 4,# 蓝宝石: DEX, 职业减伤
|
||||
GARNET = 5, # 石榴石: CON, Max SP
|
||||
ONYX = 6 # 缟玛瑙: 格挡, 反弹, 穿透抵抗
|
||||
}
|
||||
|
||||
# 5 档品阶 (Grade)
|
||||
enum Grade {
|
||||
ROUGH = 0, # 粗糙
|
||||
CUT = 1, # 切割
|
||||
RARE = 2, # 稀有
|
||||
ANCIENT = 3, # 古代
|
||||
LEGENDARY = 4 # 传奇
|
||||
}
|
||||
|
||||
# 5 档净度 (Clarity)
|
||||
enum Clarity {
|
||||
MATT = 0, # 哑光
|
||||
CLEAR = 1, # 清晰
|
||||
FLAWLESS = 2, # 无瑕
|
||||
BRILLIANT = 3, # 辉煌
|
||||
EXCELLENT = 4 # 卓越
|
||||
}
|
||||
|
||||
const DECK_SKY := 0 # 天空卡槽
|
||||
const DECK_EARTH := 1 # 大地卡槽
|
||||
const DECK_COUNT := 2
|
||||
const SLOTS_PER_DECK := 6 # 每卡槽 6 个神石槽位
|
||||
|
||||
# 双卡槽数据结构: decks[deck_idx][slot_idx] = stone_dict or null
|
||||
var decks: Array[Array] = [[], []]
|
||||
var active_deck: int = -1 # -1: 未激活, 0: 天空卡槽, 1: 大地卡槽
|
||||
|
||||
func _init() -> void:
|
||||
for d in range(DECK_COUNT):
|
||||
var slot_list: Array = []
|
||||
for s in range(SLOTS_PER_DECK):
|
||||
slot_list.append(null)
|
||||
decks[d] = slot_list
|
||||
|
||||
# 创建一颗龙神石
|
||||
static func create_dragon_stone(type: int, grade: int = 0, clarity: int = 0, refine: int = 0, duration: float = 86400.0) -> Dictionary:
|
||||
var stone: Dictionary = {
|
||||
"stone_type": type,
|
||||
"grade": clampi(grade, 0, 4),
|
||||
"clarity": clampi(clarity, 0, 4),
|
||||
"refine": clampi(refine, 0, 6),
|
||||
"duration": duration, # 激活消耗时间 (秒)
|
||||
"total_duration": duration
|
||||
}
|
||||
stone["attributes"] = calculate_stone_attributes(stone)
|
||||
return stone
|
||||
|
||||
# 计算单颗神石属性 (对齐 40250 DragonSoulTable.txt)
|
||||
static func calculate_stone_attributes(stone: Dictionary) -> Dictionary:
|
||||
var type: int = int(stone.get("stone_type", 0))
|
||||
var gr: int = int(stone.get("grade", 0))
|
||||
var cl: int = int(stone.get("clarity", 0))
|
||||
var rf: int = int(stone.get("refine", 0))
|
||||
|
||||
var attrs: Dictionary = {}
|
||||
|
||||
match type:
|
||||
DragonStoneType.DIAMOND:
|
||||
attrs["int_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
|
||||
attrs["magic_defense"] = 2 + (gr * 2) + (cl * 2) + (rf * 2)
|
||||
DragonStoneType.RUBY:
|
||||
attrs["str_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
|
||||
attrs["att_grade"] = 20 + (gr * 15) + (cl * 10) + (rf * 5)
|
||||
attrs["def_grade"] = 15 + (gr * 10) + (cl * 5) + (rf * 4)
|
||||
DragonStoneType.JADE:
|
||||
attrs["max_hp"] = 300 + (gr * 250) + (cl * 150) + (rf * 100)
|
||||
attrs["hp_recovery_pct"] = 3 + gr + cl + rf
|
||||
DragonStoneType.SAPPHIRE:
|
||||
attrs["dex_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
|
||||
attrs["resist_warrior"] = 2 + gr + cl + rf
|
||||
attrs["resist_assassin"] = 2 + gr + cl + rf
|
||||
DragonStoneType.GARNET:
|
||||
attrs["con_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
|
||||
attrs["max_sp"] = 200 + (gr * 150) + (cl * 100) + (rf * 50)
|
||||
DragonStoneType.ONYX:
|
||||
attrs["block_pct"] = 2 + (gr * 2) + cl + rf
|
||||
attrs["reflect_pct"] = 2 + (gr * 2) + cl + rf
|
||||
|
||||
return attrs
|
||||
|
||||
# 装备神石到卡槽
|
||||
func equip_stone(deck_idx: int, stone: Dictionary) -> Dictionary:
|
||||
if deck_idx < 0 or deck_idx >= DECK_COUNT:
|
||||
return {"ok": false, "reason": "INVALID_DECK"}
|
||||
|
||||
var type: int = int(stone.get("stone_type", 0))
|
||||
if type < 1 or type > 6:
|
||||
return {"ok": false, "reason": "INVALID_STONE_TYPE"}
|
||||
|
||||
var slot_idx: int = type - 1 # 1~6 映射到 0~5 号槽位
|
||||
decks[deck_idx][slot_idx] = stone
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"deck": deck_idx,
|
||||
"slot": slot_idx,
|
||||
"stone": stone
|
||||
}
|
||||
|
||||
# 卸下神石
|
||||
func unequip_stone(deck_idx: int, slot_idx: int) -> Dictionary:
|
||||
if deck_idx < 0 or deck_idx >= DECK_COUNT:
|
||||
return {"ok": false, "reason": "INVALID_DECK"}
|
||||
if slot_idx < 0 or slot_idx >= SLOTS_PER_DECK:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var removed = decks[deck_idx][slot_idx]
|
||||
decks[deck_idx][slot_idx] = null
|
||||
return {"ok": true, "stone": removed}
|
||||
|
||||
# 激活/停用卡槽
|
||||
func toggle_deck(deck_idx: int) -> bool:
|
||||
if active_deck == deck_idx:
|
||||
active_deck = -1
|
||||
deck_deactivated.emit(deck_idx)
|
||||
return false
|
||||
else:
|
||||
if active_deck != -1:
|
||||
deck_deactivated.emit(active_deck)
|
||||
active_deck = deck_idx
|
||||
deck_activated.emit(deck_idx)
|
||||
return true
|
||||
|
||||
# 计算当前已激活卡槽的总战力属性
|
||||
func get_active_bonuses() -> Dictionary:
|
||||
var total_bonuses: Dictionary = {}
|
||||
if active_deck < 0 or active_deck >= DECK_COUNT:
|
||||
return total_bonuses
|
||||
|
||||
var deck: Array = decks[active_deck]
|
||||
for slot in deck:
|
||||
if slot != null and float(slot.get("duration", 0.0)) > 0.0:
|
||||
var attrs: Dictionary = slot.get("attributes", {})
|
||||
for key in attrs:
|
||||
total_bonuses[key] = int(total_bonuses.get(key, 0)) + int(attrs[key])
|
||||
|
||||
return total_bonuses
|
||||
|
||||
# 逐帧消耗激活中的龙神石能量时间
|
||||
func update(dt: float) -> Array:
|
||||
var expired_stones: Array = []
|
||||
if active_deck < 0 or active_deck >= DECK_COUNT:
|
||||
return expired_stones
|
||||
|
||||
var deck: Array = decks[active_deck]
|
||||
for i in range(deck.size()):
|
||||
var stone = deck[i]
|
||||
if stone != null:
|
||||
var dur: float = float(stone.get("duration", 0.0))
|
||||
dur -= dt
|
||||
if dur <= 0.0:
|
||||
dur = 0.0
|
||||
expired_stones.append({"deck": active_deck, "slot": i, "stone": stone})
|
||||
stone["duration"] = dur
|
||||
|
||||
return expired_stones
|
||||
|
||||
# 升阶 (Grade Refine): 2 颗同种类、同品阶神石融合升级
|
||||
static func refine_grade(stone_a: Dictionary, stone_b: Dictionary, force_success: bool = false) -> Dictionary:
|
||||
var ta: int = int(stone_a.get("stone_type", 0))
|
||||
var tb: int = int(stone_b.get("stone_type", 0))
|
||||
var ga: int = int(stone_a.get("grade", 0))
|
||||
var gb: int = int(stone_b.get("grade", 0))
|
||||
|
||||
if ta != tb or ga != gb:
|
||||
return {"ok": false, "reason": "MISMATCHED_STONES", "msg": "只有同种类且同品阶的神石才能融合升阶!"}
|
||||
if ga >= Grade.LEGENDARY:
|
||||
return {"ok": false, "reason": "MAX_GRADE", "msg": "该神石已达到最高传奇品阶!"}
|
||||
|
||||
# 成功率 60%
|
||||
var success: bool = force_success or randf() < 0.60
|
||||
if success:
|
||||
var new_stone = create_dragon_stone(ta, ga + 1, 0, 0)
|
||||
return {"ok": true, "success": true, "stone": new_stone, "msg": "神石品阶提升成功!"}
|
||||
else:
|
||||
return {"ok": true, "success": false, "msg": "升阶失败,融合材料已碎裂。"}
|
||||
|
||||
# 升净度 (Clarity Refine): 2 颗同种类、同品阶、同净度神石融合升级
|
||||
static func refine_clarity(stone_a: Dictionary, stone_b: Dictionary, force_success: bool = false) -> Dictionary:
|
||||
var ta: int = int(stone_a.get("stone_type", 0))
|
||||
var tb: int = int(stone_b.get("stone_type", 0))
|
||||
var ca: int = int(stone_a.get("clarity", 0))
|
||||
var cb: int = int(stone_b.get("clarity", 0))
|
||||
|
||||
if ta != tb or ca != cb:
|
||||
return {"ok": false, "reason": "MISMATCHED_STONES", "msg": "只有同种类且同净度的神石才能融合提升净度!"}
|
||||
if ca >= Clarity.EXCELLENT:
|
||||
return {"ok": false, "reason": "MAX_CLARITY", "msg": "该神石已达到最高卓越净度!"}
|
||||
|
||||
var success: bool = force_success or randf() < 0.50
|
||||
if success:
|
||||
var ga: int = int(stone_a.get("grade", 0))
|
||||
var new_stone = create_dragon_stone(ta, ga, ca + 1, 0)
|
||||
return {"ok": true, "success": true, "stone": new_stone, "msg": "神石净度提升成功!"}
|
||||
else:
|
||||
return {"ok": true, "success": false, "msg": "净度提升失败,辅助材料碎裂。"}
|
||||
@@ -1 +0,0 @@
|
||||
uid://ia8t4ldbjqhp
|
||||
@@ -1,133 +0,0 @@
|
||||
# dungeon_ranking_system.gd —— Metin2 40250 地牢信息面板与最速通关记录系统 1:1
|
||||
# 对照 40250 服务端与客户端 dungeon_info.cpp, dungeon.cpp, uidungeoninfo.py
|
||||
class_name DungeonRankingSystem
|
||||
extends RefCounted
|
||||
|
||||
signal record_updated(dungeon_id: String, fastest_time: float, total_clears: int)
|
||||
signal new_fastest_record(dungeon_id: String, new_time: float, old_time: float)
|
||||
|
||||
# 40250 官方经典地牢登记表
|
||||
const DUNGEON_REGISTRY: Dictionary = {
|
||||
"demon_tower": {
|
||||
"name": "恶魔之塔",
|
||||
"min_level": 40,
|
||||
"ticket_vnum": 0,
|
||||
"ticket_count": 0,
|
||||
"ticket_name": "无",
|
||||
"cooldown_seconds": 0.0
|
||||
},
|
||||
"spider_dungeon_3": {
|
||||
"name": "蜘蛛洞穴三层",
|
||||
"min_level": 50,
|
||||
"ticket_vnum": 30327,
|
||||
"ticket_count": 1,
|
||||
"ticket_name": "蜘蛛钥匙",
|
||||
"cooldown_seconds": 1800.0 # 30 分钟
|
||||
},
|
||||
"dragon_lair": {
|
||||
"name": "冰龙贝兰巢穴",
|
||||
"min_level": 75,
|
||||
"ticket_vnum": 30179,
|
||||
"ticket_count": 3,
|
||||
"ticket_name": "扭曲钥匙",
|
||||
"cooldown_seconds": 3600.0 # 60 分钟
|
||||
},
|
||||
"devils_catacomb": {
|
||||
"name": "地下墓穴",
|
||||
"min_level": 75,
|
||||
"ticket_vnum": 30311,
|
||||
"ticket_count": 1,
|
||||
"ticket_name": "灵魂水晶钥匙",
|
||||
"cooldown_seconds": 3600.0 # 60 分钟
|
||||
}
|
||||
}
|
||||
|
||||
# 个人通关数据字典: dungeon_id -> Dictionary
|
||||
var dungeon_records: Dictionary = {}
|
||||
|
||||
func _init() -> void:
|
||||
for d_id in DUNGEON_REGISTRY.keys():
|
||||
dungeon_records[d_id] = {
|
||||
"total_clears": 0,
|
||||
"fastest_clear_time": 999999.0, # 初始极大值
|
||||
"highest_peak_damage": 0,
|
||||
"last_clear_timestamp": 0.0
|
||||
}
|
||||
|
||||
# 记录通关结算
|
||||
func record_clear(
|
||||
dungeon_id: String,
|
||||
clear_time_sec: float,
|
||||
peak_damage: int,
|
||||
current_timestamp: float = 0.0
|
||||
) -> Dictionary:
|
||||
if not DUNGEON_REGISTRY.has(dungeon_id):
|
||||
return {"ok": false, "reason": "INVALID_DUNGEON_ID", "msg": "未知的地牢!"}
|
||||
|
||||
var rec = dungeon_records[dungeon_id]
|
||||
rec["total_clears"] += 1
|
||||
rec["last_clear_timestamp"] = current_timestamp
|
||||
|
||||
# 检查并更新最高单次输出伤害
|
||||
if peak_damage > int(rec["highest_peak_damage"]):
|
||||
rec["highest_peak_damage"] = peak_damage
|
||||
|
||||
# 检查是否刷新最速通关记录
|
||||
var is_new_record := false
|
||||
var old_time: float = float(rec["fastest_clear_time"])
|
||||
if clear_time_sec < old_time:
|
||||
is_new_record = true
|
||||
rec["fastest_clear_time"] = clear_time_sec
|
||||
new_fastest_record.emit(dungeon_id, clear_time_sec, old_time)
|
||||
|
||||
record_updated.emit(dungeon_id, rec["fastest_clear_time"], rec["total_clears"])
|
||||
|
||||
var dname: String = DUNGEON_REGISTRY[dungeon_id]["name"]
|
||||
return {
|
||||
"ok": true,
|
||||
"dungeon_id": dungeon_id,
|
||||
"dungeon_name": dname,
|
||||
"clear_time": clear_time_sec,
|
||||
"is_new_record": is_new_record,
|
||||
"fastest_time": rec["fastest_clear_time"],
|
||||
"total_clears": rec["total_clears"],
|
||||
"highest_peak_damage": rec["highest_peak_damage"],
|
||||
"msg": "【%s】通关成功!耗时 %.1f 秒%s!" % [dname, clear_time_sec, " (刷新最快纪录!)" if is_new_record else ""]
|
||||
}
|
||||
|
||||
# 获取地牢详情与入场/冷却状态
|
||||
func get_dungeon_info(dungeon_id: String, current_timestamp: float = 0.0) -> Dictionary:
|
||||
if not DUNGEON_REGISTRY.has(dungeon_id):
|
||||
return {"ok": false, "reason": "INVALID_DUNGEON_ID"}
|
||||
|
||||
var cfg = DUNGEON_REGISTRY[dungeon_id]
|
||||
var rec = dungeon_records[dungeon_id]
|
||||
|
||||
# 判定冷却时间
|
||||
var cd_sec: float = float(cfg["cooldown_seconds"])
|
||||
var last_ts: float = float(rec["last_clear_timestamp"])
|
||||
var elapsed := current_timestamp - last_ts
|
||||
var is_on_cooldown := (cd_sec > 0.0 and last_ts > 0.0 and elapsed < cd_sec)
|
||||
var remaining_cd := maxf(0.0, cd_sec - elapsed) if is_on_cooldown else 0.0
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"dungeon_id": dungeon_id,
|
||||
"name": cfg["name"],
|
||||
"min_level": cfg["min_level"],
|
||||
"ticket_vnum": cfg["ticket_vnum"],
|
||||
"ticket_count": cfg["ticket_count"],
|
||||
"ticket_name": cfg["ticket_name"],
|
||||
"is_on_cooldown": is_on_cooldown,
|
||||
"remaining_cooldown": remaining_cd,
|
||||
"total_clears": rec["total_clears"],
|
||||
"fastest_clear_time": rec["fastest_clear_time"] if rec["fastest_clear_time"] < 999999.0 else 0.0,
|
||||
"highest_peak_damage": rec["highest_peak_damage"]
|
||||
}
|
||||
|
||||
# 获取全部地牢摘要速览列表
|
||||
func get_all_dungeon_summaries(current_timestamp: float = 0.0) -> Array:
|
||||
var list: Array = []
|
||||
for d_id in DUNGEON_REGISTRY.keys():
|
||||
list.append(get_dungeon_info(d_id, current_timestamp))
|
||||
return list
|
||||
@@ -1 +0,0 @@
|
||||
uid://c4msy78ir4a3v
|
||||
@@ -1,163 +0,0 @@
|
||||
# dynamic_bounty_event_system.gd —— Metin2 40250 单人单机动态事件与赏金通缉令系统 1:1
|
||||
# 对照 40250 服务端 questmanager.cpp, char.cpp, quest.h
|
||||
class_name DynamicBountyEventSystem
|
||||
extends RefCounted
|
||||
|
||||
signal bounties_refreshed(bounty_list: Array)
|
||||
signal bounty_progress_updated(bounty_id: String, current: int, target: int)
|
||||
signal bounty_completed(bounty_id: String, title: String)
|
||||
signal bounty_reward_claimed(bounty_id: String, exp: int, gold: int)
|
||||
|
||||
const BOUNTY_TYPE_HUNT := "hunt" # 怪物狩猎
|
||||
const BOUNTY_TYPE_METIN := "metin" # 魔石破除
|
||||
const BOUNTY_TYPE_BOSS := "boss" # 首领讨伐
|
||||
|
||||
const BOUNTY_CHEST_VNUM := 50180 # 赏金猎人宝箱 (Bounty Chest)
|
||||
|
||||
# 活跃悬赏任务字典: id -> Dictionary
|
||||
var active_bounties: Dictionary = {}
|
||||
var total_completed_count: int = 0
|
||||
|
||||
# 每日自动生成/刷新 3 条特色赏金任务
|
||||
func refresh_daily_bounties() -> Array:
|
||||
active_bounties.clear()
|
||||
|
||||
# 1. 狩猎任务 (野蛮士兵 501, 目标 25 只)
|
||||
var b1: Dictionary = {
|
||||
"id": "bounty_hunt_barbarian",
|
||||
"type": BOUNTY_TYPE_HUNT,
|
||||
"title": "【通缉令】清剿荒原野蛮士兵",
|
||||
"target_vnum": 501,
|
||||
"target_count": 25,
|
||||
"current_count": 0,
|
||||
"is_completed": false,
|
||||
"is_claimed": false,
|
||||
"rewards": {
|
||||
"exp": 150000,
|
||||
"gold": 500000,
|
||||
"items": [{"vnum": 27003, "name": "红色药水(特大)", "count": 100}]
|
||||
}
|
||||
}
|
||||
|
||||
# 2. 破灭魔石任务 (嫉妒之石 8008, 目标 2 颗)
|
||||
var b2: Dictionary = {
|
||||
"id": "bounty_metin_jealousy",
|
||||
"type": BOUNTY_TYPE_METIN,
|
||||
"title": "【天灾通缉】破灭野外嫉妒魔石",
|
||||
"target_vnum": 8008,
|
||||
"target_count": 2,
|
||||
"current_count": 0,
|
||||
"is_completed": false,
|
||||
"is_claimed": false,
|
||||
"rewards": {
|
||||
"exp": 300000,
|
||||
"gold": 1200000,
|
||||
"items": [{"vnum": 50300, "name": "技能书", "count": 1}]
|
||||
}
|
||||
}
|
||||
|
||||
# 3. 讨伐世界首领 (九尾妖狐 1901, 目标 1 次)
|
||||
var b3: Dictionary = {
|
||||
"id": "bounty_boss_ninetails",
|
||||
"type": BOUNTY_TYPE_BOSS,
|
||||
"title": "【大统领诛杀令】斩落雪山九尾妖狐",
|
||||
"target_vnum": 1901,
|
||||
"target_count": 1,
|
||||
"current_count": 0,
|
||||
"is_completed": false,
|
||||
"is_claimed": false,
|
||||
"rewards": {
|
||||
"exp": 1000000,
|
||||
"gold": 3000000,
|
||||
"items": [
|
||||
{"vnum": BOUNTY_CHEST_VNUM, "name": "赏金猎人宝箱", "count": 1},
|
||||
{"vnum": 71003, "name": "祝福卷轴", "count": 1}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
active_bounties[b1["id"]] = b1
|
||||
active_bounties[b2["id"]] = b2
|
||||
active_bounties[b3["id"]] = b3
|
||||
|
||||
var list = [b1, b2, b3]
|
||||
bounties_refreshed.emit(list)
|
||||
return list
|
||||
|
||||
# 怪物击杀侦测挂钩
|
||||
func on_mob_killed(mob_vnum: int, count: int = 1) -> void:
|
||||
for bid in active_bounties.keys():
|
||||
var b = active_bounties[bid]
|
||||
if b["is_completed"]:
|
||||
continue
|
||||
|
||||
if (b["type"] == BOUNTY_TYPE_HUNT or b["type"] == BOUNTY_TYPE_BOSS) and b["target_vnum"] == mob_vnum:
|
||||
b["current_count"] = min(b["target_count"], b["current_count"] + count)
|
||||
bounty_progress_updated.emit(bid, b["current_count"], b["target_count"])
|
||||
|
||||
if b["current_count"] >= b["target_count"]:
|
||||
b["is_completed"] = true
|
||||
total_completed_count += 1
|
||||
bounty_completed.emit(bid, b["title"])
|
||||
|
||||
# 魔石击碎侦测挂钩
|
||||
func on_metin_destroyed(metin_vnum: int) -> void:
|
||||
for bid in active_bounties.keys():
|
||||
var b = active_bounties[bid]
|
||||
if b["is_completed"]:
|
||||
continue
|
||||
|
||||
if b["type"] == BOUNTY_TYPE_METIN and b["target_vnum"] == metin_vnum:
|
||||
b["current_count"] = min(b["target_count"], b["current_count"] + 1)
|
||||
bounty_progress_updated.emit(bid, b["current_count"], b["target_count"])
|
||||
|
||||
if b["current_count"] >= b["target_count"]:
|
||||
b["is_completed"] = true
|
||||
total_completed_count += 1
|
||||
bounty_completed.emit(bid, b["title"])
|
||||
|
||||
# 领取赏金通缉令奖励
|
||||
func claim_bounty_reward(bounty_id: String, player_data: Dictionary, inventory: Array) -> Dictionary:
|
||||
if not active_bounties.has(bounty_id):
|
||||
return {"ok": false, "reason": "BOUNTY_NOT_FOUND", "msg": "未找到该通缉令!"}
|
||||
|
||||
var b = active_bounties[bounty_id]
|
||||
if not b["is_completed"]:
|
||||
return {"ok": false, "reason": "NOT_COMPLETED", "msg": "该通缉令目标尚未达成!"}
|
||||
|
||||
if b["is_claimed"]:
|
||||
return {"ok": false, "reason": "ALREADY_CLAIMED", "msg": "该通缉令赏金已被领取!"}
|
||||
|
||||
var rew = b["rewards"]
|
||||
var exp_gain: int = rew.get("exp", 0)
|
||||
var gold_gain: int = rew.get("gold", 0)
|
||||
var items: Array = rew.get("items", [])
|
||||
|
||||
# 发放经验与金币
|
||||
player_data["exp"] = int(player_data.get("exp", 0)) + exp_gain
|
||||
player_data["gold"] = int(player_data.get("gold", 0)) + gold_gain
|
||||
|
||||
# 物品入包
|
||||
var granted_items := []
|
||||
for it in items:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = it.duplicate(true)
|
||||
granted_items.append(it["name"])
|
||||
break
|
||||
|
||||
b["is_claimed"] = true
|
||||
bounty_reward_claimed.emit(bounty_id, exp_gain, gold_gain)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"bounty_id": bounty_id,
|
||||
"exp_gain": exp_gain,
|
||||
"gold_gain": gold_gain,
|
||||
"items_granted": granted_items,
|
||||
"msg": "通缉赏金核销!获得 %d 经验、%d 金币以及丰厚物资!" % [exp_gain, gold_gain]
|
||||
}
|
||||
|
||||
# 获取所有活跃悬赏
|
||||
func get_active_bounties() -> Array:
|
||||
return active_bounties.values()
|
||||
@@ -1 +0,0 @@
|
||||
uid://dmh3ypb8w0pwt
|
||||
@@ -1,99 +0,0 @@
|
||||
# emotion_action_system.gd —— 角色情绪动作与表情快捷键(40250 官方 1:1 对齐)
|
||||
# 对齐源码:
|
||||
# - 40250 root/emotion.py (EMOTION_DICT, EMOTION_CLAP..DANCE_6)
|
||||
# - 40250 server/game/src/cmd_emotion.cpp
|
||||
# - 40250 root/game.py: __PressNumKey (Ctrl + 1..9)
|
||||
class_name EmotionActionSystem
|
||||
extends RefCounted
|
||||
|
||||
# 40250 官方情绪动作常量
|
||||
const EMOTION_CLAP := 1
|
||||
const EMOTION_CONGRATULATION := 2
|
||||
const EMOTION_FORGIVE := 3
|
||||
const EMOTION_ANGRY := 4
|
||||
const EMOTION_ATTRACTIVE := 5
|
||||
const EMOTION_SAD := 6
|
||||
const EMOTION_SHY := 7
|
||||
const EMOTION_CHEERUP := 8
|
||||
const EMOTION_BANTER := 9
|
||||
const EMOTION_JOY := 10
|
||||
const EMOTION_CHEERS_1 := 11
|
||||
const EMOTION_CHEERS_2 := 12
|
||||
const EMOTION_DANCE_1 := 13
|
||||
const EMOTION_DANCE_2 := 14
|
||||
const EMOTION_DANCE_3 := 15
|
||||
const EMOTION_DANCE_4 := 16
|
||||
const EMOTION_DANCE_5 := 17
|
||||
const EMOTION_DANCE_6 := 18
|
||||
|
||||
# 情绪字典表 (对齐 emotion.py)
|
||||
const EMOTION_TABLE := {
|
||||
EMOTION_CLAP: {"name": "鼓掌", "command": "/clap", "motion": "clap"},
|
||||
EMOTION_CONGRATULATION: {"name": "祝贺", "command": "/congratulation", "motion": "congratulation"},
|
||||
EMOTION_FORGIVE: {"name": "原谅", "command": "/forgive", "motion": "forgive"},
|
||||
EMOTION_ANGRY: {"name": "愤怒", "command": "/angry", "motion": "angry"},
|
||||
EMOTION_ATTRACTIVE: {"name": "飞吻", "command": "/attractive", "motion": "attractive"},
|
||||
EMOTION_SAD: {"name": "悲伤", "command": "/sad", "motion": "sad"},
|
||||
EMOTION_SHY: {"name": "害羞", "command": "/shy", "motion": "shy"},
|
||||
EMOTION_CHEERUP: {"name": "加油", "command": "/cheerup", "motion": "cheerup"},
|
||||
EMOTION_BANTER: {"name": "嘲讽", "command": "/banter", "motion": "banter"},
|
||||
EMOTION_JOY: {"name": "喜悦", "command": "/joy", "motion": "joy"},
|
||||
EMOTION_CHEERS_1: {"name": "欢呼1", "command": "/cheer1", "motion": "cheers_1"},
|
||||
EMOTION_CHEERS_2: {"name": "欢呼2", "command": "/cheer2", "motion": "cheers_2"},
|
||||
EMOTION_DANCE_1: {"name": "跳舞1", "command": "/dance1", "motion": "dance_1"},
|
||||
EMOTION_DANCE_2: {"name": "跳舞2", "command": "/dance2", "motion": "dance_2"},
|
||||
EMOTION_DANCE_3: {"name": "跳舞3", "command": "/dance3", "motion": "dance_3"},
|
||||
EMOTION_DANCE_4: {"name": "跳舞4", "command": "/dance4", "motion": "dance_4"},
|
||||
EMOTION_DANCE_5: {"name": "跳舞5", "command": "/dance5", "motion": "dance_5"},
|
||||
EMOTION_DANCE_6: {"name": "跳舞6", "command": "/dance6", "motion": "dance_6"},
|
||||
}
|
||||
|
||||
## 检查是否为有效的情绪动作 ID
|
||||
static func is_valid_emotion(emotion_id: int) -> bool:
|
||||
return EMOTION_TABLE.has(emotion_id)
|
||||
|
||||
## 通过聊天命令解析情绪 (例如 "/clap" -> 1)
|
||||
static func get_emotion_from_command(cmd_text: String) -> int:
|
||||
var clean := cmd_text.strip_edges().to_lower()
|
||||
for id in EMOTION_TABLE.keys():
|
||||
if EMOTION_TABLE[id]["command"] == clean:
|
||||
return id
|
||||
return 0
|
||||
|
||||
## 通过快捷键序号获取情绪 (0..8 对应 Ctrl + 1..9)
|
||||
static func get_hotkey_emotion(hotkey_idx: int) -> int:
|
||||
# 40250 官方快捷栏默认前 9 个动作
|
||||
var list := [
|
||||
EMOTION_CLAP,
|
||||
EMOTION_CONGRATULATION,
|
||||
EMOTION_FORGIVE,
|
||||
EMOTION_ANGRY,
|
||||
EMOTION_ATTRACTIVE,
|
||||
EMOTION_SAD,
|
||||
EMOTION_SHY,
|
||||
EMOTION_CHEERUP,
|
||||
EMOTION_BANTER,
|
||||
]
|
||||
if hotkey_idx >= 0 and hotkey_idx < list.size():
|
||||
return list[hotkey_idx]
|
||||
return 0
|
||||
|
||||
## 执行情绪动作
|
||||
static func perform_emotion(emotion_id: int, is_fighting := false, is_mounted := false) -> Dictionary:
|
||||
if not is_valid_emotion(emotion_id):
|
||||
return {"ok": false, "code": "INVALID_EMOTION", "msg": "未知的动作!"}
|
||||
if is_fighting:
|
||||
return {"ok": false, "code": "CANNOT_IN_BATTLE", "msg": "战斗中无法做出情绪动作!"}
|
||||
if is_mounted:
|
||||
return {"ok": false, "code": "CANNOT_ON_MOUNT", "msg": "骑乘状态下无法做出该动作!"}
|
||||
|
||||
var em: Dictionary = EMOTION_TABLE[emotion_id]
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "SUCCESS",
|
||||
"emotion_id": emotion_id,
|
||||
"name": em["name"],
|
||||
"motion": em["motion"],
|
||||
"command": em["command"],
|
||||
"msg": "执行了【%s】动作。" % em["name"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://c7ffckxgf2fyf
|
||||
@@ -1,213 +0,0 @@
|
||||
# empire_kingdom_system.gd —— Metin2 40250 三圣帝国国家归属与领地国运系统 1:1
|
||||
# 对照 40250 服务端 empire.cpp, questlua_empire.cpp, root/introempire.py
|
||||
class_name EmpireKingdomSystem
|
||||
extends RefCounted
|
||||
|
||||
signal empire_selected(empire_id: int, empire_name: String)
|
||||
signal empire_blessing_activated(empire_id: int, blessings: Dictionary)
|
||||
signal empire_changed(old_empire: int, new_empire: int)
|
||||
signal territory_entered(map_name: String, is_home: bool, is_hostile: bool)
|
||||
|
||||
# 40250 官方三大帝国 ID
|
||||
const EMPIRE_NONE := 0
|
||||
const EMPIRE_SHINSOO := 1 # 慎修帝国 (Shinsoo / 红国)
|
||||
const EMPIRE_CHUNJO := 2 # 准照帝国 (Chunjo / 黄国)
|
||||
const EMPIRE_JINNO := 3 # 晋诺帝国 (Jinno / 蓝国)
|
||||
|
||||
const VNUM_TINCTURE_OF_EMPIRES := 71054 # 转国卷轴 (Tincture of Empires)
|
||||
|
||||
# 三大帝国官方档案与专属祈福特性表
|
||||
const EMPIRES_INFO: Dictionary = {
|
||||
EMPIRE_SHINSOO: {
|
||||
"id": EMPIRE_SHINSOO,
|
||||
"name": "慎修帝国",
|
||||
"color": "红国",
|
||||
"capital": "永安省 (Yongan)",
|
||||
"desc": "南方沿海商业贸易帝国,商贾云集,财富充盈。",
|
||||
"home_maps": ["Yongan", "Jayang"],
|
||||
"blessings": {
|
||||
"gold_bonus_pct": 10, # 金币获取 +10%
|
||||
"shop_discount_pct": 5 # 商店折扣 5%
|
||||
}
|
||||
},
|
||||
EMPIRE_CHUNJO: {
|
||||
"id": EMPIRE_CHUNJO,
|
||||
"name": "准照帝国",
|
||||
"color": "黄国",
|
||||
"capital": "准安省 (Joan)",
|
||||
"desc": "西方高原神圣宗教帝国,受大祭司与神官庇护,信仰虔诚。",
|
||||
"home_maps": ["Joan", "Bokjung"],
|
||||
"blessings": {
|
||||
"cast_speed": 10, # 施法速度 +10
|
||||
"cooldown_reduction_pct": 5 # 技能冷却缩减 5%
|
||||
}
|
||||
},
|
||||
EMPIRE_JINNO: {
|
||||
"id": EMPIRE_JINNO,
|
||||
"name": "晋诺帝国",
|
||||
"color": "蓝国",
|
||||
"capital": "平武省 (Pyungmoo)",
|
||||
"desc": "东方雄关铁血军事帝国,崇尚武道,兵强马壮。",
|
||||
"home_maps": ["Pyungmoo", "Bakra"],
|
||||
"blessings": {
|
||||
"attack_bonus": 50, # 物理攻击 +50
|
||||
"monster_damage_pct": 5 # 对怪增伤 5%
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var current_empire: int = EMPIRE_NONE
|
||||
var is_blessing_active: bool = false
|
||||
var blessing_time_remaining: float = 0.0 # 祈福持续时间 (秒)
|
||||
const BLESSING_DURATION: float = 86400.0 # 24 小时 (86400 秒)
|
||||
|
||||
# 初始选择国家
|
||||
func select_empire(empire_id: int) -> Dictionary:
|
||||
if not EMPIRES_INFO.has(empire_id):
|
||||
return {"ok": false, "reason": "INVALID_EMPIRE", "msg": "无效的帝国代码!"}
|
||||
|
||||
if current_empire != EMPIRE_NONE:
|
||||
return {"ok": false, "reason": "ALREADY_HAS_EMPIRE", "msg": "已有国家归属,需使用转国卷轴!"}
|
||||
|
||||
current_empire = empire_id
|
||||
var info: Dictionary = EMPIRES_INFO[current_empire]
|
||||
empire_selected.emit(current_empire, info["name"])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"empire_id": current_empire,
|
||||
"name": info["name"],
|
||||
"capital": info["capital"],
|
||||
"msg": "效忠【%s】成功!开启帝国护国征途。" % info["name"]
|
||||
}
|
||||
|
||||
# 激活每日帝国国运祈福 (Daily Empire Blessing)
|
||||
func activate_empire_prayer(player_stats: Dictionary) -> Dictionary:
|
||||
if current_empire == EMPIRE_NONE:
|
||||
return {"ok": false, "reason": "NO_EMPIRE", "msg": "尚未加入任何帝国!"}
|
||||
|
||||
if is_blessing_active:
|
||||
# 刷新时长
|
||||
blessing_time_remaining = BLESSING_DURATION
|
||||
return {"ok": true, "msg": "国运祈福时长已刷新为 24 小时!"}
|
||||
|
||||
var blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
|
||||
_apply_blessings_to_player(blessings, player_stats, 1)
|
||||
|
||||
is_blessing_active = true
|
||||
blessing_time_remaining = BLESSING_DURATION
|
||||
|
||||
empire_blessing_activated.emit(current_empire, blessings)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"empire_id": current_empire,
|
||||
"blessings": blessings,
|
||||
"duration": BLESSING_DURATION,
|
||||
"msg": "获得【%s】护国祈福!全境增益持续 24 小时。" % EMPIRES_INFO[current_empire]["name"]
|
||||
}
|
||||
|
||||
# 倒计时推进
|
||||
func update(delta: float, player_stats: Dictionary) -> void:
|
||||
if is_blessing_active:
|
||||
blessing_time_remaining -= delta
|
||||
if blessing_time_remaining <= 0.0:
|
||||
# 祈福过期
|
||||
var blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
|
||||
_apply_blessings_to_player(blessings, player_stats, -1)
|
||||
is_blessing_active = false
|
||||
blessing_time_remaining = 0.0
|
||||
|
||||
# 领地巡检:判断玩家在某地图是否处于敌国入侵状态
|
||||
func check_territory(map_name: String) -> Dictionary:
|
||||
if current_empire == EMPIRE_NONE:
|
||||
return {"is_home": false, "is_hostile": false, "relation": "neutral"}
|
||||
|
||||
var my_home_maps: Array = EMPIRES_INFO[current_empire]["home_maps"]
|
||||
if my_home_maps.has(map_name):
|
||||
territory_entered.emit(map_name, true, false)
|
||||
return {"is_home": true, "is_hostile": false, "relation": "homeland"}
|
||||
|
||||
# 检查是否为其他两个帝国的本土领地
|
||||
for other_id in [EMPIRE_SHINSOO, EMPIRE_CHUNJO, EMPIRE_JINNO]:
|
||||
if other_id == current_empire:
|
||||
continue
|
||||
var foreign_maps: Array = EMPIRES_INFO[other_id]["home_maps"]
|
||||
if foreign_maps.has(map_name):
|
||||
territory_entered.emit(map_name, false, true)
|
||||
return {
|
||||
"is_home": false,
|
||||
"is_hostile": true,
|
||||
"hostile_empire": EMPIRES_INFO[other_id]["name"],
|
||||
"relation": "hostile_invasion"
|
||||
}
|
||||
|
||||
# 中立公共地图 (如沙漠、雪山、烈焰、神殿)
|
||||
territory_entered.emit(map_name, false, false)
|
||||
return {"is_home": false, "is_hostile": false, "relation": "neutral"}
|
||||
|
||||
# 使用转国卷轴更换帝国 (40250 Tincture of Empires 71054)
|
||||
func change_empire(target_empire: int, item_slot: int, inventory: Array, player_stats: Dictionary, is_in_guild: bool = false) -> Dictionary:
|
||||
if not EMPIRES_INFO.has(target_empire):
|
||||
return {"ok": false, "reason": "INVALID_TARGET_EMPIRE", "msg": "无效的目标帝国!"}
|
||||
|
||||
if target_empire == current_empire:
|
||||
return {"ok": false, "reason": "CANNOT_CHANGE_TO_SAME_EMPIRE", "msg": "无法转入当前已效忠的国家!"}
|
||||
|
||||
if is_in_guild:
|
||||
return {"ok": false, "reason": "MUST_LEAVE_GUILD", "msg": "已有公会归属,必须先退出公会方可转国!"}
|
||||
|
||||
if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
|
||||
return {"ok": false, "reason": "INVALID_ITEM_SLOT"}
|
||||
|
||||
var scroll: Dictionary = inventory[item_slot]
|
||||
if int(scroll.get("vnum", 0)) != VNUM_TINCTURE_OF_EMPIRES:
|
||||
return {"ok": false, "reason": "NOT_A_TINCTURE_ITEM", "msg": "需要消耗【转国卷轴】(71054)!"}
|
||||
|
||||
# 若当前已有祈福加成,先解除旧帝国祈福
|
||||
if is_blessing_active:
|
||||
var old_blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
|
||||
_apply_blessings_to_player(old_blessings, player_stats, -1)
|
||||
is_blessing_active = false
|
||||
blessing_time_remaining = 0.0
|
||||
|
||||
# 消耗转国卷轴
|
||||
var cnt := int(scroll.get("count", 1))
|
||||
if cnt > 1:
|
||||
scroll["count"] = cnt - 1
|
||||
else:
|
||||
inventory[item_slot] = null
|
||||
|
||||
var old_emp := current_empire
|
||||
current_empire = target_empire
|
||||
|
||||
empire_changed.emit(old_emp, current_empire)
|
||||
|
||||
var new_info: Dictionary = EMPIRES_INFO[current_empire]
|
||||
return {
|
||||
"ok": true,
|
||||
"old_empire": old_emp,
|
||||
"new_empire": current_empire,
|
||||
"new_empire_name": new_info["name"],
|
||||
"msg": "成功转入【%s】!请前往母国【%s】领取新的帝国嘉奖。" % [new_info["name"], new_info["capital"]]
|
||||
}
|
||||
|
||||
func _apply_blessings_to_player(blessings: Dictionary, player_stats: Dictionary, factor: int) -> void:
|
||||
for k in blessings.keys():
|
||||
var val = int(blessings[k]) * factor
|
||||
var cur = int(player_stats.get(k, 0))
|
||||
player_stats[k] = cur + val
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"current_empire": current_empire,
|
||||
"is_blessing_active": is_blessing_active,
|
||||
"blessing_time_remaining": blessing_time_remaining
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
current_empire = int(data.get("current_empire", EMPIRE_NONE))
|
||||
is_blessing_active = bool(data.get("is_blessing_active", false))
|
||||
blessing_time_remaining = float(data.get("blessing_time_remaining", 0.0))
|
||||
@@ -1 +0,0 @@
|
||||
uid://cw0e8mkuuaupg
|
||||
@@ -1,194 +0,0 @@
|
||||
# endless_spire_dungeon_system.gd —— Metin2 40250 单人单机迷宫地牢【无尽天关】爬塔挑战 1:1
|
||||
# 对照 40250 服务端 dungeon.cpp, dungeon_info.cpp, questmanager.cpp
|
||||
class_name EndlessSpireDungeonSystem
|
||||
extends RefCounted
|
||||
|
||||
signal floor_started(floor_num: int, is_boss_floor: bool, mob_count: int)
|
||||
signal floor_cleared(floor_num: int, points_earned: int, time_used: float)
|
||||
signal checkpoint_saved(checkpoint_floor: int)
|
||||
signal spire_trial_completed(highest_floor: int, total_points: int)
|
||||
signal spire_trial_failed(floor_num: int, reason: String)
|
||||
|
||||
const MAX_FLOORS := 50
|
||||
const FLOOR_TIME_LIMIT := 120.0 # 每层 120 秒限时
|
||||
|
||||
const MILESTONE_BOSSES: Dictionary = {
|
||||
5: {"name": "半兽人首领", "vnum": 691, "hp": 50000},
|
||||
10: {"name": "蜘蛛女王", "vnum": 2091, "hp": 80000},
|
||||
15: {"name": "沙漠巨怪", "vnum": 2191, "hp": 120000},
|
||||
20: {"name": "恶魔死神", "vnum": 1093, "hp": 180000},
|
||||
25: {"name": "烈焰领主", "vnum": 2206, "hp": 220000},
|
||||
30: {"name": "九尾妖狐", "vnum": 1901, "hp": 280000},
|
||||
35: {"name": "黄虎之魂", "vnum": 1304, "hp": 320000},
|
||||
40: {"name": "冥王阿兹瑞尔", "vnum": 2598, "hp": 400000},
|
||||
45: {"name": "冰龙贝兰-塞陶", "vnum": 2493, "hp": 500000},
|
||||
50: {"name": "混沌魔神·阿萨泽尔", "vnum": 8027, "hp": 800000}
|
||||
}
|
||||
|
||||
var is_in_trial: bool = false
|
||||
var current_floor: int = 1
|
||||
var highest_checkpoint: int = 1
|
||||
var total_points: int = 0
|
||||
var floor_time_remaining: float = 0.0
|
||||
|
||||
var active_mobs: Dictionary = {} # mob_id -> { "vnum", "alive" }
|
||||
var next_mob_id: int = 1
|
||||
var personal_best_floor: int = 0
|
||||
|
||||
# 开启爬塔挑战
|
||||
func start_trial(from_checkpoint: bool = false) -> Dictionary:
|
||||
if is_in_trial:
|
||||
return {"ok": false, "reason": "ALREADY_IN_TRIAL"}
|
||||
|
||||
is_in_trial = true
|
||||
current_floor = highest_checkpoint if from_checkpoint else 1
|
||||
total_points = 0
|
||||
next_mob_id = 1
|
||||
|
||||
_start_floor(current_floor)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"starting_floor": current_floor,
|
||||
"checkpoint": highest_checkpoint,
|
||||
"msg": "踏入【无尽天关】第 %d 层!" % current_floor
|
||||
}
|
||||
|
||||
# 开启指定层数
|
||||
func _start_floor(floor_num: int) -> void:
|
||||
current_floor = floor_num
|
||||
floor_time_remaining = FLOOR_TIME_LIMIT
|
||||
active_mobs.clear()
|
||||
|
||||
var is_boss = MILESTONE_BOSSES.has(floor_num)
|
||||
if is_boss:
|
||||
var b_cfg = MILESTONE_BOSSES[floor_num]
|
||||
var mid = next_mob_id
|
||||
next_mob_id += 1
|
||||
active_mobs[mid] = {
|
||||
"id": mid,
|
||||
"vnum": b_cfg["vnum"],
|
||||
"name": b_cfg["name"],
|
||||
"is_boss": true,
|
||||
"alive": true
|
||||
}
|
||||
floor_started.emit(floor_num, true, 1)
|
||||
else:
|
||||
# 普通层小怪
|
||||
var mob_count = 4 + (floor_num % 5) * 2
|
||||
for i in range(mob_count):
|
||||
var mid = next_mob_id
|
||||
next_mob_id += 1
|
||||
active_mobs[mid] = {
|
||||
"id": mid,
|
||||
"vnum": 101 + (floor_num % 10),
|
||||
"name": "天关守卫",
|
||||
"is_boss": false,
|
||||
"alive": true
|
||||
}
|
||||
floor_started.emit(floor_num, false, mob_count)
|
||||
|
||||
# 击杀当前层怪物
|
||||
func kill_mob(mob_id: int) -> Dictionary:
|
||||
if not is_in_trial:
|
||||
return {"ok": false, "reason": "NOT_IN_TRIAL"}
|
||||
|
||||
if not active_mobs.has(mob_id) or not active_mobs[mob_id]["alive"]:
|
||||
return {"ok": false, "reason": "MOB_NOT_FOUND"}
|
||||
|
||||
active_mobs[mob_id]["alive"] = false
|
||||
active_mobs.erase(mob_id)
|
||||
|
||||
# 检查本层是否清空
|
||||
if active_mobs.is_empty():
|
||||
return _on_floor_cleared()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"remaining_mobs": active_mobs.size()
|
||||
}
|
||||
|
||||
# 本层通关结算
|
||||
func _on_floor_cleared() -> Dictionary:
|
||||
var time_used = FLOOR_TIME_LIMIT - floor_time_remaining
|
||||
var points_awarded = current_floor * 10
|
||||
total_points += points_awarded
|
||||
|
||||
if current_floor > personal_best_floor:
|
||||
personal_best_floor = current_floor
|
||||
|
||||
floor_cleared.emit(current_floor, points_awarded, time_used)
|
||||
|
||||
# 检查点保存判定 (逢 5 层通关)
|
||||
if current_floor % 5 == 0:
|
||||
var next_cp = current_floor + 1
|
||||
if next_cp > highest_checkpoint and next_cp <= MAX_FLOORS:
|
||||
highest_checkpoint = next_cp
|
||||
checkpoint_saved.emit(highest_checkpoint)
|
||||
|
||||
# 终极 50 层通关判定
|
||||
if current_floor >= MAX_FLOORS:
|
||||
is_in_trial = false
|
||||
spire_trial_completed.emit(MAX_FLOORS, total_points)
|
||||
return {
|
||||
"ok": true,
|
||||
"trial_completed": true,
|
||||
"highest_floor": MAX_FLOORS,
|
||||
"total_points": total_points,
|
||||
"msg": "登峰造极!你已彻底通关【无尽天关】第 50 层巅峰!"
|
||||
}
|
||||
|
||||
# 进阶下一层
|
||||
var next_f = current_floor + 1
|
||||
_start_floor(next_f)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"floor_cleared": current_floor - 1,
|
||||
"next_floor": next_f,
|
||||
"points": total_points,
|
||||
"msg": "第 %d 层顺利通关!进入第 %d 层!" % [current_floor - 1, next_f]
|
||||
}
|
||||
|
||||
# 帧心跳更新限时
|
||||
func update(delta: float) -> Dictionary:
|
||||
if not is_in_trial:
|
||||
return {"status": "inactive"}
|
||||
|
||||
floor_time_remaining -= delta
|
||||
if floor_time_remaining <= 0.0:
|
||||
floor_time_remaining = 0.0
|
||||
is_in_trial = false
|
||||
spire_trial_failed.emit(current_floor, "TIME_EXPIRED")
|
||||
return {
|
||||
"status": "failed",
|
||||
"reason": "TIME_EXPIRED",
|
||||
"floor": current_floor,
|
||||
"msg": "时间耗尽!被传送出天关。"
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "active",
|
||||
"floor": current_floor,
|
||||
"remaining_time": floor_time_remaining,
|
||||
"remaining_mobs": active_mobs.size()
|
||||
}
|
||||
|
||||
# 中途离开挑战
|
||||
func exit_trial() -> Dictionary:
|
||||
if not is_in_trial:
|
||||
return {"ok": false, "reason": "NOT_IN_TRIAL"}
|
||||
is_in_trial = false
|
||||
active_mobs.clear()
|
||||
return {"ok": true, "msg": "已主动退出天关,检查点进度已保存。"}
|
||||
|
||||
# 获取挑战概览
|
||||
func get_spire_status() -> Dictionary:
|
||||
return {
|
||||
"is_in_trial": is_in_trial,
|
||||
"current_floor": current_floor,
|
||||
"highest_checkpoint": highest_checkpoint,
|
||||
"personal_best_floor": personal_best_floor,
|
||||
"total_points": total_points,
|
||||
"remaining_time": floor_time_remaining
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://c6sc72uf1btdv
|
||||
@@ -1,237 +0,0 @@
|
||||
# energy_system.gd —— Metin2 40250 官方能量碎片提取与能量水晶熔炼系统 1:1
|
||||
# 对照 40250 服务端 energy_system.quest, char_item.cpp:6319 (CBuffOnAttributes), char.h (POINT_ENERGY=128)
|
||||
class_name EnergySystem
|
||||
extends RefCounted
|
||||
|
||||
signal fragments_extracted(count: int, item_name: String)
|
||||
signal crystal_fused(crystal_item: Dictionary)
|
||||
signal energy_activated(duration: float, bonus_pct: int)
|
||||
signal energy_expired()
|
||||
|
||||
const NPC_ALCHEMIST := 20001 # 炼金术士
|
||||
const MIN_PLAYER_LEVEL := 35 # 角色开启门槛 35 级
|
||||
const MIN_EQUIP_LEVEL := 35 # 装备分解门槛 35 级
|
||||
|
||||
const VNUM_ENERGY_STONE := 51001 # 能量碎片 (Energy Stone / Fragment)
|
||||
const VNUM_CHARGING_STONE := 51002 # 能量水晶 (Charging Stone / Energy Crystal)
|
||||
|
||||
const FUSION_NEED_STONES := 30 # 熔炼需要 30 颗碎片
|
||||
const FUSION_COST_GOLD := 1000 # 熔炼手续费 1000 金币
|
||||
const ENERGY_DURATION := 7200.0 # 2 小时 (7200 秒)
|
||||
const ATTR_BONUS_PCT := 10 # 全身穿戴装备属性增幅 +10%
|
||||
|
||||
var is_energy_active: bool = false
|
||||
var energy_time_remaining: float = 0.0
|
||||
var applied_bonuses: Dictionary = {}
|
||||
|
||||
# 计算装备分解产出碎片数量 (40250 energy_system.quest 概率阶梯)
|
||||
static func calculate_fragment_yield(equip_level_limit: int, rand_val: int = -1) -> int:
|
||||
var r: int = rand_val if rand_val >= 0 else (randi() % 100)
|
||||
if equip_level_limit >= 35 and equip_level_limit <= 50:
|
||||
# 35~50 级装备产出 1~5 颗
|
||||
return clampi(1 + (r % 5), 1, 5)
|
||||
elif equip_level_limit > 50 and equip_level_limit <= 70:
|
||||
# 51~70 级装备产出 3~8 颗
|
||||
return clampi(3 + (r % 6), 3, 8)
|
||||
else:
|
||||
# 70 级以上顶级装备产出 5~15 颗
|
||||
return clampi(5 + (r % 11), 5, 15)
|
||||
|
||||
# 在炼金术士处分解淘汰装备提取能量碎片
|
||||
func extract_energy_fragments(inventory: Array, slot_index: int, player_level: int) -> Dictionary:
|
||||
if player_level < MIN_PLAYER_LEVEL:
|
||||
return {"ok": false, "reason": "PLAYER_LEVEL_TOO_LOW", "msg": "炼金术士只为 35 级以上的强者提取能量!"}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
var limit_lv := int(item.get("level_limit", item.get("limit_level", 0)))
|
||||
if limit_lv < MIN_EQUIP_LEVEL:
|
||||
return {"ok": false, "reason": "EQUIP_LEVEL_TOO_LOW", "msg": "只能分解 35 级以上的武器、防具或首饰!"}
|
||||
|
||||
# 检查箭矢等消耗品
|
||||
if item.get("type", 0) == 1 and item.get("subtype", 0) == 5: # WEAPON_ARROW
|
||||
return {"ok": false, "reason": "ARROW_NOT_ALLOWED", "msg": "箭矢无法提取能量!"}
|
||||
|
||||
var item_name: String = str(item.get("name", "淘汰装备"))
|
||||
var count_yield := calculate_fragment_yield(limit_lv)
|
||||
|
||||
# 移除装备
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 发放能量碎片 (查找已有堆叠或找空位)
|
||||
var placed := false
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ENERGY_STONE:
|
||||
var cur_c := int(inventory[i].get("count", 1))
|
||||
inventory[i]["count"] = cur_c + count_yield
|
||||
placed = true
|
||||
break
|
||||
|
||||
if not placed:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = {
|
||||
"vnum": VNUM_ENERGY_STONE,
|
||||
"name": "能量碎片",
|
||||
"count": count_yield
|
||||
}
|
||||
placed = true
|
||||
break
|
||||
|
||||
fragments_extracted.emit(count_yield, item_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"yield": count_yield,
|
||||
"item_name": item_name,
|
||||
"msg": "成功分解【%s】,提取出 %d 颗【能量碎片】!" % [item_name, count_yield]
|
||||
}
|
||||
|
||||
# 熔炼能量水晶 (30 碎片 + 1000 金币)
|
||||
func fuse_energy_crystal(inventory: Array, player_gold: Dictionary) -> Dictionary:
|
||||
var cur_gold := int(player_gold.get("gold", 0))
|
||||
if cur_gold < FUSION_COST_GOLD:
|
||||
return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足!熔炼需要 1,000 金币。"}
|
||||
|
||||
# 统计能量碎片总数
|
||||
var total_stones := 0
|
||||
var stone_slots: Array = []
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ENERGY_STONE:
|
||||
total_stones += int(inventory[i].get("count", 1))
|
||||
stone_slots.append(i)
|
||||
|
||||
if total_stones < FUSION_NEED_STONES:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "NOT_ENOUGH_STONES",
|
||||
"msg": "能量碎片不足!需要 %d 颗,当前拥有 %d 颗。" % [FUSION_NEED_STONES, total_stones]
|
||||
}
|
||||
|
||||
# 寻找放置能量水晶的槽位
|
||||
var crystal_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
crystal_slot = i
|
||||
break
|
||||
|
||||
# 扣除 30 颗碎片
|
||||
var need_to_deduct := FUSION_NEED_STONES
|
||||
for slot_idx in stone_slots:
|
||||
var it: Dictionary = inventory[slot_idx]
|
||||
var c := int(it.get("count", 1))
|
||||
if c <= need_to_deduct:
|
||||
need_to_deduct -= c
|
||||
inventory[slot_idx] = null
|
||||
if crystal_slot == -1:
|
||||
crystal_slot = slot_idx # 复用清空的格子
|
||||
else:
|
||||
it["count"] = c - need_to_deduct
|
||||
need_to_deduct = 0
|
||||
break
|
||||
|
||||
player_gold["gold"] = cur_gold - FUSION_COST_GOLD
|
||||
|
||||
var crystal := {
|
||||
"vnum": VNUM_CHARGING_STONE,
|
||||
"name": "能量水晶",
|
||||
"count": 1
|
||||
}
|
||||
inventory[crystal_slot] = crystal
|
||||
|
||||
crystal_fused.emit(crystal)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"crystal": crystal,
|
||||
"slot": crystal_slot,
|
||||
"msg": "熔炼成功!纯净的【能量水晶】已凝聚成型。"
|
||||
}
|
||||
|
||||
# 使用能量水晶激活 2 小时全身穿戴装备 10% 增幅 (40250 CBuffOnAttributes)
|
||||
func use_energy_crystal(inventory: Array, slot_index: int, equipped_items: Array, player_stats: Dictionary) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
if int(item.get("vnum", 0)) != VNUM_CHARGING_STONE:
|
||||
return {"ok": false, "reason": "NOT_A_CHARGING_STONE", "msg": "此物品不是能量水晶!"}
|
||||
|
||||
# 消耗 1 颗能量水晶
|
||||
var c := int(item.get("count", 1))
|
||||
if c > 1:
|
||||
item["count"] = c - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
# 如果已激活,先清除旧加成
|
||||
if is_energy_active:
|
||||
_clear_energy_buffs(player_stats)
|
||||
|
||||
# 计算穿戴装备基础属性总和的 10%
|
||||
applied_bonuses.clear()
|
||||
for eq in equipped_items:
|
||||
if eq == null:
|
||||
continue
|
||||
var atk = int(eq.get("attack_power", 0))
|
||||
var def = int(eq.get("defense", 0))
|
||||
var hp = int(eq.get("max_hp", 0))
|
||||
if atk > 0:
|
||||
applied_bonuses["attack_power"] = int(applied_bonuses.get("attack_power", 0)) + int(ceil(atk * 0.10))
|
||||
if def > 0:
|
||||
applied_bonuses["defense"] = int(applied_bonuses.get("defense", 0)) + int(ceil(def * 0.10))
|
||||
if hp > 0:
|
||||
applied_bonuses["max_hp"] = int(applied_bonuses.get("max_hp", 0)) + int(ceil(hp * 0.10))
|
||||
|
||||
# 保底全属性增益 (即使没有极品装备,也提供保底加成)
|
||||
applied_bonuses["attack_power"] = maxi(10, int(applied_bonuses.get("attack_power", 0)))
|
||||
applied_bonuses["defense"] = maxi(10, int(applied_bonuses.get("defense", 0)))
|
||||
|
||||
# 注入玩家属性
|
||||
for k in applied_bonuses.keys():
|
||||
player_stats[k] = int(player_stats.get(k, 0)) + applied_bonuses[k]
|
||||
|
||||
is_energy_active = true
|
||||
energy_time_remaining = ENERGY_DURATION
|
||||
|
||||
energy_activated.emit(ENERGY_DURATION, ATTR_BONUS_PCT)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"duration": ENERGY_DURATION,
|
||||
"bonuses": applied_bonuses,
|
||||
"msg": "能量水晶激活!全身装备属性增幅 +10%%,持续 2 小时。"
|
||||
}
|
||||
|
||||
# 清除能量属性
|
||||
func _clear_energy_buffs(player_stats: Dictionary) -> void:
|
||||
for k in applied_bonuses.keys():
|
||||
player_stats[k] = int(player_stats.get(k, 0)) - applied_bonuses[k]
|
||||
applied_bonuses.clear()
|
||||
is_energy_active = false
|
||||
energy_time_remaining = 0.0
|
||||
|
||||
# 时钟心跳推进
|
||||
func update(delta: float, player_stats: Dictionary) -> void:
|
||||
if not is_energy_active:
|
||||
return
|
||||
energy_time_remaining -= delta
|
||||
if energy_time_remaining <= 0.0:
|
||||
_clear_energy_buffs(player_stats)
|
||||
energy_expired.emit()
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"is_energy_active": is_energy_active,
|
||||
"energy_time_remaining": energy_time_remaining,
|
||||
"applied_bonuses": applied_bonuses.duplicate(true)
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
is_energy_active = bool(data.get("is_energy_active", false))
|
||||
energy_time_remaining = float(data.get("energy_time_remaining", 0.0))
|
||||
applied_bonuses = data.get("applied_bonuses", {}).duplicate(true)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ckdibi7t8851x
|
||||
@@ -1,210 +0,0 @@
|
||||
# epic_refine_system.gd —— Metin2 40250 修元平史诗级神装升阶转换锻造 1:1
|
||||
# 对照 40250 服务端 refine.cpp, char_item.cpp, NPC 20091 (修元平 Seon-Pyeong)
|
||||
class_name EpicRefineSystem
|
||||
extends RefCounted
|
||||
|
||||
signal epic_refine_succeeded(old_vnum: int, new_vnum: int, item_data: Dictionary)
|
||||
signal epic_refine_failed(reason: String)
|
||||
|
||||
const NPC_SEON_PYEONG := 20091
|
||||
|
||||
const ITEM_DRAGON_SCALE := 71123 # 龙鳞
|
||||
const ITEM_DRAGON_CLAW := 71129 # 龙爪
|
||||
const REQUIRED_SCALE_COUNT := 3
|
||||
const REQUIRED_CLAW_COUNT := 4
|
||||
const REQUIRED_YANG := 200000
|
||||
|
||||
# 官方 40250 经典史诗升阶配方表
|
||||
const EPIC_RECIPES: Dictionary = {
|
||||
# 武器篇:65 级 +9 武器 -> 80~90 级 +0 史诗神兵
|
||||
169: {
|
||||
"target_vnum": 279,
|
||||
"name": "崔顿之剑+0",
|
||||
"desc": "战神之剑+9 升阶为 崔顿之剑+0"
|
||||
},
|
||||
3159: {
|
||||
"target_vnum": 3209,
|
||||
"name": "神圣之剑+0",
|
||||
"desc": "鬼牙刃+9 升阶为 神圣之剑+0"
|
||||
},
|
||||
1109: {
|
||||
"target_vnum": 1139,
|
||||
"name": "太阳之刃+0",
|
||||
"desc": "党人斧+9 升阶为 太阳之刃+0"
|
||||
},
|
||||
1119: {
|
||||
"target_vnum": 1129,
|
||||
"name": "龙神刀+0",
|
||||
"desc": "龙刀+9 升阶为 龙神刀+0"
|
||||
},
|
||||
2149: {
|
||||
"target_vnum": 2159,
|
||||
"name": "巨鸦弓+0",
|
||||
"desc": "黄龙弓+9 升阶为 巨鸦弓+0"
|
||||
},
|
||||
5109: {
|
||||
"target_vnum": 5119,
|
||||
"name": "恶魔之杖+0",
|
||||
"desc": "救赎之杖+9 升阶为 恶魔之杖+0"
|
||||
},
|
||||
# 铠甲篇:66 级 +9 黑钢铠甲 -> 90 级 +0 蓝钢铠甲
|
||||
11299: {
|
||||
"target_vnum": 12019,
|
||||
"name": "蓝钢甲+0",
|
||||
"desc": "黑钢甲+9 升阶为 蓝钢甲+0"
|
||||
},
|
||||
11499: {
|
||||
"target_vnum": 12029,
|
||||
"name": "蓝风甲+0",
|
||||
"desc": "黑风甲+9 升阶为 蓝风甲+0"
|
||||
},
|
||||
11699: {
|
||||
"target_vnum": 12039,
|
||||
"name": "蓝仙袍+0",
|
||||
"desc": "黑仙袍+9 升阶为 蓝仙袍+0"
|
||||
},
|
||||
11899: {
|
||||
"target_vnum": 12049,
|
||||
"name": "蓝龙袍+0",
|
||||
"desc": "黑龙袍+9 升阶为 蓝龙袍+0"
|
||||
}
|
||||
}
|
||||
|
||||
# 判定物品是否可进行史诗升阶
|
||||
func can_epic_refine(item: Dictionary) -> bool:
|
||||
if item.is_empty():
|
||||
return false
|
||||
var vnum: int = int(item.get("vnum", 0))
|
||||
return EPIC_RECIPES.has(vnum)
|
||||
|
||||
# 获取升阶配方详情
|
||||
func get_recipe(vnum: int) -> Dictionary:
|
||||
return EPIC_RECIPES.get(vnum, {})
|
||||
|
||||
# 执行史诗装备升阶转换锻造
|
||||
func do_epic_refine(
|
||||
target_slot: int,
|
||||
inventory: Array,
|
||||
player_data: Dictionary
|
||||
) -> Dictionary:
|
||||
if target_slot < 0 or target_slot >= inventory.size():
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的装备栏位!"}
|
||||
|
||||
var base_item = inventory[target_slot]
|
||||
if base_item == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有装备!"}
|
||||
|
||||
# 安全锁拦截
|
||||
if base_item.get("is_locked", false):
|
||||
return {"ok": false, "reason": "ITEM_LOCKED", "msg": "已加锁的装备无法进行史诗锻造!"}
|
||||
|
||||
var base_vnum: int = int(base_item.get("vnum", 0))
|
||||
if not EPIC_RECIPES.has(base_vnum):
|
||||
return {"ok": false, "reason": "NOT_AN_EPIC_BASE_ITEM", "msg": "该物品无法进行修元平史诗升阶!"}
|
||||
|
||||
var recipe: Dictionary = EPIC_RECIPES[base_vnum]
|
||||
|
||||
# 1. 检查金币 (Yang)
|
||||
var current_yang: int = int(player_data.get("gold", 0))
|
||||
if current_yang < REQUIRED_YANG:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "INSUFFICIENT_YANG",
|
||||
"required_yang": REQUIRED_YANG,
|
||||
"current_yang": current_yang,
|
||||
"msg": "史诗锻造需要 200,000 金币!你的金币不足。"
|
||||
}
|
||||
|
||||
# 2. 统计并收集龙鳞与龙爪
|
||||
var scale_slots: Array = []
|
||||
var claw_slots: Array = []
|
||||
var total_scales: int = 0
|
||||
var total_claws: int = 0
|
||||
|
||||
for i in range(inventory.size()):
|
||||
var item = inventory[i]
|
||||
if item == null:
|
||||
continue
|
||||
var v: int = int(item.get("vnum", 0))
|
||||
var cnt: int = int(item.get("count", 1))
|
||||
if v == ITEM_DRAGON_SCALE:
|
||||
total_scales += cnt
|
||||
scale_slots.append({"slot": i, "count": cnt})
|
||||
elif v == ITEM_DRAGON_CLAW:
|
||||
total_claws += cnt
|
||||
claw_slots.append({"slot": i, "count": cnt})
|
||||
|
||||
if total_scales < REQUIRED_SCALE_COUNT:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "MISSING_DRAGON_SCALES",
|
||||
"required": REQUIRED_SCALE_COUNT,
|
||||
"current": total_scales,
|
||||
"msg": "史诗锻造需要 3 个龙鳞!当前只有 %d 个。" % total_scales
|
||||
}
|
||||
|
||||
if total_claws < REQUIRED_CLAW_COUNT:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "MISSING_DRAGON_CLAWS",
|
||||
"required": REQUIRED_CLAW_COUNT,
|
||||
"current": total_claws,
|
||||
"msg": "史诗锻造需要 4 个龙爪!当前只有 %d 个。" % total_claws
|
||||
}
|
||||
|
||||
# 3. 扣除金币
|
||||
player_data["gold"] = current_yang - REQUIRED_YANG
|
||||
|
||||
# 4. 扣除材料 (龙鳞 3, 龙爪 4)
|
||||
_consume_materials(inventory, scale_slots, REQUIRED_SCALE_COUNT)
|
||||
_consume_materials(inventory, claw_slots, REQUIRED_CLAW_COUNT)
|
||||
|
||||
# 5. 生成新史诗装备,1:1 完整继承 5 条附加属性与已镶嵌灵石!
|
||||
var new_vnum: int = recipe["target_vnum"]
|
||||
var inherited_attrs: Array = []
|
||||
if base_item.has("attributes"):
|
||||
for attr in base_item["attributes"]:
|
||||
inherited_attrs.append(attr.duplicate())
|
||||
|
||||
var inherited_sockets: Array = []
|
||||
if base_item.has("sockets"):
|
||||
for sock in base_item["sockets"]:
|
||||
inherited_sockets.append(sock)
|
||||
|
||||
var epic_item: Dictionary = {
|
||||
"vnum": new_vnum,
|
||||
"name": recipe["name"],
|
||||
"count": 1,
|
||||
"attributes": inherited_attrs,
|
||||
"sockets": inherited_sockets,
|
||||
"socket_count": base_item.get("socket_count", 3),
|
||||
"is_locked": false
|
||||
}
|
||||
|
||||
# 替换背包中原位置的装备
|
||||
inventory[target_slot] = epic_item
|
||||
|
||||
epic_refine_succeeded.emit(base_vnum, new_vnum, epic_item)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"old_vnum": base_vnum,
|
||||
"new_vnum": new_vnum,
|
||||
"item": epic_item,
|
||||
"msg": "恭喜!修元平大师成功将装备升阶锻造为【%s】!" % recipe["name"]
|
||||
}
|
||||
|
||||
# 辅助函数:按需从多个格子中扣除材料数量
|
||||
func _consume_materials(inventory: Array, slots_info: Array, count_needed: int) -> void:
|
||||
var needed := count_needed
|
||||
for info in slots_info:
|
||||
var s: int = info["slot"]
|
||||
var have: int = info["count"]
|
||||
if have <= needed:
|
||||
needed -= have
|
||||
inventory[s] = null
|
||||
else:
|
||||
inventory[s]["count"] = have - needed
|
||||
needed = 0
|
||||
if needed <= 0:
|
||||
break
|
||||
@@ -1 +0,0 @@
|
||||
uid://cg7egxv7schkr
|
||||
@@ -1,134 +0,0 @@
|
||||
# equipment_inspect_system.gd —— 40250 装备透视检视与 GM 监察系统 1:1
|
||||
# 严格对照:
|
||||
# Client/Eternexus/root/uiequipmentdialog.py (EquipmentDialog)
|
||||
# ClientVS22/source/UserInterface/PythonPlayer.cpp
|
||||
# metin2/src/server/game/src/cmd_gm.cpp (do_view_equip)
|
||||
extends RefCounted
|
||||
|
||||
# 官方装备槽位常量定义 (1:1 ClientVS22/source/UserInterface/GameType.h)
|
||||
const WEAR_BODY := 0 # 铠甲
|
||||
const WEAR_HEAD := 1 # 头盔
|
||||
const WEAR_FOOTS := 2 # 鞋子
|
||||
const WEAR_WRIST := 3 # 手镯
|
||||
const WEAR_WEAPON := 4 # 武器
|
||||
const WEAR_NECK := 5 # 项链
|
||||
const WEAR_EAR := 6 # 耳环
|
||||
const WEAR_SHIELD := 10 # 盾牌
|
||||
const WEAR_COSTUME_BODY := 19 # 时装身体
|
||||
const WEAR_COSTUME_HAIR := 20 # 时装发型
|
||||
const WEAR_COSTUME_WEAPON := 21 # 时装武器
|
||||
|
||||
const SLOT_NAMES := {
|
||||
WEAR_BODY: "铠甲",
|
||||
WEAR_HEAD: "头盔",
|
||||
WEAR_FOOTS: "鞋子",
|
||||
WEAR_WRIST: "手镯",
|
||||
WEAR_WEAPON: "武器",
|
||||
WEAR_NECK: "项链",
|
||||
WEAR_EAR: "耳环",
|
||||
WEAR_SHIELD: "盾牌",
|
||||
WEAR_COSTUME_BODY: "时装身体",
|
||||
WEAR_COSTUME_HAIR: "时装发型",
|
||||
WEAR_COSTUME_WEAPON: "时装武器",
|
||||
}
|
||||
|
||||
# 窗口状态
|
||||
var is_open: bool = false
|
||||
var inspected_vid: int = 0
|
||||
var inspected_name: String = ""
|
||||
var equipment_slots: Dictionary = {} # slot_idx -> item_data Dictionary
|
||||
|
||||
func _init() -> void:
|
||||
is_open = false
|
||||
inspected_vid = 0
|
||||
inspected_name = ""
|
||||
equipment_slots.clear()
|
||||
|
||||
# 1. 打开检视窗口 (1:1 EquipmentDialog.Open)
|
||||
# caller_is_gm: GM 可强制穿透隐私限制
|
||||
func open_dialog(
|
||||
target_vid: int,
|
||||
target_name: String,
|
||||
target_equip_dict: Dictionary,
|
||||
allow_inspect: bool = true,
|
||||
caller_is_gm: bool = false
|
||||
) -> Dictionary:
|
||||
var res := { "success": false, "error_code": "OK", "message": "" }
|
||||
|
||||
if not allow_inspect and not caller_is_gm:
|
||||
res["error_code"] = "INSPECT_FORBIDDEN"
|
||||
res["message"] = "目标玩家设置了隐私保护,禁止查看装备。"
|
||||
return res
|
||||
|
||||
inspected_vid = target_vid
|
||||
inspected_name = target_name
|
||||
equipment_slots.clear()
|
||||
|
||||
# 载入目标玩家装备数据
|
||||
for slot_key in target_equip_dict:
|
||||
var slot_idx: int = int(slot_key)
|
||||
var it: Dictionary = target_equip_dict[slot_key]
|
||||
equipment_slots[slot_idx] = _normalize_item_data(it)
|
||||
|
||||
is_open = true
|
||||
res["success"] = true
|
||||
res["message"] = "成功载入 %s 的装备信息。" % target_name
|
||||
return res
|
||||
|
||||
# 2. 关闭检视窗口 (EquipmentDialog.Close)
|
||||
func close_dialog() -> void:
|
||||
is_open = false
|
||||
inspected_vid = 0
|
||||
inspected_name = ""
|
||||
equipment_slots.clear()
|
||||
|
||||
# 3. 规范化装备项数据 (确保含有 3 宝石孔与 7 词条结构)
|
||||
func _normalize_item_data(it: Dictionary) -> Dictionary:
|
||||
var item_copy := it.duplicate(true)
|
||||
var sockets: Array = item_copy.get("sockets", [0, 0, 0])
|
||||
while sockets.size() < 3:
|
||||
sockets.append(0)
|
||||
item_copy["sockets"] = sockets
|
||||
|
||||
var attrs: Array = item_copy.get("attributes", [])
|
||||
item_copy["attributes"] = attrs
|
||||
return item_copy
|
||||
|
||||
# 4. 获取指定槽位装备
|
||||
func get_slot_item(slot_idx: int) -> Dictionary:
|
||||
if not is_open:
|
||||
return {}
|
||||
return equipment_slots.get(slot_idx, {})
|
||||
|
||||
# 5. 悬停生成 Tooltip 详情 (1:1 EquipmentDialog.OverInItem)
|
||||
func build_slot_tooltip(slot_idx: int) -> String:
|
||||
if not is_open:
|
||||
return ""
|
||||
|
||||
if not equipment_slots.has(slot_idx):
|
||||
var slot_name: String = SLOT_NAMES.get(slot_idx, "未知槽位")
|
||||
return "[%s] 未佩戴装备" % slot_name
|
||||
|
||||
var it: Dictionary = equipment_slots[slot_idx]
|
||||
var lines: Array = []
|
||||
var title_name: String = it.get("name", "Unknown Item")
|
||||
var vnum: int = it.get("vnum", 0)
|
||||
|
||||
lines.append("=== %s (Vnum: %d) ===" % [title_name, vnum])
|
||||
lines.append("部位: %s" % SLOT_NAMES.get(slot_idx, "特殊部位"))
|
||||
|
||||
# 宝石孔位
|
||||
var sockets: Array = it.get("sockets", [])
|
||||
for i in range(sockets.size()):
|
||||
var s: int = sockets[i]
|
||||
if s > 0:
|
||||
lines.append("孔位 %d: 镶嵌宝石 [Vnum %d]" % [i + 1, s])
|
||||
elif s == 1:
|
||||
lines.append("孔位 %d: 空孔" % [i + 1])
|
||||
|
||||
# 附加属性 (1~7 词条)
|
||||
var attrs: Array = it.get("attributes", [])
|
||||
for a in attrs:
|
||||
lines.append("附加属性: 属性 %d +%d" % [a.get("type", 0), a.get("value", 0)])
|
||||
|
||||
return ("\n").join(lines)
|
||||
@@ -1 +0,0 @@
|
||||
uid://gt0l8bv2agu3
|
||||
@@ -1,171 +0,0 @@
|
||||
# event_manager_system.gd —— Metin2 40250 单人模式全服掉落活动与倍率调度器 1:1
|
||||
# 对照 40250 服务端 cmd_gm.cpp (do_priv_empire), priv_manager.cpp, event.cpp, Items 50011, 50037
|
||||
class_name EventManagerSystem
|
||||
extends RefCounted
|
||||
|
||||
signal rate_changed(rate_type: String, multiplier: float)
|
||||
signal event_state_changed(event_name: String, is_active: bool)
|
||||
signal event_box_dropped(box_vnum: int, box_name: String)
|
||||
|
||||
const ITEM_MOONLIGHT_BOX := 50011 # 月光宝盒
|
||||
const ITEM_HEXAGONAL_BOX := 50037 # 六角宝盒
|
||||
|
||||
# 宝盒奖励奖池 (40250 官方 special_item_group.txt 1:1)
|
||||
const EVENT_BOX_LOOT: Dictionary = {
|
||||
50011: [
|
||||
{"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 30},
|
||||
{"vnum": 71084, "name": "高级附魔石", "count": 1, "weight": 25},
|
||||
{"vnum": 71085, "name": "高级洗练石", "count": 1, "weight": 25},
|
||||
{"vnum": 70024, "name": "祝福大理石", "count": 1, "weight": 10},
|
||||
{"vnum": 72723, "name": "月之灵药(特)", "count": 1, "weight": 10}
|
||||
],
|
||||
50037: [
|
||||
{"vnum": 72001, "name": "经验之戒", "count": 1, "weight": 20},
|
||||
{"vnum": 72002, "name": "盗贼手套", "count": 1, "weight": 20},
|
||||
{"vnum": 27102, "name": "绿药水(大)", "count": 5, "weight": 30},
|
||||
{"vnum": 27105, "name": "紫药水(大)", "count": 5, "weight": 30}
|
||||
]
|
||||
}
|
||||
|
||||
var exp_rate: float = 1.0
|
||||
var drop_rate: float = 1.0
|
||||
var gold_rate: float = 1.0
|
||||
|
||||
var is_moonlight_active: bool = false
|
||||
var is_hexagonal_active: bool = false
|
||||
|
||||
# 设置倍率 (exp, drop, gold)
|
||||
func set_rate(rate_type: String, multiplier: float) -> Dictionary:
|
||||
if multiplier < 1.0 or multiplier > 5.0:
|
||||
return {"ok": false, "reason": "INVALID_MULTIPLIER", "msg": "倍率必须在 1.0 到 5.0 之间!"}
|
||||
|
||||
match rate_type:
|
||||
"exp":
|
||||
exp_rate = multiplier
|
||||
"drop":
|
||||
drop_rate = multiplier
|
||||
"gold":
|
||||
gold_rate = multiplier
|
||||
_:
|
||||
return {"ok": false, "reason": "INVALID_RATE_TYPE", "msg": "未知的倍率类型!"}
|
||||
|
||||
rate_changed.emit(rate_type, multiplier)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"rate_type": rate_type,
|
||||
"multiplier": multiplier,
|
||||
"msg": "全服【%s】倍率已设置为 %.1fx!" % [rate_type.to_upper(), multiplier]
|
||||
}
|
||||
|
||||
# 开关经典限时掉落活动
|
||||
func toggle_event(event_name: String, enable: bool) -> Dictionary:
|
||||
match event_name:
|
||||
"moonlight":
|
||||
is_moonlight_active = enable
|
||||
"hexagonal":
|
||||
is_hexagonal_active = enable
|
||||
_:
|
||||
return {"ok": false, "reason": "INVALID_EVENT_NAME", "msg": "未知的活动名称!"}
|
||||
|
||||
event_state_changed.emit(event_name, enable)
|
||||
|
||||
var state_str := "开启" if enable else "关闭"
|
||||
var ev_desc := "月光宝盒掉落狂欢" if event_name == "moonlight" else "六角宝盒冲级庆典"
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"event_name": event_name,
|
||||
"is_active": enable,
|
||||
"msg": "【%s】活动已%s!" % [ev_desc, state_str]
|
||||
}
|
||||
|
||||
# 怪物击杀时判定额外活动宝盒掉落
|
||||
func check_event_drops(mob_level: int, player_level: int, forced_roll: float = -1.0) -> Array:
|
||||
var drops: Array = []
|
||||
|
||||
# 等级差在 15 级以内才掉落活动宝盒
|
||||
if absi(mob_level - player_level) > 15:
|
||||
return drops
|
||||
|
||||
# 1. 月光宝盒判定 (基础概率 10% * drop_rate)
|
||||
if is_moonlight_active:
|
||||
var chance := 0.10 * drop_rate
|
||||
var roll := forced_roll if forced_roll >= 0.0 else randf()
|
||||
if roll <= chance:
|
||||
drops.append({"vnum": ITEM_MOONLIGHT_BOX, "name": "月光宝盒", "count": 1})
|
||||
event_box_dropped.emit(ITEM_MOONLIGHT_BOX, "月光宝盒")
|
||||
|
||||
# 2. 六角宝盒判定 (基础概率 15% * drop_rate)
|
||||
if is_hexagonal_active:
|
||||
var chance := 0.15 * drop_rate
|
||||
var roll := forced_roll if forced_roll >= 0.0 else randf()
|
||||
if roll <= chance:
|
||||
drops.append({"vnum": ITEM_HEXAGONAL_BOX, "name": "六角宝盒", "count": 1})
|
||||
event_box_dropped.emit(ITEM_HEXAGONAL_BOX, "六角宝盒")
|
||||
|
||||
return drops
|
||||
|
||||
# 计算经倍率加成后的经验与金币
|
||||
func calculate_exp(base_exp: int) -> int:
|
||||
return int(round(base_exp * exp_rate))
|
||||
|
||||
func calculate_gold(base_gold: int) -> int:
|
||||
return int(round(base_gold * gold_rate))
|
||||
|
||||
# 打开活动宝盒
|
||||
func open_event_box(slot_idx: int, inventory: Array) -> Dictionary:
|
||||
if slot_idx < 0 or slot_idx >= inventory.size():
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包栏位!"}
|
||||
|
||||
var box = inventory[slot_idx]
|
||||
if box == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有物品!"}
|
||||
|
||||
var box_vnum: int = int(box.get("vnum", 0))
|
||||
if not EVENT_BOX_LOOT.has(box_vnum):
|
||||
return {"ok": false, "reason": "NOT_AN_EVENT_BOX", "msg": "这不是活动宝盒!"}
|
||||
|
||||
var pool: Array = EVENT_BOX_LOOT[box_vnum]
|
||||
|
||||
# 权重抽奖
|
||||
var total_weight := 0
|
||||
for item in pool:
|
||||
total_weight += int(item.get("weight", 1))
|
||||
|
||||
var roll := randi_range(1, total_weight)
|
||||
var accumulated := 0
|
||||
var reward: Dictionary = {}
|
||||
for item in pool:
|
||||
accumulated += int(item.get("weight", 1))
|
||||
if roll <= accumulated:
|
||||
reward = item.duplicate()
|
||||
break
|
||||
|
||||
if reward.is_empty():
|
||||
reward = pool[0].duplicate()
|
||||
|
||||
# 扣除宝盒,存入奖励
|
||||
var cnt: int = int(box.get("count", 1))
|
||||
if cnt <= 1:
|
||||
inventory[slot_idx] = null
|
||||
else:
|
||||
box["count"] = cnt - 1
|
||||
|
||||
var stored_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = {
|
||||
"vnum": reward["vnum"],
|
||||
"name": reward["name"],
|
||||
"count": reward.get("count", 1)
|
||||
}
|
||||
stored_slot = i
|
||||
break
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"reward": reward,
|
||||
"stored_slot": stored_slot,
|
||||
"msg": "打开活动宝盒,获得了【%s x%d】!" % [reward["name"], reward.get("count", 1)]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://bvf0ul7tdn6o5
|
||||
@@ -1,432 +0,0 @@
|
||||
# fish_clam_roast_system.gd —— Metin2 40250 官方珍珠蚌开珠与活鱼宰杀/篝火炙烤盛宴系统 1:1
|
||||
# 100% 对照 40250 服务端 fishing.cpp, char_item.cpp (ITEM_FISH, 27987, USE_ABILITY_UP, USE_INVISIBILITY), item_proto.txt
|
||||
class_name FishClamRoastSystem
|
||||
extends RefCounted
|
||||
|
||||
signal fish_gutted(result_type: String, item_vnum: int, item_name: String)
|
||||
signal clam_opened(result_type: String, item_vnum: int, item_name: String)
|
||||
signal fish_grilled(raw_vnum: int, cooked_vnum: int, count: int)
|
||||
signal cooked_fish_consumed(fish_vnum: int, effect_type: String, value: int, duration: float)
|
||||
signal campfire_spawned(pos: Vector2, duration: float)
|
||||
|
||||
# 官方核心 Vnum 常量
|
||||
const VNUM_CLAM := 27987 # 珍珠蚌 (Clam / Istiridye)
|
||||
const VNUM_STONE_PIECE := 27990 # 碎石 (Stone Piece)
|
||||
const VNUM_WHITE_PEARL := 27992 # 白珍珠 (White Pearl)
|
||||
const VNUM_BLUE_PEARL := 27993 # 蓝珍珠 (Blue Pearl)
|
||||
const VNUM_BLOOD_PEARL := 27994 # 血红珍珠 (Blood Pearl)
|
||||
const VNUM_FISH_BONE := 27995 # 鱼骨 (Fishbone)
|
||||
const VNUM_EARTHWORM := 27801 # 蚯蚓/饵料 (Earthworm)
|
||||
const VNUM_CAMPFIRE_ITEM := 27600 # 篝火道具
|
||||
const MOB_CAMPFIRE := 12000 # 篝火 NPC/Mob
|
||||
|
||||
# 40250 官方鱼类资料表 (alive_vnum, dead_vnum, cooked_vnum, buff/effect)
|
||||
const FISH_DATA: Dictionary = {
|
||||
27803: {
|
||||
"name": "鲈鱼 (Sudak)",
|
||||
"alive_vnum": 27803,
|
||||
"dead_vnum": 27833,
|
||||
"cooked_vnum": 27863,
|
||||
"effect": "instant_hp",
|
||||
"value": 180,
|
||||
"duration": 0.0,
|
||||
"desc": "立即恢复 180 点生命值"
|
||||
},
|
||||
27804: {
|
||||
"name": "鲱鱼 (Ringa)",
|
||||
"alive_vnum": 27804,
|
||||
"dead_vnum": 27834,
|
||||
"cooked_vnum": 27864,
|
||||
"effect": "instant_sp",
|
||||
"value": 150,
|
||||
"duration": 0.0,
|
||||
"desc": "立即恢复 150 点魔法值"
|
||||
},
|
||||
27806: {
|
||||
"name": "鲤鱼 (Sazan)",
|
||||
"alive_vnum": 27806,
|
||||
"dead_vnum": 27836,
|
||||
"cooked_vnum": 27866,
|
||||
"effect": "move_speed",
|
||||
"value": 20,
|
||||
"duration": 600.0,
|
||||
"desc": "移动速度 +20 持续 10 分钟"
|
||||
},
|
||||
27807: {
|
||||
"name": "三文鱼/鲑鱼 (Som)",
|
||||
"alive_vnum": 27807,
|
||||
"dead_vnum": 27837,
|
||||
"cooked_vnum": 27867,
|
||||
"effect": "instant_sp",
|
||||
"value": 300,
|
||||
"duration": 0.0,
|
||||
"desc": "立即恢复 300 点魔法值"
|
||||
},
|
||||
27808: {
|
||||
"name": "草鱼 (Ot Sazanı)",
|
||||
"alive_vnum": 27808,
|
||||
"dead_vnum": 27838,
|
||||
"cooked_vnum": 27868,
|
||||
"effect": "attack_speed",
|
||||
"value": 20,
|
||||
"duration": 600.0,
|
||||
"desc": "攻击速度 +20 持续 10 分钟"
|
||||
},
|
||||
27810: {
|
||||
"name": "水针鱼 (Zargana)",
|
||||
"alive_vnum": 27810,
|
||||
"dead_vnum": 27840,
|
||||
"cooked_vnum": 27870,
|
||||
"effect": "strength",
|
||||
"value": 10,
|
||||
"duration": 600.0,
|
||||
"desc": "力量 (STR) +10 持续 10 分钟"
|
||||
},
|
||||
27813: {
|
||||
"name": "凤尾鱼 (Hamsi)",
|
||||
"alive_vnum": 27813,
|
||||
"dead_vnum": 27843,
|
||||
"cooked_vnum": 27873,
|
||||
"effect": "dexterity",
|
||||
"value": 10,
|
||||
"duration": 600.0,
|
||||
"desc": "敏捷 (DEX) +10 持续 10 分钟"
|
||||
},
|
||||
27814: {
|
||||
"name": "海鲈鱼 (Levrek)",
|
||||
"alive_vnum": 27814,
|
||||
"dead_vnum": 27844,
|
||||
"cooked_vnum": 27874,
|
||||
"effect": "cure_poison",
|
||||
"value": 1,
|
||||
"duration": 0.0,
|
||||
"desc": "祛除体内的一切猛毒状态"
|
||||
},
|
||||
27817: {
|
||||
"name": "泥鳅 (Çopra)",
|
||||
"alive_vnum": 27817,
|
||||
"dead_vnum": 27847,
|
||||
"cooked_vnum": 27877,
|
||||
"effect": "invisibility",
|
||||
"value": 1,
|
||||
"duration": 300.0,
|
||||
"desc": "进入幽冥迷雾隐身状态 5 分钟"
|
||||
}
|
||||
}
|
||||
|
||||
# 逆向索引
|
||||
var _dead_to_cooked: Dictionary = {}
|
||||
var _alive_to_cooked: Dictionary = {}
|
||||
var _cooked_to_data: Dictionary = {}
|
||||
|
||||
# 当前激活的烤鱼增益状态列表
|
||||
var _active_buffs: Dictionary = {} # effect_type -> {"value": int, "duration": float}
|
||||
|
||||
# 当前地图是否存在有效篝火
|
||||
var is_near_active_campfire: bool = false
|
||||
var campfire_time_left: float = 0.0
|
||||
|
||||
func _init() -> void:
|
||||
for k in FISH_DATA.keys():
|
||||
var data = FISH_DATA[k]
|
||||
_alive_to_cooked[data["alive_vnum"]] = data
|
||||
_dead_to_cooked[data["dead_vnum"]] = data
|
||||
_cooked_to_data[data["cooked_vnum"]] = data
|
||||
|
||||
# ==========================================
|
||||
# 1. 活鱼宰杀/使用 (fishing.cpp: UseFish)
|
||||
# ==========================================
|
||||
func gut_live_fish(inventory: Array, slot_index: int, forced_roll: int = -1) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有活鱼!"}
|
||||
|
||||
var item = inventory[slot_index]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
if not _alive_to_cooked.has(vnum):
|
||||
return {"ok": false, "reason": "NOT_ALIVE_FISH", "msg": "此物品不是可以剖杀的鲜活鱼类!"}
|
||||
|
||||
var fish = _alive_to_cooked[vnum]
|
||||
# 扣减活鱼数量
|
||||
_consume_item_one(inventory, slot_index)
|
||||
|
||||
# 40250 原版随机概率 1~10000:
|
||||
# r >= 4001 (60%): 产出死鱼 (dead_vnum)
|
||||
# r >= 2001 (20%): 产出鱼骨 (FISH_BONE_VNUM 27995)
|
||||
# r < 2001 (20%): 肚中有物 (蚌壳 27987、饵料 27801 或失手滑入水中)
|
||||
var r = forced_roll if forced_roll > 0 else randi_range(1, 10000)
|
||||
|
||||
if r >= 4001:
|
||||
_give_or_stack_item(inventory, fish["dead_vnum"], 1)
|
||||
fish_gutted.emit("dead_fish", fish["dead_vnum"], "死" + fish["name"])
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "dead_fish",
|
||||
"vnum": fish["dead_vnum"],
|
||||
"name": "死" + fish["name"],
|
||||
"msg": "你熟练地宰杀了活鱼,获得了可用于烤制的死鱼。"
|
||||
}
|
||||
elif r >= 2001:
|
||||
_give_or_stack_item(inventory, VNUM_FISH_BONE, 1)
|
||||
fish_gutted.emit("fish_bone", VNUM_FISH_BONE, "鱼骨")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "fish_bone",
|
||||
"vnum": VNUM_FISH_BONE,
|
||||
"name": "鱼骨",
|
||||
"msg": "活鱼体内只剩下一截晶莹剔透的坚硬鱼骨。"
|
||||
}
|
||||
else:
|
||||
# r < 2001
|
||||
if r <= 1000:
|
||||
# 珍稀产出:珍珠蚌 (Clam 27987)
|
||||
_give_or_stack_item(inventory, VNUM_CLAM, 1)
|
||||
fish_gutted.emit("clam", VNUM_CLAM, "珍珠蚌")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "clam",
|
||||
"vnum": VNUM_CLAM,
|
||||
"name": "珍珠蚌",
|
||||
"msg": "难以置信!你在鱼肚中发现了一枚紧闭的神秘【珍珠蚌】!"
|
||||
}
|
||||
elif r <= 1500:
|
||||
_give_or_stack_item(inventory, VNUM_EARTHWORM, 1)
|
||||
fish_gutted.emit("earthworm", VNUM_EARTHWORM, "蚯蚓")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "earthworm",
|
||||
"vnum": VNUM_EARTHWORM,
|
||||
"name": "蚯蚓",
|
||||
"msg": "鱼肚里还残留着一条未消化的蚯蚓饵料。"
|
||||
}
|
||||
else:
|
||||
# 滑入水中/空
|
||||
fish_gutted.emit("escape", 0, "溜走")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "escape",
|
||||
"vnum": 0,
|
||||
"name": "",
|
||||
"msg": "活鱼猛烈扑腾,从你的指缝中滑落回了深水之中!"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 2. 珍珠蚌开珠 (char_item.cpp:2965 Case 27987)
|
||||
# ==========================================
|
||||
func open_clam(
|
||||
inventory: Array,
|
||||
slot_index: int,
|
||||
forced_branch_roll: int = -1,
|
||||
forced_pearl_roll: int = -1
|
||||
) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有物品!"}
|
||||
|
||||
var item = inventory[slot_index]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
if vnum != VNUM_CLAM:
|
||||
return {"ok": false, "reason": "NOT_A_CLAM", "msg": "该物品不是珍珠蚌!"}
|
||||
|
||||
# 消耗 1 枚珍珠蚌
|
||||
_consume_item_one(inventory, slot_index)
|
||||
|
||||
# 40250 官方第一轮分支 (1~100):
|
||||
# r <= 50 (50%): 碎石 (27990)
|
||||
# r > 50 (50%): 判定珍珠概率表 prob_table {80, 90, 97}
|
||||
var r = forced_branch_roll if forced_branch_roll > 0 else randi_range(1, 100)
|
||||
|
||||
if r <= 50:
|
||||
_give_or_stack_item(inventory, VNUM_STONE_PIECE, 1)
|
||||
clam_opened.emit("stone", VNUM_STONE_PIECE, "碎石")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "stone",
|
||||
"vnum": VNUM_STONE_PIECE,
|
||||
"name": "碎石",
|
||||
"msg": "珍珠蚌打开了,但里面只包裹着一块毫无价值的普通碎石。"
|
||||
}
|
||||
else:
|
||||
# 珍珠判定表 (1~100)
|
||||
# roll <= 80 (80%): 空蚌壳 (什么都没有)
|
||||
# 81 <= roll <= 90 (10%): 白珍珠 (27992)
|
||||
# 91 <= roll <= 97 (7%): 蓝珍珠 (27993)
|
||||
# roll >= 98 (3%): 血红珍珠 (27994)
|
||||
var p_roll = forced_pearl_roll if forced_pearl_roll > 0 else randi_range(1, 100)
|
||||
if p_roll <= 80:
|
||||
clam_opened.emit("empty", 0, "空蚌")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "empty",
|
||||
"vnum": 0,
|
||||
"name": "",
|
||||
"msg": "蚌壳碎裂开来,里面空空如也,什么也没有留下。"
|
||||
}
|
||||
elif p_roll <= 90:
|
||||
_give_or_stack_item(inventory, VNUM_WHITE_PEARL, 1)
|
||||
clam_opened.emit("white_pearl", VNUM_WHITE_PEARL, "白珍珠")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "white_pearl",
|
||||
"vnum": VNUM_WHITE_PEARL,
|
||||
"name": "白珍珠",
|
||||
"msg": "璀璨温润!你从蚌肉中剥出了一颗纯净的【白珍珠】!"
|
||||
}
|
||||
elif p_roll <= 97:
|
||||
_give_or_stack_item(inventory, VNUM_BLUE_PEARL, 1)
|
||||
clam_opened.emit("blue_pearl", VNUM_BLUE_PEARL, "蓝珍珠")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "blue_pearl",
|
||||
"vnum": VNUM_BLUE_PEARL,
|
||||
"name": "蓝珍珠",
|
||||
"msg": "幽蓝如海!你收获了一颗弥足珍贵的【蓝珍珠】!"
|
||||
}
|
||||
else:
|
||||
_give_or_stack_item(inventory, VNUM_BLOOD_PEARL, 1)
|
||||
clam_opened.emit("blood_pearl", VNUM_BLOOD_PEARL, "血红珍珠")
|
||||
return {
|
||||
"ok": true,
|
||||
"result": "blood_pearl",
|
||||
"vnum": VNUM_BLOOD_PEARL,
|
||||
"name": "血红珍珠",
|
||||
"msg": "旷世奇珍!蚌心处赫然闪烁着血煞夺目的极品【血红珍珠】!"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 3. 篝火炙烤 (char_item.cpp:6880, fishing.cpp: Grill)
|
||||
# ==========================================
|
||||
func place_campfire(duration: float = 600.0) -> void:
|
||||
is_near_active_campfire = true
|
||||
campfire_time_left = duration
|
||||
campfire_spawned.emit(Vector2.ZERO, duration)
|
||||
|
||||
func grill_fish(inventory: Array, slot_index: int, near_campfire_override: bool = false) -> Dictionary:
|
||||
var can_grill = is_near_active_campfire or near_campfire_override
|
||||
if not can_grill:
|
||||
return {"ok": false, "reason": "NO_CAMPFIRE", "msg": "烤鱼必须在点燃的营火 (篝火) 旁进行!"}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有鱼类!"}
|
||||
|
||||
var item = inventory[slot_index]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
var count = int(item.get("count", 1))
|
||||
|
||||
var fish_data = null
|
||||
if _dead_to_cooked.has(vnum):
|
||||
fish_data = _dead_to_cooked[vnum]
|
||||
elif _alive_to_cooked.has(vnum):
|
||||
fish_data = _alive_to_cooked[vnum]
|
||||
else:
|
||||
return {"ok": false, "reason": "NOT_GRILLABLE", "msg": "此物品无法用于篝火炙烤!"}
|
||||
|
||||
var cooked_vnum = int(fish_data["cooked_vnum"])
|
||||
# 40250 item->SetCount(0); AutoGiveItem(grill_vnum, count);
|
||||
inventory[slot_index] = null
|
||||
_give_or_stack_item(inventory, cooked_vnum, count)
|
||||
|
||||
fish_grilled.emit(vnum, cooked_vnum, count)
|
||||
return {
|
||||
"ok": true,
|
||||
"cooked_vnum": cooked_vnum,
|
||||
"count": count,
|
||||
"name": "烤" + fish_data["name"],
|
||||
"msg": "营火噼啪作响,鱼肉散发出浓郁扑鼻的焦香!你获得了 %d 条【烤%s】。" % [count, fish_data["name"]]
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 4. 食用熟鱼获得增益 (char_item.cpp: USE_POTION / USE_ABILITY_UP)
|
||||
# ==========================================
|
||||
func eat_cooked_fish(inventory: Array, slot_index: int) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有熟鱼!"}
|
||||
|
||||
var item = inventory[slot_index]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
if not _cooked_to_data.has(vnum):
|
||||
return {"ok": false, "reason": "NOT_COOKED_FISH", "msg": "此物品不是烤制完成的熟鱼!"}
|
||||
|
||||
var data = _cooked_to_data[vnum]
|
||||
_consume_item_one(inventory, slot_index)
|
||||
|
||||
var effect = str(data["effect"])
|
||||
var val = int(data["value"])
|
||||
var dur = float(data["duration"])
|
||||
|
||||
if dur > 0.0:
|
||||
_active_buffs[effect] = {
|
||||
"value": val,
|
||||
"time_remaining": dur,
|
||||
"name": "烤" + data["name"]
|
||||
}
|
||||
|
||||
cooked_fish_consumed.emit(vnum, effect, val, dur)
|
||||
return {
|
||||
"ok": true,
|
||||
"effect": effect,
|
||||
"value": val,
|
||||
"duration": dur,
|
||||
"desc": data["desc"],
|
||||
"msg": "你大快朵颐地吃下了香喷喷的烤鱼,%s!" % data["desc"]
|
||||
}
|
||||
|
||||
func update(delta: float) -> void:
|
||||
if campfire_time_left > 0.0:
|
||||
campfire_time_left -= delta
|
||||
if campfire_time_left <= 0.0:
|
||||
campfire_time_left = 0.0
|
||||
is_near_active_campfire = false
|
||||
|
||||
# 更新 BUFF 持续倒计时
|
||||
var to_remove: Array = []
|
||||
for eff in _active_buffs.keys():
|
||||
var b = _active_buffs[eff]
|
||||
b["time_remaining"] -= delta
|
||||
if b["time_remaining"] <= 0.0:
|
||||
to_remove.append(eff)
|
||||
|
||||
for eff in to_remove:
|
||||
_active_buffs.erase(eff)
|
||||
|
||||
func get_active_buffs() -> Dictionary:
|
||||
return _active_buffs
|
||||
|
||||
func clear_buffs() -> void:
|
||||
_active_buffs.clear()
|
||||
|
||||
# ==========================================
|
||||
# 辅助函数: 背包堆叠与消耗
|
||||
# ==========================================
|
||||
func _consume_item_one(inventory: Array, slot: int) -> void:
|
||||
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})
|
||||
|
||||
# 序列化与反序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"active_buffs": _active_buffs.duplicate(true),
|
||||
"campfire_time_left": campfire_time_left,
|
||||
"is_near_active_campfire": is_near_active_campfire
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
_active_buffs = data.get("active_buffs", {}).duplicate(true)
|
||||
campfire_time_left = float(data.get("campfire_time_left", 0.0))
|
||||
is_near_active_campfire = bool(data.get("is_near_active_campfire", false))
|
||||
@@ -1 +0,0 @@
|
||||
uid://d1lso6wx22xr2
|
||||
@@ -1,214 +0,0 @@
|
||||
# fishing_cooking_camp_system.gd —— Metin2 40250 野外钓鱼烹饪与生火露营增益系统 1:1
|
||||
# 对照 40250 服务端 fishing.cpp, char_item.cpp, affect.h
|
||||
class_name FishingCookingCampSystem
|
||||
extends RefCounted
|
||||
|
||||
signal campfire_lit(pos: Vector3, duration: float)
|
||||
signal campfire_extinguished()
|
||||
signal fish_cooked(raw_vnum: int, cooked_vnum: int, cooked_name: String)
|
||||
signal food_buff_applied(buff_id: String, buff_name: String, duration: float)
|
||||
signal food_buff_expired(buff_id: String)
|
||||
|
||||
const CAMPFIRE_KIT_VNUM := 27600 # 营火工具 (Campfire)
|
||||
const CAMPFIRE_DURATION := 300.0 # 营火持续 5 分钟
|
||||
const FOOD_BUFF_DURATION := 1800.0 # 食用增益 30 分钟 (1800 秒)
|
||||
|
||||
# 鱼类与烹饪食谱
|
||||
const RECIPES: Dictionary = {
|
||||
27802: { # 生鲤鱼 -> 烤鲤鱼
|
||||
"cooked_vnum": 27832,
|
||||
"cooked_name": "烤鲤鱼",
|
||||
"buff_id": "buff_grilled_carp",
|
||||
"stat_key": "max_hp",
|
||||
"stat_val": 1000,
|
||||
"desc": "最大生命上限 +1000,持续 30 分钟"
|
||||
},
|
||||
27803: { # 生草鱼 -> 烤草鱼
|
||||
"cooked_vnum": 27833,
|
||||
"cooked_name": "烤草鱼",
|
||||
"buff_id": "buff_grilled_grass_carp",
|
||||
"stat_key": "move_speed",
|
||||
"stat_val": 15,
|
||||
"desc": "移动速度 +15,持续 30 分钟"
|
||||
},
|
||||
27815: { # 生红鳟鱼 -> 烤红鳟鱼
|
||||
"cooked_vnum": 27845,
|
||||
"cooked_name": "烤红鳟鱼",
|
||||
"buff_id": "buff_grilled_trout",
|
||||
"stat_key": "atk",
|
||||
"stat_val": 50,
|
||||
"desc": "攻击力 +50,持续 30 分钟"
|
||||
}
|
||||
}
|
||||
|
||||
# 烤鱼物品映射
|
||||
const COOKED_FOODS: Dictionary = {
|
||||
27832: {"buff_id": "buff_grilled_carp", "name": "烤鲤鱼", "stat_key": "max_hp", "stat_val": 1000},
|
||||
27833: {"buff_id": "buff_grilled_grass_carp", "name": "烤草鱼", "stat_key": "move_speed", "stat_val": 15},
|
||||
27845: {"buff_id": "buff_grilled_trout", "name": "烤红鳟鱼", "stat_key": "atk", "stat_val": 50}
|
||||
}
|
||||
|
||||
var is_campfire_active: bool = false
|
||||
var campfire_timer: float = 0.0
|
||||
var campfire_pos: Vector3 = Vector3.ZERO
|
||||
|
||||
# 活跃中的食物 Buff: buff_id -> { "remaining": float, "stat_key": String, "stat_val": int }
|
||||
var active_buffs: Dictionary = {}
|
||||
|
||||
# 点燃野外营火
|
||||
func light_campfire(pos: Vector3, inventory: Array) -> Dictionary:
|
||||
var kit_slot = -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == CAMPFIRE_KIT_VNUM:
|
||||
kit_slot = i
|
||||
break
|
||||
|
||||
if kit_slot == -1:
|
||||
return {"ok": false, "reason": "NO_CAMPFIRE_KIT", "msg": "背包中没有【营火工具】(27600)!"}
|
||||
|
||||
# 消耗 1 个营火工具
|
||||
var item = inventory[kit_slot]
|
||||
var cnt = int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[kit_slot] = null
|
||||
|
||||
is_campfire_active = true
|
||||
campfire_timer = CAMPFIRE_DURATION
|
||||
campfire_pos = pos
|
||||
|
||||
campfire_lit.emit(pos, CAMPFIRE_DURATION)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"pos": pos,
|
||||
"duration": CAMPFIRE_DURATION,
|
||||
"msg": "营火已点燃!散发着温暖火光,可在此烧烤烹饪渔获!"
|
||||
}
|
||||
|
||||
# 在营火旁烹饪烤鱼
|
||||
func cook_fish(slot: int, inventory: Array) -> Dictionary:
|
||||
if not is_campfire_active:
|
||||
return {"ok": false, "reason": "NO_ACTIVE_CAMPFIRE", "msg": "周围没有生起的营火,无法烹饪!"}
|
||||
|
||||
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var raw_item = inventory[slot]
|
||||
var raw_vnum = int(raw_item.get("vnum", 0))
|
||||
|
||||
if not RECIPES.has(raw_vnum):
|
||||
return {"ok": false, "reason": "NOT_COOKABLE", "msg": "此物品无法在营火上烹饪!"}
|
||||
|
||||
var r = RECIPES[raw_vnum]
|
||||
# 消耗 1 条生鱼
|
||||
var cnt = int(raw_item.get("count", 1))
|
||||
if cnt > 1:
|
||||
raw_item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot] = null
|
||||
|
||||
# 入包烤鱼
|
||||
var cooked_vnum: int = r["cooked_vnum"]
|
||||
var cooked_name: String = r["cooked_name"]
|
||||
|
||||
# 寻找可叠加的空格或同类
|
||||
var placed := false
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == cooked_vnum:
|
||||
inventory[i]["count"] = int(inventory[i].get("count", 1)) + 1
|
||||
placed = true
|
||||
break
|
||||
|
||||
if not placed:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = {
|
||||
"vnum": cooked_vnum,
|
||||
"name": cooked_name,
|
||||
"count": 1
|
||||
}
|
||||
placed = true
|
||||
break
|
||||
|
||||
fish_cooked.emit(raw_vnum, cooked_vnum, cooked_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"cooked_name": cooked_name,
|
||||
"desc": r["desc"],
|
||||
"msg": "滋滋作响!成功烤制出色香味俱全的【%s】!" % cooked_name
|
||||
}
|
||||
|
||||
# 食用烤鱼获得 30 分钟强力 Buff
|
||||
func eat_cooked_fish(slot: int, inventory: Array, player_data: Dictionary) -> Dictionary:
|
||||
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item = inventory[slot]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
|
||||
if not COOKED_FOODS.has(vnum):
|
||||
return {"ok": false, "reason": "NOT_EDIBLE_COOKED_FISH"}
|
||||
|
||||
var cfg = COOKED_FOODS[vnum]
|
||||
var bid: String = cfg["buff_id"]
|
||||
var stat_k: String = cfg["stat_key"]
|
||||
var stat_v: int = cfg["stat_val"]
|
||||
|
||||
# 消耗 1 条烤鱼
|
||||
var cnt = int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot] = null
|
||||
|
||||
# 如果已有同类 Buff,先刷新时长并避免重复叠加属性
|
||||
if active_buffs.has(bid):
|
||||
active_buffs[bid]["remaining"] = FOOD_BUFF_DURATION
|
||||
else:
|
||||
player_data[stat_k] = int(player_data.get(stat_k, 0)) + stat_v
|
||||
active_buffs[bid] = {
|
||||
"remaining": FOOD_BUFF_DURATION,
|
||||
"stat_key": stat_k,
|
||||
"stat_val": stat_v,
|
||||
"name": cfg["name"]
|
||||
}
|
||||
|
||||
food_buff_applied.emit(bid, cfg["name"], FOOD_BUFF_DURATION)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"buff_id": bid,
|
||||
"buff_name": cfg["name"],
|
||||
"stat_key": stat_k,
|
||||
"stat_val": stat_v,
|
||||
"current_stat": player_data[stat_k],
|
||||
"msg": "品尝了美味的【%s】!精神焕发,获得了【%s +%d】持续 30 分钟!" % [cfg["name"], stat_k, stat_v]
|
||||
}
|
||||
|
||||
# 帧心跳更新营火与食物 Buff
|
||||
func update(delta: float, player_data: Dictionary) -> void:
|
||||
# 营火计时
|
||||
if is_campfire_active:
|
||||
campfire_timer -= delta
|
||||
if campfire_timer <= 0.0:
|
||||
is_campfire_active = false
|
||||
campfire_extinguished.emit()
|
||||
|
||||
# 食物 Buff 计时与过期反扣
|
||||
var expired_ids := []
|
||||
for bid in active_buffs.keys():
|
||||
var b = active_buffs[bid]
|
||||
b["remaining"] -= delta
|
||||
if b["remaining"] <= 0.0:
|
||||
expired_ids.append(bid)
|
||||
|
||||
for bid in expired_ids:
|
||||
var b = active_buffs[bid]
|
||||
var stat_k = b["stat_key"]
|
||||
var stat_v = b["stat_val"]
|
||||
player_data[stat_k] = max(0, int(player_data.get(stat_k, 0)) - stat_v)
|
||||
active_buffs.erase(bid)
|
||||
food_buff_expired.emit(bid)
|
||||
@@ -1 +0,0 @@
|
||||
uid://bl0claww12l2
|
||||
@@ -1,213 +0,0 @@
|
||||
# fishing_mining_system.gd —— 钓鱼与采矿休闲生活系统(40250 官方 1:1 对齐)
|
||||
# 对齐源码:
|
||||
# - 40250 server/game/src/fishing.cpp:180-360 (钓竿、鱼饵、钓鱼状态机、开鱼与蚌壳珍珠)
|
||||
# - 40250 server/game/src/mining.cpp:50-180 (矿镐、矿脉 NPC、采矿倒计时与原石产出)
|
||||
class_name FishingMiningSystem
|
||||
extends RefCounted
|
||||
|
||||
# 核心道具 Vnum 常量
|
||||
const VNUM_ROD_BASE := 27400 # 钓竿 +0
|
||||
const VNUM_BAIT_EARTHWORM := 27800 # 鱼饵 (蚯蚓)
|
||||
const VNUM_BAIT_PASTE := 27801 # 鱼饵 (面团)
|
||||
|
||||
const VNUM_FISHBONE := 27990 # 鱼骨
|
||||
const VNUM_CLAM := 27987 # 蚌壳
|
||||
const VNUM_STONE_PIECE := 27991 # 碎石片
|
||||
const VNUM_WHITE_PEARL := 27992 # 白珍珠
|
||||
const VNUM_BLUE_PEARL := 27993 # 蓝珍珠
|
||||
const VNUM_BLOOD_PEARL := 27994 # 血红珍珠
|
||||
|
||||
const VNUM_PICKAXE_BASE := 29101 # 矿镐 +0
|
||||
|
||||
# 经典鱼类列表
|
||||
const FISH_TABLE := [
|
||||
{"vnum": 27802, "name": "金鲫鱼"},
|
||||
{"vnum": 27803, "name": "鲤鱼"},
|
||||
{"vnum": 27804, "name": "鲑鱼"},
|
||||
{"vnum": 27805, "name": "鳟鱼"},
|
||||
{"vnum": 27806, "name": "鲶鱼"},
|
||||
]
|
||||
|
||||
# 矿脉 NPC 与对应原石产出表 (40250 mining.cpp)
|
||||
const VEIN_TABLE := {
|
||||
20047: {"name": "钻石矿脉", "ore_vnum": 50601, "ore_name": "钻石原石"},
|
||||
20048: {"name": "琥珀矿脉", "ore_vnum": 50602, "ore_name": "琥珀原石"},
|
||||
20049: {"name": "化石木", "ore_vnum": 50603, "ore_name": "化石原石"},
|
||||
20050: {"name": "铜矿脉", "ore_vnum": 50604, "ore_name": "铜原石"},
|
||||
20051: {"name": "银矿脉", "ore_vnum": 50605, "ore_name": "银原石"},
|
||||
20052: {"name": "金矿脉", "ore_vnum": 50606, "ore_name": "金原石"},
|
||||
20053: {"name": "玉矿脉", "ore_vnum": 50607, "ore_name": "玉原石"},
|
||||
20054: {"name": "黑檀木", "ore_vnum": 50608, "ore_name": "黑檀原石"},
|
||||
20055: {"name": "珍珠矿脉", "ore_vnum": 50609, "ore_name": "珍珠原石"},
|
||||
20056: {"name": "白金矿脉", "ore_vnum": 50610, "ore_name": "白金原石"},
|
||||
20057: {"name": "水晶矿脉", "ore_vnum": 50611, "ore_name": "水晶原石"},
|
||||
20058: {"name": "紫晶矿脉", "ore_vnum": 50612, "ore_name": "紫晶原石"},
|
||||
20059: {"name": "天堂泪矿脉", "ore_vnum": 50613, "ore_name": "天堂泪原石"},
|
||||
}
|
||||
|
||||
# --- 钓鱼系统 ---
|
||||
|
||||
## 检查物品是否为钓竿
|
||||
static func is_fishing_rod(vnum: int) -> bool:
|
||||
return vnum >= 27400 and vnum <= 27590
|
||||
|
||||
## 检查物品是否为鱼饵
|
||||
static func is_bait(vnum: int) -> bool:
|
||||
return vnum == VNUM_BAIT_EARTHWORM or vnum == VNUM_BAIT_PASTE
|
||||
|
||||
## 检查是否为鲜鱼
|
||||
static func is_live_fish(vnum: int) -> bool:
|
||||
return vnum >= 27802 and vnum <= 27832
|
||||
|
||||
## 检查是否为蚌壳
|
||||
static func is_clam(vnum: int) -> bool:
|
||||
return vnum == VNUM_CLAM
|
||||
|
||||
## 钓鱼判定 (40250 fishing.cpp:210)
|
||||
## has_bait: 是否已挂饵; rod_level: 钓竿等级 (0..9)
|
||||
static func perform_fishing(has_bait: bool, rod_level := 0, force_success := false) -> Dictionary:
|
||||
if not has_bait:
|
||||
return {"ok": false, "code": "NO_BAIT", "msg": "钓钩上没有鱼饵,无法钓鱼!"}
|
||||
|
||||
# 40250 基础钓鱼成功率:40% + 钓竿等级 * 3%
|
||||
var base_rate := 40 + rod_level * 3
|
||||
var roll := 0 if force_success else (randi() % 100 + 1)
|
||||
|
||||
if not force_success and roll > base_rate:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": "FISH_ESCAPED",
|
||||
"consumed_bait": true,
|
||||
"msg": "鱼饵被咬走了,但鱼溜掉了!"
|
||||
}
|
||||
|
||||
# 成功钓上鲜鱼
|
||||
var fish_idx := randi() % FISH_TABLE.size()
|
||||
var fish: Dictionary = FISH_TABLE[fish_idx]
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "SUCCESS",
|
||||
"consumed_bait": true,
|
||||
"fish_vnum": fish["vnum"],
|
||||
"fish_name": fish["name"],
|
||||
"msg": "钓上了一条活蹦乱跳的【%s】!" % fish["name"]
|
||||
}
|
||||
|
||||
## 打开鲜鱼 (40250 fishing.cpp:330)
|
||||
## 鲜鱼开出:死鱼 (50%)、鱼骨 27990 (30%)、蚌壳 27987 (10%)、滑落逃脱 (10%)
|
||||
static func open_live_fish(fish_vnum: int) -> Dictionary:
|
||||
var roll := randi() % 100
|
||||
if roll < 50:
|
||||
# 获得死鱼(死鱼 Vnum = 活鱼 + 31)
|
||||
var dead_vnum := fish_vnum + 31
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "DEAD_FISH",
|
||||
"result_vnum": dead_vnum,
|
||||
"msg": "得到了一条死鱼,可以在篝火上烤熟食用。"
|
||||
}
|
||||
elif roll < 80:
|
||||
# 获得鱼骨 (精炼核心材料)
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "FISHBONE",
|
||||
"result_vnum": VNUM_FISHBONE,
|
||||
"msg": "剖开鲜鱼,在鱼肚里找到了一根坚硬的【鱼骨】!"
|
||||
}
|
||||
elif roll < 90:
|
||||
# 获得珍贵蚌壳
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "CLAM",
|
||||
"result_vnum": VNUM_CLAM,
|
||||
"msg": "不可思议!在鱼肚里发现了一个紧闭的【蚌壳】!"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": "ESCAPED",
|
||||
"msg": "鲜鱼一摆尾巴跳进水里溜走了!"
|
||||
}
|
||||
|
||||
## 打开蚌壳 (40250 fishing.cpp 蚌壳产出珍珠算法)
|
||||
## 碎石 (50%)、白珍珠 27992 (25%)、蓝珍珠 27993 (15%)、血红珍珠 27994 (10%)
|
||||
static func open_clam(force_pearl_type := 0) -> Dictionary:
|
||||
if force_pearl_type > 0:
|
||||
var pname := "白珍珠" if force_pearl_type == VNUM_WHITE_PEARL else ("蓝珍珠" if force_pearl_type == VNUM_BLUE_PEARL else "血红珍珠")
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "PEARL",
|
||||
"result_vnum": force_pearl_type,
|
||||
"msg": "蚌壳里散发着璀璨光芒,开出了珍稀的【%s】!" % pname
|
||||
}
|
||||
|
||||
var roll := randi() % 100
|
||||
if roll < 50:
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "STONE",
|
||||
"result_vnum": VNUM_STONE_PIECE,
|
||||
"msg": "蚌壳里只有一块无用的碎石片。"
|
||||
}
|
||||
elif roll < 75:
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "PEARL",
|
||||
"result_vnum": VNUM_WHITE_PEARL,
|
||||
"msg": "蚌壳里开出了一颗温润光泽的【白珍珠】!"
|
||||
}
|
||||
elif roll < 90:
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "PEARL",
|
||||
"result_vnum": VNUM_BLUE_PEARL,
|
||||
"msg": "蚌壳里开出了一颗幽蓝剔透的【蓝珍珠】!"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "PEARL",
|
||||
"result_vnum": VNUM_BLOOD_PEARL,
|
||||
"msg": "天赐奇宝!蚌壳里开出了一颗极品【血红珍珠】!"
|
||||
}
|
||||
|
||||
# --- 采矿系统 ---
|
||||
|
||||
## 检查是否为矿镐
|
||||
static func is_pickaxe(vnum: int) -> bool:
|
||||
return vnum >= 29101 and vnum <= 29110
|
||||
|
||||
## 检查 NPC 是否为矿脉
|
||||
static func is_vein(npc_vnum: int) -> bool:
|
||||
return VEIN_TABLE.has(npc_vnum)
|
||||
|
||||
## 获取矿脉信息
|
||||
static func get_vein_info(npc_vnum: int) -> Dictionary:
|
||||
return VEIN_TABLE.get(npc_vnum, {})
|
||||
|
||||
## 采矿判定 (40250 mining.cpp:85)
|
||||
## pickaxe_level: 矿镐等级 (0..9)
|
||||
static func perform_mining(vein_vnum: int, pickaxe_level := 0, force_success := false) -> Dictionary:
|
||||
if not is_vein(vein_vnum):
|
||||
return {"ok": false, "code": "NOT_A_VEIN", "msg": "目标不是有效的矿脉!"}
|
||||
|
||||
var vein: Dictionary = VEIN_TABLE[vein_vnum]
|
||||
var base_rate := 35 + pickaxe_level * 4
|
||||
var roll := 0 if force_success else (randi() % 100 + 1)
|
||||
|
||||
if not force_success and roll > base_rate:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": "MINING_FAIL",
|
||||
"msg": "用力敲击了矿脉,但没有采集到有效矿石。"
|
||||
}
|
||||
|
||||
var count := 1 + (randi() % 3) # 1..3 个原石
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "SUCCESS",
|
||||
"ore_vnum": vein["ore_vnum"],
|
||||
"ore_name": vein["ore_name"],
|
||||
"count": count,
|
||||
"msg": "成功从【%s】采集到了 %d 个【%s】!" % [vein["name"], count, vein["ore_name"]]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://ceequyq2hytjy
|
||||
@@ -1,307 +0,0 @@
|
||||
# fishing_rod_reel_system.gd —— Metin2 40250 官方钓鱼抛竿拉杆与浮漂反应小游戏机制 1:1
|
||||
# 100% 对照 40250 服务端 fishing.cpp (CreateFishingEvent, Take, FishingPractice, RealRefineRod), fishing.h
|
||||
class_name FishingRodReelSystem
|
||||
extends RefCounted
|
||||
|
||||
signal line_cast(rod_level: int, bait_name: String)
|
||||
signal fish_bite(reaction_time_window: float)
|
||||
signal fish_caught(fish_vnum: int, fish_name: String, fish_length_cm: float, practice_points: int)
|
||||
signal fish_escaped(reason: String, practice_points: int)
|
||||
signal rod_refined(new_vnum: int, success: bool, new_level: int)
|
||||
signal fishing_error(reason: String)
|
||||
|
||||
# 40250 官方钓竿 Vnum (27400 基础钓鱼竿+0 ~ 27500 +9)
|
||||
const BASE_ROD_VNUM := 27400 # 钓鱼竿+0
|
||||
const MAX_ROD_LEVEL := 9
|
||||
|
||||
# 40250 官方饵料 Vnum
|
||||
const VNUM_PASTE := 27800 # 鱼饵面团 (Paste)
|
||||
const VNUM_EARTHWORM := 27801 # 蚯蚓 (Earthworm)
|
||||
|
||||
# 40250 渔夫 NPC
|
||||
const NPC_FISHERMAN := 9009
|
||||
|
||||
# 钓竿进阶最大熟练度点数表与成功率 (对照 item_proto Value(2) 与 Value(3))
|
||||
const ROD_LEVEL_DATA: Dictionary = {
|
||||
0: {"max_points": 10, "refine_chance": 100, "base_catch_rate": 0.50},
|
||||
1: {"max_points": 20, "refine_chance": 90, "base_catch_rate": 0.55},
|
||||
2: {"max_points": 40, "refine_chance": 80, "base_catch_rate": 0.60},
|
||||
3: {"max_points": 80, "refine_chance": 70, "base_catch_rate": 0.65},
|
||||
4: {"max_points": 120, "refine_chance": 60, "base_catch_rate": 0.70},
|
||||
5: {"max_points": 160, "refine_chance": 50, "base_catch_rate": 0.75},
|
||||
6: {"max_points": 200, "refine_chance": 40, "base_catch_rate": 0.80},
|
||||
7: {"max_points": 250, "refine_chance": 30, "base_catch_rate": 0.85},
|
||||
8: {"max_points": 300, "refine_chance": 20, "base_catch_rate": 0.90},
|
||||
9: {"max_points": 350, "refine_chance": 10, "base_catch_rate": 0.95}
|
||||
}
|
||||
|
||||
# 常见垂钓渔获池 (对照 fishing.cpp)
|
||||
const CATCH_POOL: Array[Dictionary] = [
|
||||
{"vnum": 27803, "name": "鲈鱼 (Sudak)", "min_len": 15.0, "max_len": 35.0, "weight": 35},
|
||||
{"vnum": 27806, "name": "鲤鱼 (Sazan)", "min_len": 25.0, "max_len": 65.0, "weight": 25},
|
||||
{"vnum": 27808, "name": "草鱼 (Ot Sazanı)", "min_len": 20.0, "max_len": 55.0, "weight": 20},
|
||||
{"vnum": 27810, "name": "水针鱼 (Zargana)", "min_len": 30.0, "max_len": 70.0, "weight": 10},
|
||||
{"vnum": 27817, "name": "泥鳅 (Çopra)", "min_len": 8.0, "max_len": 18.0, "weight": 5},
|
||||
{"vnum": 70201, "name": "漂白水 (Bleach)", "min_len": 0.0, "max_len": 0.0, "weight": 3},
|
||||
{"vnum": 27987, "name": "珍稀珍珠蚌 (Clam)", "min_len": 5.0, "max_len": 12.0, "weight": 2}
|
||||
]
|
||||
|
||||
# 当前垂钓状态
|
||||
var is_fishing: bool = false
|
||||
var has_bait: bool = false
|
||||
var current_bait_vnum: int = 0
|
||||
|
||||
var rod_level: int = 0
|
||||
var rod_practice_points: int = 0
|
||||
|
||||
var bite_timer: float = 0.0
|
||||
var is_bite_active: bool = false
|
||||
var bite_reaction_time_left: float = 0.0
|
||||
const REACTION_WINDOW_SECONDS := 3.0 # 3 秒拉杆反应时间窗口
|
||||
|
||||
# ==========================================
|
||||
# 1. 挂饵与抛竿 (fishing.cpp: CreateFishingEvent)
|
||||
# ==========================================
|
||||
|
||||
# 穿戴/设定当前鱼竿
|
||||
func equip_rod(level: int, current_points: int = 0) -> void:
|
||||
rod_level = clamp(level, 0, MAX_ROD_LEVEL)
|
||||
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
|
||||
rod_practice_points = clamp(current_points, 0, max_p)
|
||||
|
||||
# 挂上鱼饵 (面团 27800 或 蚯蚓 27801)
|
||||
func attach_bait(inventory: Array, bait_slot: int) -> Dictionary:
|
||||
if is_fishing:
|
||||
fishing_error.emit("ALREADY_FISHING")
|
||||
return {"ok": false, "reason": "ALREADY_FISHING", "msg": "正在垂钓中,无法更换鱼饵!"}
|
||||
|
||||
if bait_slot < 0 or bait_slot >= inventory.size() or inventory[bait_slot] == null:
|
||||
fishing_error.emit("INVALID_SLOT")
|
||||
return {"ok": false, "reason": "INVALID_SLOT", "msg": "未找到指定的鱼饵!"}
|
||||
|
||||
var item = inventory[bait_slot]
|
||||
var vnum = int(item.get("vnum", 0))
|
||||
if vnum != VNUM_PASTE and vnum != VNUM_EARTHWORM:
|
||||
fishing_error.emit("NOT_BAIT")
|
||||
return {"ok": false, "reason": "NOT_BAIT", "msg": "该物品无法作为鱼饵挂在鱼钩上!"}
|
||||
|
||||
_consume_item_one(inventory, bait_slot)
|
||||
has_bait = true
|
||||
current_bait_vnum = vnum
|
||||
var bait_name = "鱼饵面团" if vnum == VNUM_PASTE else "蚯蚓"
|
||||
return {"ok": true, "bait_vnum": vnum, "bait_name": bait_name, "msg": "将【%s】小心地挂在了鱼钩上。" % bait_name}
|
||||
|
||||
# 抛竿垂钓
|
||||
func cast_line(forced_wait_time: float = -1.0) -> Dictionary:
|
||||
if not has_bait:
|
||||
fishing_error.emit("NO_BAIT")
|
||||
return {"ok": false, "reason": "NO_BAIT", "msg": "鱼钩上空无一物!请先挂上蚯蚓或面团饵料。"}
|
||||
|
||||
if is_fishing:
|
||||
fishing_error.emit("ALREADY_FISHING")
|
||||
return {"ok": false, "reason": "ALREADY_FISHING", "msg": "钓线已经抛入水中,静候鱼儿上钩。"}
|
||||
|
||||
is_fishing = true
|
||||
is_bite_active = false
|
||||
has_bait = false # 抛竿时消耗鱼钩上的鱼饵
|
||||
|
||||
# 40250 等待上钩时间: 2~7 秒
|
||||
bite_timer = forced_wait_time if forced_wait_time > 0.0 else randf_range(2.0, 7.0)
|
||||
bite_reaction_time_left = 0.0
|
||||
|
||||
var bait_name = "鱼饵面团" if current_bait_vnum == VNUM_PASTE else "蚯蚓"
|
||||
line_cast.emit(rod_level, bait_name)
|
||||
return {"ok": true, "msg": "钓竿轻扬,鱼线带着微澜划破水面,沉入了平静的湖心……"}
|
||||
|
||||
# ==========================================
|
||||
# 2. 反应窗口拉杆提竿 (fishing.cpp: Take)
|
||||
# ==========================================
|
||||
func pull_rod(inventory: Array, forced_catch_idx: int = -1, forced_success: bool = false) -> Dictionary:
|
||||
if not is_fishing:
|
||||
fishing_error.emit("NOT_FISHING")
|
||||
return {"ok": false, "reason": "NOT_FISHING", "msg": "你并未处于垂钓状态!"}
|
||||
|
||||
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
|
||||
|
||||
# 提前起竿 (鱼儿未咬钩)
|
||||
if not is_bite_active:
|
||||
is_fishing = false
|
||||
bite_timer = 0.0
|
||||
# 哪怕失败亦获得 1 点熟练度练习 (FishingPractice)
|
||||
if rod_practice_points < max_p:
|
||||
rod_practice_points += 1
|
||||
fish_escaped.emit("PULLED_TOO_EARLY", rod_practice_points)
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "PULLED_TOO_EARLY",
|
||||
"practice_points": rod_practice_points,
|
||||
"msg": "起竿太早了!鱼钩划破空荡荡的水面,鱼饵不翼而飞。"
|
||||
}
|
||||
|
||||
# 鱼已咬钩:计算捕获概率
|
||||
is_fishing = false
|
||||
is_bite_active = false
|
||||
|
||||
# 熟练度 +1
|
||||
if rod_practice_points < max_p:
|
||||
rod_practice_points += 1
|
||||
|
||||
var base_rate = float(ROD_LEVEL_DATA[rod_level]["base_catch_rate"])
|
||||
if current_bait_vnum == VNUM_EARTHWORM:
|
||||
base_rate += 0.05 # 蚯蚓比面团成功率多 5%
|
||||
|
||||
var roll = randf()
|
||||
var success = (roll <= base_rate) or forced_success
|
||||
|
||||
if success:
|
||||
# 捕获成功:从鱼池选取
|
||||
var picked_fish: Dictionary = {}
|
||||
if forced_catch_idx >= 0 and forced_catch_idx < CATCH_POOL.size():
|
||||
picked_fish = CATCH_POOL[forced_catch_idx]
|
||||
else:
|
||||
var total_w := 0
|
||||
for f in CATCH_POOL:
|
||||
total_w += int(f["weight"])
|
||||
var r = randi_range(1, total_w)
|
||||
var cur := 0
|
||||
for f in CATCH_POOL:
|
||||
cur += int(f["weight"])
|
||||
if r <= cur:
|
||||
picked_fish = f
|
||||
break
|
||||
|
||||
var fish_vnum = int(picked_fish["vnum"])
|
||||
var fish_name = str(picked_fish["name"])
|
||||
var length_cm = randf_range(float(picked_fish["min_len"]), float(picked_fish["max_len"]))
|
||||
|
||||
_give_or_stack_item(inventory, fish_vnum, 1)
|
||||
fish_caught.emit(fish_vnum, fish_name, length_cm, rod_practice_points)
|
||||
return {
|
||||
"ok": true,
|
||||
"fish_vnum": fish_vnum,
|
||||
"fish_name": fish_name,
|
||||
"length_cm": length_cm,
|
||||
"practice_points": rod_practice_points,
|
||||
"msg": "水花四溅!你收紧鱼线,成功钓上了一条 %.1f cm 的【%s】!" % [length_cm, fish_name]
|
||||
}
|
||||
else:
|
||||
# 挣脱脱钩
|
||||
fish_escaped.emit("FISH_GOT_AWAY", rod_practice_points)
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "FISH_GOT_AWAY",
|
||||
"practice_points": rod_practice_points,
|
||||
"msg": "大鱼猛烈摆尾,挣脱鱼钩游回了深水,只留下一串水泡……"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 3. 渔夫 NPC 鱼竿升星锻造 (fishing.cpp: RealRefineRod)
|
||||
# ==========================================
|
||||
func can_refine_rod() -> bool:
|
||||
if rod_level >= MAX_ROD_LEVEL:
|
||||
return false
|
||||
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
|
||||
return rod_practice_points >= max_p
|
||||
|
||||
func refine_rod_at_fisherman(forced_roll: int = -1) -> Dictionary:
|
||||
if not can_refine_rod():
|
||||
fishing_error.emit("PRACTICE_NOT_MAXED")
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "PRACTICE_NOT_MAXED",
|
||||
"msg": "鱼竿熟练度尚未练满!需要 %d/%d 熟练度点数方可前往渔夫处升级。" % [
|
||||
rod_practice_points, ROD_LEVEL_DATA[rod_level]["max_points"]
|
||||
]
|
||||
}
|
||||
|
||||
var chance = int(ROD_LEVEL_DATA[rod_level]["refine_chance"])
|
||||
var r = forced_roll if forced_roll > 0 else randi_range(1, 100)
|
||||
|
||||
if r <= chance:
|
||||
# 锻造升级成功
|
||||
rod_level += 1
|
||||
rod_practice_points = 0
|
||||
var new_vnum = BASE_ROD_VNUM + rod_level
|
||||
rod_refined.emit(new_vnum, true, rod_level)
|
||||
return {
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"new_level": rod_level,
|
||||
"new_vnum": new_vnum,
|
||||
"msg": "渔夫巧手翻飞,金光闪烁!你的钓鱼竿成功进阶为【钓鱼竿+%d】!" % rod_level
|
||||
}
|
||||
else:
|
||||
# 锻造失败:降 1 级 (40250 官方机制)
|
||||
rod_level = max(0, rod_level - 1)
|
||||
rod_practice_points = 0
|
||||
var new_vnum = BASE_ROD_VNUM + rod_level
|
||||
rod_refined.emit(new_vnum, false, rod_level)
|
||||
return {
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"new_level": rod_level,
|
||||
"new_vnum": new_vnum,
|
||||
"msg": "精炼失败了!鱼竿受损,品质降为了【钓鱼竿+%d】。" % rod_level
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 4. 心跳推进与超时判定
|
||||
# ==========================================
|
||||
func update(delta: float) -> void:
|
||||
if not is_fishing:
|
||||
return
|
||||
|
||||
if not is_bite_active:
|
||||
# 等待鱼咬钩
|
||||
bite_timer -= delta
|
||||
if bite_timer <= 0.0:
|
||||
is_bite_active = true
|
||||
bite_reaction_time_left = REACTION_WINDOW_SECONDS
|
||||
fish_bite.emit(REACTION_WINDOW_SECONDS)
|
||||
else:
|
||||
# 鱼咬钩等待提竿窗口
|
||||
bite_reaction_time_left -= delta
|
||||
if bite_reaction_time_left <= 0.0:
|
||||
# 超时逃跑
|
||||
is_fishing = false
|
||||
is_bite_active = false
|
||||
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
|
||||
if rod_practice_points < max_p:
|
||||
rod_practice_points += 1
|
||||
fish_escaped.emit("REACTION_TIMEOUT", rod_practice_points)
|
||||
|
||||
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})
|
||||
|
||||
# 序列化与反序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"rod_level": rod_level,
|
||||
"rod_practice_points": rod_practice_points,
|
||||
"has_bait": has_bait,
|
||||
"current_bait_vnum": current_bait_vnum
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
rod_level = int(data.get("rod_level", 0))
|
||||
rod_practice_points = int(data.get("rod_practice_points", 0))
|
||||
has_bait = bool(data.get("has_bait", false))
|
||||
current_bait_vnum = int(data.get("current_bait_vnum", 0))
|
||||
@@ -1 +0,0 @@
|
||||
uid://b1ti6751mgdy2
|
||||
@@ -1,196 +0,0 @@
|
||||
# flame_dungeon_razador_system.gd —— Metin2 40250 经典红龙烈焰神殿与拉扎多尔炼狱试炼 1:1
|
||||
# 对照 40250 服务端 flame_dungeon.start.1.script, quest/flame_dungeon*, char_item.cpp
|
||||
class_name FlameDungeonRazadorSystem
|
||||
extends RefCounted
|
||||
|
||||
signal dungeon_entered(time_limit: float)
|
||||
signal stage_advanced(new_stage: int, stage_desc: String)
|
||||
signal altar_lit(altars_lit: int, total_altars: int)
|
||||
signal razador_spawned(mob_vnum: int)
|
||||
signal razador_slain(drop_rewards: Array)
|
||||
signal dungeon_completed()
|
||||
signal dungeon_failed(reason: String)
|
||||
|
||||
const NPC_AM_HEH_WATCHER := 20385 # 烈焰守望者 (Am-heh Watcher)
|
||||
const MIN_ENTRY_LEVEL := 100 # 进入等级门槛 100 级
|
||||
const VNUM_TICKET_71095 := 71095 # 通行证
|
||||
const VNUM_TICKET_71130 := 71130 # 烈焰通行证
|
||||
|
||||
const MOB_RAZADOR := 6091 # 烈焰魔王拉扎多尔 (Razador)
|
||||
const VNUM_RAZADOR_CHEST := 50186 # 拉扎多尔宝箱
|
||||
|
||||
const DUNGEON_TIME_LIMIT := 2700.0 # 45 分钟 (2700 秒)
|
||||
|
||||
# 7 大炼狱阶段定义
|
||||
const STAGES_DESC: Array = [
|
||||
"阶段 1: 清剿外围烈焰魔群",
|
||||
"阶段 2: 寻觅正确齿轮诛灭引火者",
|
||||
"阶段 3: 破灭炼狱灭世魔石",
|
||||
"阶段 4: 诛灭四极炼狱护法",
|
||||
"阶段 5: 使用真·玛阿特之石点燃五方火盆",
|
||||
"阶段 6: 击碎烈焰火轮魔石",
|
||||
"阶段 7: 进军神殿核心,决战烈焰魔王拉扎多尔!"
|
||||
]
|
||||
|
||||
var is_active: bool = false
|
||||
var current_stage: int = 1
|
||||
var time_remaining: float = 0.0
|
||||
var altars_lit_count: int = 0
|
||||
var razador_alive: bool = false
|
||||
|
||||
# 检票进入烈焰神殿 (40250 flame_dungeon.start.1.script)
|
||||
func enter_dungeon(player_level: int, inventory: Array) -> Dictionary:
|
||||
if is_active:
|
||||
return {"ok": false, "reason": "DUNGEON_ALREADY_ACTIVE", "msg": "神殿试炼已在进行中!"}
|
||||
|
||||
if player_level < MIN_ENTRY_LEVEL:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "LEVEL_TOO_LOW",
|
||||
"msg": "实力不足!开启拉扎多尔烈焰试炼需角色达到 %d 级。" % MIN_ENTRY_LEVEL
|
||||
}
|
||||
|
||||
# 检查并消耗 1 张通行证 (71095 或 71130)
|
||||
var ticket_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] != null:
|
||||
var v := int(inventory[i].get("vnum", 0))
|
||||
if v == VNUM_TICKET_71095 or v == VNUM_TICKET_71130:
|
||||
ticket_slot = i
|
||||
break
|
||||
|
||||
if ticket_slot == -1:
|
||||
return {"ok": false, "reason": "NO_ENTRY_TICKET", "msg": "守望者拦住了去路!需要持有【烈焰通行证】(71095/71130)。"}
|
||||
|
||||
var t_item: Dictionary = inventory[ticket_slot]
|
||||
var cnt := int(t_item.get("count", 1))
|
||||
if cnt > 1:
|
||||
t_item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[ticket_slot] = null
|
||||
|
||||
is_active = true
|
||||
current_stage = 1
|
||||
time_remaining = DUNGEON_TIME_LIMIT
|
||||
altars_lit_count = 0
|
||||
razador_alive = false
|
||||
|
||||
dungeon_entered.emit(DUNGEON_TIME_LIMIT)
|
||||
stage_advanced.emit(1, STAGES_DESC[0])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"time_limit": DUNGEON_TIME_LIMIT,
|
||||
"stage": 1,
|
||||
"stage_desc": STAGES_DESC[0],
|
||||
"msg": "通行证校验通过!烈焰之门轰然开启,限时 45 分钟挑战拉扎多尔!"
|
||||
}
|
||||
|
||||
# 推进阶段目标
|
||||
func advance_stage() -> Dictionary:
|
||||
if not is_active:
|
||||
return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
|
||||
|
||||
if current_stage < 7:
|
||||
current_stage += 1
|
||||
var desc: String = STAGES_DESC[current_stage - 1]
|
||||
|
||||
if current_stage == 7:
|
||||
# 召唤最终首领拉扎多尔
|
||||
razador_alive = true
|
||||
razador_spawned.emit(MOB_RAZADOR)
|
||||
|
||||
stage_advanced.emit(current_stage, desc)
|
||||
return {
|
||||
"ok": true,
|
||||
"current_stage": current_stage,
|
||||
"stage_desc": desc,
|
||||
"msg": "试炼突破!开启【%s】。" % desc
|
||||
}
|
||||
|
||||
return {"ok": false, "reason": "ALREADY_AT_FINAL_STAGE"}
|
||||
|
||||
# 阶段 5 点燃玛阿特火盆
|
||||
func light_fire_altar() -> Dictionary:
|
||||
if not is_active or current_stage != 5:
|
||||
return {"ok": false, "reason": "NOT_IN_ALTAR_STAGE"}
|
||||
|
||||
altars_lit_count = mini(5, altars_lit_count + 1)
|
||||
altar_lit.emit(altars_lit_count, 5)
|
||||
|
||||
if altars_lit_count >= 5:
|
||||
# 5 座火盆全部点燃,自动迈入第 6 阶段
|
||||
advance_stage()
|
||||
return {
|
||||
"ok": true,
|
||||
"altars_lit": 5,
|
||||
"stage_cleared": true,
|
||||
"msg": "五方火盆全部燃起纯阳烈焰!封印解除,进入下一阶段!"
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"altars_lit": altars_lit_count,
|
||||
"stage_cleared": false,
|
||||
"msg": "成功点燃 1 座火盆!当前进度:(%d / 5)。" % [altars_lit_count]
|
||||
}
|
||||
|
||||
# 斩杀烈焰魔王拉扎多尔
|
||||
func slay_razador(inventory: Array) -> Dictionary:
|
||||
if not is_active or not razador_alive:
|
||||
return {"ok": false, "reason": "RAZADOR_NOT_ALIVE"}
|
||||
|
||||
razador_alive = false
|
||||
|
||||
var rewards: Array = [
|
||||
{"vnum": VNUM_RAZADOR_CHEST, "name": "拉扎多尔宝箱", "count": 1},
|
||||
{"vnum": 25040, "name": "祝福卷轴", "count": 3},
|
||||
{"vnum": 51501, "name": "龙石炼金原石", "count": 2}
|
||||
]
|
||||
|
||||
for drop in rewards:
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = drop.duplicate(true)
|
||||
break
|
||||
|
||||
razador_slain.emit(rewards)
|
||||
|
||||
# 通关副本
|
||||
is_active = false
|
||||
time_remaining = 0.0
|
||||
dungeon_completed.emit()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"rewards": rewards,
|
||||
"msg": "伟大的征服!烈焰魔王拉扎多尔陨落!斩获【拉扎多尔宝箱】!"
|
||||
}
|
||||
|
||||
# 逻辑心跳更新
|
||||
func update(delta: float) -> void:
|
||||
if not is_active:
|
||||
return
|
||||
time_remaining -= delta
|
||||
if time_remaining <= 0.0:
|
||||
is_active = false
|
||||
razador_alive = false
|
||||
dungeon_failed.emit("TIMEOUT")
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"is_active": is_active,
|
||||
"current_stage": current_stage,
|
||||
"time_remaining": time_remaining,
|
||||
"altars_lit_count": altars_lit_count,
|
||||
"razador_alive": razador_alive
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
is_active = bool(data.get("is_active", false))
|
||||
current_stage = int(data.get("current_stage", 1))
|
||||
time_remaining = float(data.get("time_remaining", 0.0))
|
||||
altars_lit_count = int(data.get("altars_lit_count", 0))
|
||||
razador_alive = bool(data.get("razador_alive", false))
|
||||
@@ -1 +0,0 @@
|
||||
uid://bue5wpi4eyixl
|
||||
@@ -1,127 +0,0 @@
|
||||
# game_button_money_dialog_system.gd
|
||||
# 40250 官方 1:1 浮动任务栏状态红点通知与金币分堆丢弃输入窗
|
||||
# 对照: uigamebutton.py, gamewindow.py, uipickmoney.py, PickMoneyDialog.py
|
||||
class_name GameButtonMoneyDialogSystem
|
||||
extends RefCounted
|
||||
|
||||
# =========================================================================
|
||||
# 1. 浮动任务栏状态红点与操作按钮组 (GameButtonWindow)
|
||||
# =========================================================================
|
||||
class GameButtonManager extends RefCounted:
|
||||
var button_visible: Dictionary = {
|
||||
"STATUS": false,
|
||||
"SKILL": false,
|
||||
"QUEST": false,
|
||||
"HELP": false,
|
||||
"BUILD": false,
|
||||
"EXIT_OBSERVER": false
|
||||
}
|
||||
|
||||
var is_observer_mode: bool = false
|
||||
var last_chat_packet_sent: String = ""
|
||||
|
||||
signal button_visibility_changed(btn_name: String, visible: bool)
|
||||
signal observer_exit_triggered()
|
||||
|
||||
func check_game_buttons(stat_points: int, skill_active_points: int, play_time: int) -> void:
|
||||
# player.GetStatus(player.STAT) > 0 -> StatusPlusButton
|
||||
var show_status = stat_points > 0
|
||||
_set_button("STATUS", show_status)
|
||||
|
||||
# player.GetStatus(player.SKILL_ACTIVE) > 0 -> SkillPlusButton
|
||||
var show_skill = skill_active_points > 0
|
||||
_set_button("SKILL", show_skill)
|
||||
|
||||
# 0 == player.GetPlayTime() -> HelpButton
|
||||
var show_help = (play_time == 0)
|
||||
_set_button("HELP", show_help)
|
||||
|
||||
func set_quest_notice(has_unread: bool) -> void:
|
||||
_set_button("QUEST", has_unread)
|
||||
|
||||
func set_build_button(can_build: bool) -> void:
|
||||
_set_button("BUILD", can_build)
|
||||
|
||||
func set_observer_mode(enabled: bool) -> void:
|
||||
is_observer_mode = enabled
|
||||
_set_button("EXIT_OBSERVER", enabled)
|
||||
|
||||
func click_exit_observer() -> void:
|
||||
if is_observer_mode:
|
||||
last_chat_packet_sent = "/observer_exit"
|
||||
observer_exit_triggered.emit()
|
||||
|
||||
func _set_button(btn_name: String, is_show: bool) -> void:
|
||||
if button_visible.get(btn_name, false) != is_show:
|
||||
button_visible[btn_name] = is_show
|
||||
button_visibility_changed.emit(btn_name, is_show)
|
||||
|
||||
# =========================================================================
|
||||
# 2. 金币/物品分堆输入对话框 (PickMoneyDialog)
|
||||
# =========================================================================
|
||||
class PickMoneyDialog extends RefCounted:
|
||||
const DIALOG_WIDTH: int = 170
|
||||
const DIALOG_HEIGHT: int = 90
|
||||
|
||||
var is_open: bool = false
|
||||
var max_value: int = 0
|
||||
var unit_value: int = 1
|
||||
var current_input_text: String = "1"
|
||||
|
||||
var pos_x: int = 0
|
||||
var pos_y: int = 0
|
||||
|
||||
var last_accepted_value: int = 0
|
||||
|
||||
signal opened(max_val: int, unit_val: int, x: int, y: int)
|
||||
signal accepted(amount: int)
|
||||
signal closed()
|
||||
|
||||
func open_dialog(p_max_value: int, p_unit_value: int = 1, mouse_x: int = 0, mouse_y: int = 0, screen_w: int = 1024, screen_h: int = 768) -> void:
|
||||
max_value = p_max_value
|
||||
unit_value = p_unit_value
|
||||
current_input_text = str(unit_value)
|
||||
last_accepted_value = 0
|
||||
|
||||
# 40250 官方吸附定位计算
|
||||
var half_w = DIALOG_WIDTH / 2
|
||||
if mouse_x + half_w > screen_w:
|
||||
pos_x = screen_w - DIALOG_WIDTH
|
||||
elif mouse_x - half_w < 0:
|
||||
pos_x = 0
|
||||
else:
|
||||
pos_x = mouse_x - half_w
|
||||
|
||||
pos_y = mouse_y - DIALOG_HEIGHT - 20
|
||||
if pos_y < 0:
|
||||
pos_y = 10
|
||||
|
||||
is_open = true
|
||||
opened.emit(max_value, unit_value, pos_x, pos_y)
|
||||
|
||||
func input_text(text: String) -> void:
|
||||
current_input_text = text
|
||||
|
||||
func on_accept() -> bool:
|
||||
if not is_open:
|
||||
return false
|
||||
|
||||
var text = current_input_text.strip_edges()
|
||||
if text.is_valid_int():
|
||||
var val = text.to_int()
|
||||
if val > 0:
|
||||
val = mini(val, max_value)
|
||||
last_accepted_value = val
|
||||
accepted.emit(val)
|
||||
close_dialog()
|
||||
return true
|
||||
|
||||
close_dialog()
|
||||
return false
|
||||
|
||||
func close_dialog() -> void:
|
||||
is_open = false
|
||||
closed.emit()
|
||||
|
||||
var game_buttons: GameButtonManager = GameButtonManager.new()
|
||||
var money_dialog: PickMoneyDialog = PickMoneyDialog.new()
|
||||
@@ -1 +0,0 @@
|
||||
uid://dqah8cpuylspa
|
||||
@@ -1,207 +0,0 @@
|
||||
# gm_toolkit_system.gd —— Metin2 40250 单人单机内置 GM 控制台与命令解析器 1:1
|
||||
# 对照 40250 服务端 cmd_gm.cpp, cmd.cpp
|
||||
class_name GmToolkitSystem
|
||||
extends RefCounted
|
||||
|
||||
signal command_executed(cmd: String, success: bool, result_msg: String)
|
||||
signal player_warped(target_pos: Vector3, map_name: String)
|
||||
signal mob_spawned(vnum: int, count: int, pos: Vector3)
|
||||
|
||||
const MAP_PRESETS: Dictionary = {
|
||||
"village": Vector3(0.0, 0.0, 0.0),
|
||||
"sohan": Vector3(400.0, 0.0, 600.0), # 冰山 Mount Sohan
|
||||
"desert": Vector3(200.0, 0.0, 300.0), # 永壁沙漠 Yongbi Desert
|
||||
"temple": Vector3(100.0, 0.0, 500.0), # 黄龙神殿 Hwang Temple
|
||||
"flame": Vector3(600.0, 0.0, 200.0), # 烈焰原野 Doyyumhwan
|
||||
"catacomb": Vector3(500.0, 0.0, 500.0) # 地下墓穴 Devil's Catacomb
|
||||
}
|
||||
|
||||
# 解析并执行 GM 斜杠指令
|
||||
func execute_command(cmd_string: String, ctx: Dictionary) -> Dictionary:
|
||||
var trimmed := cmd_string.strip_edges()
|
||||
if not trimmed.begins_with("/"):
|
||||
return {"ok": false, "reason": "NOT_A_COMMAND", "msg": "指令必须以 '/' 开头!"}
|
||||
|
||||
var parts := trimmed.substr(1).split(" ", false)
|
||||
if parts.is_empty():
|
||||
return {"ok": false, "reason": "EMPTY_COMMAND"}
|
||||
|
||||
var action := parts[0].to_lower()
|
||||
var res: Dictionary = {}
|
||||
|
||||
match action:
|
||||
"item":
|
||||
res = _handle_item(parts, ctx)
|
||||
"level":
|
||||
res = _handle_level(parts, ctx)
|
||||
"gold":
|
||||
res = _handle_gold(parts, ctx)
|
||||
"set_skill", "skill":
|
||||
res = _handle_skill(parts, ctx)
|
||||
"warp", "map":
|
||||
res = _handle_warp(parts, ctx)
|
||||
"spawn_mob", "mob", "m":
|
||||
res = _handle_mob(parts, ctx)
|
||||
"help":
|
||||
res = _handle_help()
|
||||
_:
|
||||
res = {"ok": false, "reason": "UNKNOWN_COMMAND", "msg": "未知的 GM 指令【%s】!输入 /help 查看支持的指令列表。" % action}
|
||||
|
||||
command_executed.emit(action, res.get("ok", false), res.get("msg", ""))
|
||||
return res
|
||||
|
||||
# /item <vnum> [count]
|
||||
func _handle_item(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /item <物品代码vnum> [数量count]"}
|
||||
|
||||
var vnum: int = int(parts[1])
|
||||
var count: int = 1
|
||||
if parts.size() >= 3 and parts[2].is_valid_int():
|
||||
count = maxi(1, int(parts[2]))
|
||||
|
||||
var inventory: Array = ctx.get("inventory", [])
|
||||
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": "背包已满,无法生成物品!"}
|
||||
|
||||
inventory[empty_slot] = {
|
||||
"vnum": vnum,
|
||||
"name": "道具_%d" % vnum,
|
||||
"count": count
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"vnum": vnum,
|
||||
"count": count,
|
||||
"slot": empty_slot,
|
||||
"msg": "【GM】已成功生成物品 [vnum: %d, count: %d] 至背包格子 %d!" % [vnum, count, empty_slot]
|
||||
}
|
||||
|
||||
# /level <1-105>
|
||||
func _handle_level(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /level <1-105>"}
|
||||
|
||||
var target_lv: int = clampi(int(parts[1]), 1, 105)
|
||||
var player_data: Dictionary = ctx.get("player_data", {})
|
||||
player_data["level"] = target_lv
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"level": target_lv,
|
||||
"msg": "【GM】角色等级已修改为 Lv %d!" % target_lv
|
||||
}
|
||||
|
||||
# /gold <amount>
|
||||
func _handle_gold(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /gold <金币数额>"}
|
||||
|
||||
var amount: int = int(parts[1])
|
||||
var player_data: Dictionary = ctx.get("player_data", {})
|
||||
var current: int = int(player_data.get("gold", 0))
|
||||
var new_gold: int = maxi(0, current + amount)
|
||||
player_data["gold"] = new_gold
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"added": amount,
|
||||
"total_gold": new_gold,
|
||||
"msg": "【GM】金币已注入!当前金币:%d Yang。" % new_gold
|
||||
}
|
||||
|
||||
# /set_skill <skill_id> <1-40>
|
||||
func _handle_skill(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
if parts.size() < 3 or not parts[1].is_valid_int() or not parts[2].is_valid_int():
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /set_skill <技能ID> <等级1-40> (40=P级)"}
|
||||
|
||||
var skill_id: int = int(parts[1])
|
||||
var skill_lv: int = clampi(int(parts[2]), 1, 40)
|
||||
var skills: Dictionary = ctx.get("skills", {})
|
||||
skills[skill_id] = skill_lv
|
||||
|
||||
var grade_str := "普通"
|
||||
if skill_lv >= 40:
|
||||
grade_str = "P级 (Perfect Master)"
|
||||
elif skill_lv >= 30:
|
||||
grade_str = "G级 (Grand Master)"
|
||||
elif skill_lv >= 20:
|
||||
grade_str = "M级 (Master)"
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"skill_id": skill_id,
|
||||
"skill_level": skill_lv,
|
||||
"grade": grade_str,
|
||||
"msg": "【GM】技能 [%d] 等级已调整为 %d (%s)!" % [skill_id, skill_lv, grade_str]
|
||||
}
|
||||
|
||||
# /warp <x> <y> 或 /map <preset_name>
|
||||
func _handle_warp(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
var target_pos := Vector3.ZERO
|
||||
var map_name := "custom"
|
||||
|
||||
if parts.size() >= 3 and parts[1].is_valid_float() and parts[2].is_valid_float():
|
||||
target_pos = Vector3(float(parts[1]), 0.0, float(parts[2]))
|
||||
elif parts.size() >= 2:
|
||||
var preset_key: String = parts[1].to_lower()
|
||||
if MAP_PRESETS.has(preset_key):
|
||||
target_pos = MAP_PRESETS[preset_key]
|
||||
map_name = preset_key
|
||||
else:
|
||||
return {"ok": false, "reason": "UNKNOWN_MAP", "msg": "未知的预设地图!可用地图: %s" % str(MAP_PRESETS.keys())}
|
||||
else:
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /warp <x> <y> 或 /map <地图名>"}
|
||||
|
||||
player_warped.emit(target_pos, map_name)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"position": target_pos,
|
||||
"map_name": map_name,
|
||||
"msg": "【GM】已瞬移传送至目标坐标 (%.1f, %.1f) [%s]!" % [target_pos.x, target_pos.z, map_name]
|
||||
}
|
||||
|
||||
# /spawn_mob <vnum> [count]
|
||||
func _handle_mob(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /spawn_mob <怪代码vnum> [数量count]"}
|
||||
|
||||
var mob_vnum: int = int(parts[1])
|
||||
var count: int = 1
|
||||
if parts.size() >= 3 and parts[2].is_valid_int():
|
||||
count = maxi(1, int(parts[2]))
|
||||
|
||||
var player_pos: Vector3 = ctx.get("player_pos", Vector3.ZERO)
|
||||
mob_spawned.emit(mob_vnum, count, player_pos)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"mob_vnum": mob_vnum,
|
||||
"count": count,
|
||||
"spawn_pos": player_pos,
|
||||
"msg": "【GM】已在身旁召唤怪物 [vnum: %d, count: %d]!" % [mob_vnum, count]
|
||||
}
|
||||
|
||||
# /help
|
||||
func _handle_help() -> Dictionary:
|
||||
return {
|
||||
"ok": true,
|
||||
"commands": [
|
||||
"/item <vnum> [count] - 生成物品",
|
||||
"/level <1-105> - 设定等级",
|
||||
"/gold <amount> - 注入金币",
|
||||
"/set_skill <id> <1-40> - 设定技能等级(40=P级)",
|
||||
"/map <village|sohan|desert|temple|flame|catacomb> - 传送至指定地图",
|
||||
"/warp <x> <y> - 坐标瞬移",
|
||||
"/spawn_mob <vnum> [count] - 刷怪/召唤首领"
|
||||
],
|
||||
"msg": "【GM指令帮助】输入对应命令快速调试单机环境!"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://c0t4mhtsnoxqc
|
||||
@@ -1,156 +0,0 @@
|
||||
# guild_mark_uploader_system.gd —— 40250 行会 16x12 图标与战旗上传分发系统 1:1
|
||||
# 严格对照:
|
||||
# Client/Eternexus/root/uiuploadmark.py (MarkItem, SymbolItem)
|
||||
# ClientVS22/source/UserInterface/GuildMarkUploader.cpp, GuildMarkDownloader.cpp
|
||||
# ClientVS22/source/UserInterface/MarkManager.cpp, MarkImage.cpp
|
||||
extends RefCounted
|
||||
|
||||
const MARK_WIDTH := 16 # 官方行会图标宽度
|
||||
const MARK_HEIGHT := 12 # 官方行会图标高度
|
||||
|
||||
const SYMBOL_WIDTH := 64 # 官方战旗宽度 (GuildMarkUploader.cpp:119)
|
||||
const SYMBOL_HEIGHT := 128 # 官方战旗高度 (GuildMarkUploader.cpp:124)
|
||||
|
||||
# 错误码 (GuildMarkUploader.cpp)
|
||||
enum UploadError {
|
||||
ERROR_NONE,
|
||||
ERROR_NOT_GUILD_MASTER,
|
||||
ERROR_LOAD,
|
||||
ERROR_WIDTH,
|
||||
ERROR_HEIGHT,
|
||||
ERROR_UNSUPPORTED_FORMAT
|
||||
}
|
||||
|
||||
class GuildMarkData:
|
||||
var guild_id: int
|
||||
var mark_type: String # "MARK" 或 "SYMBOL"
|
||||
var width: int
|
||||
var height: int
|
||||
var raw_pixels: PackedByteArray
|
||||
var mark_hash: String
|
||||
|
||||
func _init(p_gid: int, p_type: String, w: int, h: int, pixels: PackedByteArray) -> void:
|
||||
guild_id = p_gid
|
||||
mark_type = p_type
|
||||
width = w
|
||||
height = h
|
||||
raw_pixels = pixels
|
||||
mark_hash = str(p_gid) + "_" + p_type + "_" + str(Time.get_ticks_msec())
|
||||
|
||||
# 客户端本地缓存: guild_id -> { "mark": GuildMarkData, "symbol": GuildMarkData }
|
||||
var cached_marks: Dictionary = {}
|
||||
|
||||
func _init() -> void:
|
||||
cached_marks.clear()
|
||||
|
||||
# 1. 上传 16x12 行会图标 (1:1 CPythonGuild::UploadMark)
|
||||
func upload_guild_mark(
|
||||
guild_id: int,
|
||||
is_guild_master: bool,
|
||||
image_width: int,
|
||||
image_height: int,
|
||||
image_format: String = "bmp",
|
||||
pixel_bytes: PackedByteArray = PackedByteArray()
|
||||
) -> Dictionary:
|
||||
var res := {
|
||||
"success": false,
|
||||
"error_code": UploadError.ERROR_NONE,
|
||||
"mark_data": null,
|
||||
"message": ""
|
||||
}
|
||||
|
||||
# 权限检查:只有会长或持有上传权者可操作
|
||||
if not is_guild_master:
|
||||
res["error_code"] = UploadError.ERROR_NOT_GUILD_MASTER
|
||||
res["message"] = "只有行会长有权上传或更改行会图标。"
|
||||
return res
|
||||
|
||||
# 格式校验 (官方仅支持 BMP 格式)
|
||||
if image_format.to_lower() != "bmp":
|
||||
res["error_code"] = UploadError.ERROR_UNSUPPORTED_FORMAT
|
||||
res["message"] = "行会图标必须为 BMP 格式图像。"
|
||||
return res
|
||||
|
||||
# 尺寸精确校验 (16 x 12)
|
||||
if image_width != MARK_WIDTH:
|
||||
res["error_code"] = UploadError.ERROR_WIDTH
|
||||
res["message"] = "图标宽度不符合规格 (必须为 16 像素)。"
|
||||
return res
|
||||
|
||||
if image_height != MARK_HEIGHT:
|
||||
res["error_code"] = UploadError.ERROR_HEIGHT
|
||||
res["message"] = "图标高度不符合规格 (必须为 12 像素)。"
|
||||
return res
|
||||
|
||||
# 生成并存入缓存
|
||||
var mark_obj = GuildMarkData.new(guild_id, "MARK", MARK_WIDTH, MARK_HEIGHT, pixel_bytes)
|
||||
if not cached_marks.has(guild_id):
|
||||
cached_marks[guild_id] = {}
|
||||
cached_marks[guild_id]["mark"] = mark_obj
|
||||
|
||||
res["success"] = true
|
||||
res["mark_data"] = mark_obj
|
||||
res["message"] = "行会 16x12 图标上传成功,已同步至头顶战标渲染!"
|
||||
return res
|
||||
|
||||
# 2. 上传 64x128 行会战旗 (1:1 CPythonGuild::UploadSymbol)
|
||||
func upload_guild_symbol(
|
||||
guild_id: int,
|
||||
is_guild_master: bool,
|
||||
image_width: int,
|
||||
image_height: int,
|
||||
image_format: String = "bmp",
|
||||
pixel_bytes: PackedByteArray = PackedByteArray()
|
||||
) -> Dictionary:
|
||||
var res := {
|
||||
"success": false,
|
||||
"error_code": UploadError.ERROR_NONE,
|
||||
"symbol_data": null,
|
||||
"message": ""
|
||||
}
|
||||
|
||||
if not is_guild_master:
|
||||
res["error_code"] = UploadError.ERROR_NOT_GUILD_MASTER
|
||||
res["message"] = "只有行会长有权上传行会战旗。"
|
||||
return res
|
||||
|
||||
if image_format.to_lower() != "bmp":
|
||||
res["error_code"] = UploadError.ERROR_UNSUPPORTED_FORMAT
|
||||
res["message"] = "战旗必须为 BMP 格式图像。"
|
||||
return res
|
||||
|
||||
if image_width != SYMBOL_WIDTH:
|
||||
res["error_code"] = UploadError.ERROR_WIDTH
|
||||
res["message"] = "战旗宽度不符合规格 (必须为 64 像素)。"
|
||||
return res
|
||||
|
||||
if image_height != SYMBOL_HEIGHT:
|
||||
res["error_code"] = UploadError.ERROR_HEIGHT
|
||||
res["message"] = "战旗高度不符合规格 (必须为 128 像素)。"
|
||||
return res
|
||||
|
||||
var symbol_obj = GuildMarkData.new(guild_id, "SYMBOL", SYMBOL_WIDTH, SYMBOL_HEIGHT, pixel_bytes)
|
||||
if not cached_marks.has(guild_id):
|
||||
cached_marks[guild_id] = {}
|
||||
cached_marks[guild_id]["symbol"] = symbol_obj
|
||||
|
||||
res["success"] = true
|
||||
res["symbol_data"] = symbol_obj
|
||||
res["message"] = "行会 64x128 战旗上传成功,已部署至领地旗杆!"
|
||||
return res
|
||||
|
||||
# 3. 获取行会图标与战旗 (1:1 MarkManager::GetMarkImage)
|
||||
func get_guild_mark(guild_id: int) -> GuildMarkData:
|
||||
if cached_marks.has(guild_id) and cached_marks[guild_id].has("mark"):
|
||||
return cached_marks[guild_id]["mark"]
|
||||
return null
|
||||
|
||||
func get_guild_symbol(guild_id: int) -> GuildMarkData:
|
||||
if cached_marks.has(guild_id) and cached_marks[guild_id].has("symbol"):
|
||||
return cached_marks[guild_id]["symbol"]
|
||||
return null
|
||||
|
||||
# 4. 清除行会图标
|
||||
func clear_guild_mark(guild_id: int) -> void:
|
||||
if cached_marks.has(guild_id):
|
||||
cached_marks[guild_id].erase("mark")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dkhvtgc7u2vet
|
||||
@@ -1,264 +0,0 @@
|
||||
# guild_skill_system.gd —— Metin2 40250 公会等级与 7 大神龙技能树系统 1:1
|
||||
# 对照 40250 服务端 guild.cpp, guild_manager.cpp, PythonGuild.cpp, skilldesc.txt:23-29
|
||||
class_name GuildSkillSystem
|
||||
extends RefCounted
|
||||
|
||||
signal guild_level_up(new_level: int, skill_points: int)
|
||||
signal guild_skill_cast(skill_id: int, duration: float)
|
||||
|
||||
const MAX_GUILD_LEVEL := 20
|
||||
const MAX_SKILL_LEVEL := 7
|
||||
|
||||
# 7 官方公会技能 (skilldesc.txt:23-29)
|
||||
const SKILL_DRAGON_EYES := 151 # 龙神之眼: 被动增加最大神龙精力
|
||||
const SKILL_DRAGON_BLOOD := 152 # 龙神之血: 最大 HP 提升
|
||||
const SKILL_DRAGON_BLESSING := 153# 龙神庇护: 最大 SP 提升
|
||||
const SKILL_HOLY_ARMOR := 154 # 圣灵之甲: 防御力百分比提升
|
||||
const SKILL_ACCELERATE := 155 # 疾风步: 攻击与移动速度提升
|
||||
const SKILL_DRAGON_RAGE := 156 # 龙神之怒: 致命一击几率提升
|
||||
const SKILL_CASTING_AID := 157 # 咏唱神助: 施法提速 / 技能冷却缩减
|
||||
|
||||
const GUILD_SKILLS: Dictionary = {
|
||||
SKILL_DRAGON_EYES: {
|
||||
"name": "龙神之眼",
|
||||
"is_passive": true,
|
||||
"spirit_cost": 0,
|
||||
"cooldown": 0.0,
|
||||
"duration": 0.0
|
||||
},
|
||||
SKILL_DRAGON_BLOOD: {
|
||||
"name": "龙神之血",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 20,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0 # 5 分钟
|
||||
},
|
||||
SKILL_DRAGON_BLESSING: {
|
||||
"name": "龙神庇护",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 20,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0
|
||||
},
|
||||
SKILL_HOLY_ARMOR: {
|
||||
"name": "圣灵之甲",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 25,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0
|
||||
},
|
||||
SKILL_ACCELERATE: {
|
||||
"name": "疾风步",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 25,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0
|
||||
},
|
||||
SKILL_DRAGON_RAGE: {
|
||||
"name": "龙神之怒",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 30,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0
|
||||
},
|
||||
SKILL_CASTING_AID: {
|
||||
"name": "咏唱神助",
|
||||
"is_passive": false,
|
||||
"spirit_cost": 30,
|
||||
"cooldown": 60.0,
|
||||
"duration": 300.0
|
||||
}
|
||||
}
|
||||
|
||||
# 公会基础属性
|
||||
var guild_level: int = 1
|
||||
var guild_exp: int = 0
|
||||
var skill_points: int = 0
|
||||
var max_spirit: int = 100
|
||||
var current_spirit: int = 100
|
||||
|
||||
# 技能加点等级: skill_id -> level (0~7)
|
||||
var skill_levels: Dictionary = {
|
||||
SKILL_DRAGON_EYES: 0,
|
||||
SKILL_DRAGON_BLOOD: 0,
|
||||
SKILL_DRAGON_BLESSING: 0,
|
||||
SKILL_HOLY_ARMOR: 0,
|
||||
SKILL_ACCELERATE: 0,
|
||||
SKILL_DRAGON_RAGE: 0,
|
||||
SKILL_CASTING_AID: 0
|
||||
}
|
||||
|
||||
# 技能冷却与生效增益
|
||||
var skill_cooldowns: Dictionary = {}
|
||||
var active_buffs: Dictionary = {} # skill_id -> { duration, value }
|
||||
|
||||
# 计算升级所需经验 (guild.cpp)
|
||||
static func get_exp_needed_for_level(lvl: int) -> int:
|
||||
return lvl * 50000
|
||||
|
||||
# 捐献公会经验
|
||||
func donate_exp(amount: int) -> Dictionary:
|
||||
if guild_level >= MAX_GUILD_LEVEL:
|
||||
return {"ok": false, "reason": "MAX_LEVEL", "msg": "公会已达到最高 20 级!"}
|
||||
|
||||
guild_exp += amount
|
||||
var leveled_up := false
|
||||
var levels_gained := 0
|
||||
|
||||
while guild_level < MAX_GUILD_LEVEL:
|
||||
var needed = get_exp_needed_for_level(guild_level)
|
||||
if guild_exp >= needed:
|
||||
guild_exp -= needed
|
||||
guild_level += 1
|
||||
skill_points += 1
|
||||
levels_gained += 1
|
||||
leveled_up = true
|
||||
else:
|
||||
break
|
||||
|
||||
if leveled_up:
|
||||
_recalculate_spirit()
|
||||
guild_level_up.emit(guild_level, skill_points)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"guild_level": guild_level,
|
||||
"guild_exp": guild_exp,
|
||||
"needed_exp": get_exp_needed_for_level(guild_level),
|
||||
"skill_points": skill_points,
|
||||
"leveled_up": leveled_up
|
||||
}
|
||||
|
||||
# 升级公会技能
|
||||
func level_up_skill(skill_id: int) -> Dictionary:
|
||||
if not GUILD_SKILLS.has(skill_id):
|
||||
return {"ok": false, "reason": "INVALID_SKILL"}
|
||||
if skill_points <= 0:
|
||||
return {"ok": false, "reason": "NO_SKILL_POINTS", "msg": "可用公会技能点不足!"}
|
||||
|
||||
var cur_lvl: int = int(skill_levels.get(skill_id, 0))
|
||||
if cur_lvl >= MAX_SKILL_LEVEL:
|
||||
return {"ok": false, "reason": "MAX_SKILL_LEVEL", "msg": "该公会技能已满级!"}
|
||||
|
||||
skill_levels[skill_id] = cur_lvl + 1
|
||||
skill_points -= 1
|
||||
_recalculate_spirit()
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"skill_id": skill_id,
|
||||
"new_level": skill_levels[skill_id],
|
||||
"remaining_points": skill_points
|
||||
}
|
||||
|
||||
func _recalculate_spirit() -> void:
|
||||
# 151 龙神之眼扩充神龙精力
|
||||
var eyes_lvl: int = int(skill_levels.get(SKILL_DRAGON_EYES, 0))
|
||||
var bonus_sp: int = int(round(float(eyes_lvl) / float(MAX_SKILL_LEVEL) * 100.0))
|
||||
max_spirit = 100 + bonus_sp
|
||||
current_spirit = mini(current_spirit, max_spirit)
|
||||
|
||||
# 施放公会技能
|
||||
func cast_guild_skill(skill_id: int) -> Dictionary:
|
||||
if not GUILD_SKILLS.has(skill_id):
|
||||
return {"ok": false, "reason": "INVALID_SKILL"}
|
||||
|
||||
var cfg: Dictionary = GUILD_SKILLS[skill_id]
|
||||
if bool(cfg["is_passive"]):
|
||||
return {"ok": false, "reason": "PASSIVE_SKILL", "msg": "被动技能无需主动施放!"}
|
||||
|
||||
var lvl: int = int(skill_levels.get(skill_id, 0))
|
||||
if lvl <= 0:
|
||||
return {"ok": false, "reason": "NOT_LEARNED", "msg": "尚未学习该公会技能!"}
|
||||
|
||||
# 检查冷却
|
||||
if float(skill_cooldowns.get(skill_id, 0.0)) > 0.0:
|
||||
return {"ok": false, "reason": "COOLDOWN_ACTIVE", "remaining": skill_cooldowns[skill_id]}
|
||||
|
||||
# 检查精力消耗
|
||||
var cost: int = int(cfg["spirit_cost"])
|
||||
if current_spirit < cost:
|
||||
return {"ok": false, "reason": "NOT_ENOUGH_SPIRIT", "msg": "公会神龙精力不足!"}
|
||||
|
||||
# 扣除精力并进入冷却
|
||||
current_spirit -= cost
|
||||
skill_cooldowns[skill_id] = float(cfg["cooldown"])
|
||||
|
||||
# 计算技能数值 (对齐 skilldesc.txt: k = lvl / 7.0)
|
||||
var k: float = float(lvl) / float(MAX_SKILL_LEVEL)
|
||||
var dur: float = float(cfg["duration"])
|
||||
var val: float = 0.0
|
||||
|
||||
match skill_id:
|
||||
SKILL_DRAGON_BLOOD: val = k * 20.0 # Max HP +20%
|
||||
SKILL_DRAGON_BLESSING: val = k * 20.0 # Max SP +20%
|
||||
SKILL_HOLY_ARMOR: val = k * 10.0 # DEF +10%
|
||||
SKILL_ACCELERATE: val = k * 30.0 # Atk & Motion Speed +30
|
||||
SKILL_DRAGON_RAGE: val = k * 50.0 # Crit +50%
|
||||
SKILL_CASTING_AID: val = k * 50.0 # Cooldown reduction +50%
|
||||
|
||||
active_buffs[skill_id] = {
|
||||
"name": cfg["name"],
|
||||
"value": val,
|
||||
"duration": dur,
|
||||
"total_duration": dur
|
||||
}
|
||||
|
||||
guild_skill_cast.emit(skill_id, dur)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"skill_id": skill_id,
|
||||
"name": cfg["name"],
|
||||
"value": val,
|
||||
"duration": dur,
|
||||
"current_spirit": current_spirit
|
||||
}
|
||||
|
||||
# 逐帧更新冷却与生效增益
|
||||
func update(dt: float) -> void:
|
||||
# 更新技能冷却
|
||||
for sid in skill_cooldowns.keys():
|
||||
skill_cooldowns[sid] = maxf(0.0, float(skill_cooldowns[sid]) - dt)
|
||||
if float(skill_cooldowns[sid]) <= 0.0:
|
||||
skill_cooldowns.erase(sid)
|
||||
|
||||
# 更新增益持续时间
|
||||
var expired: Array[int] = []
|
||||
for sid in active_buffs.keys():
|
||||
var b: Dictionary = active_buffs[sid]
|
||||
b["duration"] = maxf(0.0, float(b["duration"]) - dt)
|
||||
if float(b["duration"]) <= 0.0:
|
||||
expired.append(sid)
|
||||
|
||||
for sid in expired:
|
||||
active_buffs.erase(sid)
|
||||
|
||||
# 恢复精力 (每秒恢复 0.5)
|
||||
current_spirit = mini(max_spirit, current_spirit + int(round(0.5 * dt)))
|
||||
|
||||
# 查询当前激活的所有公会增益
|
||||
func get_active_bonuses() -> Dictionary:
|
||||
var res: Dictionary = {
|
||||
"max_hp_pct": 0.0,
|
||||
"max_sp_pct": 0.0,
|
||||
"def_pct": 0.0,
|
||||
"speed_bonus": 0.0,
|
||||
"critical_pct": 0.0,
|
||||
"casting_aid_pct": 0.0
|
||||
}
|
||||
|
||||
if active_buffs.has(SKILL_DRAGON_BLOOD):
|
||||
res["max_hp_pct"] = float(active_buffs[SKILL_DRAGON_BLOOD]["value"])
|
||||
if active_buffs.has(SKILL_DRAGON_BLESSING):
|
||||
res["max_sp_pct"] = float(active_buffs[SKILL_DRAGON_BLESSING]["value"])
|
||||
if active_buffs.has(SKILL_HOLY_ARMOR):
|
||||
res["def_pct"] = float(active_buffs[SKILL_HOLY_ARMOR]["value"])
|
||||
if active_buffs.has(SKILL_ACCELERATE):
|
||||
res["speed_bonus"] = float(active_buffs[SKILL_ACCELERATE]["value"])
|
||||
if active_buffs.has(SKILL_DRAGON_RAGE):
|
||||
res["critical_pct"] = float(active_buffs[SKILL_DRAGON_RAGE]["value"])
|
||||
if active_buffs.has(SKILL_CASTING_AID):
|
||||
res["casting_aid_pct"] = float(active_buffs[SKILL_CASTING_AID]["value"])
|
||||
|
||||
return res
|
||||
@@ -1 +0,0 @@
|
||||
uid://dmqf2n3hr5phn
|
||||
@@ -1,188 +0,0 @@
|
||||
# guild_war_observer_arena_system.gd —— Metin2 40250 官方公会战备观战与武斗擂台传送系统 1:1
|
||||
# 100% 对照 40250 服务端 war_map.cpp, questlua_pc.cpp (warp_to_guild_war_observer_position), guild_war.cpp
|
||||
class_name GuildWarObserverArenaSystem
|
||||
extends RefCounted
|
||||
|
||||
signal observer_entered(player_id: int, war_id: int, observer_pos: Vector2)
|
||||
signal observer_left(player_id: int, return_map: String, return_pos: Vector2)
|
||||
signal war_ended_evacuated(war_id: int, observer_count: int)
|
||||
signal observer_error(reason: String)
|
||||
|
||||
# 40250 官方公会战竞技场地标与地图常数
|
||||
const MAP_GUILD_ARENA := 110 # 公会战擂台竞技场地图
|
||||
const MAP_GUILD_FLAG_WAR := 111 # 公会夺旗战场地图
|
||||
const OBSERVER_PLATFORM_POS := Vector2(2000.0, 2000.0) # 观战席高台坐标 (Start Location Index 2)
|
||||
|
||||
# 主城公会守卫 NPC (11001, 11003, 11005)
|
||||
const NPC_TOWN_GUARD_SHINSOO := 11001
|
||||
const NPC_TOWN_GUARD_CHUNJO := 11003
|
||||
const NPC_TOWN_GUARD_JINNO := 11005
|
||||
|
||||
# 正在进行的公会战清单: war_id -> { ... }
|
||||
var _active_wars: Dictionary = {}
|
||||
# 观战者档案: player_id -> { "war_id": int, "saved_map": String, "saved_pos": Vector2, "is_observer": bool }
|
||||
var _observers: Dictionary = {}
|
||||
var _next_war_id: int = 1
|
||||
|
||||
# ==========================================
|
||||
# 1. 公会战状态登记与告示 (war_map.cpp)
|
||||
# ==========================================
|
||||
func register_active_war(
|
||||
guild1_id: int,
|
||||
guild1_name: String,
|
||||
guild2_id: int,
|
||||
guild2_name: String,
|
||||
war_type: int = 1, # 1 为 Arena 擂台赛
|
||||
target_score: int = 50
|
||||
) -> int:
|
||||
var wid = _next_war_id
|
||||
_next_war_id += 1
|
||||
|
||||
_active_wars[wid] = {
|
||||
"war_id": wid,
|
||||
"guild1_id": guild1_id,
|
||||
"guild1_name": guild1_name,
|
||||
"guild2_id": guild2_id,
|
||||
"guild2_name": guild2_name,
|
||||
"war_type": war_type,
|
||||
"target_score": target_score,
|
||||
"score1": 0,
|
||||
"score2": 0,
|
||||
"map_index": MAP_GUILD_ARENA if war_type == 1 else MAP_GUILD_FLAG_WAR,
|
||||
"is_ended": false,
|
||||
"winner_id": 0
|
||||
}
|
||||
return wid
|
||||
|
||||
# 主城守卫公告栏:获取当前全部可观战的公会对决列表
|
||||
func get_active_wars_for_display() -> Array[Dictionary]:
|
||||
var list: Array[Dictionary] = []
|
||||
for wid in _active_wars.keys():
|
||||
var w = _active_wars[wid]
|
||||
if not bool(w.get("is_ended", false)):
|
||||
list.append(w.duplicate(true))
|
||||
return list
|
||||
|
||||
# 更新战局比分
|
||||
func update_war_score(war_id: int, score1: int, score2: int) -> bool:
|
||||
if not _active_wars.has(war_id):
|
||||
return false
|
||||
var w = _active_wars[war_id]
|
||||
w["score1"] = score1
|
||||
w["score2"] = score2
|
||||
if score1 >= int(w["target_score"]):
|
||||
end_war(war_id, int(w["guild1_id"]))
|
||||
elif score2 >= int(w["target_score"]):
|
||||
end_war(war_id, int(w["guild2_id"]))
|
||||
return true
|
||||
|
||||
# ==========================================
|
||||
# 2. 传送入场观战 (pc_warp_to_guild_war_observer_position)
|
||||
# ==========================================
|
||||
func enter_as_observer(
|
||||
player_id: int,
|
||||
war_id: int,
|
||||
current_map: String,
|
||||
current_pos: Vector2
|
||||
) -> Dictionary:
|
||||
if not _active_wars.has(war_id) or bool(_active_wars[war_id].get("is_ended", false)):
|
||||
observer_error.emit("WAR_NOT_ACTIVE")
|
||||
return {"ok": false, "reason": "WAR_NOT_ACTIVE", "msg": "该场公会战尚未开启或已经落下帷幕!"}
|
||||
|
||||
if is_player_observing(player_id):
|
||||
observer_error.emit("ALREADY_OBSERVING")
|
||||
return {"ok": false, "reason": "ALREADY_OBSERVING", "msg": "你已经在观战席中!"}
|
||||
|
||||
var war = _active_wars[war_id]
|
||||
# 保存玩家原始位置 (ch->SaveExitLocation)
|
||||
_observers[player_id] = {
|
||||
"war_id": war_id,
|
||||
"saved_map": current_map,
|
||||
"saved_pos": current_pos,
|
||||
"is_observer": true,
|
||||
"can_attack": false, # 幽灵模式禁止攻击
|
||||
"can_be_attacked": false # 幽灵模式免疫任何伤害
|
||||
}
|
||||
|
||||
observer_entered.emit(player_id, war_id, OBSERVER_PLATFORM_POS)
|
||||
return {
|
||||
"ok": true,
|
||||
"war_id": war_id,
|
||||
"map_index": war["map_index"],
|
||||
"observer_pos": OBSERVER_PLATFORM_POS,
|
||||
"is_observer_mode": true,
|
||||
"msg": "穿过虚空之门,你抵达了公会战竞技场高台观战席!已开启隐匿观摩形态。"
|
||||
}
|
||||
|
||||
# 离开观战席重返主城
|
||||
func leave_observer_mode(player_id: int) -> Dictionary:
|
||||
if not is_player_observing(player_id):
|
||||
observer_error.emit("NOT_OBSERVING")
|
||||
return {"ok": false, "reason": "NOT_OBSERVING", "msg": "你当前并不在观战模式中。"}
|
||||
|
||||
var obs = _observers[player_id]
|
||||
var return_map = str(obs["saved_map"])
|
||||
var return_pos = Vector2(obs["saved_pos"].x, obs["saved_pos"].y)
|
||||
|
||||
_observers.erase(player_id)
|
||||
observer_left.emit(player_id, return_map, return_pos)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"return_map": return_map,
|
||||
"return_pos": return_pos,
|
||||
"msg": "你退出了观战高台,平安返回了主城集结地。"
|
||||
}
|
||||
|
||||
func is_player_observing(player_id: int) -> bool:
|
||||
return _observers.has(player_id) and bool(_observers[player_id].get("is_observer", false))
|
||||
|
||||
func can_player_attack_or_be_attacked(player_id: int) -> bool:
|
||||
# 若处于观战模式,彻底屏蔽战斗互攻判定
|
||||
return not is_player_observing(player_id)
|
||||
|
||||
# ==========================================
|
||||
# 3. 终局结算与观战席全员疏散 (war_map.cpp)
|
||||
# ==========================================
|
||||
func end_war(war_id: int, winner_guild_id: int) -> void:
|
||||
if not _active_wars.has(war_id):
|
||||
return
|
||||
|
||||
var w = _active_wars[war_id]
|
||||
w["is_ended"] = true
|
||||
w["winner_id"] = winner_guild_id
|
||||
|
||||
# 遣返该场战斗的所有观战者
|
||||
var evacuated_count := 0
|
||||
for pid in _observers.keys().duplicate():
|
||||
if int(_observers[pid].get("war_id", 0)) == war_id:
|
||||
var obs = _observers[pid]
|
||||
observer_left.emit(pid, obs["saved_map"], obs["saved_pos"])
|
||||
_observers.erase(pid)
|
||||
evacuated_count += 1
|
||||
|
||||
war_ended_evacuated.emit(war_id, evacuated_count)
|
||||
|
||||
# 序列化与反序列化
|
||||
func serialize() -> Dictionary:
|
||||
var obs_copy = {}
|
||||
for pid in _observers:
|
||||
var item = _observers[pid].duplicate()
|
||||
item["saved_pos"] = {"x": item["saved_pos"].x, "y": item["saved_pos"].y}
|
||||
obs_copy[pid] = item
|
||||
return {
|
||||
"active_wars": _active_wars.duplicate(true),
|
||||
"observers": obs_copy,
|
||||
"next_war_id": _next_war_id
|
||||
}
|
||||
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
_active_wars = data.get("active_wars", {}).duplicate(true)
|
||||
_next_war_id = int(data.get("next_war_id", 1))
|
||||
_observers.clear()
|
||||
var raw_obs = data.get("observers", {})
|
||||
for pid in raw_obs:
|
||||
var item = raw_obs[pid].duplicate()
|
||||
var p_dict = item.get("saved_pos", {"x": 0.0, "y": 0.0})
|
||||
item["saved_pos"] = Vector2(float(p_dict.get("x", 0.0)), float(p_dict.get("y", 0.0)))
|
||||
_observers[int(pid)] = item
|
||||
@@ -1 +0,0 @@
|
||||
uid://b17l4jfxfcmlw
|
||||
@@ -1,186 +0,0 @@
|
||||
# guild_war_system.gd —— Metin2 40250 官方公会战备宣战与积分决斗裁定系统 1:1
|
||||
# 对照 40250 服务端 guild_war.cpp, guild_manager.cpp, guild.h
|
||||
class_name GuildWarSystem
|
||||
extends RefCounted
|
||||
|
||||
signal war_declared(opp_guild_name: String, war_type: int, target_score: int)
|
||||
signal war_started(war_type: int, duration: float)
|
||||
signal score_updated(score_a: int, score_b: int)
|
||||
signal war_ended(winner_side: String, score_a: int, score_b: int, reward: Dictionary)
|
||||
|
||||
# 40250 官方公会战模式类型
|
||||
const WAR_TYPE_FIELD := 0 # 野外遭遇战 (Field War)
|
||||
const WAR_TYPE_ARENA := 1 # 竞技场擂台战 (Arena War, Map 110/111)
|
||||
const WAR_TYPE_FLAG := 2 # 夺旗战 (Flag War)
|
||||
|
||||
# 公会战状态机
|
||||
const STATE_NONE := 0
|
||||
const STATE_DECLARED := 1 # 宣战中
|
||||
const STATE_PREPARING := 2 # 备战中
|
||||
const STATE_FIGHTING := 3 # 激战中
|
||||
const STATE_FINISHED := 4 # 战役结束
|
||||
|
||||
# 单机竞争公会预设库
|
||||
const RIVAL_GUILDS: Dictionary = {
|
||||
101: {"id": 101, "name": "暗影刺客团", "master": "夜刃", "level": 20, "ladder_points": 1200},
|
||||
102: {"id": 102, "name": "赤血狂战盟", "master": "狂屠", "level": 20, "ladder_points": 1350},
|
||||
103: {"id": 103, "name": "青云修罗殿", "master": "冥火", "level": 20, "ladder_points": 1400}
|
||||
}
|
||||
|
||||
var war_state: int = STATE_NONE
|
||||
var active_war_type: int = WAR_TYPE_ARENA
|
||||
var active_opp_guild: Dictionary = {}
|
||||
var target_score: int = 50 # 目标获胜积分 (50分/100分)
|
||||
var time_remaining: float = 1800.0 # 30 分钟 (1800 秒)
|
||||
var total_duration: float = 1800.0
|
||||
|
||||
var score_a: int = 0 # 玩家公会得分
|
||||
var score_b: int = 0 # 敌方公会得分
|
||||
|
||||
var player_guild_ladder: int = 1000 # 玩家公会天梯积分
|
||||
var total_wars_won: int = 0
|
||||
|
||||
# 发起公会宣战 (40250 CGuild::GuildWarPacket / UnderDeclaration)
|
||||
func declare_war(opp_guild_id: int, war_type: int = WAR_TYPE_ARENA, win_score: int = 50, duration: float = 1800.0) -> Dictionary:
|
||||
if war_state == STATE_FIGHTING or war_state == STATE_PREPARING:
|
||||
return {"ok": false, "reason": "ALREADY_IN_WAR", "msg": "当前公会已处于战役交锋中!"}
|
||||
|
||||
if not RIVAL_GUILDS.has(opp_guild_id):
|
||||
return {"ok": false, "reason": "INVALID_OPP_GUILD", "msg": "未知的敌对公会!"}
|
||||
|
||||
active_opp_guild = RIVAL_GUILDS[opp_guild_id].duplicate(true)
|
||||
active_war_type = war_type
|
||||
target_score = win_score
|
||||
time_remaining = duration
|
||||
total_duration = duration
|
||||
score_a = 0
|
||||
score_b = 0
|
||||
|
||||
war_state = STATE_DECLARED
|
||||
|
||||
war_declared.emit(active_opp_guild["name"], active_war_type, target_score)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"opp_name": active_opp_guild["name"],
|
||||
"war_type": active_war_type,
|
||||
"target_score": target_score,
|
||||
"msg": "战书已下达!向【%s】发起公会宣战。" % active_opp_guild["name"]
|
||||
}
|
||||
|
||||
# 响应接受战书开启战场
|
||||
func start_war() -> Dictionary:
|
||||
if war_state != STATE_DECLARED:
|
||||
return {"ok": false, "reason": "NOT_IN_DECLARED_STATE"}
|
||||
|
||||
war_state = STATE_FIGHTING
|
||||
war_started.emit(active_war_type, total_duration)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"war_type": active_war_type,
|
||||
"duration": total_duration,
|
||||
"msg": "战役正式打响!双方公会成员进入战场拼杀。"
|
||||
}
|
||||
|
||||
# 记录战场击杀或占旗得分
|
||||
func record_score(side: String, points: int = 1) -> Dictionary:
|
||||
if war_state != STATE_FIGHTING:
|
||||
return {"ok": false, "reason": "WAR_NOT_ACTIVE"}
|
||||
|
||||
if side == "A":
|
||||
score_a += points
|
||||
elif side == "B":
|
||||
score_b += points
|
||||
|
||||
score_updated.emit(score_a, score_b)
|
||||
|
||||
# 检查是否率先达到目标胜分
|
||||
if score_a >= target_score or score_b >= target_score:
|
||||
var winner := "A" if score_a >= target_score else "B"
|
||||
return end_war(winner)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"score_a": score_a,
|
||||
"score_b": score_b,
|
||||
"target_score": target_score
|
||||
}
|
||||
|
||||
# 结束战役与结算奖励
|
||||
func end_war(winner: String) -> Dictionary:
|
||||
war_state = STATE_FINISHED
|
||||
|
||||
var reward: Dictionary = {}
|
||||
if winner == "A":
|
||||
total_wars_won += 1
|
||||
player_guild_ladder += 30
|
||||
reward = {
|
||||
"guild_exp": 5000,
|
||||
"guild_gold": 1000000,
|
||||
"ladder_change": 30,
|
||||
"new_ladder": player_guild_ladder
|
||||
}
|
||||
elif winner == "B":
|
||||
player_guild_ladder = maxi(0, player_guild_ladder - 20)
|
||||
reward = {
|
||||
"guild_exp": 1000,
|
||||
"guild_gold": 200000,
|
||||
"ladder_change": -20,
|
||||
"new_ladder": player_guild_ladder
|
||||
}
|
||||
else:
|
||||
reward = {
|
||||
"guild_exp": 2000,
|
||||
"guild_gold": 500000,
|
||||
"ladder_change": 0,
|
||||
"new_ladder": player_guild_ladder
|
||||
}
|
||||
|
||||
war_ended.emit(winner, score_a, score_b, reward)
|
||||
|
||||
var msg_title := "大获全胜" if winner == "A" else ("虽败犹荣" if winner == "B" else "战局握手言和")
|
||||
return {
|
||||
"ok": true,
|
||||
"winner": winner,
|
||||
"score_a": score_a,
|
||||
"score_b": score_b,
|
||||
"reward": reward,
|
||||
"msg": "公会战结束【%s】!比分:%d - %d。" % [msg_title, score_a, score_b]
|
||||
}
|
||||
|
||||
# 战役时钟心跳
|
||||
func update(delta: float) -> void:
|
||||
if war_state != STATE_FIGHTING:
|
||||
return
|
||||
time_remaining -= delta
|
||||
if time_remaining <= 0.0:
|
||||
# 时间耗尽,按现有比分决出
|
||||
var winner := "A" if score_a > score_b else ("B" if score_b > score_a else "DRAW")
|
||||
end_war(winner)
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"war_state": war_state,
|
||||
"active_war_type": active_war_type,
|
||||
"active_opp_guild": active_opp_guild.duplicate(true),
|
||||
"target_score": target_score,
|
||||
"time_remaining": time_remaining,
|
||||
"score_a": score_a,
|
||||
"score_b": score_b,
|
||||
"player_guild_ladder": player_guild_ladder,
|
||||
"total_wars_won": total_wars_won
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
war_state = int(data.get("war_state", STATE_NONE))
|
||||
active_war_type = int(data.get("active_war_type", WAR_TYPE_ARENA))
|
||||
active_opp_guild = data.get("active_opp_guild", {}).duplicate(true)
|
||||
target_score = int(data.get("target_score", 50))
|
||||
time_remaining = float(data.get("time_remaining", 1800.0))
|
||||
score_a = int(data.get("score_a", 0))
|
||||
score_b = int(data.get("score_b", 0))
|
||||
player_guild_ladder = int(data.get("player_guild_ladder", 1000))
|
||||
total_wars_won = int(data.get("total_wars_won", 0))
|
||||
@@ -1 +0,0 @@
|
||||
uid://dypy1i2skjo2w
|
||||
@@ -1,105 +0,0 @@
|
||||
# hair_dye_system.gd —— Metin2 40250 官方发型染色与漂白重塑系统 1:1
|
||||
# 对照 40250 服务端 char_item.cpp:3540-3580
|
||||
class_name HairDyeSystem
|
||||
extends RefCounted
|
||||
|
||||
signal hair_dyed(color_id: int, color_name: String)
|
||||
signal hair_bleached()
|
||||
|
||||
# 40250 官方染发道具 Vnum
|
||||
const VNUM_BLEACH := 70201 # 漂白水 (重置发色为 0, 清空冷却)
|
||||
const VNUM_WHITE_DYE := 70202 # 染发剂(白) -> color 1
|
||||
const VNUM_BLONDE_DYE := 70203 # 染发剂(金) -> color 2
|
||||
const VNUM_RED_DYE := 70204 # 染发剂(红) -> color 3
|
||||
const VNUM_BROWN_DYE := 70205 # 染发剂(棕) -> color 4
|
||||
const VNUM_BLACK_DYE := 70206 # 染发剂(黑) -> color 5
|
||||
|
||||
# 颜色映射字典
|
||||
const HAIR_COLORS: Dictionary = {
|
||||
0: "原色黑发(Default)",
|
||||
1: "雪原纯白(White)",
|
||||
2: "璀璨金黄(Blonde)",
|
||||
3: "烈火赤红(Red)",
|
||||
4: "深栗浅棕(Brown)",
|
||||
5: "曜石纯黑(Black)"
|
||||
}
|
||||
|
||||
var current_hair_color: int = 0
|
||||
var current_hair_style_id: int = 0 # 0 为基础原生发型,>= 1001 为时装发型
|
||||
var last_dye_level: int = 0 # 记录上次染发时的角色等级
|
||||
|
||||
# 获取颜色名称
|
||||
static func get_color_name(color_id: int) -> String:
|
||||
return HAIR_COLORS.get(color_id, "未知发色")
|
||||
|
||||
# 执行染发或漂白 (40250 char_item.cpp:3540-3580)
|
||||
func dye_hair(inventory: Array, slot_index: int, player_level: int) -> Dictionary:
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var item: Dictionary = inventory[slot_index]
|
||||
var item_vnum := int(item.get("vnum", 0))
|
||||
if item_vnum < VNUM_BLEACH or item_vnum > VNUM_BLACK_DYE:
|
||||
return {"ok": false, "reason": "NOT_A_HAIR_DYE", "msg": "此物品不是染发剂或漂白水!"}
|
||||
|
||||
# 40250 官方规则:独立时装发型 (PART_HAIR >= 1001) 无法进行普通染色
|
||||
if current_hair_style_id >= 1001:
|
||||
return {"ok": false, "reason": "CANNOT_DYE_COSTUME_HAIR", "msg": "当前穿戴的特殊时装发型无法进行原生发色染色!"}
|
||||
|
||||
# 40250 官方冷却规则:
|
||||
# last_dye_level == 0 || last_dye_level + 3 <= GetLevel() || item->GetVnum() == 70201
|
||||
var is_bleach: bool = (item_vnum == VNUM_BLEACH)
|
||||
if not is_bleach and last_dye_level > 0 and (last_dye_level + 3 > player_level):
|
||||
var req_lv := last_dye_level + 3
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "LEVEL_COOLDOWN_ACTIVE",
|
||||
"req_level": req_lv,
|
||||
"msg": "染发药水药效尚未散去!角色需达到 %d 级方可再次染色(漂白水除外)。" % req_lv
|
||||
}
|
||||
|
||||
# 消耗 1 瓶染发剂
|
||||
var cnt := int(item.get("count", 1))
|
||||
if cnt > 1:
|
||||
item["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
if is_bleach:
|
||||
# 漂白水重置发色为 0,并清空染色等级限制
|
||||
current_hair_color = 0
|
||||
last_dye_level = 0
|
||||
hair_bleached.emit()
|
||||
return {
|
||||
"ok": true,
|
||||
"is_bleach": true,
|
||||
"hair_color": 0,
|
||||
"color_name": get_color_name(0),
|
||||
"msg": "使用漂白水褪色成功!发色已恢复经典天然原色。"
|
||||
}
|
||||
else:
|
||||
current_hair_color = item_vnum - VNUM_BLEACH
|
||||
last_dye_level = player_level
|
||||
hair_dyed.emit(current_hair_color, get_color_name(current_hair_color))
|
||||
return {
|
||||
"ok": true,
|
||||
"is_bleach": false,
|
||||
"hair_color": current_hair_color,
|
||||
"color_name": get_color_name(current_hair_color),
|
||||
"last_dye_level": last_dye_level,
|
||||
"msg": "染发成功!角色秀发已焕新为【%s】。" % get_color_name(current_hair_color)
|
||||
}
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"current_hair_color": current_hair_color,
|
||||
"current_hair_style_id": current_hair_style_id,
|
||||
"last_dye_level": last_dye_level
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
current_hair_color = int(data.get("current_hair_color", 0))
|
||||
current_hair_style_id = int(data.get("current_hair_style_id", 0))
|
||||
last_dye_level = int(data.get("last_dye_level", 0))
|
||||
@@ -1 +0,0 @@
|
||||
uid://cugg84qsxxqmy
|
||||
@@ -1,228 +0,0 @@
|
||||
# herbal_dew_system.gd —— Metin2 40250 白高草药炼丹酿造与 6 色神圣露水系统 1:1
|
||||
# 对照 40250 服务端 char_item.cpp, blend.txt, potion.quest 及 NPC 20018 Baek-Go
|
||||
class_name HerbalDewSystem
|
||||
extends RefCounted
|
||||
|
||||
const NPC_BAEK_GO := 20018
|
||||
const ITEM_EMPTY_BOTTLE := 70030
|
||||
|
||||
# 6 色神圣露水定义 (50821~50826)
|
||||
const DEW_TABLE: Dictionary = {
|
||||
50821: {
|
||||
"name": "绿露水",
|
||||
"color": "green",
|
||||
"stat": "magic_defense",
|
||||
"stat_desc": "魔法防御力",
|
||||
"min_val": 30,
|
||||
"max_val": 70,
|
||||
"duration": 600.0, # 10 分钟
|
||||
"water_vnum": 50721
|
||||
},
|
||||
50822: {
|
||||
"name": "红露水",
|
||||
"color": "red",
|
||||
"stat": "critical_pct",
|
||||
"stat_desc": "致命一击几率",
|
||||
"min_val": 8,
|
||||
"max_val": 20,
|
||||
"duration": 600.0,
|
||||
"water_vnum": 50722
|
||||
},
|
||||
50823: {
|
||||
"name": "蓝露水",
|
||||
"color": "blue",
|
||||
"stat": "att_grade",
|
||||
"stat_desc": "物理攻击力",
|
||||
"min_val": 50,
|
||||
"max_val": 120,
|
||||
"duration": 600.0,
|
||||
"water_vnum": 50723
|
||||
},
|
||||
50824: {
|
||||
"name": "白露水",
|
||||
"color": "white",
|
||||
"stat": "def_grade",
|
||||
"stat_desc": "物理防御力",
|
||||
"min_val": 40,
|
||||
"max_val": 100,
|
||||
"duration": 600.0,
|
||||
"water_vnum": 50724
|
||||
},
|
||||
50825: {
|
||||
"name": "粉露水",
|
||||
"color": "pink",
|
||||
"stat": "penetrate_pct",
|
||||
"stat_desc": "穿透一击几率",
|
||||
"min_val": 8,
|
||||
"max_val": 20,
|
||||
"duration": 600.0,
|
||||
"water_vnum": 50725
|
||||
},
|
||||
50826: {
|
||||
"name": "黄露水",
|
||||
"color": "yellow",
|
||||
"stat": "att_speed",
|
||||
"stat_desc": "攻击速度",
|
||||
"min_val": 2,
|
||||
"max_val": 8,
|
||||
"duration": 600.0,
|
||||
"water_vnum": 50726
|
||||
}
|
||||
}
|
||||
|
||||
# 草药提炼对应表 (herb_vnum -> water_vnum)
|
||||
const HERB_TO_WATER: Dictionary = {
|
||||
50701: 50721, # 桃花 -> Sim-Water
|
||||
50702: 50722, # 风铃草 -> Dok-Water
|
||||
50703: 50723, # 柿子花 -> Bo-Water
|
||||
50704: 50724, # 高山石斛 -> Young-Water
|
||||
50705: 50725, # 紫丁香 -> Zin-Water
|
||||
50708: 50726 # 桑椹 -> Sam-Water
|
||||
}
|
||||
|
||||
# 正在生效的露水增益: dew_vnum -> { name, stat, value, duration }
|
||||
var active_dews: Dictionary = {}
|
||||
|
||||
# 1. 在白高处提取草药药水 (原料草药 -> 中间提取液)
|
||||
static func brew_water(inventory: Array, herb_vnum: int) -> Dictionary:
|
||||
if not HERB_TO_WATER.has(herb_vnum):
|
||||
return {"ok": false, "reason": "INVALID_HERB", "msg": "此草药无法用于提炼!"}
|
||||
|
||||
var target_water: int = int(HERB_TO_WATER[herb_vnum])
|
||||
|
||||
# 消耗草药
|
||||
var herb_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
var it = inventory[i]
|
||||
if it != null and int(it.get("vnum", 0)) == herb_vnum:
|
||||
herb_slot = i
|
||||
break
|
||||
|
||||
if herb_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_HERB", "msg": "背包中缺少所需草药!"}
|
||||
|
||||
inventory[herb_slot] = null
|
||||
|
||||
# 寻找背包空位生成药水提取液
|
||||
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": "背包已满!"}
|
||||
|
||||
inventory[empty_slot] = {"vnum": target_water, "count": 1}
|
||||
return {"ok": true, "water_vnum": target_water, "slot": empty_slot}
|
||||
|
||||
# 2. 在白高处合成露水 (药水提取液 + 空瓶 70030 -> 神圣露水)
|
||||
static func brew_dew(inventory: Array, dew_vnum: int, force_max_roll: bool = false) -> Dictionary:
|
||||
if not DEW_TABLE.has(dew_vnum):
|
||||
return {"ok": false, "reason": "INVALID_DEW"}
|
||||
|
||||
var cfg: Dictionary = DEW_TABLE[dew_vnum]
|
||||
var req_water: int = int(cfg["water_vnum"])
|
||||
|
||||
var water_slot := -1
|
||||
var bottle_slot := -1
|
||||
|
||||
for i in range(inventory.size()):
|
||||
var it = inventory[i]
|
||||
if it != null:
|
||||
var v: int = int(it.get("vnum", 0))
|
||||
if v == req_water and water_slot == -1:
|
||||
water_slot = i
|
||||
elif v == ITEM_EMPTY_BOTTLE and bottle_slot == -1:
|
||||
bottle_slot = i
|
||||
|
||||
if water_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_WATER", "msg": "缺少对应的草药提取水剂!"}
|
||||
if bottle_slot == -1:
|
||||
return {"ok": false, "reason": "MISSING_BOTTLE", "msg": "缺少用于盛装露水的空药瓶 (70030)!"}
|
||||
|
||||
# 扣除水剂与空药瓶
|
||||
inventory[water_slot] = null
|
||||
inventory[bottle_slot] = null
|
||||
|
||||
# 抽取露水品质数值 (min_val ~ max_val)
|
||||
var min_v: int = int(cfg["min_val"])
|
||||
var max_v: int = int(cfg["max_val"])
|
||||
var roll_val: int = max_v if force_max_roll else randi_range(min_v, max_v)
|
||||
|
||||
var dew_item: Dictionary = {
|
||||
"vnum": dew_vnum,
|
||||
"name": cfg["name"],
|
||||
"value": roll_val,
|
||||
"duration": float(cfg["duration"])
|
||||
}
|
||||
|
||||
# 存入背包
|
||||
var stored_slot := -1
|
||||
for i in range(inventory.size()):
|
||||
if inventory[i] == null:
|
||||
inventory[i] = dew_item
|
||||
stored_slot = i
|
||||
break
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"dew_vnum": dew_vnum,
|
||||
"name": cfg["name"],
|
||||
"value": roll_val,
|
||||
"slot": stored_slot,
|
||||
"stat_desc": cfg["stat_desc"],
|
||||
"msg": "成功酿造出【%s】!品质增幅:%s +%d!" % [cfg["name"], cfg["stat_desc"], roll_val]
|
||||
}
|
||||
|
||||
# 3. 饮用露水获得增益
|
||||
func drink_dew(item: Dictionary) -> Dictionary:
|
||||
var vnum: int = int(item.get("vnum", 0))
|
||||
if not DEW_TABLE.has(vnum):
|
||||
return {"ok": false, "reason": "NOT_A_DEW"}
|
||||
|
||||
var cfg: Dictionary = DEW_TABLE[vnum]
|
||||
var roll_val: int = int(item.get("value", cfg["min_val"]))
|
||||
|
||||
active_dews[vnum] = {
|
||||
"name": cfg["name"],
|
||||
"stat": cfg["stat"],
|
||||
"value": roll_val,
|
||||
"duration": float(cfg["duration"]),
|
||||
"total_duration": float(cfg["duration"])
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"dew_vnum": vnum,
|
||||
"name": cfg["name"],
|
||||
"stat": cfg["stat"],
|
||||
"value": roll_val,
|
||||
"duration": float(cfg["duration"])
|
||||
}
|
||||
|
||||
# 逐帧更新倒计时
|
||||
func update(dt: float) -> Array[int]:
|
||||
var expired: Array[int] = []
|
||||
for vnum in active_dews.keys():
|
||||
var data: Dictionary = active_dews[vnum]
|
||||
data["duration"] = maxf(0.0, float(data["duration"]) - dt)
|
||||
if float(data["duration"]) <= 0.0:
|
||||
expired.append(vnum)
|
||||
|
||||
for vnum in expired:
|
||||
active_dews.erase(vnum)
|
||||
|
||||
return expired
|
||||
|
||||
# 查询某项属性在当前露水中的总加成
|
||||
func get_stat_bonus(stat_name: String) -> int:
|
||||
var total := 0
|
||||
for vnum in active_dews:
|
||||
var data: Dictionary = active_dews[vnum]
|
||||
if String(data.get("stat", "")) == stat_name:
|
||||
total += int(data.get("value", 0))
|
||||
return total
|
||||
|
||||
func has_dew(vnum: int) -> bool:
|
||||
return active_dews.has(vnum)
|
||||
@@ -1 +0,0 @@
|
||||
uid://d3w5osfswkpk
|
||||
@@ -1,207 +0,0 @@
|
||||
# horse_combat_system.gd —— Metin2 40250 战马骑乘战斗与 4 大战马专属技能 1:1
|
||||
# 对照 40250 服务端 char_skill.cpp, char_horse.cpp, skilldesc.txt:137-140
|
||||
class_name HorseCombatSystem
|
||||
extends RefCounted
|
||||
|
||||
signal horse_attack_performed(target_count: int, total_dmg: int)
|
||||
signal horse_skill_used(skill_id: int, skill_name: String, cost_sp: int)
|
||||
signal horse_stamina_changed(current: float, max_val: float)
|
||||
|
||||
const HORSE_TIER_BEGINNER := 1 # 初级马 (Lv 1-10, 仅代步)
|
||||
const HORSE_TIER_COMBAT := 2 # 战斗马 (Lv 11-20, 可马上挥砍)
|
||||
const HORSE_TIER_MILITARY := 3 # 军马 (Lv 21-30, 可马上挥砍并施展 4 大马术技能)
|
||||
|
||||
const MOUNTED_ATTACK_RANGE := 5.0
|
||||
const FOOT_ATTACK_RANGE := 3.0
|
||||
|
||||
const ITEM_FOOD_CARROT := 50054 # 胡萝卜 (战斗马饲料)
|
||||
const ITEM_FOOD_GINSENG := 50058 # 人参 (军马饲料)
|
||||
|
||||
const HORSE_SKILLS: Dictionary = {
|
||||
137: {
|
||||
"name": "战马砍击",
|
||||
"sp_cost": 30,
|
||||
"cooldown": 15.0,
|
||||
"dmg_mult": 2.5,
|
||||
"effect": "KNOCKBACK",
|
||||
"range": 5.0
|
||||
},
|
||||
138: {
|
||||
"name": "战马突刺",
|
||||
"sp_cost": 35,
|
||||
"cooldown": 20.0,
|
||||
"dmg_mult": 2.0,
|
||||
"effect": "STUMBLE",
|
||||
"range": 8.0
|
||||
},
|
||||
139: {
|
||||
"name": "战马震击",
|
||||
"sp_cost": 40,
|
||||
"cooldown": 25.0,
|
||||
"dmg_mult": 3.0,
|
||||
"effect": "AOE_STUN",
|
||||
"range": 6.0
|
||||
},
|
||||
140: {
|
||||
"name": "破浪击",
|
||||
"sp_cost": 30,
|
||||
"cooldown": 12.0,
|
||||
"dmg_mult": 2.2,
|
||||
"effect": "RANGED_WAVE",
|
||||
"range": 12.0
|
||||
}
|
||||
}
|
||||
|
||||
var is_riding: bool = false
|
||||
var horse_tier: int = HORSE_TIER_NONE
|
||||
var horse_stamina: float = 100.0
|
||||
var max_stamina: float = 100.0
|
||||
var skill_cooldowns: Dictionary = {
|
||||
137: 0.0,
|
||||
138: 0.0,
|
||||
139: 0.0,
|
||||
140: 0.0
|
||||
}
|
||||
|
||||
const HORSE_TIER_NONE := 0
|
||||
|
||||
# 设置骑乘状态
|
||||
func set_mounted_state(riding: bool, tier: int = HORSE_TIER_MILITARY) -> void:
|
||||
is_riding = riding
|
||||
horse_tier = tier if riding else HORSE_TIER_NONE
|
||||
|
||||
# 马上普通挥砍攻击 (Mounted Basic Sweep Attack)
|
||||
func perform_mounted_basic_attack(
|
||||
base_att: int,
|
||||
targets_in_range: Array
|
||||
) -> Dictionary:
|
||||
if not is_riding:
|
||||
return {"ok": false, "reason": "NOT_RIDING", "msg": "未处于骑乘状态!"}
|
||||
|
||||
if horse_tier < HORSE_TIER_COMBAT:
|
||||
return {"ok": false, "reason": "HORSE_TIER_TOO_LOW", "msg": "当前马匹等级不足,无法在马上战斗!需要战斗马或军马。"}
|
||||
|
||||
# 精力衰减
|
||||
horse_stamina = maxf(0.0, horse_stamina - 0.5)
|
||||
horse_stamina_changed.emit(horse_stamina, max_stamina)
|
||||
|
||||
# 伤害计算:马上横扫增伤 1.2x (精力枯竭时惩罚为 0.8x)
|
||||
var mult: float = 1.2 if horse_stamina > 0.0 else 0.8
|
||||
var dmg_per_target := int(base_att * mult)
|
||||
var total_dmg: int = 0
|
||||
var hit_targets: Array = []
|
||||
|
||||
for target in targets_in_range:
|
||||
var dist: float = float(target.get("distance", 999.0))
|
||||
if dist <= MOUNTED_ATTACK_RANGE:
|
||||
hit_targets.append(target)
|
||||
total_dmg += dmg_per_target
|
||||
|
||||
horse_attack_performed.emit(hit_targets.size(), total_dmg)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"attack_range": MOUNTED_ATTACK_RANGE,
|
||||
"hit_count": hit_targets.size(),
|
||||
"damage_per_hit": dmg_per_target,
|
||||
"total_damage": total_dmg,
|
||||
"stamina": horse_stamina
|
||||
}
|
||||
|
||||
# 施展 4 大战马专属技能 (137, 138, 139, 140)
|
||||
func use_horse_skill(
|
||||
skill_id: int,
|
||||
player_sp: int,
|
||||
base_att: int
|
||||
) -> Dictionary:
|
||||
if not is_riding:
|
||||
return {"ok": false, "reason": "NOT_RIDING", "msg": "未在马背上,无法施展马术技能!"}
|
||||
|
||||
if horse_tier < HORSE_TIER_MILITARY:
|
||||
return {"ok": false, "reason": "REQUIRES_MILITARY_HORSE", "msg": "马术专属技能需要军马 (Lv21+) 方可施展!"}
|
||||
|
||||
if not HORSE_SKILLS.has(skill_id):
|
||||
return {"ok": false, "reason": "INVALID_SKILL_ID", "msg": "未知的战马技能!"}
|
||||
|
||||
var cfg: Dictionary = HORSE_SKILLS[skill_id]
|
||||
|
||||
# 检查冷却
|
||||
if skill_cooldowns[skill_id] > 0.0:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "SKILL_ON_COOLDOWN",
|
||||
"remaining_cd": skill_cooldowns[skill_id],
|
||||
"msg": "技能【%s】冷却中,剩余 %.1f 秒。" % [cfg["name"], skill_cooldowns[skill_id]]
|
||||
}
|
||||
|
||||
# 检查玩家法力 (SP)
|
||||
var sp_cost: int = cfg["sp_cost"]
|
||||
if player_sp < sp_cost:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "INSUFFICIENT_SP",
|
||||
"cost": sp_cost,
|
||||
"current_sp": player_sp,
|
||||
"msg": "法力值不足,无法施展【%s】!" % cfg["name"]
|
||||
}
|
||||
|
||||
# 检查马匹精力
|
||||
if horse_stamina < 5.0:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "HORSE_EXHAUSTED",
|
||||
"msg": "战马体力已透支,请先喂食胡萝卜或人参!"
|
||||
}
|
||||
|
||||
# 施法成功:扣除精力、进入冷却
|
||||
horse_stamina = maxf(0.0, horse_stamina - 5.0)
|
||||
skill_cooldowns[skill_id] = cfg["cooldown"]
|
||||
horse_stamina_changed.emit(horse_stamina, max_stamina)
|
||||
horse_skill_used.emit(skill_id, cfg["name"], sp_cost)
|
||||
|
||||
var dealt_dmg := int(base_att * cfg["dmg_mult"])
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"skill_id": skill_id,
|
||||
"skill_name": cfg["name"],
|
||||
"sp_cost": sp_cost,
|
||||
"cooldown": cfg["cooldown"],
|
||||
"damage": dealt_dmg,
|
||||
"effect": cfg["effect"],
|
||||
"range": cfg["range"],
|
||||
"msg": "施展了战马技能【%s】!" % cfg["name"]
|
||||
}
|
||||
|
||||
# 喂食恢复马匹精力 (胡萝卜 +40, 人参 +80)
|
||||
func feed_horse(food_vnum: int) -> Dictionary:
|
||||
if horse_tier == HORSE_TIER_NONE:
|
||||
return {"ok": false, "reason": "NO_HORSE", "msg": "当前没有马匹!"}
|
||||
|
||||
var restore_amount := 0.0
|
||||
var food_name := ""
|
||||
if food_vnum == ITEM_FOOD_CARROT:
|
||||
restore_amount = 40.0
|
||||
food_name = "胡萝卜"
|
||||
elif food_vnum == ITEM_FOOD_GINSENG:
|
||||
restore_amount = 80.0
|
||||
food_name = "人参"
|
||||
else:
|
||||
return {"ok": false, "reason": "WRONG_FOOD", "msg": "这不是战马适合的食物!"}
|
||||
|
||||
horse_stamina = minf(max_stamina, horse_stamina + restore_amount)
|
||||
horse_stamina_changed.emit(horse_stamina, max_stamina)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"food_name": food_name,
|
||||
"restored": restore_amount,
|
||||
"current_stamina": horse_stamina,
|
||||
"msg": "战马食用了【%s】,恢复了体力!" % food_name
|
||||
}
|
||||
|
||||
# 冷却逐帧更新
|
||||
func update(dt: float) -> void:
|
||||
for sk in skill_cooldowns.keys():
|
||||
if skill_cooldowns[sk] > 0.0:
|
||||
skill_cooldowns[sk] = maxf(0.0, skill_cooldowns[sk] - dt)
|
||||
@@ -1 +0,0 @@
|
||||
uid://bq413tb15c7fk
|
||||
@@ -1,283 +0,0 @@
|
||||
# horse_level_growth_system.gd —— Metin2 40250 官方马匹等级进化与养殖草料喂食系统 1:1
|
||||
# 对照 40250 服务端 horse_rider.cpp, char_horse.cpp, constants.h
|
||||
class_name HorseLevelGrowthSystem
|
||||
extends RefCounted
|
||||
|
||||
signal horse_level_up(new_level: int, tier_name: String)
|
||||
signal horse_fed(new_health: int, max_health: int)
|
||||
signal horse_revived(new_health: int)
|
||||
signal horse_mounted(horse_level: int, stats_applied: Dictionary)
|
||||
signal horse_dismounted()
|
||||
signal stamina_exhausted()
|
||||
|
||||
# 40250 官方马牌与召唤道具
|
||||
const VNUM_HORSE_PICTURE := 50051 # 马牌 (初级小马召唤道具)
|
||||
const VNUM_ARMOR_HORSE_BOOK := 50052 # 战马书 (战斗装甲马召唤道具)
|
||||
const VNUM_MILITARY_HORSE_BOOK:= 50053 # 军马令 (军阶战马召唤道具)
|
||||
|
||||
# 40250 官方草料饲料
|
||||
const VNUM_HAY := 50054 # 干草 (初级小马食物)
|
||||
const VNUM_CARROT := 50055 # 胡萝卜 (战斗装甲马食物)
|
||||
const VNUM_RED_GINSENG := 50056 # 红参 (军阶战马食物)
|
||||
|
||||
# 40250 官方复活猴草
|
||||
const VNUM_HERB_EASY := 50057 # 初级猴草 (复活初级小马)
|
||||
const VNUM_HERB_NORMAL := 50058 # 中级猴草 (复活战斗装甲马)
|
||||
const VNUM_HERB_EXPERT := 50059 # 高级猴草 (复活军阶战马)
|
||||
|
||||
# 3 大战马阶层定义
|
||||
const TIER_NONE := 0
|
||||
const TIER_BEGINNER := 1 # 1~10 级 初级小马 (Race 20101, 人物需求 Lv 25)
|
||||
const TIER_COMBAT := 2 # 11~20 级 战斗装甲马 (Race 20104, 人物需求 Lv 35)
|
||||
const TIER_MILITARY := 3 # 21~30 级 军阶战马 (Race 20107, 人物需求 Lv 50)
|
||||
|
||||
# 40250 官方经典等级属性表 (选样代表级与通用成长模型)
|
||||
const HORSE_STAT_TABLE: Dictionary = {
|
||||
1: {"min_lv": 25, "race": 20101, "max_health": 3, "max_stamina": 4, "st": 26, "dx": 35, "ht": 18, "iq": 9, "atk": 54, "def": 32},
|
||||
5: {"min_lv": 25, "race": 20101, "max_health": 8, "max_stamina": 6, "st": 30, "dx": 40, "ht": 20, "iq": 10, "atk": 58, "def": 34},
|
||||
10: {"min_lv": 25, "race": 20101, "max_health": 15, "max_stamina": 10, "st": 35, "dx": 46, "ht": 23, "iq": 12, "atk": 63, "def": 37},
|
||||
11: {"min_lv": 35, "race": 20104, "max_health": 18, "max_stamina": 30, "st": 40, "dx": 53, "ht": 27, "iq": 13, "atk": 69, "def": 41},
|
||||
15: {"min_lv": 35, "race": 20104, "max_health": 24, "max_stamina": 55, "st": 44, "dx": 58, "ht": 29, "iq": 15, "atk": 73, "def": 43},
|
||||
20: {"min_lv": 35, "race": 20104, "max_health": 32, "max_stamina": 100, "st": 48, "dx": 64, "ht": 32, "iq": 16, "atk": 78, "def": 46},
|
||||
21: {"min_lv": 50, "race": 20107, "max_health": 35, "max_stamina": 120, "st": 53, "dx": 71, "ht": 36, "iq": 18, "atk": 84, "def": 50},
|
||||
25: {"min_lv": 50, "race": 20107, "max_health": 40, "max_stamina": 140, "st": 60, "dx": 80, "ht": 40, "iq": 20, "atk": 91, "def": 54},
|
||||
30: {"min_lv": 50, "race": 20107, "max_health": 50, "max_stamina": 200, "st": 67, "dx": 89, "ht": 45, "iq": 22, "atk": 99, "def": 59}
|
||||
}
|
||||
|
||||
var horse_level: int = 0
|
||||
var current_health: int = 0
|
||||
var current_stamina: int = 0
|
||||
var is_mounted: bool = false
|
||||
var applied_stats: Dictionary = {}
|
||||
|
||||
# 获取当前战马阶层
|
||||
func get_tier() -> int:
|
||||
if horse_level <= 0:
|
||||
return TIER_NONE
|
||||
elif horse_level <= 10:
|
||||
return TIER_BEGINNER
|
||||
elif horse_level <= 20:
|
||||
return TIER_COMBAT
|
||||
else:
|
||||
return TIER_MILITARY
|
||||
|
||||
# 获取阶层名称
|
||||
func get_tier_name() -> String:
|
||||
match get_tier():
|
||||
TIER_BEGINNER:
|
||||
return "初级小马"
|
||||
TIER_COMBAT:
|
||||
return "战斗装甲马"
|
||||
TIER_MILITARY:
|
||||
return "军阶战马"
|
||||
_:
|
||||
return "无战马"
|
||||
|
||||
# 获取对应等级的基础属性
|
||||
func get_horse_stat(lv: int) -> Dictionary:
|
||||
if HORSE_STAT_TABLE.has(lv):
|
||||
return HORSE_STAT_TABLE[lv].duplicate(true)
|
||||
# 近似插值计算
|
||||
var base_lv := 1
|
||||
for k in HORSE_STAT_TABLE.keys():
|
||||
if int(k) <= lv and int(k) > base_lv:
|
||||
base_lv = int(k)
|
||||
return HORSE_STAT_TABLE[base_lv].duplicate(true)
|
||||
|
||||
# 初始化/领养战马
|
||||
func initialize_horse(level: int = 1) -> void:
|
||||
horse_level = clampi(level, 1, 30)
|
||||
var stat = get_horse_stat(horse_level)
|
||||
current_health = int(stat["max_health"])
|
||||
current_stamina = int(stat["max_stamina"])
|
||||
|
||||
# 马匹等级升级
|
||||
func upgrade_horse_level(char_level: int) -> Dictionary:
|
||||
if horse_level >= 30:
|
||||
return {"ok": false, "reason": "MAX_HORSE_LEVEL", "msg": "战马已达最高等级 30 级!"}
|
||||
|
||||
var target_lv := horse_level + 1
|
||||
var target_stat := get_horse_stat(target_lv)
|
||||
var req_char_lv := int(target_stat["min_lv"])
|
||||
|
||||
if char_level < req_char_lv:
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "CHAR_LEVEL_TOO_LOW",
|
||||
"msg": "角色等级不足!晋升至 %d 级战马需要角色达到 %d 级。" % [target_lv, req_char_lv]
|
||||
}
|
||||
|
||||
horse_level = target_lv
|
||||
var new_stat := get_horse_stat(horse_level)
|
||||
current_health = int(new_stat["max_health"])
|
||||
current_stamina = int(new_stat["max_stamina"])
|
||||
|
||||
horse_level_up.emit(horse_level, get_tier_name())
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"horse_level": horse_level,
|
||||
"tier": get_tier_name(),
|
||||
"msg": "战马成功进阶至 %d 级【%s】!" % [horse_level, get_tier_name()]
|
||||
}
|
||||
|
||||
# 喂食草料恢复健康值 (40250 horse_rider.cpp:FeedHorse)
|
||||
func feed_horse(inventory: Array, slot_index: int) -> Dictionary:
|
||||
if horse_level <= 0:
|
||||
return {"ok": false, "reason": "NO_HORSE"}
|
||||
|
||||
if current_health <= 0:
|
||||
return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已重伤休克!需要喂食复活猴草唤醒。"}
|
||||
|
||||
var stat := get_horse_stat(horse_level)
|
||||
var max_hp := int(stat["max_health"])
|
||||
if current_health >= max_hp:
|
||||
return {"ok": false, "reason": "HEALTH_ALREADY_FULL", "msg": "战马饱食度与健康值已满!"}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var food: Dictionary = inventory[slot_index]
|
||||
var food_vnum := int(food.get("vnum", 0))
|
||||
var tier := get_tier()
|
||||
|
||||
# 校验草料与阶层匹配 (40250 规则: 初级吃干草, 战斗吃胡萝卜, 军马吃红参)
|
||||
var match_ok := false
|
||||
if tier == TIER_BEGINNER and food_vnum == VNUM_HAY:
|
||||
match_ok = true
|
||||
elif tier == TIER_COMBAT and food_vnum == VNUM_CARROT:
|
||||
match_ok = true
|
||||
elif tier == TIER_MILITARY and food_vnum == VNUM_RED_GINSENG:
|
||||
match_ok = true
|
||||
|
||||
if not match_ok:
|
||||
return {"ok": false, "reason": "FOOD_TIER_MISMATCH", "msg": "该饲料与当前战马阶层不匹配!"}
|
||||
|
||||
# 消耗 1 份食物
|
||||
var cnt := int(food.get("count", 1))
|
||||
if cnt > 1:
|
||||
food["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
current_health = mini(max_hp, current_health + 2)
|
||||
horse_fed.emit(current_health, max_hp)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"current_health": current_health,
|
||||
"max_health": max_hp,
|
||||
"msg": "战马享用了饲料,健康值恢复至 %d/%d!" % [current_health, max_hp]
|
||||
}
|
||||
|
||||
# 使用猴草唤醒复活战马
|
||||
func revive_horse(inventory: Array, slot_index: int) -> Dictionary:
|
||||
if current_health > 0:
|
||||
return {"ok": false, "reason": "HORSE_NOT_DEAD", "msg": "战马依然健在,无需使用复活猴草!"}
|
||||
|
||||
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
|
||||
return {"ok": false, "reason": "INVALID_SLOT"}
|
||||
|
||||
var herb: Dictionary = inventory[slot_index]
|
||||
var herb_vnum := int(herb.get("vnum", 0))
|
||||
var tier := get_tier()
|
||||
|
||||
var herb_ok := false
|
||||
if tier == TIER_BEGINNER and herb_vnum == VNUM_HERB_EASY:
|
||||
herb_ok = true
|
||||
elif tier == TIER_COMBAT and herb_vnum == VNUM_HERB_NORMAL:
|
||||
herb_ok = true
|
||||
elif tier == TIER_MILITARY and herb_vnum == VNUM_HERB_EXPERT:
|
||||
herb_ok = true
|
||||
|
||||
if not herb_ok:
|
||||
return {"ok": false, "reason": "HERB_TIER_MISMATCH", "msg": "复活猴草阶层与战马不匹配!"}
|
||||
|
||||
# 消耗 1 株猴草
|
||||
var cnt := int(herb.get("count", 1))
|
||||
if cnt > 1:
|
||||
herb["count"] = cnt - 1
|
||||
else:
|
||||
inventory[slot_index] = null
|
||||
|
||||
var stat := get_horse_stat(horse_level)
|
||||
current_health = maxi(1, int(stat["max_health"]) / 2)
|
||||
current_stamina = maxi(1, int(stat["max_stamina"]) / 2)
|
||||
|
||||
horse_revived.emit(current_health)
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"health": current_health,
|
||||
"msg": "灵草生效!战马重焕生机复活苏醒。"
|
||||
}
|
||||
|
||||
# 骑乘上马
|
||||
func mount_horse(player_stats: Dictionary) -> Dictionary:
|
||||
if horse_level <= 0:
|
||||
return {"ok": false, "reason": "NO_HORSE", "msg": "尚未获得战马!"}
|
||||
|
||||
if current_health <= 0:
|
||||
return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已昏迷,需先喂食猴草复活!"}
|
||||
|
||||
if is_mounted:
|
||||
return {"ok": false, "reason": "ALREADY_MOUNTED"}
|
||||
|
||||
var stat := get_horse_stat(horse_level)
|
||||
# 注入战马加成
|
||||
applied_stats = {
|
||||
"attack_power": int(stat["atk"]),
|
||||
"defense": int(stat["def"])
|
||||
}
|
||||
for k in applied_stats.keys():
|
||||
player_stats[k] = int(player_stats.get(k, 0)) + applied_stats[k]
|
||||
|
||||
is_mounted = true
|
||||
horse_mounted.emit(horse_level, applied_stats)
|
||||
|
||||
return {"ok": true, "msg": "成功乘骑战马!人马合一属性生效。"}
|
||||
|
||||
# 下马
|
||||
func dismount_horse(player_stats: Dictionary) -> Dictionary:
|
||||
if not is_mounted:
|
||||
return {"ok": false, "reason": "NOT_MOUNTED"}
|
||||
|
||||
for k in applied_stats.keys():
|
||||
player_stats[k] = int(player_stats.get(k, 0)) - applied_stats[k]
|
||||
applied_stats.clear()
|
||||
|
||||
is_mounted = false
|
||||
horse_dismounted.emit()
|
||||
|
||||
return {"ok": true, "msg": "已安全下马。"}
|
||||
|
||||
# 消耗耐力
|
||||
func consume_stamina(amount: int, player_stats: Dictionary) -> void:
|
||||
if not is_mounted:
|
||||
return
|
||||
current_stamina = maxi(0, current_stamina - amount)
|
||||
if current_stamina <= 0:
|
||||
stamina_exhausted.emit()
|
||||
dismount_horse(player_stats) # 耐力耗尽自动下马
|
||||
|
||||
# 恢复耐力
|
||||
func recover_stamina(amount: int) -> void:
|
||||
var stat := get_horse_stat(horse_level)
|
||||
var max_stamina := int(stat["max_stamina"])
|
||||
current_stamina = mini(max_stamina, current_stamina + amount)
|
||||
|
||||
# 序列化
|
||||
func serialize() -> Dictionary:
|
||||
return {
|
||||
"horse_level": horse_level,
|
||||
"current_health": current_health,
|
||||
"current_stamina": current_stamina,
|
||||
"is_mounted": is_mounted
|
||||
}
|
||||
|
||||
# 反序列化
|
||||
func deserialize(data: Dictionary) -> void:
|
||||
horse_level = int(data.get("horse_level", 0))
|
||||
current_health = int(data.get("current_health", 0))
|
||||
current_stamina = int(data.get("current_stamina", 0))
|
||||
is_mounted = bool(data.get("is_mounted", false))
|
||||
@@ -1 +0,0 @@
|
||||
uid://cwiwpfsupj5gr
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user