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:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+157
View File
@@ -0,0 +1,157 @@
# Minimap (P9) —— 右上角圆形小地图。北朝上。
#
# var mm := preload("res://ui/minimap.gd").new()
# add_child(mm)
# mm.setup(m2client, canvas_parent, func() -> Node3D: return player)
#
# 画:底圆 + 外环 + 玩家箭头 + 实体蓝点 + 怪红点 + NPC 黄点 + 传送青点 + 任务标记橙星 +
# 观战者紫色菱形 + 公会领地边框;支持 SetScale / 鼠标滚轮缩放。
# 世界→图:Δ = blip.xz - player.xz,按 scale 转像素,(+x 右, -z 上),超出半径夹到边缘。
extends Node
const RADIUS := 70.0
const SCALE := 0.25 # 像素/米(越大越放大)
const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送
const MIN_SCALE := 0.10
const MAX_SCALE := 1.25
var client: Node
var player_getter: Callable
var _root: Control
var _view: Control
var _ch_label: Label
var _scale := SCALE
func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
client = m2client
player_getter = get_player
_build(parent)
if client and client.has_signal("channel_changed"):
client.channel_changed.connect(func(c): _ch_label.text = "CH %d" % c)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
parent.add_child(_root)
_view = Control.new()
_view.size = Vector2(2 * RADIUS, 2 * RADIUS)
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
_view.draw.connect(_draw_map)
_root.add_child(_view)
_root.gui_input.connect(_on_gui_input)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
_ch_label = Label.new()
_ch_label.position = Vector2(0, 2 * RADIUS)
_ch_label.add_theme_font_size_override("font_size", 11)
_ch_label.text = "CH -"
_root.add_child(_ch_label)
func _process(_dt: float) -> void:
if _view:
_view.queue_redraw()
# Matches CPythonMiniMap::SetScale: callers may provide pixels per world metre.
# The wheel gesture is intentionally local to the minimap so world controls do
# not change when the pointer is elsewhere.
func set_scale(pixels_per_meter: float) -> void:
_scale = clampf(pixels_per_meter, MIN_SCALE, MAX_SCALE)
if _view:
_view.queue_redraw()
func get_scale() -> float:
return _scale
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
set_scale(_scale * 1.15)
_root.accept_event()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
set_scale(_scale / 1.15)
_root.accept_event()
func _player() -> Node3D:
return player_getter.call() if player_getter.is_valid() else null
func _to_map(world: Vector3, origin: Vector3) -> Vector2:
var d := world - origin
# screen: +x right, +y down. world z = north -> screen up (y). world +x = east -> right.
var p := Vector2(d.x, d.z) * _scale
if p.length() > RADIUS - 4.0:
p = p.normalized() * (RADIUS - 4.0)
return Vector2(RADIUS, RADIUS) + p
func _draw_map() -> void:
var c := Vector2(RADIUS, RADIUS)
_view.draw_circle(c, RADIUS, Color(0.05, 0.06, 0.08, 0.82))
_view.draw_arc(c, RADIUS - 1.5, 0, TAU, 48, Color(0.6, 0.6, 0.7, 0.7), 2.0)
var pl := _player()
if pl == null or client == null:
return
var origin: Vector3 = pl.global_position
var party_vids := {}
if client.has_method("get_party"):
for member in client.get_party():
var pvid := int(member.get("vid", 0))
if pvid != 0:
party_vids[pvid] = bool(member.get("leader", false))
# 实体:怪红、其它蓝
if client.has_method("get_entities"):
for e in client.get_entities():
if bool(e.get("is_main", false)):
continue
var pos: Vector3 = e.get("pos", Vector3.ZERO)
var vid := int(e.get("vid", 0))
var map_pos := _to_map(MapCoord.to_world(pos), origin)
var col := Color(0.35, 1.0, 0.55) if party_vids.has(vid) \
else Color(0.4, 0.6, 1.0) if int(e.get("ch_type", 0)) != 2 else Color(1.0, 0.35, 0.3)
_view.draw_circle(map_pos, 2.5, col)
if bool(party_vids.get(vid, false)):
_view.draw_arc(map_pos, 4.5, 0.0, TAU, 16, Color(1.0, 0.9, 0.25, 0.9), 1.0)
# NPC 标记:传送青、其它黄
if client.has_method("get_npc_marks"):
for m in client.get_npc_marks():
var col := Color(0.3, 0.9, 0.9) if int(m.get("type", 0)) == NPC_WARP_TYPE else Color(1.0, 0.85, 0.2)
_view.draw_circle(_to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin), 3.0, col)
# 观战者:原版在小地图上显示独立的观察点(紫色菱形)。
if client.has_method("get_observers"):
for o in client.get_observers():
var op := _to_map(MapCoord.to_world(o.get("pos", Vector3.ZERO)), origin)
var diamond := PackedVector2Array([op + Vector2(0, -4), op + Vector2(4, 0),
op + Vector2(0, 4), op + Vector2(-4, 0)])
_view.draw_colored_polygon(diamond, Color(0.8, 0.35, 1.0, 0.95))
# 公会领地:GC_LAND_LIST 的全局厘米矩形,在小地图上画细边框;
# guild_id=0 使用中性青色,其余领地使用金色。
if client.has_method("get_land_areas"):
for a in client.get_land_areas():
var x := float(a.get("x", 0))
var y := float(a.get("y", 0))
var w := float(a.get("width", 0))
var h := float(a.get("height", 0))
var p0 := _to_map(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01)), origin)
var p1 := _to_map(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01)), origin)
var rect := Rect2(Vector2(min(p0.x, p1.x), min(p0.y, p1.y)),
Vector2(abs(p1.x - p0.x), abs(p1.y - p0.y)))
var col := Color(0.3, 0.9, 0.9, 0.75) if int(a.get("guild_id", 0)) == 0 \
else Color(1.0, 0.75, 0.2, 0.85)
_view.draw_rect(rect, col, false, 1.0)
# 任务标记:橙星(小方块代替)
if client.has_method("get_world_markers"):
for m in client.get_world_markers():
var mp := _to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin)
_view.draw_rect(Rect2(mp - Vector2(3, 3), Vector2(6, 6)), Color(1.0, 0.6, 0.1))
# 玩家箭头(朝向)
var yaw: float = pl.rotation.y
var fwd := Vector2(sin(yaw), cos(yaw)) # world +? -> map;与 _to_map 的 (x,-z) 对齐
var tip := c + fwd * 8.0
var left := c + fwd.rotated(deg_to_rad(140)) * 6.0
var right := c + fwd.rotated(deg_to_rad(-140)) * 6.0
_view.draw_colored_polygon(PackedVector2Array([tip, left, right]), Color(1, 1, 1))