Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# combat_fx_test —— P4:受击硬直 / combo / 抖屏 / 状态图标 / 死亡窗 headless 自检。
|
||||
# godot --headless --path project --script combat_fx_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const NetPlay = preload("res://net_play.gd")
|
||||
const GameCamera = preload("res://game_camera.gd")
|
||||
const DeathUI = preload("res://ui/death_ui.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal entity_main_set(vid: int)
|
||||
signal entity_moved(vid: int)
|
||||
signal entity_despawned(vid: int)
|
||||
signal entity_dead(vid: int)
|
||||
signal vitals_changed(vid: int)
|
||||
signal points_changed(points: Dictionary)
|
||||
signal target_info(vid: int, hp_percent: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal fishing_event(subheader: int, info: int, dir: int)
|
||||
signal affect_added(affect: Dictionary)
|
||||
signal affect_removed(type: int)
|
||||
signal phase_changed(phase: String)
|
||||
var main := 1000
|
||||
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
|
||||
var attacks := []
|
||||
var said := []
|
||||
var affects := []
|
||||
func is_in_game() -> bool: return true
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
|
||||
func get_affects() -> Array: return affects
|
||||
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
|
||||
func set_target(v) -> bool: return true
|
||||
func move(a, b, c, d, e) -> bool: return true
|
||||
func say(t, s) -> bool: said.append([t, s]); return true
|
||||
|
||||
class FakePC extends Node:
|
||||
signal target_selected(node: Node3D)
|
||||
signal moved(pos: Vector3)
|
||||
signal anim_state(state: String)
|
||||
var player: Node3D
|
||||
var frozen := false
|
||||
|
||||
class FakeView extends Node3D:
|
||||
var states := []
|
||||
func set_anim_state(s): states.append(s)
|
||||
|
||||
class FakeHud extends Node:
|
||||
var affects := []
|
||||
func set_affects(a): affects = a
|
||||
|
||||
var _fail := 0
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: combat_fx_test (hitstun / combo / shake / affects / death)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
func _run() -> void:
|
||||
var fc := FakeClient.new()
|
||||
var pc := FakePC.new()
|
||||
var view: Node3D = FakeView.new()
|
||||
var hud := FakeHud.new()
|
||||
var cam: Camera3D = GameCamera.new()
|
||||
pc.player = Node3D.new()
|
||||
for n in [fc, pc, view, hud, cam, pc.player]:
|
||||
get_root().add_child(n)
|
||||
cam.target = pc.player
|
||||
|
||||
var np: Node = NetPlay.new()
|
||||
get_root().add_child(np)
|
||||
np.setup(fc, pc, null, hud)
|
||||
np.camera = cam
|
||||
np.player_view = view
|
||||
np._main_vid = 1000
|
||||
|
||||
# --- 受击硬直 + 抖屏 ---
|
||||
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
|
||||
_ck(np.is_stunned(), "hit -> stunned")
|
||||
_ck(cam._shake > 0.0, "hit -> camera shake queued")
|
||||
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
|
||||
np._process(0.016)
|
||||
_ck(pc.frozen == true, "stun -> pc.frozen = true (got %s)" % pc.frozen)
|
||||
|
||||
# 闪避不硬直
|
||||
np._hitstun_until = 0.0
|
||||
fc.damage.emit(1000, 0, 1 << 2) # DODGE
|
||||
_ck(not np.is_stunned(), "dodge -> not stunned")
|
||||
|
||||
# 别人受击不影响自己
|
||||
np._hitstun_until = 0.0
|
||||
fc.damage.emit(2000, 30, 0)
|
||||
_ck(not np.is_stunned(), "other's damage -> not stunned")
|
||||
|
||||
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
|
||||
np.player_view = view
|
||||
fc.fishing_event.emit(0, 1000, 18)
|
||||
_ck(view.states.back() == "fishing", "fishing start -> local fishing animation")
|
||||
fc.fishing_event.emit(3, 1000, 0)
|
||||
_ck(view.states.back() == "fishing_catch", "fishing success -> catch animation")
|
||||
var before: int = view.states.size()
|
||||
fc.fishing_event.emit(5, 30001, 0)
|
||||
_ck(view.states.size() == before, "fishing fish item -> no local motion")
|
||||
|
||||
# --- combo:连续攻击 motion 递增 ---
|
||||
np._hitstun_until = 0.0
|
||||
np._target_vid = 2000
|
||||
np.attack_period = 0.0
|
||||
np.ATTACK_RANGE # (const, no-op)
|
||||
# 让目标在攻击距离内
|
||||
# net_world 为 null -> _process 里 node_for 走不到;直接测 combo 计数逻辑:
|
||||
np._last_attack_t = np._now()
|
||||
np._combo = 0
|
||||
# 模拟三次「窗口内攻击」
|
||||
for i in 3:
|
||||
np._combo = (np._combo + 1) % 3 if (np._now() - np._last_attack_t) < np.COMBO_WINDOW else 0
|
||||
np._last_attack_t = np._now()
|
||||
_ck(np._combo == 0, "combo cycles 1->2->0 over 3 hits, got %d" % np._combo)
|
||||
|
||||
# --- 状态图标条 ---
|
||||
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
|
||||
# game_scene 里连的是 refresh lambda;这里直接调 hud
|
||||
hud.set_affects(fc.get_affects())
|
||||
_ck(hud.affects.size() == 1 and int(hud.affects[0]["type"]) == 7001, "hud.set_affects")
|
||||
|
||||
# --- 死亡窗 ---
|
||||
var canvas := CanvasLayer.new()
|
||||
get_root().add_child(canvas)
|
||||
var death: Node = DeathUI.new()
|
||||
get_root().add_child(death)
|
||||
death.setup(fc, canvas)
|
||||
_ck(not death._root.visible, "death dialog hidden initially")
|
||||
fc.ents[1000]["dead"] = true
|
||||
fc.entity_dead.emit(1000)
|
||||
_ck(death._root.visible, "entity_dead(main) -> death dialog shown")
|
||||
# 点「在此复活」
|
||||
var hb: Button = null
|
||||
for n in death._root.find_children("*", "Button", true, false):
|
||||
if n.text == "在此复活":
|
||||
hb = n
|
||||
_ck(hb != null, "restart-here button present")
|
||||
if hb:
|
||||
hb.pressed.emit()
|
||||
_ck(fc.said.size() == 1 and fc.said[0] == [0, "/restart_here"], "restart -> say(0, '/restart_here')")
|
||||
# 复活:hp 回来 -> 自动关
|
||||
fc.ents[1000]["dead"] = false
|
||||
fc.ents[1000]["hp"] = 100
|
||||
fc.vitals_changed.emit(1000)
|
||||
_ck(not death._root.visible, "revive (hp>0) -> dialog auto-hides")
|
||||
Reference in New Issue
Block a user