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
+242
View File
@@ -0,0 +1,242 @@
# AtlasUI (P9) —— 对齐 CPythonMiniMap 的 Atlas 窗口:大地图、拖动、玩家
# 标记和坐标查询。地图底图复用每个区块的 minimap.dds;缺贴图时仍显示
# 可交互的中性底图,便于无资源/打包环境继续使用坐标查询。
#
# var atlas := preload("res://ui/atlas_ui.gd").new()
# atlas.setup(world, ui_manager, func() -> Node3D: return player, "map_a1")
# atlas.toggle() # M 键
extends Node
class AtlasOverlay extends Control:
var owner_ui: Node
func _draw() -> void:
if owner_ui:
owner_ui._draw_world_marks(self)
const VIEW_SIZE := Vector2(720, 520)
const CELL_PIXELS := 128
const MARK_SIZE := 8.0
var world: Node
var client: Node
var ui: CanvasLayer
var player_getter: Callable
var map_name := ""
var _win: Control
var _map_view: Control
var _map: TextureRect
var _overlay: Control
var _player_mark: ColorRect
var _coord: Label
var _dragging := false
var _drag_origin := Vector2.ZERO
var _map_origin := Vector2.ZERO
var _map_px := Vector2.ZERO
var _world_size_m := Vector2(1, 1)
func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable,
name := "", m2client: Node = null) -> void:
world = metin_world
client = m2client
ui = ui_manager
player_getter = get_player
map_name = name if name != "" else "地图"
func is_open() -> bool:
return _win != null and is_instance_valid(_win)
func toggle() -> void:
if is_open():
close()
else:
open()
func open() -> void:
if is_open() or ui == null:
return
_build_window()
ui.open(_win, false)
_update_player_marker()
func close() -> void:
if is_open() and ui:
ui.close(_win)
_win = null
func _build_window() -> void:
_win = Control.new()
_win.name = "AtlasWindow"
_win.set_anchors_preset(Control.PRESET_CENTER)
_win.position = -VIEW_SIZE * 0.5
_win.size = VIEW_SIZE
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.055, 0.065, 0.09, 0.97)
style.border_color = Color(0.4, 0.45, 0.58, 0.9)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", style)
_win.add_child(panel)
var title := Label.new()
title.text = "Atlas · %s" % map_name
title.position = Vector2(14, 8)
title.add_theme_font_size_override("font_size", 15)
_win.add_child(title)
var close_btn := Button.new()
close_btn.text = "×"
close_btn.position = Vector2(VIEW_SIZE.x - 42, 5)
close_btn.size = Vector2(32, 28)
close_btn.pressed.connect(close)
_win.add_child(close_btn)
_coord = Label.new()
_coord.position = Vector2(14, VIEW_SIZE.y - 30)
_coord.text = "坐标:--"
_coord.add_theme_font_size_override("font_size", 12)
_win.add_child(_coord)
_map_view = Control.new()
_map_view.name = "MapView"
_map_view.position = Vector2(14, 42)
_map_view.size = Vector2(VIEW_SIZE.x - 28, VIEW_SIZE.y - 80)
_map_view.clip_contents = true
_map_view.gui_input.connect(_on_map_input)
_win.add_child(_map_view)
_overlay = AtlasOverlay.new()
_overlay.owner_ui = self
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_view.add_child(_overlay)
_build_map()
func _build_map() -> void:
var tiles := Vector2i(1, 1)
if world and world.has_method("get_map_size_tiles"):
tiles = world.get_map_size_tiles()
tiles.x = maxi(1, tiles.x)
tiles.y = maxi(1, tiles.y)
_world_size_m = Vector2(tiles.x * 256.0, tiles.y * 256.0)
_map_px = Vector2(tiles.x * CELL_PIXELS, tiles.y * CELL_PIXELS)
var image := Image.create_empty(int(_map_px.x), int(_map_px.y), false, Image.FORMAT_RGBA8)
image.fill(Color(0.12, 0.14, 0.18, 1.0))
var loaded := 0
for tx in tiles.x:
for ty in tiles.y:
if not world or not world.has_method("chunk_dir") or not world.has_method("load_dds"):
continue
var path: String = world.chunk_dir(tx, ty).path_join("minimap.dds")
if not FileAccess.file_exists(path):
continue
var tile: Image = world.load_dds(path)
if tile == null:
continue
tile.resize(CELL_PIXELS, CELL_PIXELS, Image.INTERPOLATE_BILINEAR)
image.blit_rect(tile, Rect2i(Vector2i.ZERO, Vector2i(CELL_PIXELS, CELL_PIXELS)),
Vector2i(tx * CELL_PIXELS, ty * CELL_PIXELS))
loaded += 1
var tex := ImageTexture.create_from_image(image)
_map = TextureRect.new()
_map.name = "AtlasTexture"
_map.texture = tex
_map.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_map.stretch_mode = TextureRect.STRETCH_SCALE
_map.size = _map_px
_map.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_origin = (_map_view.size - _map_px) * 0.5
_map.position = _map_origin
_map_view.add_child(_map)
_overlay.position = _map.position
_overlay.size = _map.size
_player_mark = ColorRect.new()
_player_mark.name = "PlayerMark"
_player_mark.color = Color(1.0, 0.85, 0.15, 1.0)
_player_mark.size = Vector2(MARK_SIZE, MARK_SIZE)
_player_mark.mouse_filter = Control.MOUSE_FILTER_IGNORE
_overlay.add_child(_player_mark)
_overlay.move_to_front()
if loaded == 0:
_coord.text = "坐标:--(缺 minimap.dds,仍可查询)"
func _on_map_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_dragging = event.pressed
if _dragging:
_drag_origin = event.position
_map_origin = _map.position
_map_view.accept_event()
elif event is InputEventMouseMotion and _dragging:
_map.position = _map_origin + event.position - _drag_origin
_overlay.position = _map.position
_map_view.accept_event()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_map_origin = (_map_view.size - _map.size) * 0.5
_map.position = _map_origin
_overlay.position = _map.position
_map_view.accept_event()
func _process(_dt: float) -> void:
if is_open():
_update_player_marker()
_overlay.queue_redraw()
func _player() -> Node3D:
return player_getter.call() if player_getter.is_valid() else null
func _update_player_marker() -> void:
if _player_mark == null or not is_instance_valid(_player_mark):
return
var p := _player()
if p == null:
return
var world_pos: Vector3 = p.global_position
var px := Vector2(world_pos.x / _world_size_m.x * _map_px.x,
world_pos.z / _world_size_m.y * _map_px.y)
# Marker is a child of the overlay, whose origin tracks the draggable map.
_player_mark.position = px - Vector2(MARK_SIZE, MARK_SIZE) * 0.5
var server_cm := MapCoord.to_server_cm(world_pos)
_coord.text = "坐标:X %d Y %d" % [roundi(server_cm.x), roundi(server_cm.y)]
func _world_to_px(world_pos: Vector3) -> Vector2:
return Vector2(world_pos.x / _world_size_m.x * _map_px.x,
world_pos.z / _world_size_m.y * _map_px.y)
func _draw_world_marks(canvas: CanvasItem) -> void:
if client == null:
return
if client.has_method("get_party") and client.has_method("get_entity"):
for member in client.get_party():
var vid := int(member.get("vid", 0))
if vid == 0:
continue
var entity: Dictionary = client.get_entity(vid)
if entity.is_empty():
continue
var party_pos := _world_to_px(MapCoord.to_world(entity.get("pos", Vector3.ZERO)))
canvas.draw_circle(party_pos, 5.0, Color(0.35, 1.0, 0.55, 0.95))
if bool(member.get("leader", false)):
canvas.draw_arc(party_pos, 8.0, 0.0, TAU, 20, Color(1.0, 0.9, 0.25, 0.95), 2.0)
# Guild areas arrive in global server centimetres, matching GC_LAND_LIST.
if client.has_method("get_land_areas"):
for area in client.get_land_areas():
var x := float(area.get("x", 0))
var y := float(area.get("y", 0))
var w := float(area.get("width", 0))
var h := float(area.get("height", 0))
var p0 := _world_to_px(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01)))
var p1 := _world_to_px(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01)))
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.35, 0.95, 0.95, 0.8) if int(area.get("guild_id", 0)) == 0 \
else Color(1.0, 0.75, 0.2, 0.9)
canvas.draw_rect(rect, col, false, 2.0)
# Observer points are exposed in the same network/Godot frame as entities.
if client.has_method("get_observers"):
for observer in client.get_observers():
var p: Vector3 = observer.get("pos", Vector3.ZERO)
var op := _world_to_px(MapCoord.to_world(p))
canvas.draw_colored_polygon(PackedVector2Array([op + Vector2(0, -6), op + Vector2(6, 0),
op + Vector2(0, 6), op + Vector2(-6, 0)]), Color(0.8, 0.35, 1.0, 0.95))
# Quest/warp markers remain visible when the Atlas is used for navigation.
if client.has_method("get_world_markers"):
for marker in client.get_world_markers():
var mp := _world_to_px(MapCoord.to_world(marker.get("pos", Vector3.ZERO)))
canvas.draw_rect(Rect2(mp - Vector2(4, 4), Vector2(8, 8)), Color(1.0, 0.55, 0.1, 0.95))