Adapt Android mobile controls and native client UI
This commit is contained in:
+3
-25
@@ -1,11 +1,7 @@
|
|||||||
# Metin2 game assets (project/asset_root.gd). Committed to THIS self-hosted
|
# Metin2 game assets are private. Do not publish or redistribute Ymir art.
|
||||||
# private repo for one-clone multi-device builds. Do not publish/redistribute
|
|
||||||
# — copyrighted Ymir art.
|
|
||||||
|
|
||||||
# build
|
# build
|
||||||
/build/
|
/build/
|
||||||
# 渲染测试的落图输出(test/rendering 用例自建)
|
|
||||||
/project/build/
|
|
||||||
/build-ios/
|
/build-ios/
|
||||||
/build-android/
|
/build-android/
|
||||||
/build-classic/
|
/build-classic/
|
||||||
@@ -16,24 +12,6 @@
|
|||||||
/build-native/
|
/build-native/
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|
||||||
# godot
|
|
||||||
/project/.godot/
|
|
||||||
/project/bin/*.dylib
|
|
||||||
/project/bin/*.dSYM/
|
|
||||||
/project/bin/*.so
|
|
||||||
/project/bin/*.dll
|
|
||||||
/project/bin/*.wasm
|
|
||||||
/project/bin/ios/
|
|
||||||
/project/bin/android/
|
|
||||||
# The embedded interpreter's stdlib, copied here from the build tree so the exporter packs it
|
|
||||||
# (extension/CMakeLists.txt, 批次 2P step 3c). Derived — never committed.
|
|
||||||
/project/python27.zip
|
|
||||||
/project/python27.zip.sha256
|
|
||||||
# Android Gradle build template (~1.2 GB of regenerable Gradle output + Godot
|
|
||||||
# .aar). Recreate per machine:
|
|
||||||
# godot --headless --path project --install-android-build-template \
|
|
||||||
# --export-debug Android build/export/mtgodot-poc.apk
|
|
||||||
/project/android/
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -76,8 +54,8 @@ __pycache__/
|
|||||||
/test/compare-shots/
|
/test/compare-shots/
|
||||||
/test/compare-report.json
|
/test/compare-report.json
|
||||||
|
|
||||||
# Written by 40250 uitaskbar.py (SaveMouseButtonSettings) into the working directory at runtime.
|
# Written by 40250 uitaskbar.py at runtime.
|
||||||
/project/mouse.cfg
|
/mouse.cfg
|
||||||
|
|
||||||
# native Android client (./build-android-native.sh): SDL3 download, NDK builds, Gradle output
|
# native Android client (./build-android-native.sh): SDL3 download, NDK builds, Gradle output
|
||||||
/build-android-native/
|
/build-android-native/
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
[submodule "extension/godot-cpp"]
|
|
||||||
path = extension/godot-cpp
|
|
||||||
url = https://github.com/godotengine/godot-cpp.git
|
|
||||||
[submodule "extension/third_party/zstd"]
|
[submodule "extension/third_party/zstd"]
|
||||||
path = extension/third_party/zstd
|
path = extension/third_party/zstd
|
||||||
url = https://github.com/facebook/zstd.git
|
url = https://github.com/facebook/zstd.git
|
||||||
|
|||||||
+17
-50
@@ -1,62 +1,38 @@
|
|||||||
# mtgodot-poc — top level
|
|
||||||
# Phase 1 (macOS) scaffolding. See docs/GODOT-POC-PLAN.md §M0'.
|
|
||||||
#
|
|
||||||
# libgr2 / formats / oracle / tools were vendored in from the retired xrender-poc
|
|
||||||
# repo (2026-08-29). libgr2 is the shared core (gr2 v6/v7 reader, oracle-verified);
|
|
||||||
# formats = .msa/.msm parsers (needed M2 T2.2/T2.3); tools = libgr2 validation.
|
|
||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(mtgodot_poc CXX C)
|
project(metin2_native CXX C)
|
||||||
|
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_C_STANDARD 17)
|
set(CMAKE_C_STANDARD 17)
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||||
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
|
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
option(MT_BUILD_TOOLS "Build libgr2 validation tools" OFF)
|
||||||
|
option(MT_BUILD_NET_TOOLS "Build the host-only classic protocol probe" OFF)
|
||||||
|
option(MT_BUILD_PORT "Build the 40250 port and its tests" ON)
|
||||||
|
option(MT_BUILD_NATIVE_RENDER "Build the SDL3 and Vulkan client" OFF)
|
||||||
|
|
||||||
option(MTGODOT_BUILD_EXTENSION "Build the Godot GDExtension (pulls in godot-cpp)" ON)
|
|
||||||
option(MTGODOT_BUILD_TOOLS "Build libgr2 validation tools (gr2dump/gr2fuzz/oracle_diff)" OFF)
|
|
||||||
option(MTGODOT_BUILD_NET_TOOLS "Build host-only protocol probes without Godot" OFF)
|
|
||||||
option(MTGODOT_BUILD_PORT "Build only port_logic (with MTGODOT_BUILD_EXTENSION=OFF), e.g. for Linux/Windows" OFF)
|
|
||||||
option(MT_BUILD_NATIVE_RENDER_PROTOTYPE "Build the standalone Vulkan draw-command prototype" OFF)
|
|
||||||
|
|
||||||
# --- shared, cross-platform core (no godot-cpp, no third-party) ---
|
|
||||||
add_subdirectory(libgr2)
|
add_subdirectory(libgr2)
|
||||||
add_subdirectory(formats)
|
add_subdirectory(formats)
|
||||||
|
add_subdirectory(extension/third_party)
|
||||||
|
add_subdirectory(extension/src/codepage)
|
||||||
|
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
|
||||||
|
add_subdirectory(extension/src/port)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(MTGODOT_BUILD_TOOLS)
|
if(MT_BUILD_TOOLS)
|
||||||
add_subdirectory(tools)
|
add_subdirectory(tools)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# --- the Godot extension ---
|
# The protocol probe remains useful on the archived server's FreeBSD host.
|
||||||
# Phase 1 was macOS-only; Phase 2 (BACKLOG F1/F2) adds iOS + Android. The three
|
if(MT_BUILD_NET_TOOLS)
|
||||||
# native deps are vendored (extension/third_party), so the only gate now is
|
|
||||||
# "is this a platform we've wired output naming + a toolchain for".
|
|
||||||
if(MTGODOT_BUILD_EXTENSION)
|
|
||||||
set(_mt_plat "${CMAKE_SYSTEM_NAME}")
|
|
||||||
if(_mt_plat STREQUAL "Darwin" OR _mt_plat STREQUAL "iOS" OR _mt_plat STREQUAL "Android")
|
|
||||||
message(STATUS "mtgodot extension: building for ${_mt_plat}")
|
|
||||||
else()
|
|
||||||
message(FATAL_ERROR
|
|
||||||
"MTGODOT_BUILD_EXTENSION: supported targets are macOS / iOS / Android "
|
|
||||||
"(got CMAKE_SYSTEM_NAME='${_mt_plat}'). See docs/PLATFORMS.md. "
|
|
||||||
"Use -DMTGODOT_BUILD_EXTENSION=OFF to build just libgr2/formats/tools.")
|
|
||||||
endif()
|
|
||||||
add_subdirectory(extension)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# FreeBSD is the native build target of the archived 40250 server, but it is
|
|
||||||
# not a supported Godot extension target. Keep the live classic probe
|
|
||||||
# available as a small host-only build so a FreeBSD jail/VM can compile and run
|
|
||||||
# it without pulling in godot-cpp or the Darwin/iOS/Android extension.
|
|
||||||
if(MTGODOT_BUILD_NET_TOOLS AND NOT MTGODOT_BUILD_EXTENSION)
|
|
||||||
add_subdirectory(extension/third_party)
|
|
||||||
add_library(mtnet_host STATIC
|
add_library(mtnet_host STATIC
|
||||||
|
extension/src/net/text_codec.cpp
|
||||||
extension/src/net/secure_cipher.cpp
|
extension/src/net/secure_cipher.cpp
|
||||||
extension/src/net/net_stream.cpp
|
extension/src/net/net_stream.cpp
|
||||||
extension/src/net/entity_store.cpp
|
extension/src/net/entity_store.cpp
|
||||||
@@ -67,22 +43,13 @@ if(MTGODOT_BUILD_NET_TOOLS AND NOT MTGODOT_BUILD_EXTENSION)
|
|||||||
extension/src/net/classic/classic_cipher.cpp
|
extension/src/net/classic/classic_cipher.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(mtnet_host PUBLIC extension/src/net)
|
target_include_directories(mtnet_host PUBLIC extension/src/net)
|
||||||
target_link_libraries(mtnet_host PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp)
|
target_link_libraries(mtnet_host PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
|
||||||
target_compile_features(mtnet_host PUBLIC cxx_std_20)
|
target_compile_features(mtnet_host PUBLIC cxx_std_20)
|
||||||
|
|
||||||
add_executable(net_classic_e2e_host extension/tools/net_classic_e2e.cpp)
|
add_executable(net_classic_e2e_host extension/tools/net_classic_e2e.cpp)
|
||||||
target_link_libraries(net_classic_e2e_host PRIVATE mtnet_host)
|
target_link_libraries(net_classic_e2e_host PRIVATE mtnet_host)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# port_logic has no Godot dependency. On targets without an extension build (Linux, Windows) it is
|
if(MT_BUILD_NATIVE_RENDER)
|
||||||
# built on its own so the 2A gate (header self-containment, static_asserts) runs per platform.
|
|
||||||
if(MTGODOT_BUILD_PORT AND NOT MTGODOT_BUILD_EXTENSION)
|
|
||||||
if(NOT TARGET mt3p::cryptopp)
|
|
||||||
add_subdirectory(extension/third_party)
|
|
||||||
endif()
|
|
||||||
add_subdirectory(extension/src/port)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
if(MT_BUILD_NATIVE_RENDER_PROTOTYPE)
|
|
||||||
add_subdirectory(native_render)
|
add_subdirectory(native_render)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -1,193 +1,36 @@
|
|||||||
# mtgodot-poc
|
# Metin2 原生客户端
|
||||||
|
|
||||||
「**Godot 4 + 自研资源 loader**」跨平台方案的渲染 Demo。
|
40250 客户端的 macOS / Android 移植。SDL3 负责窗口与输入,Vulkan 负责 3D 和 UI 渲染;macOS 上通过 MoltenVK 使用 Metal。40250 的 C++ 逻辑、Python 2.7 脚本和 EPK 资源由 `extension/src/port` 与 `extension/src/platform` 提供。运行时不依赖 Godot。
|
||||||
用一个 C++ GDExtension 把 Metin2 的 `.gr2` 骨骼资源渲染进 Godot,用 Godot 内置渲染器。
|
|
||||||
|
|
||||||
> **2026-08-29:并入 `xrender-poc`。** 原先与本仓库并行的 bgfx 自研引擎方案(`xrender-poc`)
|
## macOS
|
||||||
> 经中期评审([`docs/MIDREVIEW.md`](docs/MIDREVIEW.md))**停止开发**,其可复用部分——
|
|
||||||
> `libgr2`(gr2 v6/v7 读取器,已对拍 Granny)、`formats`(.msa/.msm)、`oracle`(Granny 真值工具)、
|
|
||||||
> `tools`(libgr2 校验工具)、`docs/reference`(格式逆向笔记)——已 **vendored 进本仓库**。
|
|
||||||
> bgfx 侧的渲染器 / 平台壳 / 第三方库全部弃用(Godot 取代)。bgfx demo 的参考截图留在
|
|
||||||
> `test/bgfx-reference/` 供交叉核对。`xrender-poc` 目录本身已删除(2026-08-30)。
|
|
||||||
|
|
||||||
自用项目(内部研究,不对外发布),分两阶段推进:
|
需要 CMake、SDL3、Vulkan loader、MoltenVK、glslc,以及仓库中的第三方子模块:
|
||||||
|
|
||||||
| 阶段 | 平台 | 里程碑 | 产出 |
|
```sh
|
||||||
|---|---|---|---|
|
git submodule update --init --recursive
|
||||||
| **Phase 1**(当前) | macOS | M0' · M1 · M2 · M2.5 | 中期评审 |
|
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release \
|
||||||
| Phase 2 | Android(一加 13 / Vulkan)· iOS(iPhone 16 / Metal) | M3 | 三设备各一次 bring-up + 性能留档 |
|
-DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
||||||
|
cmake --build build-release --target mt_native_render -j8
|
||||||
自用、不对外发布,**无正式 go/no-go 门禁**;三台目标设备都是现代硬件,**全程不涉及 GLES3 / Compatibility 渲染器**,不做中低端 / 老机器。
|
./build-release/native_render/mt_native_render \
|
||||||
|
--live-client "/path/to/40250/Client" --login-screen --width 1024 --height 768
|
||||||
完整计划见 [`docs/GODOT-POC-PLAN.md`](docs/GODOT-POC-PLAN.md);离可用客户端还差什么见
|
|
||||||
[`docs/BACKLOG.md`](docs/BACKLOG.md)(分层未完成清单);把参考图那种整张地图画面做出来的实施规格见
|
|
||||||
[`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后);
|
|
||||||
距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。
|
|
||||||
|
|
||||||
当前进入游戏、模型/发型、贴地与技能栏问题的分阶段修复步骤,以及结合两个 Godot 项目的
|
|
||||||
对照验证和 macOS 包验收要求,见 [`docs/RENDERING-REPAIR-PLAN.md`](docs/RENDERING-REPAIR-PLAN.md)
|
|
||||||
(2026-09-08,实施计划;不表示其中工作已完成)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 现状
|
|
||||||
|
|
||||||
- [x] **M0'** — 脚手架。GDExtension 加载 + `Metin2Model` 注册 + `libgr2` 链接可调用
|
|
||||||
+ Godot 4.7 工程经 Metal Forward+ 出画。T0.4 实际 `export` 待装 4.7.1 导出模板。
|
|
||||||
- [x] **M1** — 静态渲染。`gr2` → `Skeleton3D` + `ArrayMesh` + `Skin` + 材质。
|
|
||||||
`warrior_cheongrin`(75 骨 / 5 表面 / 3324 顶点)正立、朝 +Z、贴图正确
|
|
||||||
(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、
|
|
||||||
`shaman_lord`(**v7**,93) 均正确加载渲染。
|
|
||||||
- [x] **M2** — 骨骼动画。`Metin2AnimPlayer` `_process` 里 `gr2::sample_pose`(跨文件)
|
|
||||||
→ **默认 CPU 线性混合蒙皮**(把 libgr2 的 `Σ w·invWorld·world` 直接作用到顶点,每帧重建 mesh)。
|
|
||||||
`get_bone_global_pose` vs `conv(world)` 全骨 ≤2.6e-5;`selfcheck` 0 NaN;`dance_1` 等所有动作
|
|
||||||
头/护甲/躯干正确,与 xrender-poc bgfx demo 一致。
|
|
||||||
`MTGODOT_GPUSKIN=1` 走**自写蒙皮顶点着色器**(`m2_material` `SRC_SKIN`:逐骨完整 4×3 矩阵
|
|
||||||
存 RGBAF 纹理,绕过 `Skeleton3D` —— Godot 内置 GPU 蒙皮会正交化、丢 Granny 烘在 rig 骨上的
|
|
||||||
shear,warrior 7 根 / **sura 16 根**,见 `test/shear-bones-survey.md`)。人群场景用它。
|
|
||||||
**`.msa`**:`anim_path` 收 `.msa` → motion `.gr2` + `Accumulation` + `MotionEventData`
|
|
||||||
(`get_events()` / `motion_event` 信号,带循环回绕)+ `LoopData` 元数据;播放器 `loop=false`
|
|
||||||
可播一次并发 `playback_finished`(片段循环次数执行仍在 backlog C4)。
|
|
||||||
**`.msm`**:`gr2_path` 收 `.msm` → 自动加载 `BaseModelFileName`(含散包目录扫描)+
|
|
||||||
`get_hair_options()` 发型目录(发型 mesh 挂接留 Phase 2)。
|
|
||||||
- [x] **M2.5** — 材质与观感保真。`ShaderMaterial`(modulate tex×COLOR×light、
|
|
||||||
opaque/alpha/alpha-test/add 四模式)+ 方向光阴影 + procedural sky + 引擎雾。
|
|
||||||
**贴图走 gr2 material 绑定**:`build_parts()` 按 `tri_groups[].material_index` 把一个 gr2 mesh
|
|
||||||
拆成多个 Godot surface,各 surface 取 `Mesh::material_textures[matidx]`(mesh-local)→
|
|
||||||
同目录大小写不敏感查 `.dds`;找不到才回退文件名启发式。shaman(1→2 面)/assassin(17→18 面)
|
|
||||||
各面贴图正确。MODULATE2X 等 stage op 仍只近似(差距清单见 `docs/GODOT-POC-PLAN.md` §M2.5 T2.5.6)。
|
|
||||||
- [~] **中期评审** — [`docs/MIDREVIEW.md`](docs/MIDREVIEW.md)。结论:桥接层工程量小、
|
|
||||||
风险基本出清,**建议进入 Phase 2**。`compare.py` 已有进程/截图/结构硬门禁,像素差仍为
|
|
||||||
advisory;收尾重点是前台/真机性能重测与桥接层全语料 draw 冒烟(默认已是 CPU LBS)。
|
|
||||||
|
|
||||||
50 角色压力采样:`test/godot-macos-stress.json`(后台窗口 30fps 节流,仅构建成本
|
|
||||||
~165ms/角色 和 100 角色破顶到 52ms 是可信信号)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 环境
|
|
||||||
|
|
||||||
| 组件 | 版本 / 位置 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| Godot | **4.7.1**(`/opt/homebrew/bin/godot`,Homebrew cask) | 编辑器 + headless |
|
|
||||||
| godot-cpp | submodule `extension/godot-cpp` @ `101ae38034304346a46ea9ea84ae156d3e860496` | 精确 gitlink 锁定(`.gitmodules` 不跟踪移动分支);bundled `extension_api-4-7.json` |
|
|
||||||
| Xcode | 26.4.1 | macOS 构建 |
|
|
||||||
| CMake | 已装;**不需要 SCons** | godot-cpp 走 CMake 路径 |
|
|
||||||
| libgr2 / formats / oracle / tools | vendored 进本仓库 | 见下「vendored 库」 |
|
|
||||||
|
|
||||||
**导出模板**(M0' T0.4 / 真正 export 才需要):编辑器内 `Editor → Manage Export Templates → Download`,或 `godot --headless --install-export-templates`。~600MB,版本须与编辑器一致(4.7.1)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive # 拉 godot-cpp(首次)
|
|
||||||
./build.sh # Debug;产物 → project/bin/libmtgodot.macos.template_debug.dylib
|
|
||||||
./build.sh Release # template_release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
首次会编译 godot-cpp(几分钟)。扩展构建自动带上 vendored `libgr2` + `formats`。
|
详见 [native_render/README.md](native_render/README.md)。
|
||||||
|
|
||||||
只想构建 `libgr2` / `formats` / 校验工具(不碰 godot-cpp):
|
## Android
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON
|
./build-android-native.sh Release --install
|
||||||
cmake --build build-tools -j8
|
./push-android-client.sh /path/to/40250/Client
|
||||||
./build-tools/tools/gr2fuzz "$PWD/assets" # 全量解析冒烟
|
adb shell am start -n org.metin2port.client/.MainActivity
|
||||||
./build-tools/tools/gr2dump <file.gr2>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行
|
APK 由 SDLActivity、`libSDL3.so` 和 Vulkan 原生 `libmain.so` 组成。40250 `Client` 资源单独复制到应用数据目录;`--live-server HOST:AUTH_PORT:GAME_PORT --login-screen` 可连接服务器。构建与启动细节见 [native_render/README.md](native_render/README.md)。
|
||||||
|
|
||||||
```bash
|
## 测试
|
||||||
# 完整客户端:登录 → 选人 → 进游戏(用仓库内 assets/)
|
|
||||||
./run-client.command # 或双击
|
|
||||||
# = MT_ASSETS=$PWD/assets godot --path project res://client_main.tscn
|
|
||||||
|
|
||||||
# 打包 .app(需先装 Godot 4.7.1 macOS 导出模板)
|
```sh
|
||||||
./build-macos-client.sh [debug|release] # -> build/export/mtgodot-poc.app
|
./script/port_gate.sh macos
|
||||||
|
|
||||||
# 编辑器打开
|
|
||||||
godot -e --path project
|
|
||||||
|
|
||||||
# 模型查看器 harness(旧 main_scene)
|
|
||||||
godot --path project res://main.tscn
|
|
||||||
|
|
||||||
# headless 冒烟测试(验证 GDExtension 加载 + 类注册)
|
|
||||||
godot --headless --path project --quit-after 3
|
|
||||||
|
|
||||||
# 跑一个真实 gr2 过 libgr2(可选)
|
|
||||||
MTGODOT_PROBE_GR2="$PWD/assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" \
|
|
||||||
godot --headless --path project --quit-after 3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
控制台出现 `[mtgodot] Metin2Model registered OK — libgr2 linked; ...` = M0' 的 T0.1/T0.2 通过。
|
`port_gate.sh` 构建并运行 40250 移植层的 C++ 测试。原生渲染验收入口是 `script/native_mac_acceptance.mjs`。历史迁移记录保留在 `docs/PORT-PLAN.md`;其中旧 Godot 路径已经退役。
|
||||||
|
|
||||||
窗口内:拖拽 = 轨道,滚轮 = 缩放,`F2` = 截图(→ `user://`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## vendored 库(并入自 xrender-poc,2026-08-29)
|
|
||||||
|
|
||||||
| 目录 | 内容 | 谁用 |
|
|
||||||
|---|---|---|
|
|
||||||
| `libgr2/` | gr2 v6/v7 只读读取器(header/section/Oodle1/类型树/骨架/网格/曲线/材质)。零第三方依赖。9166 语料 fuzz 0 崩溃;bind pose + 蒙皮对拍 Granny 2.9.12 ≤6.5e-5。含 `dump_materials()`。 | `extension`(`xrender::libgr2`)、`tools` |
|
|
||||||
| `formats/` | `.msa`(动作)/ `.msm`(模型 + 发型)/ textscript 解析。 | M2 T2.2/T2.3 起(retarget + motion event) |
|
|
||||||
| `oracle/` | Wine + MinGW 交叉编译的 Granny 真值工具(`oracle.c` + 脚本 + `RUNBOOK.md`)。`oracle.exe` / `granny2_x64.dll` 不入库(体积 + RAD IP),`build-wine.sh` 本地重建。 | M2 数值门禁、M2.5 材质对拍 |
|
|
||||||
| `tools/` | `gr2dump`(转储)/ `gr2fuzz`(全量解析冒烟)/ `oracle_diff`(libgr2 vs oracle 逐字段对拍)/ `anim_probe`(每骨 world 3×3 的 det/shear/scale 探针,查蒙皮走样)。 | libgr2 回归 |
|
|
||||||
| `docs/reference/` | xrender-poc 的格式逆向笔记(M0–M4 施工文档 + PLAN)。见 `docs/reference/README.md`。 | 查格式细节 |
|
|
||||||
| `test/bgfx-reference/` | bgfx demo 的参考截图 + `noise_floor.json` / `m2-numeric.json` / `assets.list` 基线。 | 与 Godot 输出交叉核对 |
|
|
||||||
|
|
||||||
CMake:顶层 `add_subdirectory(libgr2)` + `add_subdirectory(formats)`(跨平台,不碰 godot-cpp);
|
|
||||||
`extension` 只 `target_link_libraries(... xrender::libgr2)`。`tools` 由
|
|
||||||
`-DMTGODOT_BUILD_TOOLS=ON` 开启。
|
|
||||||
|
|
||||||
> `libgr2/README.md` 里的 **oodle1.c clean-room 提醒**依然适用:`src/oodle1.c` 是泄露 SDK 端口,
|
|
||||||
> 仅限内部研究 / 非发布 / 非商用;对外发布前必须 clean-room 重写。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
```
|
|
||||||
mtgodot-poc/
|
|
||||||
CMakeLists.txt 顶层:add_subdirectory(libgr2/formats/[tools]/[extension])
|
|
||||||
build.sh 便捷构建(扩展)
|
|
||||||
libgr2/ ← vendored:gr2 读取器(xrender::libgr2)
|
|
||||||
formats/ ← vendored:msa/msm/textscript
|
|
||||||
oracle/ ← vendored:Granny 真值工具(exe/dll 不入库)
|
|
||||||
tools/ ← vendored:gr2dump / gr2fuzz / oracle_diff
|
|
||||||
extension/
|
|
||||||
CMakeLists.txt mtgodot SHARED + godot-cpp(consume xrender::libgr2)
|
|
||||||
godot-cpp/ submodule @ 101ae38034304346a46ea9ea84ae156d3e860496
|
|
||||||
src/
|
|
||||||
register_types.{h,cpp} GDExtension 入口
|
|
||||||
metin2_model.{h,cpp} gr2 → Skeleton3D + ArrayMesh + Skin + 材质
|
|
||||||
metin2_anim.{h,cpp} 每帧 sample_pose → Skeleton3D;selfcheck
|
|
||||||
gr2_bridge.{h,cpp} libgr2 POD → Godot 类型;basis + 单位换算
|
|
||||||
m2_material.{h,cpp} Metin2 风格 ShaderMaterial(mix / add)
|
|
||||||
dxt.{h,cpp} DDS DXT1/3/5 软解
|
|
||||||
assets/ Metin2 资产(.gr2/.dds/locale/OutdoorA1/…,gitignored;换位置设 MT_ASSETS)
|
|
||||||
bgm/ 背景音乐(assets/ 同级,gitignored)
|
|
||||||
project/ Godot 4.7 工程
|
|
||||||
client_main.tscn/.gd 完整客户端入口(main_scene)→ AppFlow 登录串场
|
|
||||||
app_flow.gd LOGIN → SELECT → GAME 状态机
|
|
||||||
main.tscn / main.gd 旧模型查看器 harness
|
|
||||||
asset_root.gd AssetRoot:资源目录解析(MT_ASSETS / res://../assets / .app 内外)
|
|
||||||
docs/
|
|
||||||
GODOT-POC-PLAN.md 开发计划(M0'–M4 + 风险 + 验收)
|
|
||||||
MIDREVIEW.md Phase 1 中期评审
|
|
||||||
BACKLOG.md 离可用客户端的分层未完成清单
|
|
||||||
reference/ ← xrender-poc 的格式逆向笔记(archived)
|
|
||||||
test/
|
|
||||||
golden/ Godot 对拍截图
|
|
||||||
bgfx-reference/ bgfx demo 参考截图 + 数值基线
|
|
||||||
*.json noise_floor / m2-numeric / stress
|
|
||||||
```
|
|
||||||
|
|
||||||
## 授权
|
|
||||||
|
|
||||||
`libgr2` 全自研,不含 Granny SDK 代码 —— 例外:`libgr2/src/oodle1.c` 是泄露 SDK 的解码路径端口
|
|
||||||
(见 `libgr2/README.md`,仅限内部研究 / 非发布,对外发布前必须 clean-room 重写)。
|
|
||||||
本仓库内部研究用途,不对外公开。
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package org.metin2port.client;
|
package org.metin2port.client;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ActivityInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsController;
|
||||||
|
|
||||||
import org.libsdl.app.SDLActivity;
|
import org.libsdl.app.SDLActivity;
|
||||||
|
|
||||||
@@ -11,6 +18,47 @@ import org.libsdl.app.SDLActivity;
|
|||||||
* Without arguments the client runs against the in-process fake server once Client/ is in files/.
|
* Without arguments the client runs against the in-process fake server once Client/ is in files/.
|
||||||
*/
|
*/
|
||||||
public class MainActivity extends SDLActivity {
|
public class MainActivity extends SDLActivity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
|
||||||
|
applyImmersiveMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
applyImmersiveMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
|
super.onWindowFocusChanged(hasFocus);
|
||||||
|
if (hasFocus)
|
||||||
|
applyImmersiveMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyImmersiveMode() {
|
||||||
|
final View decor = getWindow().getDecorView();
|
||||||
|
decor.setSystemUiVisibility(
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN |
|
||||||
|
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
final WindowInsetsController controller = getWindow().getInsetsController();
|
||||||
|
if (controller != null) {
|
||||||
|
controller.hide(WindowInsets.Type.systemBars());
|
||||||
|
controller.setSystemBarsBehavior(
|
||||||
|
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String[] getLibraries() {
|
protected String[] getLibraries() {
|
||||||
return new String[] { "SDL3", "main" };
|
return new String[] { "SDL3", "main" };
|
||||||
@@ -20,8 +68,15 @@ public class MainActivity extends SDLActivity {
|
|||||||
protected String[] getArguments() {
|
protected String[] getArguments() {
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
String args = intent != null ? intent.getStringExtra("args") : null;
|
String args = intent != null ? intent.getStringExtra("args") : null;
|
||||||
|
Log.i("Metin2Args", "native args: " + args);
|
||||||
if (args == null || args.trim().isEmpty())
|
if (args == null || args.trim().isEmpty())
|
||||||
return new String[0];
|
return new String[0];
|
||||||
return args.trim().split("\\s+");
|
return args.trim().split("\\s+");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
|
||||||
|
// Enforce landscape-only on mobile devices (sensorLandscape allows flipping 180 deg but never portrait).
|
||||||
|
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ fi
|
|||||||
# --- libmain.so (native_render + port_platform + embedded Python) and its assets ---
|
# --- libmain.so (native_render + port_platform + embedded Python) and its assets ---
|
||||||
cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \
|
cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" \
|
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" \
|
||||||
-DCMAKE_BUILD_TYPE="$CONFIG" -DMTGODOT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
|
-DCMAKE_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
|
||||||
-DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \
|
-DMT_BUILD_NATIVE_RENDER=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \
|
||||||
-DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null
|
-DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null
|
||||||
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
|
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Cross-compile the mtgodot GDExtension for Android (OnePlus 13 / arm64 / Vulkan).
|
|
||||||
# Produces project/bin/libmtgodot.android.template_{debug,release}.arm64.so.
|
|
||||||
#
|
|
||||||
# On-device run + APK export is the rest of BACKLOG F2; see docs/PLATFORMS.md.
|
|
||||||
#
|
|
||||||
# Needs the Android NDK. Point at it with ANDROID_NDK_ROOT (or install via
|
|
||||||
# Android Studio → SDK Manager → NDK, default ~/Library/Android/sdk/ndk/<ver>).
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
CONFIG="${1:-Debug}" # Debug | Release
|
|
||||||
ABI="${ANDROID_ABI:-arm64-v8a}"
|
|
||||||
API="${ANDROID_PLATFORM:-24}"
|
|
||||||
BUILD_DIR="build-android"
|
|
||||||
JOBS="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}"
|
|
||||||
|
|
||||||
# --- locate the NDK ---
|
|
||||||
NDK="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
|
|
||||||
if [ -z "$NDK" ]; then
|
|
||||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
|
|
||||||
if [ -d "$SDK/ndk" ]; then
|
|
||||||
NDK="$SDK/ndk/$(ls -1 "$SDK/ndk" | sort -V | tail -1)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -z "$NDK" ] || [ ! -f "$NDK/build/cmake/android.toolchain.cmake" ]; then
|
|
||||||
echo "Android NDK not found. Set ANDROID_NDK_ROOT to an NDK with" >&2
|
|
||||||
echo " build/cmake/android.toolchain.cmake (install via Android Studio SDK Manager)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "NDK: $NDK"
|
|
||||||
|
|
||||||
if [ ! -f extension/godot-cpp/cmake/android.cmake ]; then
|
|
||||||
echo "godot-cpp submodule missing — git submodule update --init --recursive" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cmake -S . -B "$BUILD_DIR" -G "Unix Makefiles" \
|
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
|
|
||||||
-DANDROID_ABI="$ABI" \
|
|
||||||
-DANDROID_PLATFORM="android-$API" \
|
|
||||||
-DCMAKE_BUILD_TYPE="$CONFIG" \
|
|
||||||
-DMTGODOT_BUILD_TOOLS=OFF \
|
|
||||||
-DBUILD_TESTING=OFF
|
|
||||||
|
|
||||||
cmake --build "$BUILD_DIR" --target mtgodot -j "$JOBS"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Android ($CONFIG / $ABI) library:"
|
|
||||||
ls -la project/bin/libmtgodot.android.*.so
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Cross-compile the mtgodot GDExtension for iOS (iPhone 16 / arm64 device) and
|
|
||||||
# stage every static archive the app needs to link into project/bin/ios/.
|
|
||||||
#
|
|
||||||
# This gets you a compiled .a — it does NOT do the Xcode project / signing /
|
|
||||||
# on-device run. That's the rest of BACKLOG F1; see docs/PLATFORMS.md.
|
|
||||||
#
|
|
||||||
# Needs: full Xcode + iPhoneOS SDK. Submodules (recursive) must be present.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
CONFIG="${1:-Debug}" # Debug | Release
|
|
||||||
DEPLOY="${IOS_DEPLOYMENT_TARGET:-15.0}"
|
|
||||||
BUILD_DIR="build-ios"
|
|
||||||
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
|
|
||||||
TOOLCHAIN="extension/godot-cpp/cmake/ios.toolchain.cmake"
|
|
||||||
|
|
||||||
case "$CONFIG" in
|
|
||||||
Release) TPL="template_release" ;;
|
|
||||||
*) TPL="template_debug" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ! -f "$TOOLCHAIN" ]; then
|
|
||||||
echo "godot-cpp submodule missing — git submodule update --init --recursive" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cmake -S . -B "$BUILD_DIR" -G "Unix Makefiles" \
|
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
|
|
||||||
-DPLATFORM=OS64 \
|
|
||||||
-DDEPLOYMENT_TARGET="$DEPLOY" \
|
|
||||||
-DCMAKE_BUILD_TYPE="$CONFIG" \
|
|
||||||
-DMTGODOT_BUILD_TOOLS=OFF \
|
|
||||||
-DBUILD_TESTING=OFF
|
|
||||||
|
|
||||||
cmake --build "$BUILD_DIR" --target mtgodot -j "$JOBS"
|
|
||||||
|
|
||||||
# --- stage archives for the .gdextension [dependencies] block ---
|
|
||||||
OUT="project/bin/ios"
|
|
||||||
mkdir -p "$OUT"
|
|
||||||
cp -f "$BUILD_DIR/bin/libgodot-cpp.ios.${TPL}.arm64.a" "$OUT/"
|
|
||||||
cp -f "$BUILD_DIR/libgr2/liblibgr2.a" "$OUT/"
|
|
||||||
cp -f "$BUILD_DIR/formats/libxr_formats.a" "$OUT/"
|
|
||||||
cp -f "$BUILD_DIR/extension/libmtnet.a" "$OUT/"
|
|
||||||
cp -f "$(find "$BUILD_DIR" -name libsodium.a | head -1)" "$OUT/"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "iOS ($CONFIG) archives:"
|
|
||||||
ls -la "project/bin/libmtgodot.ios.${TPL}.a" "$OUT"/*.a
|
|
||||||
lipo -info "project/bin/libmtgodot.ios.${TPL}.a"
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 打包 macOS 完整客户端(登录 → 选人 → 进游戏)。
|
|
||||||
#
|
|
||||||
# ./build-macos-client.sh [debug|release]
|
|
||||||
#
|
|
||||||
# 产物:build/export/mtgodot-poc.app(40250 Client/pack 和 BGM 自包含)。
|
|
||||||
# 依赖:Godot 4.7.1 的 macOS 导出模板(editor 里 “管理导出模板” 装,或放
|
|
||||||
# ~/Library/Application Support/Godot/export_templates/4.7.1.stable/macos.zip)。
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
REPO="$(pwd)"
|
|
||||||
SOURCE_CLIENT="${MT_40250_CLIENT:-$REPO/Client}"
|
|
||||||
if [ ! -f "$SOURCE_CLIENT/pack/Index" ] && [ -f "$REPO/../40250/Server Client TMP4/Client/pack/Index" ]; then
|
|
||||||
SOURCE_CLIENT="$REPO/../40250/Server Client TMP4/Client"
|
|
||||||
fi
|
|
||||||
CFG="${1:-debug}"
|
|
||||||
OUT="${MT_MAC_OUTPUT_DIR:-$REPO/build/export}"
|
|
||||||
APP="$OUT/mtgodot-poc.app"
|
|
||||||
GODOT="${GODOT:-godot}"
|
|
||||||
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
|
|
||||||
ENGINE_OVERRIDE="${MT_MAC_ENGINE:-}"
|
|
||||||
test -f "$SOURCE_CLIENT/pack/Index" || {
|
|
||||||
echo "错误:缺少 40250 Client/pack/Index:$SOURCE_CLIENT" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if [ -n "$ENGINE_OVERRIDE" ]; then
|
|
||||||
test -x "$ENGINE_OVERRIDE"
|
|
||||||
ENGINE_INFO="$(lipo -info "$ENGINE_OVERRIDE")"
|
|
||||||
case "$MAC_ARCHES:$ENGINE_INFO" in
|
|
||||||
universal:*x86_64*arm64*|universal:*arm64*x86_64*|arm64:*arm64*|x86_64:*x86_64*) ;;
|
|
||||||
*) echo "错误:自定义引擎架构不匹配:$ENGINE_INFO" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
echo "自定义引擎:$ENGINE_OVERRIDE"
|
|
||||||
"$ENGINE_OVERRIDE" --version
|
|
||||||
fi
|
|
||||||
BUILD_TYPE="Debug"
|
|
||||||
if [ "$CFG" = release ]; then
|
|
||||||
BUILD_TYPE="Release"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== 1. GDExtension .dylib =="
|
|
||||||
if [ "$MAC_ARCHES" = universal ]; then
|
|
||||||
CMAKE_ARCHES="arm64;x86_64"
|
|
||||||
else
|
|
||||||
CMAKE_ARCHES="$MAC_ARCHES"
|
|
||||||
fi
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_OSX_ARCHITECTURES="$CMAKE_ARCHES"
|
|
||||||
cmake --build build --target mtgodot -j8
|
|
||||||
DYLIB="project/bin/libmtgodot.macos.template_${CFG}.dylib"
|
|
||||||
if [ ! -f "$DYLIB" ]; then
|
|
||||||
# CMake 目前只出 debug 名;release 就复用它
|
|
||||||
cp -f project/bin/libmtgodot.macos.template_debug.dylib "$DYLIB"
|
|
||||||
fi
|
|
||||||
if [ "$MAC_ARCHES" = universal ]; then
|
|
||||||
LIPO_INFO="$(lipo -info "$DYLIB")"
|
|
||||||
case "$LIPO_INFO" in
|
|
||||||
*x86_64*arm64*|*arm64*x86_64*) ;;
|
|
||||||
*) echo "错误:GDExtension 不是 universal:$LIPO_INFO" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== 2. 导出 =="
|
|
||||||
mkdir -p "$OUT"
|
|
||||||
if [ "$CFG" = release ]; then
|
|
||||||
"$GODOT" --headless --path project --export-release macOS "$APP"
|
|
||||||
else
|
|
||||||
"$GODOT" --headless --path project --export-debug macOS "$APP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== 3. 40250 资源(自包含)=="
|
|
||||||
BUNDLE_CLIENT="$APP/Contents/Resources/40250/Client"
|
|
||||||
mkdir -p "$BUNDLE_CLIENT"
|
|
||||||
ditto "$SOURCE_CLIENT/pack" "$BUNDLE_CLIENT/pack"
|
|
||||||
if [ -d "$SOURCE_CLIENT/BGM" ]; then ditto "$SOURCE_CLIENT/BGM" "$BUNDLE_CLIENT/BGM"; fi
|
|
||||||
if [ -f "$SOURCE_CLIENT/locale.cfg" ]; then cp "$SOURCE_CLIENT/locale.cfg" "$BUNDLE_CLIENT/locale.cfg"; fi
|
|
||||||
if [ -d "$SOURCE_CLIENT/sound" ]; then
|
|
||||||
ditto "$SOURCE_CLIENT/sound" "$BUNDLE_CLIENT/sound"
|
|
||||||
elif [ -d "$REPO/assets/Sound/sound" ]; then
|
|
||||||
ditto "$REPO/assets/Sound/sound" "$BUNDLE_CLIENT/sound"
|
|
||||||
fi
|
|
||||||
test -s "$BUNDLE_CLIENT/pack/Index"
|
|
||||||
|
|
||||||
# Record the exact source revision inside the signed bundle. A Beta release is built only
|
|
||||||
# after its changes are committed, so the identity remains useful when the checkout moves on.
|
|
||||||
python3 - "$REPO" "$APP/Contents/Resources/beta-build.json" <<'PY'
|
|
||||||
import json, pathlib, subprocess, sys
|
|
||||||
repo, output = sys.argv[1:]
|
|
||||||
commit = subprocess.check_output(['git', '-C', repo, 'rev-parse', 'HEAD'], text=True).strip()
|
|
||||||
dirty = bool(subprocess.check_output(['git', '-C', repo, 'status', '--porcelain', '--untracked-files=normal'], text=True).strip())
|
|
||||||
pathlib.Path(output).write_text(json.dumps({'source_commit': commit, 'source_dirty': dirty}, indent=2) + '\n')
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)=="
|
|
||||||
if [ -n "$ENGINE_OVERRIDE" ]; then
|
|
||||||
cp "$ENGINE_OVERRIDE" "$APP/Contents/MacOS/mtgodot-poc"
|
|
||||||
fi
|
|
||||||
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
|
|
||||||
ENTITLEMENTS="$REPO/macos.entitlements"
|
|
||||||
test -f "$ENTITLEMENTS"
|
|
||||||
|
|
||||||
# The exported Godot executable loads the GDExtension from Contents/Frameworks.
|
|
||||||
# Because this package uses hardened runtime, the main executable must carry
|
|
||||||
# disable-library-validation or macOS rejects the ad-hoc/formally signed dylib
|
|
||||||
# at runtime even when the bundle passes a static codesign verification.
|
|
||||||
find "$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -name '*.so' \) -exec \
|
|
||||||
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \;
|
|
||||||
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
|
|
||||||
--sign "$CODESIGN_IDENTITY" "$APP/Contents/MacOS/mtgodot-poc"
|
|
||||||
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
|
|
||||||
--sign "$CODESIGN_IDENTITY" "$APP"
|
|
||||||
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "OK -> $APP"
|
|
||||||
echo "跑: open '$APP'"
|
|
||||||
echo "开发覆盖资源:MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc'"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the mtgodot GDExtension into project/bin/. macOS only (Phase 1).
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
CONFIG="${1:-Debug}" # Debug | Release
|
|
||||||
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
|
|
||||||
MAC_ARCHES="${MT_MAC_ARCHES:-$(uname -m)}" # arm64, x86_64, or universal
|
|
||||||
|
|
||||||
# godot-cpp + the vendored deps (zstd, libsodium-cmake and its nested libsodium)
|
|
||||||
# are submodules — recurse. miniLZO is vendored in-tree, nothing to fetch.
|
|
||||||
if [ ! -f extension/godot-cpp/CMakeLists.txt ] \
|
|
||||||
|| [ ! -f extension/third_party/zstd/build/cmake/CMakeLists.txt ] \
|
|
||||||
|| [ ! -f extension/third_party/libsodium-cmake/libsodium/src/libsodium/include/sodium.h ]; then
|
|
||||||
echo "submodules missing — fetching (git submodule update --init --recursive)…" >&2
|
|
||||||
git submodule update --init --recursive
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$MAC_ARCHES" = universal ]; then
|
|
||||||
MAC_ARCHES="arm64;x86_64"
|
|
||||||
fi
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG" -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHES"
|
|
||||||
cmake --build build --config "$CONFIG" -j "$JOBS"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Built libs in project/bin/:"
|
|
||||||
ls -la project/bin/*.dylib 2>/dev/null || echo " (none — check build output above)"
|
|
||||||
+15
-384
@@ -1,393 +1,24 @@
|
|||||||
<!-- generated-by: gsd-doc-writer -->
|
# Android 原生客户端测试
|
||||||
# Android 真机测试指南
|
|
||||||
|
|
||||||
本文说明如何在 Android 真机上安装本客户端(APK 产物名仍是 `mtgodot-poc.apk`,包名 `org.internal.mtgodotpoc`)、生成并导入资源包、验证资源挂载,以及进行 40250 客户端测试。
|
当前 APK 由 `build-android-native.sh` 构建,使用 SDL3 + Vulkan;应用包名 `org.metin2port.client`,ABI 为 `arm64-v8a`,最低 API 24。
|
||||||
|
|
||||||
## 1. 当前测试范围与已知前置条件
|
## 安装和资源
|
||||||
|
|
||||||
当前 Android 导出目标是 `arm64-v8a`,APK 包名为 `org.internal.mtgodotpoc`,最低 Android SDK 为 24。APK 只包含代码和 Godot 工程资源;Metin2 的 `assets/` 与 `bgm/` 不打进 APK,测试时必须单独导入 `assets.zip`。
|
```sh
|
||||||
|
./build-android-native.sh Release --install
|
||||||
当前仓库的资源部署存在一个需要特别注意的限制:
|
./push-android-client.sh /path/to/40250/Client
|
||||||
|
adb shell am start -n org.metin2port.client/.MainActivity
|
||||||
- `export-android.sh --install` 会把资源推到 `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip`。
|
|
||||||
- `project/asset_pack.gd` 的候选顺序是 `MT_ASSETS_ZIP` 环境变量 → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → 可执行文件目录 → `res://../assets.zip`(见 `_find_local_zip()`);上述外部目录不在候选路径里。
|
|
||||||
- 因此,Debug 真机测试应把外部目录中的压缩包再复制到应用私有 `files/` 目录,确保客户端的 `user://assets.zip` 能命中。
|
|
||||||
|
|
||||||
另外,40250 classic 网络栈当前由进程环境变量 `MT_PROTOCOL=classic` 选择。Android 通过 Activity 启动 APK 时不能直接继承 Mac 终端的环境变量;如果 APK 尚未增加 Android 侧的协议配置入口或将 classic 设为移动端默认值,则只能完成 APK、资源和 UI 验证,不能据此宣称已经完成真实 40250 登录验收。
|
|
||||||
|
|
||||||
公会功能按当前项目范围暂缓;龙魂玩法不属于目标 40250 服务器功能,不纳入 Android 验收。
|
|
||||||
|
|
||||||
## 2. 主机环境
|
|
||||||
|
|
||||||
以下版本来自当前 Android Gradle 配置和构建脚本:
|
|
||||||
|
|
||||||
| 项目 | 要求 |
|
|
||||||
|---|---|
|
|
||||||
| 主机 | macOS,Apple Silicon 构建脚本已按当前机器验证 |
|
|
||||||
| Godot | `4.7.1.stable`,并安装对应 Android 导出模板 |
|
|
||||||
| Android SDK | compile/target SDK `36`,Build Tools `36.1.0` |
|
|
||||||
| Android NDK | `29.0.14206865` |
|
|
||||||
| Java | Gradle 配置最低 Java 17;当前脚本默认使用 OpenJDK 21 |
|
|
||||||
| Android 手机 | `arm64-v8a`,建议使用 Vulkan 设备 |
|
|
||||||
| 磁盘 | 至少预留 APK、资源包和临时构建空间;当前 `assets.zip` 约 2.1–2.2 GB |
|
|
||||||
|
|
||||||
进入仓库并设置环境变量。路径按本机安装位置调整:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/shenlei/Work/mt/metin2-client
|
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
|
|
||||||
export ANDROID_HOME="$ANDROID_SDK_ROOT"
|
|
||||||
export ANDROID_NDK_ROOT="$ANDROID_SDK_ROOT/ndk/29.0.14206865"
|
|
||||||
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
|
|
||||||
export GODOT="/opt/homebrew/bin/godot"
|
|
||||||
export ADB="$ANDROID_SDK_ROOT/platform-tools/adb"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
先确认工具存在:
|
`push-android-client.sh` 将 40250 `Client` 目录复制到应用私有目录。APK 本身包含 `libmain.so`、`libSDL3.so`、着色器和 Python 2.7 标准库,不包含约 1.4 GB 的 40250 客户端资源。使用中文资源时运行 `./push-android-client.sh --locale zh`。
|
||||||
|
|
||||||
```bash
|
## 连服务器
|
||||||
"$GODOT" --version
|
|
||||||
java -version
|
```sh
|
||||||
"$ADB" version
|
adb shell am start -n org.metin2port.client/.MainActivity \
|
||||||
test -f "$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake"
|
--es args "--live-server HOST:AUTH_PORT:GAME_PORT --login-screen"
|
||||||
```
|
```
|
||||||
|
|
||||||
如果 Android SDK/NDK 尚未安装,可在 Android Studio 的 SDK Manager 中安装,或使用 `sdkmanager` 安装与上表一致的版本。不要只安装一个较旧 NDK 后直接构建;Gradle 模板要求的 NDK 版本以 `project/android/build/config.gradle` 为准。
|
默认不传参数时连接进程内假服。测试真服前确认模拟器/设备能访问相应主机与端口。用 `adb logcat` 查看崩溃和启动错误。`adb devices -l` 没有设备时,先连接模拟器或开启真机 USB 调试。
|
||||||
|
|
||||||
Godot 还需要安装与 `4.7.1` 完全匹配的 Android 导出模板。可在 Godot 的“编辑器设置 → 导出 → 导出模板”中安装。
|
更详细的构建依赖与命令见 [native_render/README.md](../native_render/README.md)。
|
||||||
|
|
||||||
首次导出前生成 Debug keystore:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gen-debug-keystore.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 连接 Android 真机
|
|
||||||
|
|
||||||
在手机上完成以下设置:
|
|
||||||
|
|
||||||
1. 设置 → 关于手机 → 连续点击版本号,开启开发者选项。
|
|
||||||
2. 开发者选项中开启“USB 调试”。
|
|
||||||
3. USB 连接模式选择“文件传输/MTP”。
|
|
||||||
4. 首次连接时,在手机上确认“允许 USB 调试”。
|
|
||||||
|
|
||||||
在 Mac 上检查设备:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" kill-server
|
|
||||||
"$ADB" start-server
|
|
||||||
"$ADB" devices -l
|
|
||||||
```
|
|
||||||
|
|
||||||
期望看到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<设备序列号> device
|
|
||||||
```
|
|
||||||
|
|
||||||
状态为 `unauthorized` 时,解锁手机并接受 RSA 授权;没有设备时,优先检查 USB 线、USB 模式、开发者选项和手机是否允许该电脑调试。
|
|
||||||
|
|
||||||
## 4. 生成资源包
|
|
||||||
|
|
||||||
资源包必须从与 APK 同一份工作树生成。执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./pack-assets.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本会:
|
|
||||||
|
|
||||||
1. 执行 `bake_asset_index.gd`,生成 `assets/asset_index.txt`。
|
|
||||||
2. 将 `assets/` 和 `bgm/` 打成 `build/export/assets.zip`。
|
|
||||||
3. 使用 `zip -0` 原样存储,不压缩 `.dds`、`.gr2` 等大型资源。
|
|
||||||
4. 排除 `.git`、`.DS_Store` 和 `assets/.gdignore`。
|
|
||||||
|
|
||||||
检查资源包:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -lh build/export/assets.zip
|
|
||||||
unzip -l build/export/assets.zip | rg 'assets/asset_index\.txt|assets/|bgm/' | head -n 20
|
|
||||||
```
|
|
||||||
|
|
||||||
不要手工解压到 APK,也不要把完整 `assets/` 强行放进 `res://`。客户端启动时通过 `ProjectSettings.load_resource_pack()` 挂载 zip,挂载成功后资源以 `res://assets/...` 和 `res://bgm/...` 访问。
|
|
||||||
|
|
||||||
资源发生变化后必须重新执行 `./pack-assets.sh`。只重新导出 APK 不会更新已经生成的 `assets.zip`。
|
|
||||||
|
|
||||||
## 5. 构建 APK
|
|
||||||
|
|
||||||
建议先单独构建,不要在第一次测试时直接使用 `--install`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./export-android.sh Debug
|
|
||||||
```
|
|
||||||
|
|
||||||
构建成功后应得到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
build/export/mtgodot-poc.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
检查 APK 和 native library:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -lh build/export/mtgodot-poc.apk
|
|
||||||
unzip -l build/export/mtgodot-poc.apk | rg 'lib/arm64-v8a/.*\.so'
|
|
||||||
```
|
|
||||||
|
|
||||||
应至少包含 `lib/arm64-v8a/` 下的 Godot Android 库和 `libmtgodot` 扩展库。
|
|
||||||
|
|
||||||
## 6. 安装 APK 并导入资源
|
|
||||||
|
|
||||||
### 6.1 安装 APK
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" install -r build/export/mtgodot-poc.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
`-r` 会保留应用数据。若要完全清理测试数据,可选执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" shell pm clear org.internal.mtgodotpoc
|
|
||||||
```
|
|
||||||
|
|
||||||
清理后必须重新导入资源包。
|
|
||||||
|
|
||||||
### 6.2 先推送到 Android 外部目录
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PKG=org.internal.mtgodotpoc
|
|
||||||
EXT_DIR="/sdcard/Android/data/$PKG/files"
|
|
||||||
|
|
||||||
"$ADB" shell mkdir -p "$EXT_DIR"
|
|
||||||
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
|
|
||||||
"$ADB" shell ls -lh "$EXT_DIR/assets.zip"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Debug 测试复制到 Godot `user://`
|
|
||||||
|
|
||||||
当前 `Debug` APK 可使用 `run-as` 把外部文件复制到应用私有 `files/` 目录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" shell run-as "$PKG" sh -c \
|
|
||||||
"cp '$EXT_DIR/assets.zip' files/assets.zip"
|
|
||||||
|
|
||||||
"$ADB" shell run-as "$PKG" ls -lh files/assets.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
最后一条命令能看到约 2.1–2.2 GB 的文件,才继续启动客户端。这个步骤是当前代码路径下的关键步骤;只执行 `adb push` 到 `/sdcard/Android/data/.../files/`,不能替代它。
|
|
||||||
|
|
||||||
如果 `run-as` 报告应用不可调试,确认安装的是 `Debug` APK,而不是 Release APK。Release 流程需要先修改资源查找/部署实现,使客户端明确支持外部目录,不能假设 `run-as` 可用。
|
|
||||||
|
|
||||||
### 6.4 资源更新
|
|
||||||
|
|
||||||
客户端退出后,只更新资源包时执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./pack-assets.sh
|
|
||||||
|
|
||||||
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
|
|
||||||
"$ADB" shell run-as "$PKG" sh -c \
|
|
||||||
"cp '$EXT_DIR/assets.zip' files/assets.zip"
|
|
||||||
```
|
|
||||||
|
|
||||||
资源挂载只在 `client_main.gd::_ready()` 中执行,因此更新后必须重启客户端,不能只切回前台:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" shell am force-stop "$PKG"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 启动客户端并确认资源挂载
|
|
||||||
|
|
||||||
清空旧日志后启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" logcat -c
|
|
||||||
"$ADB" shell am start -n \
|
|
||||||
org.internal.mtgodotpoc/com.godot.game.GodotApp
|
|
||||||
```
|
|
||||||
|
|
||||||
实时查看日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"$ADB" logcat -v time -s godot GodotError AndroidRuntime
|
|
||||||
```
|
|
||||||
|
|
||||||
资源挂载成功的关键日志是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[AssetPack] mounted .../assets.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
以下日志表示资源没有被找到或挂载失败:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[AssetPack] 找不到 assets.zip
|
|
||||||
[AssetPack] load_resource_pack 失败
|
|
||||||
[client] 资源目录不存在
|
|
||||||
```
|
|
||||||
|
|
||||||
启动验收至少包含:
|
|
||||||
|
|
||||||
- 登录界面正常出现,而不是黑屏或立即退出。
|
|
||||||
- 登录背景、控件和字体资源正常显示。
|
|
||||||
- 选人页能加载职业模型、背景和界面资源。
|
|
||||||
- 进入游戏后能看到地图、地形、建筑、树木或角色模型。
|
|
||||||
- 日志中没有持续出现资源文件缺失、`FileAccess` 打开失败或 native library 加载失败。
|
|
||||||
|
|
||||||
可保存截图作为测试证据:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p build/export/android-evidence
|
|
||||||
"$ADB" exec-out screencap -p > build/export/android-evidence/launch.png
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 40250 服务器连接配置
|
|
||||||
|
|
||||||
### 8.1 手机网络要求
|
|
||||||
|
|
||||||
手机必须能直接访问认证服/游戏服的 IP 和端口:
|
|
||||||
|
|
||||||
- 手机与局域网测试服连接到同一网络,或服务器开放公网访问。
|
|
||||||
- 客户端不能使用 `127.0.0.1` 或 `localhost` 指向 Mac;在手机上它们指向手机自身。
|
|
||||||
- 防火墙和 FreeBSD jail 的端口映射必须允许手机来源地址访问。
|
|
||||||
- 服务器端口以实际 `CONFIG` 为准,不要照抄示例端口。
|
|
||||||
|
|
||||||
### 8.2 `serverlist.txt`
|
|
||||||
|
|
||||||
客户端启动时会优先读取 `res://serverlist.txt`;没有该文件时使用 `project/net/serverinfo.gd` 中的内置默认值。真实 40250 测试应在导出前准备 `project/serverlist.txt`,格式为 TSV:
|
|
||||||
|
|
||||||
```text
|
|
||||||
# 名称\t认证地址\t认证端口\t游戏地址\t游戏端口\t频道列表\t端口步长\t会徽端口
|
|
||||||
龙魂40250\t<服务器IP>\t<auth-port>\t<服务器IP>\t<channel1-port>\t1,2,3,4\t1\t0
|
|
||||||
```
|
|
||||||
|
|
||||||
例如 40250 仓库脚本中的默认 channel1 端口与当前客户端内置默认值并不相同,必须以目标 FreeBSD 服务器的 `CONFIG` 和实际监听结果为准。
|
|
||||||
|
|
||||||
<!-- VERIFY: 真实 40250 服务器 IP、auth/game 端口、频道数量和防火墙策略必须由当前 FreeBSD 部署确认。 -->
|
|
||||||
|
|
||||||
### 8.3 classic 协议选择限制
|
|
||||||
|
|
||||||
当前 native 代码仅在进程环境变量满足以下条件时切换 40250 classic 后端:
|
|
||||||
|
|
||||||
```text
|
|
||||||
MT_PROTOCOL=classic
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Mac 上运行 Godot 时可以通过环境变量设置;但 Android APK 是由 Activity 启动的,普通的 `adb shell am start` 不会把 Mac 的环境变量传入 APK。因此,真实 Android 40250 登录测试前必须确认以下任一项已经完成:
|
|
||||||
|
|
||||||
1. Android 版本把 classic 设置为默认网络后端;
|
|
||||||
2. 客户端增加可持久化的协议配置并在 Android 导出中设为 `classic`;或
|
|
||||||
3. Android 测试包提供协议选择入口。
|
|
||||||
|
|
||||||
不要把下面的命令当作可靠解决方案:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb shell MT_PROTOCOL=classic am start ...
|
|
||||||
```
|
|
||||||
|
|
||||||
它设置的是 `am` 命令进程的环境,不等于给已启动的 Android Activity 设置环境变量。
|
|
||||||
|
|
||||||
## 9. 真机功能验收清单
|
|
||||||
|
|
||||||
以下项目按实际 40250 测试账号、角色和 NPC 条件逐项记录:
|
|
||||||
|
|
||||||
### 启动与网络
|
|
||||||
|
|
||||||
- [ ] APK 安装成功,包名为 `org.internal.mtgodotpoc`。
|
|
||||||
- [ ] `assets.zip` 已复制到应用私有 `files/`,大小与主机一致。
|
|
||||||
- [ ] 日志出现 `[AssetPack] mounted`。
|
|
||||||
- [ ] 登录界面资源完整显示。
|
|
||||||
- [ ] 服务器/频道检测能得到预期结果。
|
|
||||||
- [ ] 认证成功后进入选人页。
|
|
||||||
- [ ] 选择角色后进入游戏场景。
|
|
||||||
|
|
||||||
### 世界与基础操作
|
|
||||||
|
|
||||||
- [ ] 地图、地形、建筑、树木、角色和纹理正常加载。
|
|
||||||
- [ ] 点击地面移动,角色位置与服务器同步。
|
|
||||||
- [ ] 普通攻击、目标选择、技能和动作没有异常断线。
|
|
||||||
- [ ] 聊天发送与接收正常。
|
|
||||||
- [ ] 切换前后台后连接和场景状态符合预期;断线时能看到重连提示。
|
|
||||||
|
|
||||||
### 已接入的常用功能
|
|
||||||
|
|
||||||
- [ ] 背包打开、物品使用、丢弃、移动和快捷栏操作。
|
|
||||||
- [ ] NPC 对话、脚本选项、输入框和确认框。
|
|
||||||
- [ ] 普通商店查看、切换货架和购买。
|
|
||||||
- [ ] 私人商店物品选择、定价、开设和撤收。
|
|
||||||
- [ ] 交易发起、物品/金币添加、确认和取消。
|
|
||||||
- [ ] 仓库密码、存入、取出和物品移动。
|
|
||||||
- [ ] 商城物品列表和取出。
|
|
||||||
- [ ] 组队邀请、接受、离队、成员状态和治疗入口。
|
|
||||||
- [ ] ESC 系统菜单、音量、镜头、雾效和显示设置持久化。
|
|
||||||
|
|
||||||
### 明确不纳入本轮
|
|
||||||
|
|
||||||
- 公会相关功能:按当前范围暂缓。
|
|
||||||
- 龙魂:目标 40250 服务器没有该功能,不进行功能验收。
|
|
||||||
- 真实 FreeBSD + MySQL 全流程验收:需要服务器环境和测试账号,不能用本机离线测试结果替代。
|
|
||||||
|
|
||||||
## 10. 测试证据保存
|
|
||||||
|
|
||||||
建议每次测试保留 APK、资源包校验值、设备信息和日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p build/export/android-evidence
|
|
||||||
|
|
||||||
shasum -a 256 build/export/mtgodot-poc.apk \
|
|
||||||
build/export/assets.zip \
|
|
||||||
> build/export/android-evidence/artifacts.sha256
|
|
||||||
|
|
||||||
"$ADB" shell getprop ro.product.model \
|
|
||||||
> build/export/android-evidence/device-model.txt
|
|
||||||
"$ADB" shell getprop ro.build.version.release \
|
|
||||||
> build/export/android-evidence/android-version.txt
|
|
||||||
"$ADB" logcat -d -v time -s godot GodotError AndroidRuntime \
|
|
||||||
> build/export/android-evidence/logcat.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
测试记录至少填写:构建时间、Git commit、APK SHA-256、`assets.zip` SHA-256、手机型号、Android 版本、服务器地址/端口、账号、测试结果和失败日志。
|
|
||||||
|
|
||||||
## 11. 常见问题
|
|
||||||
|
|
||||||
| 现象 | 处理 |
|
|
||||||
|---|---|
|
|
||||||
| `adb devices` 没有设备 | 开启 USB 调试、接受 RSA、切换 MTP、检查数据线;必要时重新执行 `adb kill-server` / `adb start-server` |
|
|
||||||
| 状态是 `unauthorized` | 解锁手机并接受“允许 USB 调试”,然后重新执行 `adb devices` |
|
|
||||||
| APK 安装失败 | 检查设备是否为 arm64;卸载旧包后再安装;查看 `adb install` 的具体错误 |
|
|
||||||
| `run-as` 报不可调试 | 安装 `Debug` APK;Release APK 不能依赖 `run-as` 导入资源 |
|
|
||||||
| 日志找不到 `assets.zip` | 确认外部 push 成功,再执行 `run-as ... cp ... files/assets.zip`,然后重启应用 |
|
|
||||||
| `load_resource_pack 失败` | 检查压缩包完整性、磁盘空间、是否由 `./pack-assets.sh` 生成,以及 zip 内是否存在 `assets/asset_index.txt` |
|
|
||||||
| 登录连接超时 | 手机不能使用 `localhost`;检查服务器 IP、端口、Wi-Fi/VPN、防火墙和 FreeBSD jail 映射 |
|
|
||||||
| 登录协议不对或立即断线 | 确认 Android 包实际选择了 `classic`,仅在 Mac 终端设置 `MT_PROTOCOL` 不会自动传入 APK |
|
|
||||||
| 黑屏或 Vulkan 崩溃 | 保存完整 `logcat`;当前 Android 移动 Vulkan 的 shader/GPU-skin 真机验证仍需在目标设备上进行 |
|
|
||||||
| 更新资源后画面不变 | 资源包挂载发生在启动阶段,执行 `am force-stop` 后重新启动;必要时清理应用数据并重新导入 |
|
|
||||||
|
|
||||||
## 12. 推荐的最短测试流程
|
|
||||||
|
|
||||||
首次测试按以下顺序执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/shenlei/Work/mt/metin2-client
|
|
||||||
|
|
||||||
./pack-assets.sh
|
|
||||||
./export-android.sh Debug
|
|
||||||
|
|
||||||
export ADB="${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}/platform-tools/adb"
|
|
||||||
PKG=org.internal.mtgodotpoc
|
|
||||||
EXT_DIR="/sdcard/Android/data/$PKG/files"
|
|
||||||
|
|
||||||
"$ADB" install -r build/export/mtgodot-poc.apk
|
|
||||||
"$ADB" shell mkdir -p "$EXT_DIR"
|
|
||||||
"$ADB" push build/export/assets.zip "$EXT_DIR/assets.zip"
|
|
||||||
"$ADB" shell run-as "$PKG" sh -c \
|
|
||||||
"cp '$EXT_DIR/assets.zip' files/assets.zip"
|
|
||||||
"$ADB" shell run-as "$PKG" ls -lh files/assets.zip
|
|
||||||
|
|
||||||
"$ADB" logcat -c
|
|
||||||
"$ADB" shell am start -n "$PKG/com.godot.game.GodotApp"
|
|
||||||
"$ADB" logcat -v time -s godot GodotError AndroidRuntime
|
|
||||||
```
|
|
||||||
|
|
||||||
只有在看到 `[AssetPack] mounted`、登录界面资源正常、并确认 Android 包使用正确的 40250 classic 后端后,才进入真实服务器功能验收。
|
|
||||||
|
|||||||
+8
-203
@@ -1,207 +1,12 @@
|
|||||||
# Platform builds (macOS / iOS / Android)
|
# 平台构建
|
||||||
|
|
||||||
Phase 1 shipped the GDExtension for macOS only. As of 2026-08-30 the build system
|
当前客户端使用 SDL3 + Vulkan 原生渲染,目标平台是 macOS arm64 和 Android arm64。核心 40250 移植代码在 `extension/src/port` 与 `extension/src/platform`,与 UI 引擎无关。
|
||||||
is opened up for the two Phase 2 devices (BACKLOG F1/F2): **iPhone 16** (iOS,
|
|
||||||
Metal) and **OnePlus 13** (Android arm64, Vulkan). The native deps that used to
|
|
||||||
come from Homebrew are now vendored and cross-compile from the same tree — see
|
|
||||||
`docs/THIRD-PARTY.md`.
|
|
||||||
|
|
||||||
**Scope (decided 2026-09-23, PORT-PLAN §1):** the shipping targets are **macOS
|
| 平台 | 构建入口 | 产物 |
|
||||||
arm64** and **Android arm64**. iOS arm64 stays cross-compile-clean but is not an
|
| --- | --- | --- |
|
||||||
acceptance target. **Linux and Windows are not done** — the mingw-w64 cross
|
| macOS | `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew`,然后 `cmake --build build-release --target mt_native_render -j8` | `build-release/native_render/mt_native_render` |
|
||||||
build is kept only as a portability gate (it catches real include/layering bugs),
|
| Android | `./build-android-native.sh Release` | `android-native/app/build/outputs/apk/debug/app-debug.apk` |
|
||||||
not as Windows support, and MSVC is not built at all.
|
|
||||||
|
|
||||||
## What each script does
|
macOS 的 Vulkan loader 通过 MoltenVK 使用 Metal。Android APK 使用 SDLActivity 和 `libmain.so`。40250 `Client` 资源在 macOS 通过 `--live-client` 指定,在 Android 通过 `push-android-client.sh` 放进应用目录。
|
||||||
|
|
||||||
| Script | Target | Output |
|
原生客户端的详细配置与验收步骤见 [native_render/README.md](../native_render/README.md) 和 [ANDROID-TESTING.md](ANDROID-TESTING.md)。
|
||||||
|---------------------|---------------------------|---------------------------------------------------------|
|
|
||||||
| `./build.sh` | macOS (host) | `project/bin/libmtgodot.macos.template_{debug,release}.dylib` |
|
|
||||||
| `./build-ios.sh` | iOS arm64 device (`OS64`) | `project/bin/libmtgodot.ios.template_{debug,release}.a` + staged deps in `project/bin/ios/` |
|
|
||||||
| `./build-android.sh`| Android `arm64-v8a` | `project/bin/libmtgodot.android.template_{debug,release}.arm64.so` |
|
|
||||||
| `./export-android.sh [Debug\|Release] [--install]` | Android APK (wraps build-android + Godot export) | `build/export/mtgodot-poc[-release].apk` |
|
|
||||||
| `./gen-debug-keystore.sh` | Android debug keystore (once) | `~/Library/Application Support/Godot/keystores/debug.keystore` |
|
|
||||||
| `./pack-assets.sh` | Metin2 assets → mountable zip | `build/export/assets.zip` (`zip -0`, ~2.2 GB) |
|
|
||||||
|
|
||||||
All three run `git submodule update --init --recursive` if needed. The host tools
|
|
||||||
(`packtool`, `net_probe`) and CTest suite build only on a host build
|
|
||||||
(`CMAKE_SYSTEM_NAME == CMAKE_HOST_SYSTEM_NAME`), never when cross-compiling.
|
|
||||||
|
|
||||||
`project/bin/mtgodot.gdextension` has the `[libraries]` entries for all three
|
|
||||||
platforms and an `[dependencies]` block for iOS (see below).
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
### macOS — ✅ done
|
|
||||||
Unchanged. `./build.sh` → dylib, `ctest` 9/9.
|
|
||||||
|
|
||||||
### iOS — 🟡 compiles; export/sign/device pending (F1)
|
|
||||||
**Verified here (Xcode 26.4.1 + iPhoneOS 26.4 SDK):**
|
|
||||||
- `./build-ios.sh Debug` cross-compiles the whole surface to **arm64, minos 15.0**:
|
|
||||||
libsodium, libzstd, miniLZO, libgr2, xr_formats, mtnet, godot-cpp, and the
|
|
||||||
extension itself → `libmtgodot.ios.template_debug.a` (valid `ar` archive).
|
|
||||||
- iOS forces the extension to **STATIC** (`MT_LIB_KIND` in `extension/CMakeLists.txt`)
|
|
||||||
because the platform can't `dlopen`. Godot links the archive + its deps into the
|
|
||||||
app at export time, which is why `.gdextension` carries an `[dependencies]`
|
|
||||||
`ios.debug` / `ios.release` map listing every archive `build-ios.sh` stages
|
|
||||||
into `project/bin/ios/`.
|
|
||||||
|
|
||||||
**Not done (needs the device + Godot iOS export templates, ~2–3 days):**
|
|
||||||
- Install matching Godot 4.7 iOS export templates.
|
|
||||||
- Godot editor → Export → iOS preset; add `project/bin/ios/*.a` as extra link
|
|
||||||
libraries (or fold into an `.xcframework`).
|
|
||||||
- Free personal provisioning profile + signing for on-device install.
|
|
||||||
- First run on the iPhone 16; capture a frame for F3.
|
|
||||||
- Release build (`./build-ios.sh Release`) and size/strip pass.
|
|
||||||
|
|
||||||
### Android — ✅ APK builds; on-device run pending USB debugging (F2/F3)
|
|
||||||
**Done (2026-08-31):**
|
|
||||||
- `extension/CMakeLists.txt` handles `CMAKE_SYSTEM_NAME == Android` (SHARED `.so`,
|
|
||||||
`arm64` output-name suffix). `./build-android.sh` drives it through the NDK's own
|
|
||||||
`build/cmake/android.toolchain.cmake` (`ANDROID_ABI=arm64-v8a`, `android-24`).
|
|
||||||
- Toolchain on this machine: `sdkmanager "ndk;27.2.12479018" "platforms;android-35"
|
|
||||||
"build-tools;35.0.0"`; `brew install openjdk@21`; Godot 4.7.1 export templates
|
|
||||||
installed; `./gen-debug-keystore.sh`.
|
|
||||||
- `editor_settings-4.7.tres`: `export/android/android_sdk_path` = `~/Library/Android/sdk`,
|
|
||||||
`java_sdk_path` = `/opt/homebrew/opt/openjdk@21/.../Home`, `debug_keystore` = the
|
|
||||||
generated one (pass `android`).
|
|
||||||
- `project/export_presets.cfg` `[preset.1]` "Android": `arm64-v8a` only,
|
|
||||||
`gradle_build/use_gradle_build=false` (prebuilt template APK — no Gradle/JDK17 build),
|
|
||||||
`permissions/internet=true`, pkg `org.internal.mtgodotpoc`, minSdk 24.
|
|
||||||
- `./export-android.sh Debug` → `build/export/mtgodot-poc.apk` (~73 MB), signed with the
|
|
||||||
debug keystore via `build-tools/28.0.3/apksigner`. Contains
|
|
||||||
`lib/arm64-v8a/{libgodot_android.so, libmtgodot.android.template_debug.arm64.so, libc++_shared.so}`.
|
|
||||||
- Renderer stays `forward_plus` (OnePlus 13 = flagship Vulkan). No GLES3 / Compatibility
|
|
||||||
fallback — all target devices are modern.
|
|
||||||
|
|
||||||
**Asset IO portability (A1, 2026-08-31):** the extension used to read every asset with raw
|
|
||||||
`std::fopen` / `std::ifstream`, which cannot read a Godot PCK — so a bundled-assets APK
|
|
||||||
would launch to nothing. Fixed by routing all *running-extension* reads through
|
|
||||||
`godot::FileAccess` (works for `res://` PCK, `user://`, and absolute OS paths):
|
|
||||||
- `extension/src/asset_io.{h,cpp}` — `read_file()` / `dds_from_file()` / `gr2_from_file()`.
|
|
||||||
All `gr2::File::load_path` and `load_dds_path` call sites (model / anim / weapon / hair /
|
|
||||||
static objects / trees / terrain splat / water / shadowmap) now go through it.
|
|
||||||
- `formats/` funnels every read through `fmt::read_file`; added `fmt::set_file_reader()` and
|
|
||||||
`register_types.cpp` installs a `FileAccess`-backed reader → `height.raw` / `tile.raw` /
|
|
||||||
`water.wtr` / `.msenv` / `areadata.txt` / `map_setting` / `property` / `.spt` /
|
|
||||||
`texture_set` / `.msm` / `.msa` (textscript) all portable.
|
|
||||||
- `mtproto`: added `load_proto_bytes()`; `Metin2Proto` reads via `asset_io`.
|
|
||||||
- The standalone-lib `*_path()` functions are untouched, so the four non-Godot CTests
|
|
||||||
(`proto.item_mob`, `pack.roundtrip`, `formats.map_formats`, `libgr2.loader_errors`) stay green.
|
|
||||||
- **Phase 2b done (2026-08-31):** `fmt::AssetResolver` can no longer `std::filesystem`-scan a
|
|
||||||
PCK, so it's now a **baked index**: `AssetResolver::save_index()` / `load_index()` (line format
|
|
||||||
`MTIDX1` + `Y|R\t<key>\t<rel>`, paths relative to assets_root), and
|
|
||||||
`build_or_load(root)` → loads `<root>/asset_index.txt` via `fmt::read_file` if present, else
|
|
||||||
scans (desktop dev). `resolve()` now returns `assets_root + "/" + rel` so it's portable
|
|
||||||
(`res://assets/...` on device). Generate the index with
|
|
||||||
`godot --headless --path project --script bake_asset_index.gd` (→ `assets/asset_index.txt`,
|
|
||||||
~8 MB, 54k files) — `export-android.sh` does this automatically. `metin2_world.cpp` calls
|
|
||||||
`build_or_load`; `Metin2World.bake_asset_index(out)` is the bound builder method.
|
|
||||||
- **`eterpack` / `asset_source`** — unused while the dev asset tree is fully loose (no `.epk`).
|
|
||||||
|
|
||||||
### Bundling the assets (mobile) — the mount-a-zip approach
|
|
||||||
|
|
||||||
The APK/IPA is **code-only** (73 MB): `assets/` (2.1 GB) + `bgm/` (80 MB) are gitignored and
|
|
||||||
sit beside `project/`, not under `res://`. Getting them into `res://` via the exporter fails:
|
|
||||||
`.gdignore` blocks `include_filter` too, and without `.gdignore` Godot imports the ~10 k
|
|
||||||
`.dds`/`.tga`/`.jpg`/`.wav` → converts them to `.ctex`/etc → `FileAccess("res://.../x.dds")`
|
|
||||||
no longer yields raw DXT bytes → the C++ decoder breaks. (It also litters the asset tree with
|
|
||||||
`.import`/`.uid` sidecars and bloats `.godot/` to ~190 MB.)
|
|
||||||
|
|
||||||
Instead: **ship a plain `zip -0` and mount it at runtime.** All C++ asset IO now goes through
|
|
||||||
`godot::FileAccess` (see A1 above), so once the zip is mounted at `res://` everything reads
|
|
||||||
from it transparently.
|
|
||||||
- `./pack-assets.sh` → `build/export/assets.zip` (bakes `asset_index.txt` first; `zip -0`
|
|
||||||
store, no compression — DXT/gr2 don't compress and we want fast random access; paths stay
|
|
||||||
`assets/…` `bgm/…` so they mount as `res://assets/…` `res://bgm/…`).
|
|
||||||
- `project/asset_pack.gd` (`class_name AssetPack`) — `ensure()` finds the zip
|
|
||||||
(`MT_ASSETS_ZIP` env → `user://assets.zip` → `OS.get_user_data_dir()/assets.zip` → exe-dir →
|
|
||||||
`res://../assets.zip`) and `ProjectSettings.load_resource_pack()`s it. `client_main.gd`
|
|
||||||
calls it in `_ready()` before `AppFlow.start()`.
|
|
||||||
- `project/asset_root.gd` — adds `res://assets` as the first candidate **iff**
|
|
||||||
`res://assets/asset_index.txt` exists (the "pack mounted" sentinel; the empty
|
|
||||||
`project/assets/.gdignore` stub doesn't count).
|
|
||||||
- Deploy: `./export-android.sh Debug --install` pushes both the APK and (if present)
|
|
||||||
`assets.zip` → `/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip` over USB.
|
|
||||||
iOS later: `ios-deploy --bundle_id … --upload assets.zip --to Documents/`. Same mount code.
|
|
||||||
- **Future (download instead of push):** only `AssetPack._find_local_zip()`'s failure branch
|
|
||||||
changes — HTTPRequest `ASSET_PACK_URL` → `user://assets.zip` (resume + sha256 + progress UI)
|
|
||||||
→ same `load_resource_pack`. Structurally additive.
|
|
||||||
- One more `std::filesystem` scan turned up: `fmt::PropertyRegistry::scan()` walks
|
|
||||||
`<root>/Property/` for `.pr*` CRC files (drives building/tree placement). Added
|
|
||||||
`PropertyRegistry::scan_list(root, rel_paths)` + `AssetResolver::all_rel()`; `metin2_world.cpp`
|
|
||||||
feeds it the resolver's file list (both index-loaded and dir-scanned modes). `scan()` kept
|
|
||||||
for the `formats.map_formats` CTest.
|
|
||||||
- **Verified end to end**: force-mount the real `assets.zip` (2.1 GB, 58183 entries) with the
|
|
||||||
loose tree hidden → `Metin2World.load_map("OutdoorA1/metin2_map_a1")` →
|
|
||||||
20/20 chunks, 20 splatted, **601 objects (0 missing), 368 trees, 1330 property CRCs**, water,
|
|
||||||
env — byte-for-byte the same result as the loose tree. Desktop unaffected. ctest 10/10,
|
|
||||||
GDScript 34/34. Android `.so` + iOS `.a` rebuilt.
|
|
||||||
|
|
||||||
**Pending:** the OnePlus 13 is on USB and macOS sees it (`ioreg` shows vendor "OnePlus"),
|
|
||||||
but `adb devices` is empty → enable **Developer options → USB debugging** on the phone and
|
|
||||||
accept the "Allow USB debugging?" RSA prompt (USB mode: File transfer / MTP). Then:
|
|
||||||
- `./export-android.sh Debug --install` (or `adb install -r build/export/mtgodot-poc.apk`
|
|
||||||
then `adb shell am start -n org.internal.mtgodotpoc/com.godot.game.GodotApp`).
|
|
||||||
- `adb logcat -s godot GodotError Godot` — first run WILL need shader / GPU-skin fixes
|
|
||||||
under Godot's mobile Vulkan path (never exercised); capture a frame for F3.
|
|
||||||
- adb note: a stale `sdk/platform-tools/adb` daemon can hold `:5037` ("Address already in
|
|
||||||
use"); `pkill -9 -f adb` then use one adb consistently (`/opt/homebrew/bin/adb`).
|
|
||||||
|
|
||||||
## Notes / gotchas
|
|
||||||
|
|
||||||
- The `ranlib: ... has no symbols` spam during a libsodium build is harmless: the
|
|
||||||
x86 SIMD translation units compile to empty objects on arm64 (their bodies are
|
|
||||||
guarded by CPU-feature macros). libsodium-cmake compiles the full file list on
|
|
||||||
every arch by design.
|
|
||||||
- The staged `project/bin/ios/libgodot-cpp.*.a` is ~430 MB in Debug (all classes,
|
|
||||||
unstripped). Fine for a dev link; Release + dead-strip shrinks it hard.
|
|
||||||
`project/bin/ios/` and `project/bin/android/` are gitignored.
|
|
||||||
- Shaders (`SRC_TERRAIN/WATER/LEAF/SKIN/MIX` + the §2.7 spec branch) and the
|
|
||||||
GPU-skin bone-texture path have **not** been exercised under Godot's Mobile
|
|
||||||
renderer yet — that's a separate verification pass once a device boots.
|
|
||||||
- `net_stream.cpp` is pure POSIX sockets → fine on iOS/Android/Linux/macOS; only
|
|
||||||
a Windows target would need a Winsock shim.
|
|
||||||
|
|
||||||
## GPU texture compression (F4)
|
|
||||||
|
|
||||||
`extension/src/texture_util.{h,cpp}` — `make_color_texture(w,h,rgba,len,mipmaps)`
|
|
||||||
is the single funnel for "decoded RGBA8 → `ImageTexture`". On a mobile OS (or
|
|
||||||
`MTGODOT_TEXCOMP=1`) it runs the decoded colour image through runtime **ASTC 8x8**
|
|
||||||
(~2 bpp vs 32 for RGBA8), falling back to RGBA8 if the encoder is unavailable.
|
|
||||||
Desktop default is **off** and byte-identical to the old per-site code.
|
|
||||||
|
|
||||||
Wired: character skins (`metin2_model::_load_dds`), building albedo
|
|
||||||
(`static_object`), tree bark / leaf-composite (`tree_placeholder`). **Not** wired
|
|
||||||
(exact texel values matter): splat control maps, `shadowmap`, water, HUD
|
|
||||||
`load_dds` / minimap.
|
|
||||||
|
|
||||||
`texcomp_set_enabled(bool)` overrides at runtime. Verified: macOS + iOS compile,
|
|
||||||
`ctest` 9/9, desktop character render unchanged, `MTGODOT_TEXCOMP=1` render OK.
|
|
||||||
Remaining: on-device quality check + VRAM/bandwidth numbers (needs F1/F2).
|
|
||||||
|
|
||||||
## App lifecycle (F5)
|
|
||||||
|
|
||||||
`project/app_lifecycle.gd` — an `AppLifecycle` node. Add it early (or as an
|
|
||||||
autoload); `bind(m2client, audio)` wires the common consumers. It turns the
|
|
||||||
SceneTree-forwarded MainLoop notifications into signals
|
|
||||||
(`paused` / `resumed` / `focus_changed(bool)` / `memory_warning` /
|
|
||||||
`back_requested` / `close_requested`) and, by default, on background: pauses the
|
|
||||||
tree, stops BGM, drops `Engine.max_fps` to 8; restores on foreground.
|
|
||||||
`pause_tree_on_background` / `background_max_fps` are tunable (login screen sets
|
|
||||||
`pause_tree_on_background = false`).
|
|
||||||
|
|
||||||
`M2Client` (C++) handles `NOTIFICATION_APPLICATION_PAUSED/RESUMED` itself →
|
|
||||||
`suspend()` / `resume()`. While suspended `_process()` doesn't pump the socket.
|
|
||||||
A mobile OS drops the TCP connection within ~30 s of backgrounding, so the first
|
|
||||||
pump after `resume()` surfaces `disconnected`; `reconnect()` redoes the full
|
|
||||||
auth→game login from stored credentials. `login.gd` auto-calls it on `resumed`
|
|
||||||
when the stage was in-game. New signals: `suspended`, `resumed`.
|
|
||||||
|
|
||||||
Rendering-context loss (Android Vulkan surface) is handled by Godot; every GPU
|
|
||||||
resource we hold (bone textures, MultiMesh, ShaderMaterials, ImageTextures) is
|
|
||||||
RenderingServer-managed and survives — nothing for the extension to do.
|
|
||||||
|
|
||||||
Verified: headless smoke (`suspend`/`resume`/`reconnect` bound, idempotent,
|
|
||||||
signals fire once), `ctest` 9/9, iOS compile. Remaining: on-device background/
|
|
||||||
resume/reconnect cycle and a real iOS memory-warning.
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 40250 1:1 移植:路线与方案
|
# 40250 1:1 移植:路线与方案
|
||||||
|
|
||||||
|
> 历史迁移记录:2026-09-28 起,客户端使用 `native_render/` 的 SDL3 + Vulkan 原生路径。本文关于 Godot 工程、GDExtension、`project/`、Godot 导出和相应门禁的描述仅记录过去的迁移过程;这些代码已删除。当前构建方法见根目录 README 和 `native_render/README.md`。
|
||||||
|
|
||||||
本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。
|
本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。
|
||||||
|
|
||||||
- 方法细则:`.agents/skills/metin2-40250-parity-audit/SKILL.md`(每轮的做法、状态定义、工具)
|
- 方法细则:`.agents/skills/metin2-40250-parity-audit/SKILL.md`(每轮的做法、状态定义、工具)
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the Android APK end to end: native GDExtension .so -> Godot export -> (opt) install.
|
|
||||||
#
|
|
||||||
# ./export-android.sh [Debug|Release] [--install]
|
|
||||||
#
|
|
||||||
# Prereqs (one-time), see docs/PLATFORMS.md:
|
|
||||||
# - Android SDK at ~/Library/Android/sdk with an NDK under ndk/<ver>
|
|
||||||
# (sdkmanager "ndk;27.2.12479018" "platforms;android-35" "build-tools;35.0.0")
|
|
||||||
# - JDK 17+ (brew install openjdk@21)
|
|
||||||
# - Godot 4.7.1 export templates installed
|
|
||||||
# - ./gen-debug-keystore.sh (once)
|
|
||||||
# - editor_settings-4.7.tres: export/android/{android_sdk_path,java_sdk_path,debug_keystore}
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
CONFIG="${1:-Debug}"
|
|
||||||
INSTALL=0
|
|
||||||
[ "${2:-}" = "--install" ] && INSTALL=1
|
|
||||||
|
|
||||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
|
|
||||||
export ANDROID_NDK_ROOT="${ANDROID_NDK_ROOT:-$SDK/ndk/$(ls -1 "$SDK/ndk" 2>/dev/null | sort -V | tail -1)}"
|
|
||||||
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}"
|
|
||||||
[ -d "$JAVA_HOME" ] || export JAVA_HOME=/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
|
|
||||||
GODOT="${GODOT:-godot}"
|
|
||||||
ADB="${ADB:-$SDK/platform-tools/adb}"
|
|
||||||
|
|
||||||
case "$CONFIG" in
|
|
||||||
Release)
|
|
||||||
MODE="--export-release"
|
|
||||||
OUT="build/export/mtgodot-poc-release.apk"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
MODE="--export-debug"
|
|
||||||
OUT="build/export/mtgodot-poc.apk"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo ">> NDK: $ANDROID_NDK_ROOT"
|
|
||||||
echo ">> JDK: $JAVA_HOME"
|
|
||||||
|
|
||||||
# 1. native GDExtension
|
|
||||||
./build-android.sh "$CONFIG"
|
|
||||||
|
|
||||||
# 2. bake the AssetResolver index (PCK can't std::filesystem-scan on device).
|
|
||||||
# Regenerate whenever assets/ changes. Harmless if assets are missing.
|
|
||||||
"$GODOT" --headless --path project --script bake_asset_index.gd || \
|
|
||||||
echo ">> (asset_index bake skipped — no assets on this machine)"
|
|
||||||
|
|
||||||
# 3. Godot export
|
|
||||||
mkdir -p build/export
|
|
||||||
"$GODOT" --headless --path project "$MODE" "Android" "$(pwd)/$OUT"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo ">> APK: $OUT (index: $([ -f assets/asset_index.txt ] && echo baked || echo MISSING))"
|
|
||||||
unzip -l "$OUT" | grep -E "lib/arm64-v8a/.*\.so" || true
|
|
||||||
|
|
||||||
# 3. optional install (+ push the asset zip if it exists)
|
|
||||||
if [ "$INSTALL" = 1 ]; then
|
|
||||||
"$ADB" devices -l
|
|
||||||
"$ADB" install -r "$OUT"
|
|
||||||
if [ -f build/export/assets.zip ]; then
|
|
||||||
DEST="/sdcard/Android/data/org.internal.mtgodotpoc/files/assets.zip"
|
|
||||||
echo ">> pushing assets.zip ($(du -h build/export/assets.zip | cut -f1)) -> $DEST"
|
|
||||||
"$ADB" shell mkdir -p /sdcard/Android/data/org.internal.mtgodotpoc/files/ || true
|
|
||||||
"$ADB" push build/export/assets.zip "$DEST"
|
|
||||||
if [ "$CONFIG" != "Release" ]; then
|
|
||||||
echo ">> copying assets.zip into the Debug app user:// directory"
|
|
||||||
# Android 14/15 may deny run-as reading /sdcard even for the app's own
|
|
||||||
# external files directory. Stream through adb instead.
|
|
||||||
"$ADB" shell "run-as org.internal.mtgodotpoc sh -c 'cat > files/assets.zip'" \
|
|
||||||
< build/export/assets.zip
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo ">> NOTE: no build/export/assets.zip — run ./pack-assets.sh; the world won't render without it"
|
|
||||||
fi
|
|
||||||
"$ADB" shell am start -W -n org.internal.mtgodotpoc/com.godot.game.GodotAppLauncher \
|
|
||||||
-a android.intent.action.MAIN -c android.intent.category.LAUNCHER
|
|
||||||
echo ">> logcat: $ADB logcat -s godot GodotError Godot"
|
|
||||||
fi
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# mtgodot GDExtension — registers Metin2Model / Metin2AnimPlayer, links libgr2.
|
|
||||||
# libgr2 is added at the top-level CMakeLists (vendored under ../libgr2), so here
|
|
||||||
# we just consume the xrender::libgr2 target.
|
|
||||||
|
|
||||||
# --- godot-cpp (submodule pinned at 101ae38034304346a46ea9ea84ae156d3e860496) ---
|
|
||||||
# The parent gitlink is the source-of-truth; .gitmodules intentionally has no
|
|
||||||
# moving branch. The selected bundle targets Godot 4.7's extension API.
|
|
||||||
set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Target Godot API version" FORCE)
|
|
||||||
add_subdirectory(godot-cpp)
|
|
||||||
|
|
||||||
# --- vendored native deps: libsodium / libzstd / miniLZO ---
|
|
||||||
# Built from source (pinned submodules + vendored miniLZO) so the same tree
|
|
||||||
# compiles on the Android NDK and iOS SDK, which have no Homebrew. Exposes the
|
|
||||||
# aliases mt3p::sodium / mt3p::zstd / mt3p::minilzo. See docs/THIRD-PARTY.md.
|
|
||||||
add_subdirectory(third_party)
|
|
||||||
|
|
||||||
add_subdirectory(src/codepage)
|
|
||||||
|
|
||||||
# --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
|
|
||||||
add_library(mtnet STATIC
|
|
||||||
src/net/text_codec.cpp
|
|
||||||
src/net/secure_cipher.cpp
|
|
||||||
src/net/net_stream.cpp
|
|
||||||
src/net/entity_store.cpp
|
|
||||||
src/net/mark_image.cpp
|
|
||||||
src/net/classic/classic_stream.cpp # MT_PROTOCOL=classic (40250)
|
|
||||||
src/net/classic/classic_parser.cpp
|
|
||||||
src/net/classic/classic_session.cpp
|
|
||||||
src/net/classic/classic_cipher.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(mtnet PUBLIC src/net)
|
|
||||||
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
|
|
||||||
target_compile_features(mtnet PUBLIC cxx_std_20)
|
|
||||||
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
|
|
||||||
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
|
|
||||||
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
|
|
||||||
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
|
|
||||||
|
|
||||||
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
|
|
||||||
add_library(mtpack STATIC
|
|
||||||
src/pack/eterpack.cpp
|
|
||||||
src/pack/pack_mount.cpp
|
|
||||||
src/pack/asset_source.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(mtpack PUBLIC src/pack)
|
|
||||||
target_link_libraries(mtpack PUBLIC mt3p::sodium mt3p::zstd xrender::formats)
|
|
||||||
target_compile_features(mtpack PUBLIC cxx_std_20)
|
|
||||||
|
|
||||||
# --- mtproto: item_proto / mob_proto reader, 40250 format (CLZO/TEA from port_platform) ---
|
|
||||||
add_library(mtproto STATIC src/proto/proto.cpp)
|
|
||||||
target_include_directories(mtproto PUBLIC src/proto)
|
|
||||||
target_link_libraries(mtproto PUBLIC port_platform)
|
|
||||||
target_compile_features(mtproto PUBLIC cxx_std_20)
|
|
||||||
|
|
||||||
# --- port_logic + port_platform: 40250 mirror (extension/src/port) and its platform layer
|
|
||||||
# (extension/src/platform), docs/PORT-PLAN.md ---
|
|
||||||
add_subdirectory(src/port)
|
|
||||||
|
|
||||||
# Host-only CLIs + tests: they run on the build machine, so skip them entirely
|
|
||||||
# when cross-compiling the extension for a device.
|
|
||||||
set(MT_HOST_BUILD FALSE)
|
|
||||||
if(CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
|
||||||
set(MT_HOST_BUILD TRUE)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
if(MT_HOST_BUILD)
|
|
||||||
add_executable(packtool tools/packtool.cpp)
|
|
||||||
target_link_libraries(packtool PRIVATE mtpack)
|
|
||||||
|
|
||||||
# net_probe: connect to an auth server, run the handshake + CG_LOGIN3, report.
|
|
||||||
add_executable(net_probe tools/net_probe.cpp)
|
|
||||||
target_link_libraries(net_probe PRIVATE mtnet)
|
|
||||||
|
|
||||||
# net_e2e: full auth -> game -> char list -> select -> PHASE_GAME against a
|
|
||||||
# real server; dumps entities / points / inventory. Live integration check.
|
|
||||||
add_executable(net_e2e tools/net_e2e.cpp)
|
|
||||||
target_link_libraries(net_e2e PRIVATE mtnet)
|
|
||||||
|
|
||||||
# net_classic_e2e: live 40250 classic handshake -> login -> char select -> PHASE_GAME probe.
|
|
||||||
add_executable(net_classic_e2e tools/net_classic_e2e.cpp)
|
|
||||||
target_link_libraries(net_classic_e2e PRIVATE mtnet)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
if(BUILD_TESTING AND MT_HOST_BUILD)
|
|
||||||
add_executable(dxt_decode_test tests/dxt_decode_test.cpp src/dxt.cpp)
|
|
||||||
target_include_directories(dxt_decode_test PRIVATE src)
|
|
||||||
add_test(NAME dxt.decode COMMAND $<TARGET_FILE:dxt_decode_test>)
|
|
||||||
|
|
||||||
add_executable(net_cipher_test tests/net_cipher_test.cpp)
|
|
||||||
target_link_libraries(net_cipher_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.cipher_roundtrip COMMAND $<TARGET_FILE:net_cipher_test>)
|
|
||||||
|
|
||||||
add_executable(net_loopback_test tests/net_loopback_test.cpp)
|
|
||||||
target_link_libraries(net_loopback_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.loopback_flow COMMAND $<TARGET_FILE:net_loopback_test>)
|
|
||||||
|
|
||||||
add_executable(net_entity_test tests/net_entity_test.cpp)
|
|
||||||
target_link_libraries(net_entity_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.entity_store COMMAND $<TARGET_FILE:net_entity_test>)
|
|
||||||
|
|
||||||
add_executable(net_state_queue_test tests/net_state_queue_test.cpp)
|
|
||||||
target_link_libraries(net_state_queue_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.state_queue COMMAND $<TARGET_FILE:net_state_queue_test>)
|
|
||||||
|
|
||||||
# 40250 "classic" backend (MT_PROTOCOL=classic).
|
|
||||||
add_executable(net_classic_wire_test tests/net_classic_wire_test.cpp)
|
|
||||||
target_include_directories(net_classic_wire_test PRIVATE src/net)
|
|
||||||
target_compile_features(net_classic_wire_test PRIVATE cxx_std_20)
|
|
||||||
add_test(NAME net.classic_wire COMMAND $<TARGET_FILE:net_classic_wire_test>)
|
|
||||||
|
|
||||||
add_executable(net_classic_stream_test tests/net_classic_stream_test.cpp)
|
|
||||||
target_link_libraries(net_classic_stream_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.classic_stream COMMAND $<TARGET_FILE:net_classic_stream_test>)
|
|
||||||
|
|
||||||
add_executable(net_classic_session_test tests/net_classic_session_test.cpp)
|
|
||||||
target_link_libraries(net_classic_session_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.classic_session COMMAND $<TARGET_FILE:net_classic_session_test>)
|
|
||||||
|
|
||||||
add_executable(net_classic_cipher_test tests/net_classic_cipher_test.cpp)
|
|
||||||
target_link_libraries(net_classic_cipher_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.classic_cipher COMMAND $<TARGET_FILE:net_classic_cipher_test>)
|
|
||||||
|
|
||||||
add_executable(net_classic_encstream_test tests/net_classic_encstream_test.cpp)
|
|
||||||
target_link_libraries(net_classic_encstream_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.classic_encstream COMMAND $<TARGET_FILE:net_classic_encstream_test>)
|
|
||||||
|
|
||||||
add_executable(net_classic_mark_test tests/net_classic_mark_test.cpp)
|
|
||||||
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
|
|
||||||
|
|
||||||
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
|
|
||||||
target_link_libraries(net_text_codec_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
|
|
||||||
|
|
||||||
add_executable(net_bounds_test tests/net_bounds_test.cpp)
|
|
||||||
target_link_libraries(net_bounds_test PRIVATE mtnet)
|
|
||||||
add_test(NAME net.bounds COMMAND $<TARGET_FILE:net_bounds_test>)
|
|
||||||
|
|
||||||
add_executable(net_mark_test tests/net_mark_test.cpp)
|
|
||||||
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
|
|
||||||
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
|
|
||||||
|
|
||||||
add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp)
|
|
||||||
target_link_libraries(pack_roundtrip_test PRIVATE mtpack)
|
|
||||||
add_test(NAME pack.roundtrip COMMAND $<TARGET_FILE:pack_roundtrip_test>)
|
|
||||||
|
|
||||||
add_executable(proto_test tests/proto_test.cpp)
|
|
||||||
target_link_libraries(proto_test PRIVATE mtproto)
|
|
||||||
# MT_40250_CLIENT comes from src/port/CMakeLists.txt; skipped (77) without the client, failed under
|
|
||||||
# MT_ASSETS_STRICT=1.
|
|
||||||
add_test(NAME proto.item_mob COMMAND $<TARGET_FILE:proto_test> ${MT_40250_CLIENT})
|
|
||||||
set_tests_properties(proto.item_mob PROPERTIES SKIP_RETURN_CODE 77)
|
|
||||||
|
|
||||||
add_executable(proto_item_layout_test tests/proto_item_layout_test.cpp)
|
|
||||||
target_link_libraries(proto_item_layout_test PRIVATE mtproto)
|
|
||||||
add_test(NAME proto.item_layout COMMAND $<TARGET_FILE:proto_item_layout_test>)
|
|
||||||
|
|
||||||
# The embedded interpreter, when third_party built it (-DMTGODOT_EMBED_PYTHON=ON).
|
|
||||||
if(TARGET mtpython)
|
|
||||||
add_executable(py_embed_test tests/py_embed_test.cpp)
|
|
||||||
target_link_libraries(py_embed_test PRIVATE mtpython)
|
|
||||||
add_dependencies(py_embed_test mtpython_stdlib)
|
|
||||||
# The stdlib exactly as the host ships it: python27.zip, read through zipimport (批次 2P step 3).
|
|
||||||
add_test(NAME python.embed COMMAND $<TARGET_FILE:py_embed_test> "${MT_PYTHON_STDLIB_ZIP}")
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# --- the extension library ---
|
|
||||||
# macOS/Android: SHARED (Godot dlopen()s it). iOS: STATIC — the platform forbids
|
|
||||||
# loading dynamic libraries, so the archive is linked into the app at export time
|
|
||||||
# (see docs/PLATFORMS.md for the remaining F1 export/sign steps).
|
|
||||||
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
|
||||||
set(MT_LIB_KIND STATIC)
|
|
||||||
set(MT_PLAT_TAG "ios")
|
|
||||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
|
||||||
set(MT_LIB_KIND SHARED)
|
|
||||||
set(MT_PLAT_TAG "android")
|
|
||||||
else()
|
|
||||||
set(MT_LIB_KIND SHARED)
|
|
||||||
set(MT_PLAT_TAG "macos")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_library(mtgodot ${MT_LIB_KIND}
|
|
||||||
src/register_types.cpp
|
|
||||||
src/metin2_model.cpp
|
|
||||||
src/metin2_anim.cpp
|
|
||||||
src/metin2_world.cpp
|
|
||||||
src/terrain_splat.cpp
|
|
||||||
src/static_object.cpp
|
|
||||||
src/tree_placeholder.cpp
|
|
||||||
src/environment_builder.cpp
|
|
||||||
src/water_builder.cpp
|
|
||||||
src/gr2_bridge.cpp
|
|
||||||
src/m2_material.cpp
|
|
||||||
src/dxt.cpp
|
|
||||||
src/asset_io.cpp
|
|
||||||
src/texture_util.cpp
|
|
||||||
src/net/m2_client.cpp
|
|
||||||
src/proto/proto_node.cpp
|
|
||||||
src/pack40250_node.cpp
|
|
||||||
src/python_stdlib.cpp
|
|
||||||
src/python_host_node.cpp
|
|
||||||
)
|
|
||||||
target_compile_features(mtgodot PRIVATE cxx_std_20)
|
|
||||||
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto port_platform)
|
|
||||||
|
|
||||||
# The embedded interpreter's standard library (批次 2P step 3c). python27.zip and its digest are
|
|
||||||
# copied next to project.godot so the exporter packs them into the APK/IPA (the export presets'
|
|
||||||
# include_filter lists them); src/python_stdlib.cpp stages them out of the PCK at runtime.
|
|
||||||
if(TARGET mtpython_stdlib)
|
|
||||||
target_compile_definitions(mtgodot PRIVATE MTGODOT_HAVE_PYTHON)
|
|
||||||
# A target rather than a custom command on the two files: the command that produces them lives in
|
|
||||||
# third_party/, and a Makefile generator only resolves a generated-file dependency inside the
|
|
||||||
# directory that declared it. copy_if_different keeps the no-op build a compare.
|
|
||||||
add_custom_target(mtpython_stdlib_project ALL
|
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
|
||||||
"${MT_PYTHON_STDLIB_ZIP}" "${CMAKE_SOURCE_DIR}/project/python27.zip"
|
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
|
||||||
"${MT_PYTHON_STDLIB_SHA}" "${CMAKE_SOURCE_DIR}/project/python27.zip.sha256"
|
|
||||||
COMMENT "Copying python27.zip into project/ (res://)")
|
|
||||||
add_dependencies(mtpython_stdlib_project mtpython_stdlib)
|
|
||||||
add_dependencies(mtgodot mtpython_stdlib_project)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Godot's .gdextension expects, per platform:
|
|
||||||
# project/bin/libmtgodot.macos.template_{debug,release}.dylib
|
|
||||||
# project/bin/libmtgodot.ios.template_{debug,release}.a
|
|
||||||
# project/bin/libmtgodot.android.template_{debug,release}.<arch>.so
|
|
||||||
set(MT_CFG_TAG "$<IF:$<CONFIG:Release>,template_release,template_debug>")
|
|
||||||
if(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
|
||||||
# godot_arch_name-style suffix; OnePlus 13 = arm64.
|
|
||||||
set(MT_ARCH_SUFFIX ".${CMAKE_ANDROID_ARCH_ABI}")
|
|
||||||
string(REPLACE "arm64-v8a" "arm64" MT_ARCH_SUFFIX "${MT_ARCH_SUFFIX}")
|
|
||||||
else()
|
|
||||||
set(MT_ARCH_SUFFIX "")
|
|
||||||
endif()
|
|
||||||
set_target_properties(mtgodot PROPERTIES
|
|
||||||
PREFIX "lib"
|
|
||||||
OUTPUT_NAME "mtgodot.${MT_PLAT_TAG}.${MT_CFG_TAG}${MT_ARCH_SUFFIX}"
|
|
||||||
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
|
|
||||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
|
|
||||||
)
|
|
||||||
Submodule extension/godot-cpp deleted from 101ae38034
@@ -1,72 +0,0 @@
|
|||||||
#include "asset_io.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/file_access.hpp>
|
|
||||||
|
|
||||||
#include "platform/PackBackend.h"
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
bool pack_name(const String &path, std::string &name) {
|
|
||||||
if (!path.begins_with(PACK_SCHEME)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
name = path.substr(String(PACK_SCHEME).length()).utf8().get_data();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
PackedByteArray read_file(const String &path) {
|
|
||||||
if (path.is_empty()) {
|
|
||||||
return PackedByteArray();
|
|
||||||
}
|
|
||||||
std::string name;
|
|
||||||
if (pack_name(path, name)) {
|
|
||||||
std::vector<uint8_t> bytes;
|
|
||||||
PackedByteArray out;
|
|
||||||
if (mtpack40250::read(name, bytes)) {
|
|
||||||
out.resize((int64_t)bytes.size());
|
|
||||||
if (!bytes.empty()) {
|
|
||||||
std::memcpy(out.ptrw(), bytes.data(), bytes.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
|
|
||||||
return FileAccess::get_file_as_bytes(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool file_exists(const String &path) {
|
|
||||||
std::string name;
|
|
||||||
if (pack_name(path, name)) {
|
|
||||||
return mtpack40250::exists(name);
|
|
||||||
}
|
|
||||||
return !path.is_empty() && FileAccess::file_exists(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Image dds_from_file(const String &path) {
|
|
||||||
PackedByteArray b = read_file(path);
|
|
||||||
if (b.is_empty()) {
|
|
||||||
return Image{};
|
|
||||||
}
|
|
||||||
return load_dds(b.ptr(), (size_t)b.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<gr2::File> gr2_from_file(const String &path, gr2::LoadError *err) {
|
|
||||||
PackedByteArray b = read_file(path);
|
|
||||||
if (b.is_empty()) {
|
|
||||||
if (err) {
|
|
||||||
*err = {"open", std::string("cannot read ") + path.utf8().get_data()};
|
|
||||||
}
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
return gr2::File::load(b.ptr(), (size_t)b.size(), err);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// asset_io — the one portable asset-read point.
|
|
||||||
//
|
|
||||||
// The standalone libs (libgr2, formats, mtproto, mtpack) keep their raw
|
|
||||||
// fopen/ifstream `*_path()` entry points for the non-Godot CTests. Every read
|
|
||||||
// from the *running extension* goes through here instead, so it works for:
|
|
||||||
// - res:// (loose files in dev, PCK on iOS/Android read-only bundles)
|
|
||||||
// - user:// (extracted cache)
|
|
||||||
// - absolute OS paths (dev: AssetRoot points at mtgodot-poc/assets/)
|
|
||||||
// godot::FileAccess handles all three transparently.
|
|
||||||
// - pack://<40250 path> (e.g. pack://locale/en/item_proto, pack://d:/ymir work/pc/...): the 40250 packs,
|
|
||||||
// read through the ported CEterPackManager once Metin2Pack.initialize(<Client dir>) has run
|
|
||||||
// (platform/PackBackend.h). The path after pack:// is passed as UTF-8.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <gr2/gr2.h>
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
#include "dxt.h"
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
inline constexpr const char *PACK_SCHEME = "pack://";
|
|
||||||
|
|
||||||
// Whole-file read. Empty PackedByteArray on failure (path missing / unreadable).
|
|
||||||
godot::PackedByteArray read_file(const godot::String &path);
|
|
||||||
bool file_exists(const godot::String &path); // FileAccess::file_exists wrapper
|
|
||||||
|
|
||||||
// .dds -> RGBA8 level 0 via read_file. !ok() on failure.
|
|
||||||
Image dds_from_file(const godot::String &path);
|
|
||||||
|
|
||||||
// .gr2 parse via read_file. nullopt on read or parse failure.
|
|
||||||
std::optional<gr2::File> gr2_from_file(const godot::String &path, gr2::LoadError *err = nullptr);
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
namespace mtgodot {
|
namespace mtimage {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
inline uint32_t rd_le32(const uint8_t* p) {
|
inline uint32_t rd_le32(const uint8_t* p) {
|
||||||
@@ -209,4 +209,4 @@ Image load_dds_path(const char* path) {
|
|||||||
return decode(buf.data(), buf.size());
|
return decode(buf.data(), buf.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace mtgodot
|
} // namespace mtimage
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
|
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
|
||||||
// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
|
// (namespace engine -> mtimage). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
|
||||||
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
|
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
|
||||||
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
|
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
|
||||||
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
|
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace mtgodot {
|
namespace mtimage {
|
||||||
|
|
||||||
struct Image {
|
struct Image {
|
||||||
uint16_t w = 0, h = 0;
|
uint16_t w = 0, h = 0;
|
||||||
@@ -24,4 +24,4 @@ struct Image {
|
|||||||
Image load_dds(const uint8_t* bytes, size_t len);
|
Image load_dds(const uint8_t* bytes, size_t len);
|
||||||
Image load_dds_path(const char* path);
|
Image load_dds_path(const char* path);
|
||||||
|
|
||||||
} // namespace mtgodot
|
} // namespace mtimage
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
#include "environment_builder.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "dxt.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/environment.hpp>
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/procedural_sky_material.hpp>
|
|
||||||
#include <godot_cpp/classes/shader.hpp>
|
|
||||||
#include <godot_cpp/classes/shader_material.hpp>
|
|
||||||
#include <godot_cpp/classes/sky.hpp>
|
|
||||||
#include <godot_cpp/classes/texture2d.hpp>
|
|
||||||
#include <godot_cpp/core/object.hpp>
|
|
||||||
|
|
||||||
#include <m2_coord.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
|
|
||||||
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
|
|
||||||
|
|
||||||
const char *SRC_SKYBOX = R"(shader_type sky;
|
|
||||||
render_mode use_debanding;
|
|
||||||
|
|
||||||
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform bool has_front = false;
|
|
||||||
uniform bool has_back = false;
|
|
||||||
uniform bool has_left = false;
|
|
||||||
uniform bool has_right = false;
|
|
||||||
uniform bool has_top = false;
|
|
||||||
uniform bool has_bottom = false;
|
|
||||||
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
|
|
||||||
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
|
|
||||||
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
|
|
||||||
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform bool has_cloud = false;
|
|
||||||
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
|
|
||||||
uniform float cloud_height = 300.0;
|
|
||||||
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
|
|
||||||
uniform vec2 cloud_speed = vec2(0.001, 0.001);
|
|
||||||
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
|
|
||||||
|
|
||||||
vec3 gradient_color(vec3 direction) {
|
|
||||||
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
|
|
||||||
if (height < 0.5)
|
|
||||||
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
|
|
||||||
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec3 apply_cloud(vec3 color, vec3 direction) {
|
|
||||||
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
|
|
||||||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
|
|
||||||
return color;
|
|
||||||
|
|
||||||
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
|
|
||||||
// After the Metin2 -> Godot conversion this is camera.y + height. The
|
|
||||||
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
|
|
||||||
// Godot -Z, so keep that orientation here.
|
|
||||||
float distance_to_cloud = cloud_height / direction.y;
|
|
||||||
vec2 plane = direction.xz * distance_to_cloud;
|
|
||||||
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
|
|
||||||
return color;
|
|
||||||
vec2 uv = vec2(
|
|
||||||
0.5 + 0.5 * plane.y / cloud_scale.y,
|
|
||||||
0.5 + 0.5 * plane.x / cloud_scale.x);
|
|
||||||
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
|
|
||||||
|
|
||||||
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
|
|
||||||
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
|
|
||||||
vec4 texel = texture(cloud_tex, uv);
|
|
||||||
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
|
|
||||||
0.0, 1.0);
|
|
||||||
return cloud_rgb + color * (1.0 - cloud_rgb);
|
|
||||||
}
|
|
||||||
|
|
||||||
void sky() {
|
|
||||||
vec3 direction = normalize(EYEDIR);
|
|
||||||
vec3 color = gradient_color(direction);
|
|
||||||
float ax = abs(direction.x);
|
|
||||||
float ay = abs(direction.y);
|
|
||||||
float az = abs(direction.z);
|
|
||||||
vec2 uv;
|
|
||||||
|
|
||||||
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
|
|
||||||
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
|
|
||||||
if (az >= ax && az >= ay) {
|
|
||||||
if (direction.z > 0.0) {
|
|
||||||
uv = vec2(0.5 - 0.5 * direction.x / az,
|
|
||||||
0.5 - 0.5 * direction.y / az);
|
|
||||||
if (has_front)
|
|
||||||
color = texture(front_tex, uv).rgb;
|
|
||||||
} else {
|
|
||||||
uv = vec2(0.5 + 0.5 * direction.x / az,
|
|
||||||
0.5 - 0.5 * direction.y / az);
|
|
||||||
if (has_back)
|
|
||||||
color = texture(back_tex, uv).rgb;
|
|
||||||
}
|
|
||||||
} else if (ax >= ay) {
|
|
||||||
if (direction.x > 0.0) {
|
|
||||||
uv = vec2(0.5 + 0.5 * direction.z / ax,
|
|
||||||
0.5 - 0.5 * direction.y / ax);
|
|
||||||
if (has_left)
|
|
||||||
color = texture(left_tex, uv).rgb;
|
|
||||||
} else {
|
|
||||||
uv = vec2(0.5 - 0.5 * direction.z / ax,
|
|
||||||
0.5 - 0.5 * direction.y / ax);
|
|
||||||
if (has_right)
|
|
||||||
color = texture(right_tex, uv).rgb;
|
|
||||||
}
|
|
||||||
} else if (direction.y > 0.0) {
|
|
||||||
uv = vec2(0.5 - 0.5 * direction.x / ay,
|
|
||||||
0.5 - 0.5 * direction.z / ay);
|
|
||||||
if (has_top)
|
|
||||||
color = texture(top_tex, uv).rgb;
|
|
||||||
} else {
|
|
||||||
uv = vec2(0.5 - 0.5 * direction.x / ay,
|
|
||||||
0.5 - 0.5 * direction.z / ay);
|
|
||||||
if (has_bottom)
|
|
||||||
color = texture(bottom_tex, uv).rgb;
|
|
||||||
}
|
|
||||||
COLOR = apply_cloud(color, direction);
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
Ref<ImageTexture> solid_texture(const Color &color) {
|
|
||||||
PackedByteArray pixels;
|
|
||||||
pixels.resize(4);
|
|
||||||
uint8_t *p = pixels.ptrw();
|
|
||||||
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
|
|
||||||
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
|
|
||||||
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
|
|
||||||
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
|
|
||||||
Ref<godot::Image> image = godot::Image::create_from_data(
|
|
||||||
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
|
|
||||||
image->generate_mipmaps();
|
|
||||||
return ImageTexture::create_from_image(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
|
|
||||||
if (name.empty() || resolver == nullptr)
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
const std::string path = resolver->resolve(name, nullptr);
|
|
||||||
if (path.empty())
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
Ref<godot::Image> image;
|
|
||||||
const String godot_path(path.c_str());
|
|
||||||
if (godot_path.get_extension().to_lower() == "dds") {
|
|
||||||
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
|
|
||||||
if (!decoded.ok())
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
PackedByteArray pixels;
|
|
||||||
pixels.resize((int64_t)decoded.rgba.size());
|
|
||||||
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
|
|
||||||
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
|
|
||||||
godot::Image::FORMAT_RGBA8, pixels);
|
|
||||||
} else {
|
|
||||||
// Image::load_from_file cannot open the pack:// virtual filesystem.
|
|
||||||
if (godot_path.begins_with("pack://")) {
|
|
||||||
PackedByteArray bytes = mtgodot::read_file(godot_path);
|
|
||||||
image.instantiate();
|
|
||||||
if (godot_path.get_extension().to_lower() == "tga")
|
|
||||||
image->load_tga_from_buffer(bytes);
|
|
||||||
else if (godot_path.get_extension().to_lower() == "png")
|
|
||||||
image->load_png_from_buffer(bytes);
|
|
||||||
else if (godot_path.get_extension().to_lower() == "jpg")
|
|
||||||
image->load_jpg_from_buffer(bytes);
|
|
||||||
} else {
|
|
||||||
image = godot::Image::load_from_file(godot_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (image.is_null() || image->is_empty())
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
image->generate_mipmaps();
|
|
||||||
return ImageTexture::create_from_image(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
|
|
||||||
const fmt::AssetResolver *resolver) {
|
|
||||||
EnvNodes out;
|
|
||||||
|
|
||||||
// --- DirectionalLight (Background) ---
|
|
||||||
out.sun = Object::cast_to<DirectionalLight3D>(parent->get_node_or_null(NodePath("Sun")));
|
|
||||||
if (!out.sun) {
|
|
||||||
out.sun = memnew(DirectionalLight3D);
|
|
||||||
out.sun->set_name("Sun");
|
|
||||||
parent->add_child(out.sun);
|
|
||||||
}
|
|
||||||
out.sun->set_cull_mask(1u << 0); // background / terrain layer
|
|
||||||
out.sun->set_visible(env.dir_light.bg_enable);
|
|
||||||
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
|
|
||||||
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
|
|
||||||
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
|
|
||||||
Vector3 fwd(d.x, d.y, d.z);
|
|
||||||
if (fwd.length() < 1e-4f)
|
|
||||||
fwd = Vector3(-0.4f, -0.7f, -0.55f);
|
|
||||||
fwd.normalize();
|
|
||||||
// Godot 光沿自身 -Z 照射 -> basis 的 -Z = fwd
|
|
||||||
Basis b = Basis::looking_at(fwd, Vector3(0, 1, 0));
|
|
||||||
out.sun->set_transform(Transform3D(b, Vector3(0, 0, 0)));
|
|
||||||
if (env.dir_light.bg_enable || luma(env.dir_light.bg_diffuse) > 0.01f) {
|
|
||||||
out.sun->set_color(rgba(env.dir_light.bg_diffuse));
|
|
||||||
out.sun->set_param(Light3D::PARAM_ENERGY,
|
|
||||||
std::clamp(0.9f + luma(env.dir_light.bg_diffuse) * 0.4f, 0.7f, 1.6f));
|
|
||||||
}
|
|
||||||
out.sun->set_shadow(true);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_NORMAL_BIAS, 2.0f);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_BIAS, 0.06f);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_MAX_DISTANCE, 500.0f);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_1_OFFSET, 0.08f);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_2_OFFSET, 0.22f);
|
|
||||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_3_OFFSET, 0.5f);
|
|
||||||
out.sun->set_shadow_mode(DirectionalLight3D::SHADOW_PARALLEL_4_SPLITS);
|
|
||||||
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
|
|
||||||
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
|
|
||||||
|
|
||||||
// ClientVS22 keeps the character light separate from the background light
|
|
||||||
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
|
|
||||||
// cull mask is the closest exact scene-level equivalent: static world
|
|
||||||
// geometry is layer 1, Metin2Model geometry is layer 2.
|
|
||||||
out.character_light = Object::cast_to<DirectionalLight3D>(
|
|
||||||
parent->get_node_or_null(NodePath("CharacterLight")));
|
|
||||||
if (!out.character_light) {
|
|
||||||
out.character_light = memnew(DirectionalLight3D);
|
|
||||||
out.character_light->set_name("CharacterLight");
|
|
||||||
parent->add_child(out.character_light);
|
|
||||||
}
|
|
||||||
out.character_light->set_cull_mask(1u << 1);
|
|
||||||
out.character_light->set_visible(env.dir_light.ch_enable);
|
|
||||||
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
|
|
||||||
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
|
|
||||||
out.character_light->set_param(Light3D::PARAM_ENERGY,
|
|
||||||
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
|
|
||||||
out.character_light->set_shadow(false);
|
|
||||||
out.character_light->set_param(Light3D::PARAM_SPECULAR, 0.4f);
|
|
||||||
|
|
||||||
// --- WorldEnvironment ---
|
|
||||||
out.world_env =
|
|
||||||
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
|
|
||||||
if (!out.world_env) {
|
|
||||||
out.world_env = memnew(WorldEnvironment);
|
|
||||||
out.world_env->set_name("WorldEnv");
|
|
||||||
parent->add_child(out.world_env);
|
|
||||||
}
|
|
||||||
Ref<godot::Environment> e = out.world_env->get_environment();
|
|
||||||
if (e.is_null())
|
|
||||||
e.instantiate();
|
|
||||||
|
|
||||||
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground)。
|
|
||||||
// 参考端的 texture mode 是独立六面 quad;Godot Sky shader 用 EYEDIR 采样
|
|
||||||
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
|
|
||||||
int sky_face_count = 0;
|
|
||||||
bool sky_texture_applied = false;
|
|
||||||
bool sky_cloud_applied = false;
|
|
||||||
{
|
|
||||||
Ref<ProceduralSkyMaterial> psm;
|
|
||||||
psm.instantiate();
|
|
||||||
const auto &g = env.sky.gradient;
|
|
||||||
if (g.size() >= 2) {
|
|
||||||
psm->set_sky_top_color(rgba(g.front()));
|
|
||||||
psm->set_sky_horizon_color(rgba(g[g.size() / 2]));
|
|
||||||
psm->set_ground_horizon_color(rgba(g.back()));
|
|
||||||
Color gb = rgba(g.back());
|
|
||||||
psm->set_ground_bottom_color(Color(gb.r * 0.6f, gb.g * 0.6f, gb.b * 0.65f));
|
|
||||||
}
|
|
||||||
psm->set_sun_angle_max(6.0f);
|
|
||||||
Ref<Sky> sky;
|
|
||||||
sky.instantiate();
|
|
||||||
std::array<Ref<ImageTexture>, 6> faces;
|
|
||||||
std::array<bool, 6> face_loaded{};
|
|
||||||
for (int i = 0; i < 6; ++i) {
|
|
||||||
if (env.sky.face_textures[i].empty())
|
|
||||||
continue;
|
|
||||||
faces[i] = load_texture(env.sky.face_textures[i], resolver);
|
|
||||||
face_loaded[i] = faces[i].is_valid();
|
|
||||||
if (face_loaded[i])
|
|
||||||
++sky_face_count;
|
|
||||||
}
|
|
||||||
Ref<ImageTexture> cloud_texture;
|
|
||||||
if (!env.sky.cloud_texture.empty())
|
|
||||||
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
|
|
||||||
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
|
|
||||||
sky_texture_applied = use_texture_sky;
|
|
||||||
if (use_texture_sky) {
|
|
||||||
Ref<Shader> shader;
|
|
||||||
shader.instantiate();
|
|
||||||
shader->set_code(String(SRC_SKYBOX));
|
|
||||||
Ref<ShaderMaterial> material;
|
|
||||||
material.instantiate();
|
|
||||||
material->set_shader(shader);
|
|
||||||
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
|
|
||||||
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
|
|
||||||
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
|
|
||||||
material->set_shader_parameter("gradient_top", fallback);
|
|
||||||
material->set_shader_parameter("gradient_horizon", horizon);
|
|
||||||
material->set_shader_parameter("gradient_bottom", ground);
|
|
||||||
static constexpr const char *kFaceParams[6] = {
|
|
||||||
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
|
|
||||||
static constexpr const char *kFaceFlags[6] = {
|
|
||||||
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
|
|
||||||
for (int i = 0; i < 6; ++i) {
|
|
||||||
if (!faces[i].is_valid())
|
|
||||||
faces[i] = solid_texture(i == 5 ? ground : horizon);
|
|
||||||
material->set_shader_parameter(kFaceParams[i], faces[i]);
|
|
||||||
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
|
|
||||||
}
|
|
||||||
if (cloud_texture.is_valid()) {
|
|
||||||
material->set_shader_parameter("cloud_tex", cloud_texture);
|
|
||||||
material->set_shader_parameter("has_cloud", true);
|
|
||||||
material->set_shader_parameter("cloud_scale", Vector2(
|
|
||||||
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
|
|
||||||
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
|
|
||||||
material->set_shader_parameter("cloud_height",
|
|
||||||
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
|
|
||||||
material->set_shader_parameter("cloud_texture_scale", Vector2(
|
|
||||||
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
|
|
||||||
material->set_shader_parameter("cloud_speed", Vector2(
|
|
||||||
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
|
|
||||||
Color tint = Color(0, 0, 0, 0);
|
|
||||||
if (!env.sky.cloud_color.empty())
|
|
||||||
tint = rgba(env.sky.cloud_color.front());
|
|
||||||
material->set_shader_parameter("cloud_tint", tint);
|
|
||||||
sky_cloud_applied = true;
|
|
||||||
}
|
|
||||||
sky->set_material(material);
|
|
||||||
} else {
|
|
||||||
sky->set_material(psm);
|
|
||||||
}
|
|
||||||
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
|
|
||||||
// sky has no equivalent UV-speed control, but sky_cover preserves the real
|
|
||||||
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
|
|
||||||
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
|
|
||||||
if (cloud_texture.is_valid()) {
|
|
||||||
psm->set_sky_cover(cloud_texture);
|
|
||||||
Color tint = Color(1, 1, 1, 1);
|
|
||||||
if (!env.sky.cloud_color.empty())
|
|
||||||
tint = rgba(env.sky.cloud_color.front());
|
|
||||||
psm->set_sky_cover_modulate(tint);
|
|
||||||
sky_cloud_applied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e->set_sky(sky);
|
|
||||||
e->set_background(godot::Environment::BG_SKY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 环境光:Material.Ambient 定色 + 亮度(Metin2 用暖色环境光提亮阴影面)
|
|
||||||
e->set_ambient_source(godot::Environment::AMBIENT_SOURCE_COLOR);
|
|
||||||
Color amb = rgba(env.material.ambient);
|
|
||||||
e->set_ambient_light_color(amb);
|
|
||||||
e->set_ambient_light_energy(std::clamp(0.5f + luma(env.material.ambient) * 0.4f, 0.35f, 0.95f));
|
|
||||||
e->set_ambient_light_sky_contribution(0.35f);
|
|
||||||
// Emissive 当作全局轻微自发光提亮(避免死黑)
|
|
||||||
e->set_bg_energy_multiplier(1.0f);
|
|
||||||
|
|
||||||
// 雾:优先用 .msenv 的 NearDistance/FarDistance(cm,A1 = 5000/20000 -> 50/200m
|
|
||||||
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
|
|
||||||
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
|
|
||||||
if (env.fog.enable) {
|
|
||||||
e->set_fog_enabled(true);
|
|
||||||
e->set_fog_light_color(rgba(env.fog.color));
|
|
||||||
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
|
|
||||||
float begin_m, end_m;
|
|
||||||
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
|
|
||||||
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
|
|
||||||
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
|
|
||||||
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
|
|
||||||
begin_m = std::max(begin_m, 120.0f);
|
|
||||||
end_m = std::max(end_m, begin_m + 400.0f);
|
|
||||||
} else {
|
|
||||||
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
|
|
||||||
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
|
|
||||||
end_m = begin_m + 700.0f;
|
|
||||||
}
|
|
||||||
e->set_fog_depth_begin(begin_m);
|
|
||||||
e->set_fog_depth_end(end_m);
|
|
||||||
e->set_fog_depth_curve(0.5f);
|
|
||||||
e->set_fog_density(0.0f);
|
|
||||||
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
|
|
||||||
e->set_fog_sun_scatter(0.05f);
|
|
||||||
} else {
|
|
||||||
e->set_fog_enabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
|
|
||||||
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
|
|
||||||
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
|
|
||||||
e->set_tonemap_exposure(1.0f);
|
|
||||||
e->set_adjustment_enabled(true);
|
|
||||||
e->set_adjustment_saturation(1.12f);
|
|
||||||
e->set_adjustment_contrast(1.05f);
|
|
||||||
e->set_adjustment_brightness(1.0f);
|
|
||||||
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
|
|
||||||
e->set_glow_enabled(true);
|
|
||||||
e->set_glow_intensity(0.06f);
|
|
||||||
e->set_glow_strength(0.7f);
|
|
||||||
e->set_glow_bloom(0.0f);
|
|
||||||
e->set_glow_hdr_bleed_threshold(1.6f);
|
|
||||||
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
|
|
||||||
e->set_ssao_enabled(true);
|
|
||||||
e->set_ssao_radius(1.2f);
|
|
||||||
e->set_ssao_intensity(0.9f);
|
|
||||||
|
|
||||||
// Keep parsed reference values visible to the runtime/debug probe even where
|
|
||||||
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
|
|
||||||
// and lens flare). This also prevents a future builder from silently losing
|
|
||||||
// the fields while the renderer equivalent is being implemented.
|
|
||||||
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
|
|
||||||
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
|
|
||||||
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
|
|
||||||
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
|
|
||||||
out.world_env->set_meta("msenv_sky_scale",
|
|
||||||
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
|
|
||||||
out.world_env->set_meta("msenv_cloud_scale",
|
|
||||||
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
|
|
||||||
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
|
|
||||||
out.world_env->set_meta("msenv_cloud_texture_scale",
|
|
||||||
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
|
|
||||||
out.world_env->set_meta("msenv_cloud_speed",
|
|
||||||
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
|
|
||||||
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
|
|
||||||
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
|
|
||||||
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
|
|
||||||
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
|
|
||||||
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
|
|
||||||
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
|
|
||||||
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
|
|
||||||
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
|
|
||||||
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
|
|
||||||
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
|
|
||||||
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
|
|
||||||
out.world_env->set_meta("msenv_wind_strength", 0.2f);
|
|
||||||
out.world_env->set_meta("msenv_wind_random", 0.0f);
|
|
||||||
|
|
||||||
out.world_env->set_environment(e);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/directional_light3d.hpp>
|
|
||||||
#include <godot_cpp/classes/world_environment.hpp>
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
#include <environment.h>
|
|
||||||
|
|
||||||
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
|
|
||||||
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
|
|
||||||
// ProceduralSkyMaterial 的 sky cover;texture-mode 下再用 Godot sky shader 叠加
|
|
||||||
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
|
|
||||||
// 不把不等价的近似标成完成。
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
struct EnvNodes {
|
|
||||||
godot::DirectionalLight3D *sun = nullptr;
|
|
||||||
godot::DirectionalLight3D *character_light = nullptr;
|
|
||||||
godot::WorldEnvironment *world_env = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
|
|
||||||
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
|
|
||||||
const fmt::AssetResolver *resolver = nullptr);
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
#include "gr2_bridge.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/skeleton3d.hpp>
|
|
||||||
#include <godot_cpp/classes/skin.hpp>
|
|
||||||
#include <godot_cpp/core/math.hpp>
|
|
||||||
#include <godot_cpp/core/memory.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B) {
|
|
||||||
gr2::Mat4 R{};
|
|
||||||
for (int i = 0; i < 3; ++i) {
|
|
||||||
for (int k = 0; k < 3; ++k)
|
|
||||||
R[i * 4 + k] = A[i * 4 + 0] * B[0 * 4 + k] + A[i * 4 + 1] * B[1 * 4 + k] +
|
|
||||||
A[i * 4 + 2] * B[2 * 4 + k];
|
|
||||||
}
|
|
||||||
for (int k = 0; k < 3; ++k)
|
|
||||||
R[12 + k] = A[12] * B[k] + A[13] * B[4 + k] + A[14] * B[8 + k] + B[12 + k];
|
|
||||||
R[15] = 1.0f;
|
|
||||||
return R;
|
|
||||||
}
|
|
||||||
|
|
||||||
Transform3D gr2_to_godot(const gr2::Mat4 &m) {
|
|
||||||
// gr2 row-major, row-vector: basis columns are (m0,m1,m2),(m4,m5,m6),(m8,m9,m10);
|
|
||||||
// translation is the 4th row (m12,m13,m14).
|
|
||||||
Basis b(
|
|
||||||
Vector3(m[0], m[1], m[2]),
|
|
||||||
Vector3(m[4], m[5], m[6]),
|
|
||||||
Vector3(m[8], m[9], m[10]));
|
|
||||||
return Transform3D(b, Vector3(m[12], m[13], m[14]));
|
|
||||||
}
|
|
||||||
|
|
||||||
Transform3D make_conv(float unit_scale, bool flip_z) {
|
|
||||||
// Z-up -> Y-up: rotate -90 deg about X. Then uniform scale. Optional Z flip.
|
|
||||||
Basis b;
|
|
||||||
b = b.rotated(Vector3(1, 0, 0), Math::deg_to_rad(-90.0));
|
|
||||||
b = b.scaled(Vector3(unit_scale, unit_scale, flip_z ? -unit_scale : unit_scale));
|
|
||||||
return Transform3D(b, Vector3());
|
|
||||||
}
|
|
||||||
|
|
||||||
Skeleton3D *build_skeleton(const gr2::Skeleton &sk) {
|
|
||||||
if (sk.bones.empty()) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
Skeleton3D *skel = memnew(Skeleton3D);
|
|
||||||
skel->set_name("Skeleton3D");
|
|
||||||
|
|
||||||
const Transform3D ip = gr2_to_godot(sk.initial_placement);
|
|
||||||
|
|
||||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
|
||||||
skel->add_bone(String(sk.bones[i].name.c_str()));
|
|
||||||
}
|
|
||||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
|
||||||
const gr2::Bone &bn = sk.bones[i];
|
|
||||||
skel->set_bone_parent((int)i, bn.parent);
|
|
||||||
Transform3D rest = gr2_to_godot(bn.local_transform);
|
|
||||||
if (bn.parent < 0) {
|
|
||||||
rest = ip * rest; // fold initial_placement into root bones
|
|
||||||
}
|
|
||||||
skel->set_bone_rest((int)i, rest);
|
|
||||||
skel->reset_bone_pose((int)i);
|
|
||||||
}
|
|
||||||
return skel;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<Skin> build_skin(const gr2::Skeleton &sk) {
|
|
||||||
Ref<Skin> skin;
|
|
||||||
skin.instantiate();
|
|
||||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
|
||||||
skin->add_bind((int)i, gr2_to_godot(sk.bones[i].inverse_world));
|
|
||||||
skin->set_bind_name((int)i, StringName(sk.bones[i].name.c_str()));
|
|
||||||
}
|
|
||||||
return skin;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi) {
|
|
||||||
std::vector<RenderPart> parts;
|
|
||||||
for (int mi = 0; mi < (int)fi.meshes.size(); ++mi) {
|
|
||||||
const gr2::Mesh &m = fi.meshes[mi];
|
|
||||||
if (m.vertices.empty() || m.indices.empty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const uint32_t total = (uint32_t)m.indices.size();
|
|
||||||
if (m.tri_groups.size() <= 1) {
|
|
||||||
RenderPart p;
|
|
||||||
p.mesh = mi;
|
|
||||||
p.mat_index = m.tri_groups.empty() ? -1 : m.tri_groups[0].material_index;
|
|
||||||
p.idx_first = 0;
|
|
||||||
p.idx_count = total;
|
|
||||||
parts.push_back(p);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (int g = 0; g < (int)m.tri_groups.size(); ++g) {
|
|
||||||
const gr2::TriGroup &tg = m.tri_groups[g];
|
|
||||||
if (tg.tri_count <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
uint32_t first = (uint32_t)(tg.tri_first < 0 ? 0 : tg.tri_first) * 3u;
|
|
||||||
uint32_t count = (uint32_t)tg.tri_count * 3u;
|
|
||||||
if (first >= total) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (first + count > total) {
|
|
||||||
count = total - first;
|
|
||||||
}
|
|
||||||
RenderPart p;
|
|
||||||
p.mesh = mi;
|
|
||||||
p.group = g;
|
|
||||||
p.mat_index = tg.material_index;
|
|
||||||
p.idx_first = first;
|
|
||||||
p.idx_count = count;
|
|
||||||
parts.push_back(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, const std::vector<RenderPart> &parts,
|
|
||||||
bool flip_winding, AABB &out_bounds) {
|
|
||||||
Ref<ArrayMesh> am;
|
|
||||||
am.instantiate();
|
|
||||||
am->set_name("ArrayMesh");
|
|
||||||
|
|
||||||
bool have_bounds = false;
|
|
||||||
|
|
||||||
// vertex arrays are per gr2 mesh; multiple parts of one mesh reuse them.
|
|
||||||
for (const RenderPart &part : parts) {
|
|
||||||
const gr2::Mesh &m = fi.meshes[part.mesh];
|
|
||||||
const int vcount = (int)m.vertices.size();
|
|
||||||
|
|
||||||
PackedVector3Array pos;
|
|
||||||
PackedVector3Array nrm;
|
|
||||||
PackedVector2Array uv;
|
|
||||||
PackedInt32Array bones;
|
|
||||||
PackedFloat32Array weights;
|
|
||||||
pos.resize(vcount);
|
|
||||||
nrm.resize(vcount);
|
|
||||||
uv.resize(vcount);
|
|
||||||
bones.resize(vcount * 4);
|
|
||||||
weights.resize(vcount * 4);
|
|
||||||
|
|
||||||
Vector3 *pos_w = pos.ptrw();
|
|
||||||
Vector3 *nrm_w = nrm.ptrw();
|
|
||||||
Vector2 *uv_w = uv.ptrw();
|
|
||||||
int32_t *bn_w = bones.ptrw();
|
|
||||||
float *wt_w = weights.ptrw();
|
|
||||||
|
|
||||||
auto slot_to_bone = [&](int slot) -> int {
|
|
||||||
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
int b = m.bone_bindings[slot];
|
|
||||||
return (b < 0) ? 0 : b;
|
|
||||||
};
|
|
||||||
|
|
||||||
const int rigid_bone = m.rigid ? slot_to_bone(m.bone_bindings.empty() ? -1 : 0) : 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < vcount; ++i) {
|
|
||||||
const gr2::Vertex &v = m.vertices[i];
|
|
||||||
pos_w[i] = Vector3(v.pos[0], v.pos[1], v.pos[2]);
|
|
||||||
nrm_w[i] = Vector3(v.normal[0], v.normal[1], v.normal[2]);
|
|
||||||
uv_w[i] = Vector2(v.uv0[0], v.uv0[1]);
|
|
||||||
|
|
||||||
if (!have_bounds) {
|
|
||||||
out_bounds.position = pos_w[i];
|
|
||||||
out_bounds.size = Vector3();
|
|
||||||
have_bounds = true;
|
|
||||||
} else {
|
|
||||||
out_bounds = out_bounds.expand(pos_w[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m.rigid) {
|
|
||||||
bn_w[i * 4 + 0] = rigid_bone;
|
|
||||||
bn_w[i * 4 + 1] = bn_w[i * 4 + 2] = bn_w[i * 4 + 3] = 0;
|
|
||||||
wt_w[i * 4 + 0] = 1.0f;
|
|
||||||
wt_w[i * 4 + 1] = wt_w[i * 4 + 2] = wt_w[i * 4 + 3] = 0.0f;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wsum = 0.0f;
|
|
||||||
for (int k = 0; k < 4; ++k) {
|
|
||||||
wsum += v.bone_weight[k];
|
|
||||||
}
|
|
||||||
for (int k = 0; k < 4; ++k) {
|
|
||||||
bn_w[i * 4 + k] = slot_to_bone(v.bone_index[k]);
|
|
||||||
wt_w[i * 4 + k] = (wsum > 0.0f) ? (v.bone_weight[k] / (float)wsum) : (k == 0 ? 1.0f : 0.0f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// indices: this part's sub-range of mesh.indices only
|
|
||||||
const uint32_t ib = part.idx_first;
|
|
||||||
const uint32_t ic = (part.idx_count && part.idx_first + part.idx_count <= m.indices.size())
|
|
||||||
? part.idx_count
|
|
||||||
: (uint32_t)m.indices.size() - ib;
|
|
||||||
PackedInt32Array idx;
|
|
||||||
idx.resize((int)ic);
|
|
||||||
int32_t *idx_w = idx.ptrw();
|
|
||||||
if (flip_winding) {
|
|
||||||
for (uint32_t t = 0; t + 2 < ic; t += 3) {
|
|
||||||
idx_w[t + 0] = (int)m.indices[ib + t + 0];
|
|
||||||
idx_w[t + 1] = (int)m.indices[ib + t + 2];
|
|
||||||
idx_w[t + 2] = (int)m.indices[ib + t + 1];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (uint32_t t = 0; t < ic; ++t) {
|
|
||||||
idx_w[t] = (int)m.indices[ib + t];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Array arrays;
|
|
||||||
arrays.resize(Mesh::ARRAY_MAX);
|
|
||||||
arrays[Mesh::ARRAY_VERTEX] = pos;
|
|
||||||
arrays[Mesh::ARRAY_NORMAL] = nrm;
|
|
||||||
arrays[Mesh::ARRAY_TEX_UV] = uv;
|
|
||||||
arrays[Mesh::ARRAY_BONES] = bones;
|
|
||||||
arrays[Mesh::ARRAY_WEIGHTS] = weights;
|
|
||||||
arrays[Mesh::ARRAY_INDEX] = idx;
|
|
||||||
|
|
||||||
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
|
|
||||||
String nm = m.name.empty() ? String("surf_") + itos(part.mesh) : String(m.name.c_str());
|
|
||||||
if (part.group >= 0) {
|
|
||||||
nm += String("#") + itos(part.group);
|
|
||||||
}
|
|
||||||
am->surface_set_name(am->get_surface_count() - 1, nm);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!have_bounds) {
|
|
||||||
out_bounds = AABB();
|
|
||||||
}
|
|
||||||
return am;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// gr2_bridge — libgr2 POD views -> Godot scene objects.
|
|
||||||
//
|
|
||||||
// Coordinate handling (the one place it lives; see docs/GODOT-POC-PLAN.md §02):
|
|
||||||
// - gr2 Mat4 is row-major, row-vector math, translation in elements 12..14.
|
|
||||||
// - A plain 4x4 transpose maps it to a Godot Transform3D (column-vector,
|
|
||||||
// translation in .origin). gr2_to_godot() does exactly that.
|
|
||||||
// - Z-up(cm) -> Y-up(m) and optional handedness flip are NOT applied here;
|
|
||||||
// they go on the Metin2Model node transform (make_conv()).
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/variant/aabb.hpp>
|
|
||||||
#include <godot_cpp/variant/transform3d.hpp>
|
|
||||||
|
|
||||||
#include <gr2/gr2.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace godot {
|
|
||||||
class Skeleton3D;
|
|
||||||
class Skin;
|
|
||||||
class ArrayMesh;
|
|
||||||
} // namespace godot
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D.
|
|
||||||
godot::Transform3D gr2_to_godot(const gr2::Mat4 &m);
|
|
||||||
|
|
||||||
// gr2 affine compose (row-vector: result applies A then B), same as libgr2's
|
|
||||||
// internal mul4x3. R = A · B with the 4th row treated as translation.
|
|
||||||
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B);
|
|
||||||
|
|
||||||
// Z-up cm -> Y-up m (+ optional Z flip for LH->RH content).
|
|
||||||
godot::Transform3D make_conv(float unit_scale, bool flip_z);
|
|
||||||
|
|
||||||
// Build a Skeleton3D (rest pose from bone local transforms; initial_placement
|
|
||||||
// folded into root bones). Returns nullptr if the skeleton has no bones.
|
|
||||||
godot::Skeleton3D *build_skeleton(const gr2::Skeleton &sk);
|
|
||||||
|
|
||||||
// One render part = one Godot surface. A gr2 mesh with N>1 tri_groups splits into
|
|
||||||
// N parts (each its own material); a mesh with 0/1 groups is one whole-mesh part.
|
|
||||||
struct RenderPart {
|
|
||||||
int mesh = -1; // gr2::FileInfo::meshes index
|
|
||||||
int group = -1; // gr2::Mesh::tri_groups index, or -1 = whole mesh
|
|
||||||
int mat_index = -1; // tri_groups[group].material_index (mesh-local), or -1
|
|
||||||
uint32_t idx_first = 0; // start into mesh.indices (= tri_first * 3)
|
|
||||||
uint32_t idx_count = 0; // length into mesh.indices (= tri_count * 3)
|
|
||||||
};
|
|
||||||
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi);
|
|
||||||
|
|
||||||
// Skin whose bind list is parallel to the skeleton bones:
|
|
||||||
// bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world)
|
|
||||||
godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk);
|
|
||||||
|
|
||||||
// One ArrayMesh with a surface per RenderPart (see build_parts).
|
|
||||||
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
|
|
||||||
// mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0].
|
|
||||||
// Fills out_bounds with the untransformed vertex AABB.
|
|
||||||
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
|
|
||||||
const std::vector<RenderPart> &parts, bool flip_winding,
|
|
||||||
godot::AABB &out_bounds);
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
#include "m2_material.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/shader.hpp>
|
|
||||||
#include <godot_cpp/classes/shader_material.hpp>
|
|
||||||
#include <godot_cpp/classes/texture2d.hpp>
|
|
||||||
#include <godot_cpp/variant/color.hpp>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
|
|
||||||
const char *SRC_MIX = R"(shader_type spatial;
|
|
||||||
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
|
|
||||||
|
|
||||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform bool use_texture = true;
|
|
||||||
uniform vec4 modulate : source_color = vec4(1.0);
|
|
||||||
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
|
|
||||||
uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
|
|
||||||
// sphere-map specular (EterGrnLib/Material.cpp:305 __ApplySpecularRenderState)
|
|
||||||
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform float spec_power = 0.0;
|
|
||||||
uniform bool spec_enable = false;
|
|
||||||
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
|
||||||
float spec_mask = c.a; // tex.a before modulate == D3DTA_TEXTURE alpha
|
|
||||||
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
|
|
||||||
if (mode == 2 && c.a < alpha_scissor) {
|
|
||||||
discard;
|
|
||||||
}
|
|
||||||
ALBEDO = c.rgb;
|
|
||||||
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
|
|
||||||
// Opaque only: client early-outs to plain diffuse when D3DRS_ALPHABLENDENABLE.
|
|
||||||
// stage1 COLOROP MODULATEALPHA_ADDCOLOR = CURRENT.rgb + CURRENT.a*sphere.rgb,
|
|
||||||
// CURRENT.a = tex.a * D3DRS_TEXTUREFACTOR.a (= spec_power). texcoord =
|
|
||||||
// D3DTSS_TCI_CAMERASPACEREFLECTIONVECTOR -> view-space reflect(); .xy as UV.
|
|
||||||
if (spec_enable && spec_power > 0.0 && mode == 0) {
|
|
||||||
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
|
|
||||||
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
|
|
||||||
// texture resolution can't be trusted for that alpha, so bias it to
|
|
||||||
// grazing angles (Fresnel) and knock the level down — reads as an edge
|
|
||||||
// sheen instead of a full-body chrome mirror.
|
|
||||||
vec3 vdir = normalize(VERTEX);
|
|
||||||
vec3 ndir = normalize(NORMAL);
|
|
||||||
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
|
|
||||||
vec3 refl = reflect(vdir, ndir);
|
|
||||||
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
|
|
||||||
* (spec_mask * spec_power * fres * 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
// Skinned variant of SRC_MIX: LBS in vertex() with FULL per-bone matrices from a
|
|
||||||
// float texture (no Skeleton3D). bones_tex is RGBAF, 3 x bone_count; row = bone,
|
|
||||||
// texel j = column j of the row-vector skin matrix (skinned = [pos 1] * M).
|
|
||||||
const char *SRC_SKIN = R"(shader_type spatial;
|
|
||||||
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
|
|
||||||
|
|
||||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform bool use_texture = true;
|
|
||||||
uniform vec4 modulate : source_color = vec4(1.0);
|
|
||||||
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
|
|
||||||
uniform int mode = 0;
|
|
||||||
uniform sampler2D bones_tex : filter_nearest; // RGBAF, 3 x bone_count
|
|
||||||
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform float spec_power = 0.0;
|
|
||||||
uniform bool spec_enable = false;
|
|
||||||
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
|
|
||||||
|
|
||||||
void vertex() {
|
|
||||||
vec4 p = vec4(VERTEX, 1.0);
|
|
||||||
vec3 sp = vec3(0.0);
|
|
||||||
vec3 sn = vec3(0.0);
|
|
||||||
ivec4 bi = ivec4(BONE_INDICES);
|
|
||||||
vec4 bw = BONE_WEIGHTS;
|
|
||||||
float wsum = bw.x + bw.y + bw.z + bw.w;
|
|
||||||
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
|
|
||||||
for (int k = 0; k < 4; k++) {
|
|
||||||
float w = bw[k] / wsum;
|
|
||||||
if (w <= 0.0) { continue; }
|
|
||||||
int r = bi[k];
|
|
||||||
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
|
|
||||||
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
|
|
||||||
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
|
|
||||||
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
|
|
||||||
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
|
|
||||||
}
|
|
||||||
VERTEX = sp;
|
|
||||||
NORMAL = normalize(sn);
|
|
||||||
}
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
|
||||||
float spec_mask = c.a;
|
|
||||||
c *= modulate;
|
|
||||||
if (mode == 2 && c.a < alpha_scissor) { discard; }
|
|
||||||
ALBEDO = c.rgb;
|
|
||||||
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
|
|
||||||
if (spec_enable && spec_power > 0.0 && mode == 0) {
|
|
||||||
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
|
|
||||||
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
|
|
||||||
// texture resolution can't be trusted for that alpha, so bias it to
|
|
||||||
// grazing angles (Fresnel) and knock the level down — reads as an edge
|
|
||||||
// sheen instead of a full-body chrome mirror.
|
|
||||||
vec3 vdir = normalize(VERTEX);
|
|
||||||
vec3 ndir = normalize(NORMAL);
|
|
||||||
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
|
|
||||||
vec3 refl = reflect(vdir, ndir);
|
|
||||||
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
|
|
||||||
* (spec_mask * spec_power * fres * 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
// blend_add shader: additive glow (effects, enchant overlays).
|
|
||||||
const char *SRC_ADD = R"(shader_type spatial;
|
|
||||||
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
|
|
||||||
|
|
||||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform bool use_texture = true;
|
|
||||||
uniform vec4 modulate : source_color = vec4(1.0);
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
|
||||||
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
|
|
||||||
ALBEDO = c.rgb * c.a;
|
|
||||||
ALPHA = 1.0;
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
// Additive surfaces need the same vertex deformation as opaque/alpha surfaces.
|
|
||||||
// Keeping this as a separate shader preserves blend_add/unshaded render modes.
|
|
||||||
const char *SRC_ADD_SKIN = R"(shader_type spatial;
|
|
||||||
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
|
|
||||||
|
|
||||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform bool use_texture = true;
|
|
||||||
uniform vec4 modulate : source_color = vec4(1.0);
|
|
||||||
uniform sampler2D bones_tex : filter_nearest;
|
|
||||||
|
|
||||||
void vertex() {
|
|
||||||
vec4 p = vec4(VERTEX, 1.0);
|
|
||||||
vec3 sp = vec3(0.0);
|
|
||||||
vec3 sn = vec3(0.0);
|
|
||||||
ivec4 bi = ivec4(BONE_INDICES);
|
|
||||||
vec4 bw = BONE_WEIGHTS;
|
|
||||||
float wsum = bw.x + bw.y + bw.z + bw.w;
|
|
||||||
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
|
|
||||||
for (int k = 0; k < 4; k++) {
|
|
||||||
float w = bw[k] / wsum;
|
|
||||||
if (w <= 0.0) { continue; }
|
|
||||||
int r = bi[k];
|
|
||||||
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
|
|
||||||
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
|
|
||||||
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
|
|
||||||
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
|
|
||||||
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
|
|
||||||
}
|
|
||||||
VERTEX = sp;
|
|
||||||
NORMAL = normalize(sn);
|
|
||||||
}
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
|
||||||
c *= modulate;
|
|
||||||
ALBEDO = c.rgb * c.a;
|
|
||||||
ALPHA = 1.0;
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
Ref<Shader> s_mix;
|
|
||||||
Ref<Shader> s_add;
|
|
||||||
Ref<Shader> s_skin;
|
|
||||||
Ref<Shader> s_add_skin;
|
|
||||||
// cull_disabled variants for two-sided parts (hair / cloth / foliage; client
|
|
||||||
// ExtendedData "Two-sided" -> D3DRS_CULLMODE = D3DCULL_NONE).
|
|
||||||
Ref<Shader> s_mix_2s;
|
|
||||||
Ref<Shader> s_add_2s;
|
|
||||||
Ref<Shader> s_skin_2s;
|
|
||||||
Ref<Shader> s_add_skin_2s;
|
|
||||||
Ref<Shader> s_opaque[4];
|
|
||||||
|
|
||||||
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided, bool opaque) {
|
|
||||||
if (opaque) {
|
|
||||||
Ref<Shader> &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)];
|
|
||||||
if (solid.is_null()) {
|
|
||||||
String code = skinned ? SRC_SKIN : SRC_MIX;
|
|
||||||
// Writing ALPHA puts even alpha=1 into Godot's transparent pass.
|
|
||||||
// Solid faces must write depth before the hair cutout is drawn.
|
|
||||||
code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;",
|
|
||||||
"if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }");
|
|
||||||
if (two_sided)
|
|
||||||
code = code.replace("cull_back", "cull_disabled");
|
|
||||||
solid.instantiate();
|
|
||||||
solid->set_code(code);
|
|
||||||
}
|
|
||||||
return solid;
|
|
||||||
}
|
|
||||||
Ref<Shader> &slot = two_sided
|
|
||||||
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
|
|
||||||
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
|
|
||||||
if (slot.is_null()) {
|
|
||||||
slot.instantiate();
|
|
||||||
const char *base =
|
|
||||||
additive ? (skinned ? SRC_ADD_SKIN : SRC_ADD) : (skinned ? SRC_SKIN : SRC_MIX);
|
|
||||||
slot->set_code(two_sided ? String(base).replace("cull_back", "cull_disabled")
|
|
||||||
: String(base));
|
|
||||||
}
|
|
||||||
return slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
|
|
||||||
Ref<ShaderMaterial> m;
|
|
||||||
m.instantiate();
|
|
||||||
|
|
||||||
const bool additive = (d.blend == BlendMode::Add);
|
|
||||||
const bool skinned = d.skinned;
|
|
||||||
m->set_shader(shader_for(additive, skinned, d.two_sided,
|
|
||||||
d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest));
|
|
||||||
if (skinned && d.bones_tex.is_valid()) {
|
|
||||||
m->set_shader_parameter("bones_tex", d.bones_tex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool has_tex = d.albedo.is_valid();
|
|
||||||
m->set_shader_parameter("use_texture", has_tex);
|
|
||||||
if (has_tex) {
|
|
||||||
m->set_shader_parameter("albedo_tex", d.albedo);
|
|
||||||
}
|
|
||||||
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
|
|
||||||
|
|
||||||
if (!additive) {
|
|
||||||
// sphere-map specular (dormant unless spec_power > 0 and a map is bound)
|
|
||||||
const bool spec = d.spec_power > 0.0f && d.spec_map.is_valid();
|
|
||||||
m->set_shader_parameter("spec_enable", spec);
|
|
||||||
m->set_shader_parameter("spec_power", d.spec_power);
|
|
||||||
if (spec) {
|
|
||||||
m->set_shader_parameter("spec_map", d.spec_map);
|
|
||||||
}
|
|
||||||
m->set_shader_parameter("alpha_scissor", d.alpha_scissor);
|
|
||||||
int mode = 0;
|
|
||||||
if (d.blend == BlendMode::Alpha) {
|
|
||||||
mode = 1;
|
|
||||||
} else if (d.blend == BlendMode::AlphaTest) {
|
|
||||||
mode = 2;
|
|
||||||
}
|
|
||||||
m->set_shader_parameter("mode", mode);
|
|
||||||
}
|
|
||||||
if (d.blend == BlendMode::Alpha) {
|
|
||||||
m->set_render_priority(1);
|
|
||||||
} else if (additive) {
|
|
||||||
m->set_render_priority(2);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cleanup_material_shaders() {
|
|
||||||
for (auto &shader : s_opaque)
|
|
||||||
shader.unref();
|
|
||||||
s_mix.unref();
|
|
||||||
s_add.unref();
|
|
||||||
s_skin.unref();
|
|
||||||
s_add_skin.unref();
|
|
||||||
s_mix_2s.unref();
|
|
||||||
s_add_2s.unref();
|
|
||||||
s_skin_2s.unref();
|
|
||||||
s_add_skin_2s.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// m2_material — Metin2-style forward material as a Godot ShaderMaterial.
|
|
||||||
//
|
|
||||||
// M2.5 infrastructure. The gr2 fixed-function texture-stage / blend data is not
|
|
||||||
// yet exposed by libgr2's POD API (see docs/GODOT-POC-PLAN.md §M2.5 gap list),
|
|
||||||
// so callers pick the blend mode heuristically for now. The shader itself
|
|
||||||
// already covers the common Metin2 cases: modulate(tex, vertex_color) + light,
|
|
||||||
// alpha-test, alpha-blend, additive.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
|
|
||||||
namespace godot {
|
|
||||||
class ShaderMaterial;
|
|
||||||
class Texture2D;
|
|
||||||
class ImageTexture;
|
|
||||||
} // namespace godot
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
enum class BlendMode {
|
|
||||||
Opaque, // blend_mix, ALPHA=1, no discard
|
|
||||||
Alpha, // blend_mix, sorted, uses texture alpha
|
|
||||||
AlphaTest, // blend_mix, discard below alpha_scissor (cutout)
|
|
||||||
Add, // blend_add (glow / effects)
|
|
||||||
};
|
|
||||||
|
|
||||||
struct MaterialDesc {
|
|
||||||
godot::Ref<godot::Texture2D> albedo; // null -> flat white
|
|
||||||
BlendMode blend = BlendMode::Opaque;
|
|
||||||
float alpha_scissor = 0.5f;
|
|
||||||
bool two_sided = true; // winding not yet verified per-model
|
|
||||||
// GPU skinning: LBS with FULL per-bone affine matrices in the vertex shader
|
|
||||||
// (no Skeleton3D -> no quaternion orthonormalization -> shear preserved).
|
|
||||||
// `bones_tex` is RGBAF, size 3 x bone_count; columns 0..2 = the 3 columns of
|
|
||||||
// the row-vector skin matrix (see metin2_model.cpp::gpu_skin).
|
|
||||||
bool skinned = false;
|
|
||||||
godot::Ref<godot::ImageTexture> bones_tex;
|
|
||||||
|
|
||||||
// Sphere-map specular — port of CGrannyMaterial::__ApplySpecularRenderState
|
|
||||||
// (EterGrnLib/Material.cpp:305). Opaque armor gets a metallic highlight from a
|
|
||||||
// camera-space reflection-vector lookup into a shared sphere map, added on top
|
|
||||||
// of the lit diffuse:
|
|
||||||
// out.rgb = tex.rgb*modulate + (tex.a * spec_power) * sphere(reflect_uv)
|
|
||||||
// Client enables this per skin-part only when the equipped body-armor item's
|
|
||||||
// item_proto `bSpecular > 0` (power = bSpecular/100); the base body is flat.
|
|
||||||
// Dormant here until an equipment layer feeds a power: spec_power <= 0 ->
|
|
||||||
// the shader branch is skipped and output is byte-identical to before.
|
|
||||||
godot::Ref<godot::Texture2D> spec_map; // shared sphere map; null -> disabled
|
|
||||||
float spec_power = 0.0f; // fSpecularPower (D3DRS_TEXTUREFACTOR.a); 0 = off
|
|
||||||
};
|
|
||||||
|
|
||||||
godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d);
|
|
||||||
|
|
||||||
// Drop the extension-owned shader cache before GDExtension teardown. Materials
|
|
||||||
// still alive in the scene keep their own Ref, so this only removes the static
|
|
||||||
// lifetime that otherwise survives Godot's leak check.
|
|
||||||
void cleanup_material_shaders();
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,856 +0,0 @@
|
|||||||
#include "metin2_anim.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "gr2_bridge.h"
|
|
||||||
#include "metin2_model.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/skeleton3d.hpp>
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
#include <cmath>
|
|
||||||
#include <string>
|
|
||||||
#include <list>
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
|
||||||
#include <godot_cpp/classes/os.hpp>
|
|
||||||
#include <godot_cpp/classes/skin.hpp>
|
|
||||||
#include <godot_cpp/classes/file_access.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
|
||||||
|
|
||||||
#include <combo_table.h>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
struct ClipEntry {
|
|
||||||
String path;
|
|
||||||
uint64_t modified;
|
|
||||||
size_t bytes;
|
|
||||||
std::shared_ptr<const gr2::File> file;
|
|
||||||
};
|
|
||||||
// Immutable decoded clips are shared, not playback time/pose/event cursors.
|
|
||||||
// Main-thread animation loading only; no Godot objects survive in this cache.
|
|
||||||
std::list<ClipEntry> clip_cache;
|
|
||||||
size_t clip_bytes = 0;
|
|
||||||
uint64_t clip_hits = 0, clip_misses = 0;
|
|
||||||
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
|
|
||||||
struct MsaEntry {
|
|
||||||
String path;
|
|
||||||
uint64_t modified;
|
|
||||||
fmt::Msa metadata;
|
|
||||||
};
|
|
||||||
std::list<MsaEntry> msa_cache;
|
|
||||||
uint64_t msa_hits = 0, msa_misses = 0;
|
|
||||||
// Keep events immutable in the cache; reload copies them into each actor.
|
|
||||||
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
|
|
||||||
const uint64_t modified = FileAccess::get_modified_time(path);
|
|
||||||
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
|
|
||||||
if (it->path != path) continue;
|
|
||||||
if (it->modified == modified) {
|
|
||||||
++msa_hits;
|
|
||||||
metadata = it->metadata;
|
|
||||||
msa_cache.splice(msa_cache.begin(), msa_cache, it);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
msa_cache.erase(it);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
++msa_misses;
|
|
||||||
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
|
|
||||||
if (msa_cache.size() >= 128) msa_cache.pop_back();
|
|
||||||
msa_cache.push_front({path, modified, metadata});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
|
|
||||||
uint64_t modified = FileAccess::get_modified_time(path);
|
|
||||||
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
|
|
||||||
if (it->path != path) continue;
|
|
||||||
if (it->modified == modified) {
|
|
||||||
++clip_hits;
|
|
||||||
clip_cache.splice(clip_cache.begin(), clip_cache, it);
|
|
||||||
return clip_cache.front().file;
|
|
||||||
}
|
|
||||||
clip_bytes -= it->bytes;
|
|
||||||
clip_cache.erase(it);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
++clip_misses;
|
|
||||||
auto loaded = gr2_from_file(path, err);
|
|
||||||
if (!loaded) return {};
|
|
||||||
size_t bytes = 0;
|
|
||||||
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
|
|
||||||
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
|
|
||||||
if (bytes <= CLIP_BYTE_LIMIT) {
|
|
||||||
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
|
|
||||||
clip_bytes -= clip_cache.back().bytes;
|
|
||||||
clip_cache.pop_back();
|
|
||||||
}
|
|
||||||
clip_cache.push_front({path, modified, bytes, file});
|
|
||||||
clip_bytes += bytes;
|
|
||||||
}
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
|
|
||||||
Dictionary d;
|
|
||||||
d["hits"] = clip_hits;
|
|
||||||
d["misses"] = clip_misses;
|
|
||||||
d["entries"] = static_cast<int64_t>(clip_cache.size());
|
|
||||||
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
|
|
||||||
d["msa_hits"] = msa_hits;
|
|
||||||
d["msa_misses"] = msa_misses;
|
|
||||||
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
void Metin2AnimPlayer::clear_clip_cache() {
|
|
||||||
clip_cache.clear();
|
|
||||||
clip_bytes = 0;
|
|
||||||
clip_hits = clip_misses = 0;
|
|
||||||
msa_cache.clear();
|
|
||||||
msa_hits = msa_misses = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Metin2AnimPlayer::Metin2AnimPlayer() {}
|
|
||||||
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::_bind_methods() {
|
|
||||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
|
|
||||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_motion_loop_count", "count"), &Metin2AnimPlayer::set_motion_loop_count);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_motion_loop_count"), &Metin2AnimPlayer::get_motion_loop_count);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time);
|
|
||||||
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
|
|
||||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
|
|
||||||
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
|
|
||||||
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
|
|
||||||
ClassDB::bind_static_method("Metin2AnimPlayer",
|
|
||||||
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
|
|
||||||
ClassDB::bind_static_method("Metin2AnimPlayer",
|
|
||||||
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
|
|
||||||
|
|
||||||
// Fired when playback time crosses a .msa MotionEventData entry.
|
|
||||||
ADD_SIGNAL(MethodInfo("motion_event",
|
|
||||||
PropertyInfo(Variant::INT, "type"),
|
|
||||||
PropertyInfo(Variant::STRING, "effect"),
|
|
||||||
PropertyInfo(Variant::STRING, "sound"),
|
|
||||||
PropertyInfo(Variant::VECTOR3, "pos")));
|
|
||||||
ADD_SIGNAL(MethodInfo("playback_finished"));
|
|
||||||
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
|
|
||||||
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
|
|
||||||
"set_anim_path", "get_anim_path");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
|
|
||||||
"set_model_path", "get_model_path");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
|
|
||||||
"set_time_scale", "get_time_scale");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::INT, "motion_loop_count"),
|
|
||||||
"set_motion_loop_count", "get_motion_loop_count");
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"),
|
|
||||||
"set_blend_time", "get_blend_time");
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
|
||||||
if (p == anim_path) {
|
|
||||||
if (!playing) {
|
|
||||||
time = 0.0;
|
|
||||||
prev_time = 0.0;
|
|
||||||
playing = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SetMotionLoopCount belongs to the newly bound motion. Do not leak a
|
|
||||||
// previous skill's override into a normal walk/attack/action clip.
|
|
||||||
motion_loop_override = false;
|
|
||||||
motion_loop_count = 0;
|
|
||||||
// Start a crossfade from the clip that is currently playing.
|
|
||||||
if (is_inside_tree() && anim_file && blend_time > 0.0) {
|
|
||||||
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
|
|
||||||
prev_anim_duration = duration;
|
|
||||||
prev_anim_loop = loop;
|
|
||||||
double pt = time;
|
|
||||||
if (prev_anim_duration > 0.0) {
|
|
||||||
if (prev_anim_loop) {
|
|
||||||
pt = std::fmod(pt, prev_anim_duration);
|
|
||||||
if (pt < 0.0) {
|
|
||||||
pt += prev_anim_duration;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev_anim_time = pt;
|
|
||||||
blend_elapsed = 0.0;
|
|
||||||
}
|
|
||||||
anim_path = p;
|
|
||||||
if (is_inside_tree()) {
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void Metin2AnimPlayer::set_motion_loop_count(int count) {
|
|
||||||
if (count > 0) {
|
|
||||||
motion_loop_override = true;
|
|
||||||
motion_loop_count = count;
|
|
||||||
} else {
|
|
||||||
motion_loop_override = false;
|
|
||||||
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void Metin2AnimPlayer::set_model_path(const NodePath &p) {
|
|
||||||
model_path = p;
|
|
||||||
}
|
|
||||||
void Metin2AnimPlayer::set_time(double t) {
|
|
||||||
time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
|
|
||||||
prev_time = time; // scrubbing must not replay every event since the old cursor
|
|
||||||
if (is_inside_tree()) {
|
|
||||||
apply_pose(time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Metin2Model *Metin2AnimPlayer::resolve_model() const {
|
|
||||||
if (model_path.is_empty()) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
Node *n = get_node_or_null(model_path);
|
|
||||||
return Object::cast_to<Metin2Model>(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
|
|
||||||
// where <root> is found by walking up from the .msa's own dir past "ymir work".
|
|
||||||
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
|
|
||||||
static String strip_dpath(const String &raw) {
|
|
||||||
String p = raw.replace("\\", "/");
|
|
||||||
int k = p.to_lower().find("ymir work");
|
|
||||||
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
|
|
||||||
String low = spec.to_lower();
|
|
||||||
if (!low.ends_with(".msa")) {
|
|
||||||
return spec; // already a .gr2 path
|
|
||||||
}
|
|
||||||
const String motion = String(m.motion_gr2.c_str());
|
|
||||||
const String base = motion.replace("\\", "/").get_file();
|
|
||||||
const String msa_dir = spec.get_base_dir();
|
|
||||||
|
|
||||||
// 1. sibling of the .msa
|
|
||||||
String cand = msa_dir.path_join(base);
|
|
||||||
if (FileAccess::file_exists(cand)) {
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
|
|
||||||
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
|
|
||||||
int ky = msa_dir.to_lower().find("ymir work");
|
|
||||||
if (ky > 0) {
|
|
||||||
String root = spec.substr(0, ky); // ".../assets/PC/"
|
|
||||||
cand = root.path_join(tail.substr(String("ymir work/").length()));
|
|
||||||
if (FileAccess::file_exists(cand)) {
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
|
|
||||||
if (FileAccess::file_exists(cand)) {
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
|
|
||||||
" (tried " + msa_dir.path_join(base) + ")");
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
|
|
||||||
Dictionary d;
|
|
||||||
d["type"] = ev.type;
|
|
||||||
d["start_time"] = ev.start_time;
|
|
||||||
d["duration_time"] = ev.duration_time;
|
|
||||||
d["effect"] = String(ev.effect_file.c_str());
|
|
||||||
d["sound"] = String(ev.sound_file.c_str());
|
|
||||||
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
|
|
||||||
d["bone"] = String(ev.attaching_bone.c_str());
|
|
||||||
d["attaching"] = ev.attaching;
|
|
||||||
d["following"] = ev.following;
|
|
||||||
d["independent"] = ev.independent;
|
|
||||||
d["fishing_effect"] = ev.fishing_effect;
|
|
||||||
d["fly_file"] = String(ev.fly_file.c_str());
|
|
||||||
d["fly_bone"] = String(ev.fly_attaching_bone.c_str());
|
|
||||||
d["fly_attaching"] = ev.fly_attaching;
|
|
||||||
d["fly_pos"] = Vector3(ev.fly_position[0], ev.fly_position[1], ev.fly_position[2]);
|
|
||||||
d["power"] = ev.power;
|
|
||||||
d["affecting_range"] = ev.affecting_range;
|
|
||||||
d["enable_hit_process"] = ev.enable_hit_process;
|
|
||||||
d["attack_type"] = ev.attack_type;
|
|
||||||
d["hitting_type"] = ev.hitting_type;
|
|
||||||
d["stiffen_time"] = ev.stiffen_time;
|
|
||||||
d["invisible_time"] = ev.invisible_time;
|
|
||||||
d["external_force"] = ev.external_force;
|
|
||||||
d["hit_limit_count"] = ev.hit_limit_count;
|
|
||||||
d["collision_type"] = ev.collision_type;
|
|
||||||
Array spheres;
|
|
||||||
for (const fmt::MotionEvent::CollisionSphere &sphere : ev.collision_spheres) {
|
|
||||||
Dictionary sd;
|
|
||||||
sd["radius"] = sphere.radius;
|
|
||||||
sd["pos"] = Vector3(sphere.position[0], sphere.position[1], sphere.position[2]);
|
|
||||||
spheres.push_back(sd);
|
|
||||||
}
|
|
||||||
d["collision_spheres"] = spheres;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
|
|
||||||
Dictionary result;
|
|
||||||
Metin2Model *model = resolve_model();
|
|
||||||
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
|
|
||||||
if (!sk || !anim_file) return result;
|
|
||||||
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
|
|
||||||
if (String(sk->bones[i].name.c_str()) == bone) {
|
|
||||||
result["transform"] = gr2_to_godot(world_buf[i]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
|
|
||||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
|
||||||
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
|
||||||
emit_signal("motion_event_detailed", motion_event_dict(ev));
|
|
||||||
}
|
|
||||||
|
|
||||||
Array Metin2AnimPlayer::get_events() const {
|
|
||||||
Array a;
|
|
||||||
for (const fmt::MotionEvent &ev : events) {
|
|
||||||
a.push_back(motion_event_dict(ev));
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2AnimPlayer::get_loop_data() const {
|
|
||||||
Dictionary d;
|
|
||||||
d["present"] = msa_metadata.has_loop_data;
|
|
||||||
d["count"] = msa_metadata.motion_loop_count;
|
|
||||||
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
|
|
||||||
d["start_time"] = msa_metadata.loop_start_time;
|
|
||||||
d["end_time"] = msa_metadata.loop_end_time;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
|
|
||||||
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
|
|
||||||
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
|
|
||||||
Dictionary Metin2AnimPlayer::get_motion_data() const {
|
|
||||||
const fmt::Msa &m = msa_metadata;
|
|
||||||
Dictionary d;
|
|
||||||
d["duration"] = duration > 0.0 ? duration : m.duration;
|
|
||||||
d["has_combo_input"] = m.has_combo_input;
|
|
||||||
d["pre_input_time"] = m.combo_pre_input_time;
|
|
||||||
d["direct_input_time"] = m.combo_direct_input_time;
|
|
||||||
d["input_limit_time"] = m.combo_input_limit_time;
|
|
||||||
d["link_time"] = m.combo_link_time;
|
|
||||||
const double dur = duration > 0.0 ? duration : m.duration;
|
|
||||||
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
|
|
||||||
? (double)m.combo_direct_input_time
|
|
||||||
: dur * 0.9;
|
|
||||||
d["has_attacking_data"] = m.has_attacking_data;
|
|
||||||
d["attacking_type"] = m.attacking_type;
|
|
||||||
d["motion_type"] = m.motion_type;
|
|
||||||
d["hitting_type"] = m.hitting_type;
|
|
||||||
d["attack_start_time"] = m.attack_start_time;
|
|
||||||
d["attack_end_time"] = m.attack_end_time;
|
|
||||||
d["stiffen_time"] = m.stiffen_time;
|
|
||||||
d["invisible_time"] = m.invisible_time;
|
|
||||||
d["external_force"] = m.external_force;
|
|
||||||
d["hit_limit_count"] = m.hit_limit_count;
|
|
||||||
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
|
|
||||||
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
|
|
||||||
Array windows;
|
|
||||||
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
|
|
||||||
Dictionary wd;
|
|
||||||
wd["start_time"] = w.start_time;
|
|
||||||
wd["end_time"] = w.end_time;
|
|
||||||
wd["bone"] = String::utf8(w.bone_name.c_str());
|
|
||||||
wd["weapon_length"] = w.weapon_length;
|
|
||||||
Array samples;
|
|
||||||
for (const fmt::Msa::HitSample &s : w.samples) {
|
|
||||||
Dictionary sd;
|
|
||||||
sd["time"] = s.time;
|
|
||||||
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
|
|
||||||
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
|
|
||||||
samples.push_back(sd);
|
|
||||||
}
|
|
||||||
wd["samples"] = samples;
|
|
||||||
windows.push_back(wd);
|
|
||||||
}
|
|
||||||
d["hit_windows"] = windows;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
|
|
||||||
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
|
|
||||||
Dictionary out;
|
|
||||||
fmt::PlayerComboTables tables;
|
|
||||||
std::string err;
|
|
||||||
if (!fmt::parse_player_combo_tables_file(
|
|
||||||
std::string(py_path.utf8().get_data()), tables, &err)) {
|
|
||||||
UtilityFunctions::push_warning(
|
|
||||||
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
|
|
||||||
Dictionary cls;
|
|
||||||
for (const auto &kv : tables.klass(c).combos) {
|
|
||||||
PackedInt32Array segs;
|
|
||||||
segs.resize((int)kv.second.size());
|
|
||||||
for (int i = 0; i < (int)kv.second.size(); ++i) {
|
|
||||||
segs.set(i, (int)kv.second[i]);
|
|
||||||
}
|
|
||||||
cls[(int)kv.first] = segs;
|
|
||||||
}
|
|
||||||
out[c] = cls;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::_ready() {
|
|
||||||
set_process(true);
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::reload() {
|
|
||||||
anim_file.reset();
|
|
||||||
duration = 0.0;
|
|
||||||
time = 0.0;
|
|
||||||
prev_time = 0.0;
|
|
||||||
next_event = 0;
|
|
||||||
accumulation = Vector3();
|
|
||||||
events.clear();
|
|
||||||
msa_metadata = fmt::Msa{};
|
|
||||||
last_info = "";
|
|
||||||
playing = true;
|
|
||||||
if (anim_path.is_empty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
|
|
||||||
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
|
|
||||||
String gr2_spec = anim_path;
|
|
||||||
if (anim_path.to_lower().ends_with(".msa")) {
|
|
||||||
fmt::Msa m;
|
|
||||||
std::string e;
|
|
||||||
if (cached_msa(anim_path, m, &e)) {
|
|
||||||
msa_metadata = m;
|
|
||||||
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
|
|
||||||
events = m.events;
|
|
||||||
// Reuse the metadata just parsed; path resolution must not parse it again.
|
|
||||||
gr2_spec = resolve_anim_gr2(anim_path, m);
|
|
||||||
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
|
|
||||||
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
|
|
||||||
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
|
|
||||||
m.loop_start_time, m.loop_end_time) : String("none")));
|
|
||||||
} else {
|
|
||||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!motion_loop_override)
|
|
||||||
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
|
|
||||||
|
|
||||||
gr2::LoadError err;
|
|
||||||
auto f = cached_clip(gr2_spec, &err);
|
|
||||||
if (!f) {
|
|
||||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
|
|
||||||
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gr2::FileInfo &fi = f->file_info();
|
|
||||||
if (fi.animations.empty()) {
|
|
||||||
UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
anim_file = std::move(f);
|
|
||||||
const gr2::Animation &an = anim_file->file_info().animations[0];
|
|
||||||
// 40250 CRaceMotionData::GetMotionDuration() is sourced from the MSA
|
|
||||||
// MotionDuration field. GR2 contains sampled tracks, but its clip length
|
|
||||||
// is not the motion-data authority used by actor timing, LoopData and
|
|
||||||
// MotionEventData. Keep GR2 duration only for raw .gr2 paths or malformed
|
|
||||||
// MSA files without a positive MotionDuration.
|
|
||||||
duration = msa_metadata.duration > 0.0 ? msa_metadata.duration : an.duration;
|
|
||||||
|
|
||||||
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
|
|
||||||
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
|
|
||||||
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
|
|
||||||
int matched = 0;
|
|
||||||
godot::String unmatched;
|
|
||||||
for (const auto &tr : an.tracks) {
|
|
||||||
bool hit = false;
|
|
||||||
for (const auto &b : sk->bones) {
|
|
||||||
if (b.name == tr.bone_name) {
|
|
||||||
hit = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hit) {
|
|
||||||
++matched;
|
|
||||||
} else if (unmatched.length() < 300) {
|
|
||||||
unmatched += String(tr.bone_name.c_str()) + " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (diagnostics) {
|
|
||||||
UtilityFunctions::print(vformat(
|
|
||||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
|
||||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
|
||||||
String(an.name.c_str()), duration, (int)an.tracks.size(),
|
|
||||||
(int)anim_file->file_info().animations.size());
|
|
||||||
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
|
|
||||||
apply_pose(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
|
||||||
|
|
||||||
// Blend two local bone transforms: slerp rotation, lerp translation and the
|
|
||||||
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
|
|
||||||
// depend on authored shear, so it must survive the transition as well as steady
|
|
||||||
// animation sampling.
|
|
||||||
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
|
||||||
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
|
|
||||||
godot::Vector3(a[8], a[9], a[10]));
|
|
||||||
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
|
|
||||||
godot::Vector3(b[8], b[9], b[10]));
|
|
||||||
const godot::Quaternion qa = ba.get_rotation_quaternion();
|
|
||||||
const godot::Quaternion qb = bb.get_rotation_quaternion();
|
|
||||||
godot::Quaternion q = qa.slerp(qb, w);
|
|
||||||
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
|
|
||||||
// changes authored joints even at the endpoints and is especially visible in
|
|
||||||
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
|
|
||||||
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
|
|
||||||
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
|
|
||||||
godot::Basis ss;
|
|
||||||
for (int row = 0; row < 3; ++row) {
|
|
||||||
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
|
|
||||||
}
|
|
||||||
godot::Vector3 tt =
|
|
||||||
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
|
||||||
const godot::Basis rb = godot::Basis(q) * ss;
|
|
||||||
gr2::Mat4 o{};
|
|
||||||
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
|
|
||||||
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
|
|
||||||
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
|
|
||||||
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
|
|
||||||
// blends controls before BuildWorldPose; doing the crossfade after world-pose
|
|
||||||
// accumulation independently interpolates every joint endpoint and temporarily
|
|
||||||
// changes parent/child distances (visible as stretched or collapsed limbs when
|
|
||||||
// wait/walk/run switches).
|
|
||||||
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
|
|
||||||
std::vector<gr2::Mat4> &local) {
|
|
||||||
std::vector<gr2::Mat4> track_local;
|
|
||||||
an.sample_local(t, track_local);
|
|
||||||
local.resize(sk.bones.size());
|
|
||||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
|
||||||
local[i] = sk.bones[i].local_transform;
|
|
||||||
}
|
|
||||||
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
|
|
||||||
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
|
|
||||||
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
|
|
||||||
local[bi] = track_local[ti];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
|
|
||||||
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
|
|
||||||
std::vector<gr2::Mat4> &skin) {
|
|
||||||
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
|
||||||
const size_t n = sk.bones.size();
|
|
||||||
world.assign(n, I);
|
|
||||||
skin.assign(n, I);
|
|
||||||
for (size_t i = 0; i < n; ++i) {
|
|
||||||
const int parent = sk.bones[i].parent;
|
|
||||||
const gr2::Mat4 &parent_world =
|
|
||||||
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
|
|
||||||
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
|
|
||||||
parent_world);
|
|
||||||
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::apply_pose(double t) {
|
|
||||||
if (!anim_file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Metin2Model *model = resolve_model();
|
|
||||||
if (!model) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gr2::Skeleton *sk = model->gr2_skeleton();
|
|
||||||
Skeleton3D *skel = model->skeleton_node();
|
|
||||||
if (!sk || !skel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gr2::Animation &an = anim_file->file_info().animations[0];
|
|
||||||
|
|
||||||
double tt = t;
|
|
||||||
if (duration > 0.0 && loop) {
|
|
||||||
tt = std::fmod(t, duration);
|
|
||||||
if (tt < 0.0) {
|
|
||||||
tt += duration;
|
|
||||||
}
|
|
||||||
} else if (duration > 0.0) {
|
|
||||||
tt = std::fmax(0.0, std::fmin(t, duration));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
|
|
||||||
//
|
|
||||||
// gr2::sample_pose defaults to sk.initial_placement because that is what the
|
|
||||||
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
|
|
||||||
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
|
|
||||||
// carries the root bone's pelvis height inside its own track, so composing it
|
|
||||||
// onto InitialPlacement counts that placement twice and lifts the whole actor
|
|
||||||
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
|
|
||||||
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
|
|
||||||
// Measured with tools/rendering foot probe: with identity the lowest skinned
|
|
||||||
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
|
|
||||||
// only leaves the ground during run's airborne frames.
|
|
||||||
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
|
||||||
bool pose_ready = false;
|
|
||||||
|
|
||||||
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
|
|
||||||
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
|
|
||||||
// take independent straight-line paths, shortening limbs around the midpoint.
|
|
||||||
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
|
|
||||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
|
||||||
if (!pfi.animations.empty()) {
|
|
||||||
sample_local_pose(*sk, an, (float)tt, local_buf);
|
|
||||||
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
|
|
||||||
double w = blend_elapsed / blend_time;
|
|
||||||
// ease-in on the incoming clip, matching the client's
|
|
||||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
|
||||||
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
|
|
||||||
w = w * w * (2.0 - w);
|
|
||||||
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
|
|
||||||
blend_local_buf.resize(local_buf.size());
|
|
||||||
for (size_t i = 0; i < n; ++i) {
|
|
||||||
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
|
|
||||||
}
|
|
||||||
for (size_t i = n; i < local_buf.size(); ++i) {
|
|
||||||
blend_local_buf[i] = local_buf[i];
|
|
||||||
}
|
|
||||||
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
|
|
||||||
pose_ready = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!pose_ready) {
|
|
||||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both skinning paths apply libgr2's per-bone deformer matrices
|
|
||||||
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
|
|
||||||
// default : CPU LBS, ArrayMesh rebuilt each frame.
|
|
||||||
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
|
|
||||||
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
|
||||||
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
|
|
||||||
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
|
|
||||||
static const String kZero("0");
|
|
||||||
static const String kEnvName("MTGODOT_GPUSKIN");
|
|
||||||
String env = godot::OS::get_singleton()->get_environment(kEnvName);
|
|
||||||
// Default to GPU skinning (true) unless explicitly set to "0"
|
|
||||||
if (env != kZero) {
|
|
||||||
model->enable_gpu_skin(true);
|
|
||||||
model->gpu_skin(skin_buf);
|
|
||||||
} else {
|
|
||||||
model->enable_cpu_skin(true);
|
|
||||||
model->cpu_skin(skin_buf);
|
|
||||||
}
|
|
||||||
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
|
|
||||||
model->update_weapon_pose(world_buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2AnimPlayer::_process(double delta) {
|
|
||||||
if (!playing || !anim_file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (prev_anim_file) {
|
|
||||||
blend_elapsed += delta; // real seconds, independent of time_scale
|
|
||||||
if (blend_elapsed >= blend_time) {
|
|
||||||
prev_anim_file.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
double next = time + delta * time_scale;
|
|
||||||
// 40250 does not loop the whole clip. For a motion with LoopData it keeps
|
|
||||||
// the lead-in and tail, and only rewinds LoopStartTime..LoopEndTime while
|
|
||||||
// the actor's remaining loop count is > 1 (or -1 for infinite looping).
|
|
||||||
// The legacy actor resets to LoopStartTime at the first frame after the
|
|
||||||
// loop end, so the frame remainder is intentionally discarded here too.
|
|
||||||
const bool has_segment_loop = !loop && msa_metadata.has_loop_data &&
|
|
||||||
motion_loop_count != 0 &&
|
|
||||||
(motion_loop_count == -1 || motion_loop_count > 1) &&
|
|
||||||
msa_metadata.loop_end_time > msa_metadata.loop_start_time &&
|
|
||||||
msa_metadata.loop_end_time <= duration;
|
|
||||||
if (has_segment_loop && next > msa_metadata.loop_end_time) {
|
|
||||||
dispatch_events(time, msa_metadata.loop_end_time);
|
|
||||||
time = msa_metadata.loop_start_time;
|
|
||||||
if (motion_loop_count > 0)
|
|
||||||
--motion_loop_count;
|
|
||||||
apply_pose(time);
|
|
||||||
// ActorInstance resets dwcurFrame to LoopStartTime and processes
|
|
||||||
// that frame immediately. Without an inclusive point dispatch,
|
|
||||||
// events authored exactly at the loop boundary never fire.
|
|
||||||
dispatch_events(time, time, true);
|
|
||||||
prev_time = time;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!loop && duration > 0.0 && next >= duration) {
|
|
||||||
next = duration;
|
|
||||||
}
|
|
||||||
time = next;
|
|
||||||
apply_pose(time);
|
|
||||||
dispatch_events(prev_time, time);
|
|
||||||
prev_time = time;
|
|
||||||
if (!loop && duration > 0.0 && time >= duration) {
|
|
||||||
playing = false;
|
|
||||||
emit_signal("playback_finished");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
|
|
||||||
// handling loop wrap-around within one clip.
|
|
||||||
void Metin2AnimPlayer::dispatch_events(double from, double to, bool include_from) {
|
|
||||||
if (events.empty() || duration <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const double epsilon = 1e-9;
|
|
||||||
if (to < from - epsilon) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!loop) {
|
|
||||||
double a = std::fmax(0.0, std::fmin(from, duration));
|
|
||||||
double b = std::fmax(0.0, std::fmin(to, duration));
|
|
||||||
for (const fmt::MotionEvent &ev : events) {
|
|
||||||
const bool at_inclusive_start = include_from &&
|
|
||||||
std::fabs(double(ev.start_time) - a) <= epsilon;
|
|
||||||
// The first 40250 motion frame is frame 0, not frame 1. The
|
|
||||||
// normal interval remains half-open on the left so a regular
|
|
||||||
// frame cannot replay an event on every tick.
|
|
||||||
const bool initial_zero = !include_from && from <= epsilon &&
|
|
||||||
to > from + epsilon && std::fabs(double(ev.start_time)) <= epsilon;
|
|
||||||
if (at_inclusive_start || initial_zero ||
|
|
||||||
(a < ev.start_time && ev.start_time <= b)) {
|
|
||||||
emit_motion_event(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Walk each clip segment independently. The old single-wrap expression
|
|
||||||
// could emit at most one copy of an event when time_scale/delta crossed
|
|
||||||
// two or more full durations; ActorInstance processes every frame and
|
|
||||||
// therefore reaches every crossed MotionEventData entry.
|
|
||||||
double cursor = from;
|
|
||||||
double remaining = to - from;
|
|
||||||
if (remaining <= epsilon) {
|
|
||||||
if (!include_from) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
double point = std::fmod(cursor, duration);
|
|
||||||
if (point < 0) point += duration;
|
|
||||||
for (const fmt::MotionEvent &ev : events) {
|
|
||||||
if (std::fabs(double(ev.start_time) - point) <= epsilon) {
|
|
||||||
emit_motion_event(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while (remaining > epsilon) {
|
|
||||||
double a = std::fmod(cursor, duration);
|
|
||||||
if (a < 0) a += duration;
|
|
||||||
double segment = std::fmin(remaining, duration - a);
|
|
||||||
if (segment <= epsilon) {
|
|
||||||
cursor += duration;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
double b = a + segment;
|
|
||||||
const bool include_segment_start = a <= epsilon;
|
|
||||||
for (const fmt::MotionEvent &ev : events) {
|
|
||||||
const bool at_segment_start = include_segment_start &&
|
|
||||||
std::fabs(double(ev.start_time) - a) <= epsilon;
|
|
||||||
const bool crossed = a < ev.start_time && ev.start_time <= b;
|
|
||||||
if (at_segment_start || crossed) {
|
|
||||||
emit_motion_event(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor += segment;
|
|
||||||
remaining -= segment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2AnimPlayer::selfcheck(int samples) {
|
|
||||||
if (!anim_file) {
|
|
||||||
return "no anim loaded";
|
|
||||||
}
|
|
||||||
Metin2Model *model = resolve_model();
|
|
||||||
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
|
|
||||||
if (!sk) {
|
|
||||||
return "no model/skeleton";
|
|
||||||
}
|
|
||||||
const auto &anims = anim_file->file_info().animations;
|
|
||||||
int total = 0;
|
|
||||||
int nan_hits = 0;
|
|
||||||
std::vector<gr2::Mat4> w, s;
|
|
||||||
for (const auto &an : anims) {
|
|
||||||
double dur = an.duration > 0.0 ? an.duration : 1.0;
|
|
||||||
for (int k = 0; k <= samples; ++k) {
|
|
||||||
float tt = (float)(dur * k / samples);
|
|
||||||
gr2::sample_pose(*sk, an, tt, w, s);
|
|
||||||
for (const auto &mtx : w) {
|
|
||||||
for (float v : mtx) {
|
|
||||||
++total;
|
|
||||||
if (std::isnan(v) || std::isinf(v)) {
|
|
||||||
++nan_hits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d",
|
|
||||||
(int)anims.size(), samples + 1, total, nan_hits);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/node3d.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/node_path.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
#include <godot_cpp/variant/vector3.hpp>
|
|
||||||
|
|
||||||
#include <gr2/gr2.h>
|
|
||||||
#include <msa.h>
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
#include <memory>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
class Metin2Model;
|
|
||||||
|
|
||||||
// Drives a Metin2Model each frame by sampling a (possibly separate) animation
|
|
||||||
// .gr2 with libgr2, then skinning it with the resulting matrices:
|
|
||||||
//
|
|
||||||
// sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
|
|
||||||
// skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
|
|
||||||
//
|
|
||||||
// The skinning itself is done in Metin2Model (NOT via Skeleton3D — Godot's
|
|
||||||
// built-in skinning orthonormalizes the bone matrix and drops the shear Granny
|
|
||||||
// bakes onto some rig bones; see docs/MIDREVIEW.md §4):
|
|
||||||
// - default : cpu_skin(skin[]) — CPU LBS, ArrayMesh rebuilt/frame
|
|
||||||
// - MTGODOT_GPUSKIN=1 : gpu_skin(skin[]) — full 4x3 matrices in a float
|
|
||||||
// texture, LBS in the SRC_SKIN vertex shader
|
|
||||||
class Metin2AnimPlayer : public godot::Node3D {
|
|
||||||
GDCLASS(Metin2AnimPlayer, godot::Node3D)
|
|
||||||
|
|
||||||
public:
|
|
||||||
Metin2AnimPlayer();
|
|
||||||
~Metin2AnimPlayer() override;
|
|
||||||
|
|
||||||
void _ready() override;
|
|
||||||
void _process(double delta) override;
|
|
||||||
|
|
||||||
void set_anim_path(const godot::String &p);
|
|
||||||
godot::String get_anim_path() const { return anim_path; }
|
|
||||||
|
|
||||||
void set_model_path(const godot::NodePath &p);
|
|
||||||
godot::NodePath get_model_path() const { return model_path; }
|
|
||||||
|
|
||||||
void set_playing(bool v) { playing = v; }
|
|
||||||
bool get_playing() const { return playing; }
|
|
||||||
void set_loop(bool v) { loop = v; }
|
|
||||||
bool get_loop() const { return loop; }
|
|
||||||
|
|
||||||
void set_time_scale(double s) { time_scale = s; }
|
|
||||||
double get_time_scale() const { return time_scale; }
|
|
||||||
|
|
||||||
// 40250 CActorInstance::SetMotionLoopCount. A positive value overrides the
|
|
||||||
// current .msa LoopData count; zero restores the resource-defined count.
|
|
||||||
void set_motion_loop_count(int count);
|
|
||||||
int get_motion_loop_count() const { return motion_loop_count; }
|
|
||||||
|
|
||||||
// Crossfade duration (s) applied when anim_path changes while a clip is
|
|
||||||
// already playing (PARITY §2.9). 0 = hard cut (old behaviour).
|
|
||||||
void set_blend_time(double s) { blend_time = s < 0.0 ? 0.0 : s; }
|
|
||||||
double get_blend_time() const { return blend_time; }
|
|
||||||
|
|
||||||
void set_time(double t);
|
|
||||||
double get_time() const { return time; }
|
|
||||||
|
|
||||||
double get_duration() const { return duration; }
|
|
||||||
void reload();
|
|
||||||
godot::String get_info() const { return last_info; }
|
|
||||||
|
|
||||||
// .msa metadata (empty / zero when anim_path is a raw .gr2).
|
|
||||||
godot::Vector3 get_accumulation() const { return accumulation; }
|
|
||||||
godot::Array get_events() const; // detailed MotionEvent dictionaries, including type-specific payloads
|
|
||||||
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
|
|
||||||
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
|
|
||||||
godot::Dictionary get_loop_data() const;
|
|
||||||
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
|
|
||||||
static godot::Dictionary get_clip_cache_stats();
|
|
||||||
static void clear_clip_cache();
|
|
||||||
|
|
||||||
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
|
|
||||||
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
|
|
||||||
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
|
|
||||||
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
|
|
||||||
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
|
|
||||||
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
|
|
||||||
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
|
|
||||||
static int make_combo_key(int motion_mode, int combo_type);
|
|
||||||
|
|
||||||
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
|
|
||||||
godot::String selfcheck(int samples = 24);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
|
|
||||||
private:
|
|
||||||
void emit_motion_event(const fmt::MotionEvent &ev);
|
|
||||||
godot::String anim_path;
|
|
||||||
godot::NodePath model_path;
|
|
||||||
bool playing = true;
|
|
||||||
bool loop = true; // whole-clip playback mode; independent from .msa LoopData
|
|
||||||
double time_scale = 1.0;
|
|
||||||
int motion_loop_count = 0;
|
|
||||||
bool motion_loop_override = false;
|
|
||||||
double time = 0.0;
|
|
||||||
double duration = 0.0;
|
|
||||||
godot::String last_info;
|
|
||||||
|
|
||||||
std::shared_ptr<const gr2::File> anim_file;
|
|
||||||
std::vector<gr2::Mat4> world_buf;
|
|
||||||
std::vector<gr2::Mat4> skin_buf;
|
|
||||||
|
|
||||||
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
|
|
||||||
// its pose blended into the incoming clip for `blend_time` seconds.
|
|
||||||
double blend_time = 0.15;
|
|
||||||
std::shared_ptr<const gr2::File> prev_anim_file;
|
|
||||||
double prev_anim_time = 0.0;
|
|
||||||
double prev_anim_duration = 0.0;
|
|
||||||
bool prev_anim_loop = true;
|
|
||||||
double blend_elapsed = 0.0;
|
|
||||||
// Crossfades operate before hierarchy accumulation so parent/child lengths
|
|
||||||
// remain stable while switching locomotion clips.
|
|
||||||
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
|
|
||||||
|
|
||||||
godot::Vector3 accumulation; // .msa Accumulation (root motion)
|
|
||||||
std::vector<fmt::MotionEvent> events;
|
|
||||||
fmt::Msa msa_metadata;
|
|
||||||
int next_event = 0; // index into `events`, for _process dispatch
|
|
||||||
double prev_time = 0.0;
|
|
||||||
|
|
||||||
Metin2Model *resolve_model() const;
|
|
||||||
void apply_pose(double t);
|
|
||||||
void dispatch_events(double from, double to, bool include_from = false);
|
|
||||||
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
|
|
||||||
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/node3d.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/templates/hash_map.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/variant/aabb.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <gr2/gr2.h>
|
|
||||||
|
|
||||||
#include "gr2_bridge.h"
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace godot {
|
|
||||||
class Skeleton3D;
|
|
||||||
class MeshInstance3D;
|
|
||||||
} // namespace godot
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// Loads a Metin2 .gr2 and builds a Godot subtree:
|
|
||||||
// Metin2Model (transform = Z-up/cm -> Y-up/m conversion)
|
|
||||||
// └─ Skeleton3D (rest pose from gr2 bones)
|
|
||||||
// └─ MeshInstance3D (ArrayMesh, one surface per gr2 mesh; skinned)
|
|
||||||
//
|
|
||||||
// M1: static bind-pose render. M2's Metin2AnimPlayer drives the Skeleton3D.
|
|
||||||
class Metin2Model : public godot::Node3D {
|
|
||||||
GDCLASS(Metin2Model, godot::Node3D)
|
|
||||||
|
|
||||||
public:
|
|
||||||
Metin2Model();
|
|
||||||
~Metin2Model() override;
|
|
||||||
|
|
||||||
void _ready() override;
|
|
||||||
void _process(double delta) override;
|
|
||||||
|
|
||||||
// --- inspector properties ---
|
|
||||||
// §2.10 LOD: loads <base>_lod_01/02/03.gr2 (same 75-bone skeleton, decimated
|
|
||||||
// meshes) and swaps the rendered mesh by camera distance. `lod_distances` =
|
|
||||||
// [d1,d2,d3]; >d3 -> LOD3. Skinning is unaffected (shared skeleton).
|
|
||||||
void set_lod_enabled(bool v);
|
|
||||||
bool get_lod_enabled() const { return lod_enabled; }
|
|
||||||
void set_lod_distances(const godot::PackedFloat32Array &d);
|
|
||||||
godot::PackedFloat32Array get_lod_distances() const { return lod_dist; }
|
|
||||||
int get_lod_level() const { return lod_level; }
|
|
||||||
void set_gr2_path(const godot::String &p);
|
|
||||||
godot::String get_gr2_path() const { return gr2_path; }
|
|
||||||
|
|
||||||
void set_texture_dir(const godot::String &p);
|
|
||||||
godot::String get_texture_dir() const { return texture_dir; }
|
|
||||||
|
|
||||||
void set_unit_scale(double s);
|
|
||||||
double get_unit_scale() const { return unit_scale; }
|
|
||||||
|
|
||||||
void set_flip_z(bool v);
|
|
||||||
bool get_flip_z() const { return flip_z; }
|
|
||||||
|
|
||||||
void set_flip_winding(bool v);
|
|
||||||
bool get_flip_winding() const { return flip_winding; }
|
|
||||||
|
|
||||||
void set_material_mode(const godot::String &m);
|
|
||||||
godot::String get_material_mode() const { return material_mode; }
|
|
||||||
|
|
||||||
void set_use_gr2_materials(bool v);
|
|
||||||
bool get_use_gr2_materials() const { return use_gr2_materials; }
|
|
||||||
|
|
||||||
// Sphere-map specular power (PARITY §2.7 / BACKLOG B9). In the original client
|
|
||||||
// this is per skin-part, driven by the equipped body-armor item_proto
|
|
||||||
// `bSpecular / 100`; 0 = flat (the base body). No equipment layer here yet, so
|
|
||||||
// this is a manual hook — 0 keeps output identical to before.
|
|
||||||
void set_specular_power(double p);
|
|
||||||
double get_specular_power() const { return specular_power; }
|
|
||||||
|
|
||||||
// Explicit per-surface texture path override (index -> absolute .dds path).
|
|
||||||
void set_surface_texture(int surface, const godot::String &path);
|
|
||||||
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
|
|
||||||
// every GR2 material whose texture basename matches source, rather than
|
|
||||||
// assuming that an armour skin always belongs to surface 0.
|
|
||||||
void set_skin_texture(const godot::String &source, const godot::String &target);
|
|
||||||
void clear_skin_textures();
|
|
||||||
|
|
||||||
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
|
|
||||||
godot::Array get_hair_options() const { return hair_options; }
|
|
||||||
|
|
||||||
// Attach a hair .gr2 whose skeleton bone names match the base. Its mesh is
|
|
||||||
// merged into the CPU-skin path (skinned by the base skeleton's matrices,
|
|
||||||
// bones remapped by name). "" detaches. GPU-skin path ignores hair for now.
|
|
||||||
void set_hair_gr2(const godot::String &p);
|
|
||||||
godot::String get_hair_gr2() const { return hair_gr2; }
|
|
||||||
|
|
||||||
// SourceSkin -> TargetSkin recolour (PARITY §2.4 / BACKLOG D3). The client's
|
|
||||||
// `.msm` HairData ships one hair .gr2 with a `SourceSkin` (the texture baked
|
|
||||||
// into the gr2 material) and a per-colour `TargetSkin` dds; `SetMaterialImage
|
|
||||||
// Pointer(part, SourceSkin, load(TargetSkin))` swaps it. Here: when set, the
|
|
||||||
// hair mesh uses this dds as its albedo instead of the gr2's sibling texture.
|
|
||||||
// Absolute path (an entry's resolved `target_skin` from get_hair_options()).
|
|
||||||
void set_hair_skin(const godot::String &p);
|
|
||||||
godot::String get_hair_skin() const { return hair_skin; }
|
|
||||||
|
|
||||||
// Attach a rigid weapon .gr2 to a base-skeleton bone (PARITY §2.1 / BACKLOG
|
|
||||||
// C5). The client links the weapon model instance to `equip_right_hand`
|
|
||||||
// (`playersettingmodule.py`, warrior) and drives it with that bone's world
|
|
||||||
// matrix (`ModelInstanceUpdate.cpp:148` GetBoneMatrixPointer). Here the weapon
|
|
||||||
// mesh is a child MeshInstance3D whose transform = the bone's world pose from
|
|
||||||
// gr2::sample_pose each frame. "" detaches. Path may be relative to the base
|
|
||||||
// gr2 ("d:/ymir work/item/weapon/00040.gr2") or absolute.
|
|
||||||
void set_weapon_gr2(const godot::String &p);
|
|
||||||
godot::String get_weapon_gr2() const { return weapon_gr2; }
|
|
||||||
void set_weapon_bone(const godot::String &b);
|
|
||||||
godot::String get_weapon_bone() const { return weapon_bone; }
|
|
||||||
// Off-hand shield (rigid, attaches to equip_left_hand like the weapon).
|
|
||||||
void set_shield_gr2(const godot::String &p);
|
|
||||||
godot::String get_shield_gr2() const { return shield_gr2; }
|
|
||||||
void set_shield_bone(const godot::String &b);
|
|
||||||
godot::String get_shield_bone() const { return shield_bone; }
|
|
||||||
|
|
||||||
// Fed by Metin2AnimPlayer after each gr2::sample_pose: base-skeleton world
|
|
||||||
// matrices (world_pose output). Repositions attached rigid parts (weapon +
|
|
||||||
// shield). No-op for a slot with no gr2 or an unresolved bone name.
|
|
||||||
void update_weapon_pose(const std::vector<gr2::Mat4> &world_pose);
|
|
||||||
|
|
||||||
// Rebuild the subtree from the current properties.
|
|
||||||
void reload();
|
|
||||||
|
|
||||||
// One-line summary (bones / meshes / verts / bounds).
|
|
||||||
godot::String get_info() const;
|
|
||||||
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
|
|
||||||
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
|
|
||||||
godot::AABB get_visual_aabb();
|
|
||||||
godot::Dictionary get_fly_target_bounds();
|
|
||||||
// Parent-space Y adjustment that places the bind-pose model's lowest point
|
|
||||||
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
|
|
||||||
double get_ground_offset() const { return ground_offset; }
|
|
||||||
|
|
||||||
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
|
|
||||||
const gr2::Skeleton *gr2_skeleton() const;
|
|
||||||
godot::Skeleton3D *skeleton_node() const { return skel; }
|
|
||||||
const gr2::FileInfo *gr2_fileinfo() const;
|
|
||||||
// Latest full-affine deformer matrices. Initialized to bind pose on reload
|
|
||||||
// and refreshed by either skinning path; attachments can share this pose.
|
|
||||||
const std::vector<gr2::Mat4> ¤t_skin_matrices() const { return current_skin; }
|
|
||||||
|
|
||||||
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
|
|
||||||
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
|
|
||||||
// reference path; bypasses Godot's Skeleton3D skinning entirely.
|
|
||||||
void cpu_skin(const std::vector<gr2::Mat4> &skin);
|
|
||||||
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
|
|
||||||
void enable_cpu_skin(bool on);
|
|
||||||
|
|
||||||
// GPU linear-blend skinning: LBS in a custom vertex shader with the FULL
|
|
||||||
// per-bone matrices uploaded as a float texture (no Skeleton3D -> shear kept).
|
|
||||||
// Keeps the static build_mesh() output; just swaps materials + feeds the
|
|
||||||
// bone texture each frame. MTGODOT_GPUSKIN=1 route.
|
|
||||||
void enable_gpu_skin(bool on);
|
|
||||||
void gpu_skin(const std::vector<gr2::Mat4> &skin);
|
|
||||||
|
|
||||||
// libgr2 sanity probe (kept from M0').
|
|
||||||
godot::String probe_gr2(const godot::String &path) const;
|
|
||||||
|
|
||||||
static void clear_texture_cache();
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
|
|
||||||
private:
|
|
||||||
godot::String gr2_path;
|
|
||||||
godot::String texture_dir;
|
|
||||||
double unit_scale = 0.01;
|
|
||||||
bool flip_z = false;
|
|
||||||
bool flip_winding = false;
|
|
||||||
|
|
||||||
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
|
|
||||||
bool use_gr2_materials = true; // gr2 MaterialBindings -> texture (else: filename heuristic only)
|
|
||||||
double specular_power = 0.0; // PARITY §2.7; 0 = disabled
|
|
||||||
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
|
|
||||||
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
|
|
||||||
godot::PackedStringArray surface_tex_override;
|
|
||||||
godot::Dictionary skin_tex_override; // lowercase source basename -> target path
|
|
||||||
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
|
|
||||||
godot::Array hair_options; // from .msm HairData
|
|
||||||
|
|
||||||
godot::String hair_gr2; // attached hair .gr2 (or "")
|
|
||||||
godot::String hair_skin; // TargetSkin dds override (or "")
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> hair_file; // loaded hair gr2
|
|
||||||
std::vector<mtgodot::RenderPart> hair_parts;
|
|
||||||
std::vector<int> hair_bone_remap; // hair skel idx -> base skel idx (by name)
|
|
||||||
godot::Ref<godot::Material> hair_mat;
|
|
||||||
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
|
|
||||||
int gpu_base_surf = 0; // base surfaces before appended GPU hair
|
|
||||||
void _load_hair();
|
|
||||||
void _refresh_hair_mesh();
|
|
||||||
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
|
|
||||||
|
|
||||||
godot::String weapon_gr2; // attached weapon .gr2 (or "")
|
|
||||||
godot::String weapon_bone = "equip_right_hand"; // base-skeleton attach bone
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
|
|
||||||
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
|
|
||||||
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
|
|
||||||
// Weapon root rigid-mesh composite (invWorld · local),
|
|
||||||
// folded in before the parent hand matrix exactly as Granny does for an attached
|
|
||||||
// model. This may contain a large translation which cancels an authored vertex
|
|
||||||
// offset (notably 03150), so it must not be clamped.
|
|
||||||
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
|
||||||
godot::String shield_gr2;
|
|
||||||
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> shield_file;
|
|
||||||
godot::MeshInstance3D *shield_mi = nullptr;
|
|
||||||
int shield_bone_idx = -1;
|
|
||||||
gr2::Mat4 shield_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
|
||||||
void _load_weapon();
|
|
||||||
void _load_shield();
|
|
||||||
// shared rigid-attach loader used by _load_weapon / _load_shield.
|
|
||||||
void _load_attach(const godot::String &gr2_rel, const godot::String &bone_name,
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> &slot_file, godot::MeshInstance3D *&slot_mi,
|
|
||||||
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label);
|
|
||||||
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
|
|
||||||
std::vector<gr2::MaterialInfo> materials;
|
|
||||||
godot::Skeleton3D *skel = nullptr;
|
|
||||||
godot::MeshInstance3D *mi = nullptr;
|
|
||||||
godot::String last_info;
|
|
||||||
double ground_offset = 0.0;
|
|
||||||
|
|
||||||
// §2.10 LOD
|
|
||||||
bool lod_enabled = true;
|
|
||||||
int lod_level = 0; // 0 = full model, 1..3 = _lod_0N
|
|
||||||
godot::PackedFloat32Array lod_dist;
|
|
||||||
std::vector<std::shared_ptr<std::optional<gr2::File>>> lod_files; // [0]=_lod_01 ...
|
|
||||||
std::vector<std::vector<mtgodot::RenderPart>> lod_parts; // parallel to lod_files
|
|
||||||
// LOD crossfade: a frozen ghost of the outgoing level fades out while the new
|
|
||||||
// mesh fades in (LODController::BlendRenderWithOneTexture). ~0.18 s.
|
|
||||||
godot::MeshInstance3D *lod_prev_mi = nullptr;
|
|
||||||
double lod_fade_t = -1.0; // <0 = not fading
|
|
||||||
std::vector<godot::Ref<godot::Material>> lod_ghost_mats;
|
|
||||||
void _end_lod_fade();
|
|
||||||
std::vector<mtgodot::RenderPart> base_parts;
|
|
||||||
void _load_lods(const godot::String &loaded_spec);
|
|
||||||
void _set_lod(int n);
|
|
||||||
const gr2::FileInfo *active_fi() const; // base or current LOD (mesh/material data)
|
|
||||||
|
|
||||||
std::vector<mtgodot::RenderPart> parts; // surface s -> (gr2 mesh, tri_group, material)
|
|
||||||
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
|
|
||||||
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
|
|
||||||
bool gpu_skin_active = false;
|
|
||||||
bool gpu_visual_bounds_ready = false;
|
|
||||||
bool have_visual_bounds = false;
|
|
||||||
godot::AABB visual_bounds;
|
|
||||||
std::vector<gr2::Mat4> current_world_pose;
|
|
||||||
godot::AABB fly_target_bounds;
|
|
||||||
bool fly_target_bounds_valid = false;
|
|
||||||
std::vector<gr2::Mat4> current_skin;
|
|
||||||
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
|
|
||||||
godot::Ref<godot::ImageTexture> bones_tex;
|
|
||||||
|
|
||||||
void _clear_children();
|
|
||||||
void _apply_materials();
|
|
||||||
bool _update_skinned_bounds(const std::vector<gr2::Mat4> &skin);
|
|
||||||
godot::String _guess_texture_dir() const;
|
|
||||||
// Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an
|
|
||||||
// absolute file. Passes existing absolute paths through.
|
|
||||||
static godot::String resolve_rel_gr2(const godot::String &base, const godot::String &spec);
|
|
||||||
godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path);
|
|
||||||
godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir,
|
|
||||||
const godot::String &stem, const godot::String &surface_name,
|
|
||||||
const godot::PackedStringArray &dds_files);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/node3d.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
#include <environment.h>
|
|
||||||
#include <map_setting.h>
|
|
||||||
#include <property.h>
|
|
||||||
#include <terrain_files.h>
|
|
||||||
#include <texture_set.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "static_object.h"
|
|
||||||
|
|
||||||
namespace godot {
|
|
||||||
class MeshInstance3D;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// W1 —— 加载一张 Metin2 户外地图并渲染灰色高度地形。
|
|
||||||
// SHINSOO-WORLD-RENDERING.md §8(Metin2World API 契约)/ §9-W1。
|
|
||||||
//
|
|
||||||
// Metin2World (Node3D)
|
|
||||||
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
|
|
||||||
//
|
|
||||||
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
|
|
||||||
class Metin2World : public godot::Node3D {
|
|
||||||
GDCLASS(Metin2World, godot::Node3D)
|
|
||||||
|
|
||||||
public:
|
|
||||||
struct HeightTriangle {
|
|
||||||
godot::Vector3 v0;
|
|
||||||
godot::Vector3 v1;
|
|
||||||
godot::Vector3 v2;
|
|
||||||
float min_x = 0.0f, max_x = 0.0f;
|
|
||||||
float min_z = 0.0f, max_z = 0.0f;
|
|
||||||
godot::Vector3 normal;
|
|
||||||
float d = 0.0f;
|
|
||||||
};
|
|
||||||
|
|
||||||
Metin2World();
|
|
||||||
~Metin2World() override;
|
|
||||||
|
|
||||||
void _ready() override;
|
|
||||||
void _process(double delta) override;
|
|
||||||
|
|
||||||
void set_assets_root(const godot::String &p);
|
|
||||||
godot::String get_assets_root() const { return assets_root; }
|
|
||||||
void set_map_path(const godot::String &p);
|
|
||||||
godot::String get_map_path() const { return map_path; }
|
|
||||||
void set_load_radius_tiles(int r) { load_radius = r; }
|
|
||||||
int get_load_radius_tiles() const { return load_radius; }
|
|
||||||
void set_focus_tile(godot::Vector2i t);
|
|
||||||
void set_initial_focus_cm(godot::Vector2 position) { initial_focus_cm = position; }
|
|
||||||
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
|
|
||||||
void set_auto_load(bool v) { auto_load = v; }
|
|
||||||
bool get_auto_load() const { return auto_load; }
|
|
||||||
void set_splat_enabled(bool v) { splat_enabled = v; }
|
|
||||||
bool get_splat_enabled() const { return splat_enabled; }
|
|
||||||
void set_terrain_patches(int n) { terrain_patches = n; }
|
|
||||||
int get_terrain_patches() const { return terrain_patches; }
|
|
||||||
void set_objects_enabled(bool v) { objects_enabled = v; }
|
|
||||||
bool get_objects_enabled() const { return objects_enabled; }
|
|
||||||
void set_env_enabled(bool v) { env_enabled = v; }
|
|
||||||
bool get_env_enabled() const { return env_enabled; }
|
|
||||||
void set_water_enabled(bool v) { water_enabled = v; }
|
|
||||||
void set_collision_enabled(bool v) { collision_enabled = v; }
|
|
||||||
bool get_water_enabled() const { return water_enabled; }
|
|
||||||
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
|
|
||||||
// they don't cast realtime sun shadows by default (avoids double-darkening).
|
|
||||||
// Only shadowflag=1 buildings and dynamic actors cast realtime. Flip these on
|
|
||||||
// for a modernized look.
|
|
||||||
void set_tree_shadows(bool v) { tree_shadows = v; }
|
|
||||||
bool get_tree_shadows() const { return tree_shadows; }
|
|
||||||
void set_static_shadows(bool v) { static_shadows = v; }
|
|
||||||
bool get_static_shadows() const { return static_shadows; }
|
|
||||||
void set_stream_budget(int v) { stream_budget = v < 1 ? 1 : v; }
|
|
||||||
int get_stream_budget() const { return stream_budget; }
|
|
||||||
|
|
||||||
bool load_map();
|
|
||||||
void unload_map();
|
|
||||||
// 流式:把关注点设到某全局米坐标;load_radius_tiles >= 0 时按 3×3(radius) 装/卸区块。
|
|
||||||
void set_focus_position(double gx_m, double gz_m);
|
|
||||||
godot::Dictionary get_perf() const; // fps / frame ms / draw calls / prims / vram / 节点数
|
|
||||||
// 全局米坐标(Godot 空间)下的地表高度;地图外返回 0。
|
|
||||||
godot::Vector2 get_map_base_cm() const; // setting.txt BasePosition, in cm
|
|
||||||
godot::Vector2i get_map_size_tiles() const;
|
|
||||||
double sample_height(double gx_m, double gz_m) const;
|
|
||||||
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
|
|
||||||
int sample_attribute(double gx_m, double gz_m) const;
|
|
||||||
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
|
|
||||||
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
|
|
||||||
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
|
|
||||||
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
|
|
||||||
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
|
|
||||||
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
|
|
||||||
godot::Dictionary get_load_report() const;
|
|
||||||
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
|
|
||||||
// playback; the world only exposes the reference source records.
|
|
||||||
godot::Array get_ambience_sources() const;
|
|
||||||
|
|
||||||
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
|
|
||||||
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
|
|
||||||
// map_path 下某区块目录的绝对路径(HUD 找 minimap.dds 用)。
|
|
||||||
godot::String chunk_dir(int tile_x, int tile_y) const;
|
|
||||||
|
|
||||||
// 构建期用:扫 assets_root 建 AssetResolver 索引,写到 out_path(asset_index.txt)。
|
|
||||||
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
|
|
||||||
bool bake_asset_index(const godot::String &out_path);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
|
|
||||||
private:
|
|
||||||
godot::String assets_root;
|
|
||||||
godot::String map_path = "OutdoorA1/metin2_map_a1";
|
|
||||||
int load_radius = -1; // <0 = 全图
|
|
||||||
int focus_tx = 0, focus_ty = 0;
|
|
||||||
godot::Vector2 initial_focus_cm;
|
|
||||||
bool auto_load = true;
|
|
||||||
bool splat_enabled = true;
|
|
||||||
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
|
|
||||||
// visibility_range 远距整片剔除。1 = 不拆(旧行为)。必须整除 128。
|
|
||||||
int terrain_patches = 4;
|
|
||||||
// patch 超过这个距离整片不画(米)。默认 3500 覆盖全图+俯视调试,实际增益来自
|
|
||||||
// 逐 patch 视锥剔除;游戏内可调低省远景地形。
|
|
||||||
float terrain_patch_view = 3500.0f;
|
|
||||||
bool objects_enabled = true;
|
|
||||||
bool env_enabled = true;
|
|
||||||
bool water_enabled = true;
|
|
||||||
bool collision_enabled = true;
|
|
||||||
bool tree_shadows = false; // trees: baked in shadowmap.dds -> no realtime cast
|
|
||||||
bool static_shadows = false; // shadowflag=0/empty buildings: same
|
|
||||||
int water_pieces = 0;
|
|
||||||
|
|
||||||
fmt::MapSetting setting;
|
|
||||||
fmt::TextureSet texture_set;
|
|
||||||
fmt::Environment env;
|
|
||||||
bool env_ok = false;
|
|
||||||
std::shared_ptr<fmt::AssetResolver> resolver;
|
|
||||||
fmt::PropertyRegistry registry;
|
|
||||||
StaticMeshCache static_cache;
|
|
||||||
bool setting_ok = false;
|
|
||||||
bool splat_ready = false;
|
|
||||||
bool registry_ok = false;
|
|
||||||
godot::String last_error;
|
|
||||||
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
|
|
||||||
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
|
|
||||||
int trees_placed = 0, tree_species = 0;
|
|
||||||
int native_trees_placed = 0, proxy_trees_placed = 0;
|
|
||||||
int objects_mdatr_built = 0;
|
|
||||||
godot::Node3D *objects_root = nullptr;
|
|
||||||
double build_ms = 0;
|
|
||||||
|
|
||||||
struct Chunk {
|
|
||||||
int tx = 0, ty = 0;
|
|
||||||
std::shared_ptr<fmt::HeightMap> hm;
|
|
||||||
std::shared_ptr<fmt::AttrMap> am;
|
|
||||||
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
|
|
||||||
int objects = 0, trees = 0;
|
|
||||||
std::vector<HeightTriangle> height_triangles;
|
|
||||||
};
|
|
||||||
struct AmbienceSource {
|
|
||||||
int tile_x = 0, tile_y = 0;
|
|
||||||
int object_index = 0;
|
|
||||||
godot::Vector3 position;
|
|
||||||
int range_cm = 0;
|
|
||||||
float max_volume_area_percentage = 0.0f;
|
|
||||||
float play_interval = 0.0f;
|
|
||||||
float play_interval_variation = 0.0f;
|
|
||||||
godot::String play_type;
|
|
||||||
std::vector<std::string> sounds;
|
|
||||||
};
|
|
||||||
std::vector<Chunk> chunks;
|
|
||||||
std::vector<std::pair<int, int>> stream_queue; // 待建区块
|
|
||||||
std::vector<AmbienceSource> ambience_sources;
|
|
||||||
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
|
|
||||||
|
|
||||||
godot::String map_dir() const;
|
|
||||||
bool build_chunk(int tx, int ty);
|
|
||||||
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree,
|
|
||||||
std::vector<HeightTriangle> &out_heights);
|
|
||||||
void place_chunk_ambience(int tx, int ty);
|
|
||||||
void unload_chunk(int idx);
|
|
||||||
void stream_update();
|
|
||||||
const Chunk *chunk_at(int tx, int ty) const;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,433 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
|
|
||||||
//
|
|
||||||
// var c = M2Client.new()
|
|
||||||
// add_child(c)
|
|
||||||
// c.phase_changed.connect(func(p): print("phase ", p))
|
|
||||||
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
|
|
||||||
// c.entered_game.connect(func(): print("in game"))
|
|
||||||
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
|
|
||||||
// "admin", "123456789")
|
|
||||||
//
|
|
||||||
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
|
|
||||||
// GameClient(game_host:game_port) which does its own KX handshake, sends
|
|
||||||
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
|
|
||||||
// game phase. _process() pumps whichever stream is active.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/node.hpp>
|
|
||||||
#include <godot_cpp/core/object_id.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace mtnet {
|
|
||||||
class AuthClient;
|
|
||||||
class GameClient;
|
|
||||||
class MarkClient;
|
|
||||||
class MarkImageSet;
|
|
||||||
class EntityStore;
|
|
||||||
namespace classic {
|
|
||||||
class ClassicSession;
|
|
||||||
class ClassicMarkClient;
|
|
||||||
}
|
|
||||||
} // namespace mtnet
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
class M2Client : public godot::Node {
|
|
||||||
GDCLASS(M2Client, godot::Node)
|
|
||||||
|
|
||||||
public:
|
|
||||||
M2Client();
|
|
||||||
~M2Client() override;
|
|
||||||
|
|
||||||
void _process(double delta) override;
|
|
||||||
|
|
||||||
// host/port for auth and game servers; account credentials.
|
|
||||||
void connect_to_server(const godot::String &auth_host, int auth_port,
|
|
||||||
const godot::String &game_host, int game_port, const godot::String &id,
|
|
||||||
const godot::String &pw);
|
|
||||||
// LoginPhase retry equivalent of LoginWindow.Connect. The implementation
|
|
||||||
// reuses a pending classic session and replaces only its auth transport.
|
|
||||||
void retry_login(const godot::String &auth_host, int auth_port,
|
|
||||||
const godot::String &game_host, int game_port, const godot::String &id,
|
|
||||||
const godot::String &pw);
|
|
||||||
void disconnect_from_server();
|
|
||||||
// Share the already-loaded item_proto with native action gates. The object
|
|
||||||
// is held by instance id so unloading a scene cannot leave a dangling ptr.
|
|
||||||
void set_item_proto(godot::Object *proto);
|
|
||||||
// Resolve the same item use/drop sound CPythonItem selects from item_proto.
|
|
||||||
// This small public seam keeps the resource-side branch directly testable.
|
|
||||||
godot::String item_action_sound_for_item(int vnum, bool drop) const;
|
|
||||||
|
|
||||||
// pick a slot from the char_list payload (its "index" field).
|
|
||||||
bool select_character(int index);
|
|
||||||
// Enter a character through the stock two-connection direct-enter flow.
|
|
||||||
bool enter_game(int index);
|
|
||||||
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
|
|
||||||
bool create_character(int slot, const godot::String &name, int job, int shape,
|
|
||||||
int con, int intel, int str, int dex);
|
|
||||||
bool delete_character(int slot, const godot::String &private_code);
|
|
||||||
// Rename a character slot with a server-side rename card.
|
|
||||||
bool change_name(int slot, const godot::String &name);
|
|
||||||
// GC_EMPIRE (0 = server still wants an empire pick before select).
|
|
||||||
int get_empire() const;
|
|
||||||
// CG_EMPIRE: choose one of the three empires when the account has no empire.
|
|
||||||
// Both network backends expose the same intent for the character-select UI.
|
|
||||||
bool select_empire(int empire);
|
|
||||||
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
|
|
||||||
int get_slot_count() const;
|
|
||||||
|
|
||||||
// --- in-game intents (mirror CPythonNetworkStream) ---
|
|
||||||
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
|
|
||||||
// original client. x/y are server cm. time is filled from the frame clock.
|
|
||||||
bool move(int func, int arg, double rot_deg, int x, int y);
|
|
||||||
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
|
|
||||||
bool character_position(int position);
|
|
||||||
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
|
|
||||||
// the legacy packet allows at most 16 entries.
|
|
||||||
bool sync_positions(const godot::Array &positions);
|
|
||||||
bool request_warp();
|
|
||||||
bool fishing(double rot_deg);
|
|
||||||
bool request_dungeon();
|
|
||||||
bool attack(int motion, int victim_vid);
|
|
||||||
bool set_target(int victim_vid);
|
|
||||||
bool say(int type, const godot::String &text);
|
|
||||||
// Reference client SendEmoticon: send the registered chat token and let the
|
|
||||||
// presentation layer play the matching local effect.
|
|
||||||
bool send_emoticon(int index);
|
|
||||||
bool whisper(const godot::String &to, const godot::String &text);
|
|
||||||
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F),
|
|
||||||
// arg = loop count low nibble | moving-skill bit (1 << 4).
|
|
||||||
bool cast_skill(int motion_idx, double rot_deg, int x, int y, int arg = 0);
|
|
||||||
// Real skill intent. The non-classic backend may include its primary target
|
|
||||||
// packet here; the classic 40250 caller sends primary fly targeting from the
|
|
||||||
// bow event and uses this method for CG_USE_SKILL itself.
|
|
||||||
bool use_skill(int skill_id, int target_vid);
|
|
||||||
// Ranged animation events and area-target selection use separate packets.
|
|
||||||
bool shoot(int skill_id);
|
|
||||||
// Primary bow/skill fly target: CG_FLY_TARGETING. Area skills use the
|
|
||||||
// separate CG_ADD_FLY_TARGETING path below.
|
|
||||||
bool fly_targeting(int target_vid, int x, int y);
|
|
||||||
bool add_fly_targeting(int target_vid, int x, int y);
|
|
||||||
// server-driven quest command: "/skillup <skill_id>".
|
|
||||||
bool skill_up(int skill_id);
|
|
||||||
int get_skill_group() const;
|
|
||||||
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
|
|
||||||
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
|
|
||||||
bool quickslot_add(int pos, int type, int ref);
|
|
||||||
bool quickslot_del(int pos);
|
|
||||||
bool quickslot_swap(int pos, int change_pos);
|
|
||||||
|
|
||||||
// quest / NPC
|
|
||||||
bool click_npc(int vid);
|
|
||||||
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
|
|
||||||
bool script_button(int idx); // quest-log button
|
|
||||||
bool script_select_item(int selection); // inventory cell/item position
|
|
||||||
bool quest_input(const godot::String &text);
|
|
||||||
bool quest_confirm(bool yes, int request_pid);
|
|
||||||
bool quest_cancel();
|
|
||||||
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
|
|
||||||
|
|
||||||
// --- P8 party ---
|
|
||||||
bool party_invite(int vid);
|
|
||||||
bool party_answer(int leader_pid, bool accept);
|
|
||||||
bool party_leave(int pid); // expel <pid>, or your own pid to leave
|
|
||||||
bool party_use_skill(int skill_index, int target_vid);
|
|
||||||
bool party_set_distribute(int mode);
|
|
||||||
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
|
|
||||||
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
|
|
||||||
int get_party_distribute_mode() const;
|
|
||||||
|
|
||||||
// --- P8 messenger / friends ---
|
|
||||||
bool add_friend(const godot::String &name);
|
|
||||||
bool remove_friend(const godot::String &name);
|
|
||||||
bool friend_answer(const godot::String &name, bool accept);
|
|
||||||
godot::Array get_friends() const; // [{name, online}]
|
|
||||||
godot::Dictionary get_lover() const; // {valid, name, love_point}
|
|
||||||
|
|
||||||
// --- P8 NPC shop ---
|
|
||||||
bool shop_buy(int pos, int count);
|
|
||||||
bool shop_sell(int inv_cell, int count);
|
|
||||||
bool shop_close();
|
|
||||||
bool is_shop_open() const;
|
|
||||||
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
|
|
||||||
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
|
|
||||||
|
|
||||||
// --- P8 exchange / trade ---
|
|
||||||
bool exchange_start(int vid);
|
|
||||||
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
|
|
||||||
bool exchange_add_gold(int gold);
|
|
||||||
bool exchange_accept();
|
|
||||||
bool exchange_cancel();
|
|
||||||
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
|
|
||||||
|
|
||||||
// --- P8 safebox / storage ---
|
|
||||||
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
|
|
||||||
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
|
|
||||||
bool safebox_move(int from_cell, int to_cell, int count);
|
|
||||||
bool is_safebox_open() const;
|
|
||||||
int get_safebox_size() const;
|
|
||||||
int get_safebox_gold() const;
|
|
||||||
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
|
|
||||||
bool safebox_password(const godot::String &password);
|
|
||||||
// 40250 的仓库关闭/改密走 chat 命令(cmd.cpp do_safebox_close /
|
|
||||||
// do_safebox_change_password,均 GM_PLAYER),与 safebox_password 同通道。
|
|
||||||
bool safebox_change_password(const godot::String &old_password,
|
|
||||||
const godot::String &new_password);
|
|
||||||
bool safebox_close();
|
|
||||||
|
|
||||||
// --- item-mall (창고몰) ---
|
|
||||||
bool is_mall_open() const;
|
|
||||||
int get_mall_size() const;
|
|
||||||
godot::Array get_mall_items() const; // [{cell, vnum, count}]
|
|
||||||
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
|
|
||||||
bool mall_password(const godot::String &password);
|
|
||||||
bool mall_close(); // "/mall_close"(cmd.cpp do_mall_close)
|
|
||||||
|
|
||||||
// --- private (PC) shop ---
|
|
||||||
// items: Array of {vnum, count, inv_cell, price, display_pos}
|
|
||||||
bool open_private_shop(const godot::String &sign, const godot::Array &items);
|
|
||||||
bool close_private_shop();
|
|
||||||
|
|
||||||
// --- cube (제작) ---
|
|
||||||
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
|
|
||||||
bool cube_make(int result_index);
|
|
||||||
bool cube_request_result_list(int npc_vnum);
|
|
||||||
bool cube_request_materials(int start_index, int count);
|
|
||||||
bool cube_open();
|
|
||||||
bool cube_close();
|
|
||||||
bool cube_list();
|
|
||||||
bool cube_add_item(int cube_index, int inventory_index);
|
|
||||||
bool cube_delete_item(int cube_index);
|
|
||||||
|
|
||||||
// --- guild ---
|
|
||||||
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
|
|
||||||
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
|
|
||||||
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
|
|
||||||
bool guild_add_member(int vid);
|
|
||||||
bool guild_remove_member(int pid);
|
|
||||||
bool guild_offer(int amount);
|
|
||||||
bool guild_charge_gsp(int amount);
|
|
||||||
// 40250 服务器把这两个子头禁用了(input_main.cpp 直接 return,死代码在下方),
|
|
||||||
// 但原版 ClientVS22 UI 仍照发(uiguild.py OnDeposit/OnWithdraw)——保持 1:1。
|
|
||||||
bool guild_deposit_money(int amount);
|
|
||||||
bool guild_withdraw_money(int amount);
|
|
||||||
bool guild_change_grade_name(int grade, const godot::String &name);
|
|
||||||
bool guild_change_grade_authority(int grade, int authority);
|
|
||||||
bool guild_change_member_grade(int pid, int grade);
|
|
||||||
bool guild_change_member_general(int pid, bool enabled);
|
|
||||||
bool guild_post_comment(const godot::String &text);
|
|
||||||
bool guild_delete_comment(int comment_id);
|
|
||||||
bool guild_refresh_comments();
|
|
||||||
bool guild_answer_invite(int guild_id, bool accept);
|
|
||||||
bool guild_answer_make(const godot::String &name);
|
|
||||||
godot::Array get_guild_comments() const; // [{id, name, content}]
|
|
||||||
|
|
||||||
// --- guild war / guild skill ---
|
|
||||||
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
|
|
||||||
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
|
|
||||||
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
|
|
||||||
godot::String get_guild_name(int guild_id) const;
|
|
||||||
bool use_guild_skill(int skill_vnum, int target_vid);
|
|
||||||
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
|
|
||||||
|
|
||||||
// --- guild marks (会徽) ---
|
|
||||||
// Opens the side connection to `host:port` and pulls the mark images using
|
|
||||||
// the handle/random_key from login. `guild_marks_ready` fires when done.
|
|
||||||
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
|
|
||||||
bool download_guild_marks(const godot::String &host, int port);
|
|
||||||
// Download one raw guild-symbol file via the mark side connection.
|
|
||||||
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
|
|
||||||
// Raw bytes from the last completed download_guild_symbol call.
|
|
||||||
godot::PackedByteArray get_guild_symbol() const;
|
|
||||||
bool are_guild_marks_ready() const;
|
|
||||||
// {host, port} last used for a mark connection (0 port = none configured yet).
|
|
||||||
godot::Dictionary get_mark_server() const;
|
|
||||||
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
|
|
||||||
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
|
|
||||||
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
|
|
||||||
const godot::Ref<godot::Image> &img);
|
|
||||||
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
|
|
||||||
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
|
|
||||||
const godot::PackedByteArray &file_bytes);
|
|
||||||
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
|
|
||||||
godot::Dictionary get_guild_mark(int guild_id) const;
|
|
||||||
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
|
|
||||||
// have no mark for it yet.
|
|
||||||
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
|
|
||||||
|
|
||||||
// --- refine / upgrade ---
|
|
||||||
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
|
|
||||||
|
|
||||||
// --- dragon soul refine ---
|
|
||||||
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
|
|
||||||
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
|
|
||||||
bool ds_refine(int mode, const godot::Array &cells);
|
|
||||||
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
|
|
||||||
|
|
||||||
// --- P9 world systems ---
|
|
||||||
int get_channel() const;
|
|
||||||
int64_t get_server_time() const; // unix seconds as of last GC_TIME
|
|
||||||
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
|
|
||||||
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
|
|
||||||
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
|
|
||||||
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
|
|
||||||
bool is_observer_mode() const;
|
|
||||||
int get_observer_count() const;
|
|
||||||
bool has_mobile_flag() const;
|
|
||||||
bool combo_skill_enabled() const;
|
|
||||||
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
|
|
||||||
|
|
||||||
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
|
|
||||||
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
|
|
||||||
bool use_item(int window, int cell);
|
|
||||||
bool drop_item(int window, int cell, int gold);
|
|
||||||
bool drop_item_count(int window, int cell, int gold, int count);
|
|
||||||
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
|
|
||||||
bool give_item(int target_vid, int window, int cell, int count);
|
|
||||||
bool pickup_item(int ground_vid);
|
|
||||||
godot::Array get_inventory() const; // non-empty slots
|
|
||||||
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
|
|
||||||
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
|
|
||||||
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
|
|
||||||
godot::Dictionary get_item(int window, int cell) const;
|
|
||||||
godot::Array get_ground_items() const; // {vid, vnum, pos}
|
|
||||||
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
|
|
||||||
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
|
|
||||||
|
|
||||||
// --- networked world snapshot (positions already Godot-space, metres) ---
|
|
||||||
godot::Dictionary get_entity(int vid) const;
|
|
||||||
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
|
|
||||||
// measured by the view from its .msa; drives the reference-style remote walk.
|
|
||||||
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
|
|
||||||
// Presentation-owned CInstanceBase network-state gates. These are updated
|
|
||||||
// when a remote skill/emotion starts or reaches its motion tail.
|
|
||||||
void set_entity_network_state_gate(int vid, bool skill_active, bool skill_can_cancel,
|
|
||||||
bool acting_emotion, bool tcp_state_enabled);
|
|
||||||
// Presentation-owned knockdown latch used by TEMP_Push's
|
|
||||||
// DAMAGE_FLYING -> STAND_UP motion chain.
|
|
||||||
void set_entity_knockdown(int vid, bool on);
|
|
||||||
godot::Array get_entities() const;
|
|
||||||
int get_main_vid() const;
|
|
||||||
int get_main_pid() const;
|
|
||||||
godot::Dictionary get_points() const; // local player stat block
|
|
||||||
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
|
|
||||||
godot::Array get_affects() const; // active buffs/debuffs on the local player
|
|
||||||
|
|
||||||
// App lifecycle (F5). suspend() stops pumping the socket (called
|
|
||||||
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
|
|
||||||
// A mobile OS tears the TCP connection down within seconds of backgrounding,
|
|
||||||
// so the first pump after resume typically surfaces `disconnected` — call
|
|
||||||
// reconnect() to redo the login with the stored credentials.
|
|
||||||
void suspend();
|
|
||||||
void resume();
|
|
||||||
bool reconnect();
|
|
||||||
bool is_suspended() const { return suspended; }
|
|
||||||
|
|
||||||
godot::String get_stage() const { return stage_name(); }
|
|
||||||
bool is_in_game() const;
|
|
||||||
|
|
||||||
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
|
|
||||||
// signals. Call this from GDScript around any blocking work (model loads,
|
|
||||||
// map build) so the server doesn't drop us for going silent.
|
|
||||||
void net_poll();
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
|
|
||||||
private:
|
|
||||||
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
|
|
||||||
Stage stage = Stage::Idle;
|
|
||||||
godot::String stage_name() const;
|
|
||||||
void set_stage(Stage s);
|
|
||||||
|
|
||||||
godot::String game_host;
|
|
||||||
int game_port = 0;
|
|
||||||
godot::String account_id;
|
|
||||||
|
|
||||||
// Stored only until the classic session has sent its login packet. The
|
|
||||||
// reference client clears its network password at that boundary; classic
|
|
||||||
// reconnects then use last_login_key or require a fresh explicit login.
|
|
||||||
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
|
|
||||||
int cfg_auth_port = 0, cfg_game_port = 0;
|
|
||||||
bool have_cfg = false;
|
|
||||||
bool suspended = false;
|
|
||||||
|
|
||||||
std::unique_ptr<mtnet::AuthClient> auth;
|
|
||||||
std::unique_ptr<mtnet::GameClient> game;
|
|
||||||
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
|
|
||||||
// (connect/login/select/enter-game/world/intents). Null otherwise.
|
|
||||||
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
|
|
||||||
bool classic_list_emitted = false;
|
|
||||||
// A peer-close or replacement-connect failure has already returned
|
|
||||||
// ClassicSession to LoginPhase, but the host must still allow the next
|
|
||||||
// reconnect request to replace the old socket instead of treating the
|
|
||||||
// transient GameLogin stage as in-flight.
|
|
||||||
bool classic_remote_disconnect_pending = false;
|
|
||||||
// Keep the auth Offline owner retryable across pumps after
|
|
||||||
// AccountConnector::OnConnectFailure; a later frame must not synthesize a
|
|
||||||
// terminal Failed state for the same transport error.
|
|
||||||
bool classic_auth_connect_failure_pending = false;
|
|
||||||
// AccountConnector::OnRemoteDisconnect returns to Offline without invoking
|
|
||||||
// OnLoginFailure/OnConnectFailure; keep that owner non-terminal across pumps.
|
|
||||||
bool classic_auth_remote_disconnect_pending = false;
|
|
||||||
int classic_last_stage = -1;
|
|
||||||
int classic_last_empire = -1;
|
|
||||||
void pump_classic();
|
|
||||||
void bind_classic_loading_reset();
|
|
||||||
// the EntityStore of whichever backend is active (classic_sess or game).
|
|
||||||
const mtnet::EntityStore *active_world() const;
|
|
||||||
std::unique_ptr<mtnet::MarkClient> mark;
|
|
||||||
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
|
|
||||||
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
|
|
||||||
godot::String mark_host;
|
|
||||||
int mark_port = 0;
|
|
||||||
bool mark_ready = false;
|
|
||||||
uint32_t symbol_guild_id = 0;
|
|
||||||
std::vector<uint8_t> symbol_data;
|
|
||||||
void pump_mark();
|
|
||||||
void pump_classic_mark();
|
|
||||||
|
|
||||||
int last_auth_state = -1;
|
|
||||||
int last_game_state = -1;
|
|
||||||
int last_game_phase = -1;
|
|
||||||
int last_game_empire = -1;
|
|
||||||
bool char_list_emitted = false;
|
|
||||||
uint32_t selected_pid = 0;
|
|
||||||
godot::Array build_char_list() const;
|
|
||||||
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
|
|
||||||
// it before the server finishes the spawn burst makes it drop us ~10s later.
|
|
||||||
uint64_t loading_since_ms = 0;
|
|
||||||
bool enter_game_sent = false;
|
|
||||||
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
|
||||||
bool mall_open_seen = false;
|
|
||||||
bool safebox_open_seen = false;
|
|
||||||
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
|
||||||
void warp_to_game_server(const godot::String &host, int port);
|
|
||||||
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
|
||||||
|
|
||||||
void pump_auth();
|
|
||||||
void pump_game();
|
|
||||||
bool item_action_allowed(bool equipment_touched) const;
|
|
||||||
bool is_equip_item_in_slot(int window, int cell) const;
|
|
||||||
bool is_equipment_cell(int window, int cell) const;
|
|
||||||
bool item_proto_known(uint32_t vnum) const;
|
|
||||||
bool item_proto_type(uint32_t vnum, uint8_t &type) const;
|
|
||||||
void emit_item_action_sound(int window, int cell, bool drop);
|
|
||||||
void apply_item_proto_validator();
|
|
||||||
godot::ObjectID item_proto_id;
|
|
||||||
bool item_proto_ready = false;
|
|
||||||
mtnet::EntityStore *item_validator_world = nullptr;
|
|
||||||
bool item_validator_ready = false;
|
|
||||||
bool local_main_attacking = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -7,29 +7,18 @@
|
|||||||
// with the Chinese build using GB2312-compatible bytes) and fills fixed C
|
// with the Chinese build using GB2312-compatible bytes) and fills fixed C
|
||||||
// buffers by byte capacity, never by UTF-8 character count.
|
// buffers by byte capacity, never by UTF-8 character count.
|
||||||
//
|
//
|
||||||
// This project carries text as UTF-8 end to end (GDScript String ->
|
// This project carries text as UTF-8 in std::string. The public helpers below are the one boundary where fixed
|
||||||
// std::string). The public helpers below are the one boundary where fixed
|
|
||||||
// 40250 fields are converted to/from the wire code page. Map names, quest
|
// 40250 fields are converted to/from the wire code page. Map names, quest
|
||||||
// titles, system text and other dynamic text must use their own packet capacity
|
// titles, system text and other dynamic text must use their own packet capacity
|
||||||
// and must not be validated as CHARACTER_NAME_MAX_LEN fields.
|
// and must not be validated as CHARACTER_NAME_MAX_LEN fields.
|
||||||
//
|
//
|
||||||
// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the
|
// ClassicParser and ClassicSession use the std::string_view API below.
|
||||||
// std::string_view core below is the one those call sites use. The
|
|
||||||
// godot::String overloads are compiled only where <godot_cpp/...> is on the
|
|
||||||
// include path (the M2Client / GDScript boundary).
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
#if defined(__has_include)
|
|
||||||
# if __has_include(<godot_cpp/variant/string.hpp>)
|
|
||||||
# include <godot_cpp/variant/char_string.hpp>
|
|
||||||
# include <godot_cpp/variant/string.hpp>
|
|
||||||
# define MT_TEXT_CODEC_HAS_GODOT 1
|
|
||||||
# endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
namespace mtnet {
|
namespace mtnet {
|
||||||
|
|
||||||
@@ -83,19 +72,4 @@ inline bool protocol_text_fits(std::string_view utf8, size_t cap, bool legacy_co
|
|||||||
return legacy_codepage ? wire_text_fits(utf8, cap) : utf8.size() <= cap;
|
return legacy_codepage ? wire_text_fits(utf8, cap) : utf8.size() <= cap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef MT_TEXT_CODEC_HAS_GODOT
|
|
||||||
// §1.10 named API — the GDScript / M2Client boundary.
|
|
||||||
inline std::string to_wire(const godot::String &s, size_t cap) {
|
|
||||||
const godot::CharString u = s.utf8();
|
|
||||||
const char *data = u.get_data();
|
|
||||||
return to_wire(std::string_view(data ? data : "", data ? static_cast<size_t>(u.length()) : 0),
|
|
||||||
cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
inline godot::String from_wire(const char *bytes, size_t cap) {
|
|
||||||
const std::string s = from_wire_str(bytes, cap);
|
|
||||||
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
} // namespace mtnet
|
} // namespace mtnet
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
#include "pack40250_node.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "platform/PackBackend.h"
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
bool Metin2Pack::initialize(const String &client_dir) {
|
|
||||||
return mtpack40250::initialize(client_dir.utf8().get_data());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2Pack::is_ready() {
|
|
||||||
return mtpack40250::ready();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2Pack::exists(const String &name) {
|
|
||||||
return file_exists(String(PACK_SCHEME) + name);
|
|
||||||
}
|
|
||||||
|
|
||||||
PackedByteArray Metin2Pack::get_bytes(const String &name) {
|
|
||||||
return read_file(String(PACK_SCHEME) + name);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2Pack::_bind_methods() {
|
|
||||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("initialize", "client_dir"), &Metin2Pack::initialize);
|
|
||||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("is_ready"), &Metin2Pack::is_ready);
|
|
||||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("exists", "name"), &Metin2Pack::exists);
|
|
||||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("get_bytes", "name"), &Metin2Pack::get_bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Metin2Pack — GDScript access to the 40250 packs (platform/PackBackend.h).
|
|
||||||
//
|
|
||||||
// Metin2Pack.initialize(AssetRoot.client_path()) # registers <Client>/pack/Index once
|
|
||||||
// var b := Metin2Pack.get_bytes("locale/en/item_proto")
|
|
||||||
//
|
|
||||||
// Every native reader that takes a path accepts "pack://<40250 path>" through asset_io.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/object.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
class Metin2Pack : public godot::Object {
|
|
||||||
GDCLASS(Metin2Pack, godot::Object)
|
|
||||||
|
|
||||||
public:
|
|
||||||
static bool initialize(const godot::String &client_dir);
|
|
||||||
static bool is_ready();
|
|
||||||
static bool exists(const godot::String &name);
|
|
||||||
static godot::PackedByteArray get_bytes(const godot::String &name);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "EterBase/Timer.h"
|
#include "EterBase/Timer.h"
|
||||||
#include "EterLib/Util.h"
|
#include "EterLib/Util.h"
|
||||||
#include "EterLib/GrpBase.h"
|
#include "EterLib/GrpBase.h"
|
||||||
|
#include "EterLib/GrpImage.h"
|
||||||
#include "EterLib/Camera.h"
|
#include "EterLib/Camera.h"
|
||||||
#include "../EterLib/HostCursor.h"
|
#include "../EterLib/HostCursor.h"
|
||||||
#include "UserInterface/PythonNetworkStream.h"
|
#include "UserInterface/PythonNetworkStream.h"
|
||||||
@@ -408,6 +409,44 @@ void SetAttackKey(bool pressed)
|
|||||||
CPythonPlayer::Instance().SetAttackKeyState(pressed);
|
CPythonPlayer::Instance().SetAttackKeyState(pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UseLocalQuickSlot(int localSlotIndex)
|
||||||
|
{
|
||||||
|
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return;
|
||||||
|
CPythonPlayer::Instance().RequestUseLocalQuickSlot(static_cast<DWORD>(localSlotIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex)
|
||||||
|
{
|
||||||
|
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false;
|
||||||
|
std::string attached;
|
||||||
|
std::string error;
|
||||||
|
if (!Evaluate("__import__('mouseModule').mouseController.isAttached()", &attached, &error) ||
|
||||||
|
(attached != "True" && attached != "1"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const std::string command =
|
||||||
|
"_stream.curPhaseWindow.interface.wndTaskBar.AddQuickSlot(" + std::to_string(localSlotIndex) + ")";
|
||||||
|
return RunLine(command.c_str(), &error);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LocalQuickSlotSkillIcon(int localSlotIndex)
|
||||||
|
{
|
||||||
|
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return {};
|
||||||
|
DWORD type = SLOT_TYPE_NONE;
|
||||||
|
DWORD position = 0;
|
||||||
|
CPythonPlayer& player = CPythonPlayer::Instance();
|
||||||
|
player.GetLocalQuickSlotData(static_cast<DWORD>(localSlotIndex), &type, &position);
|
||||||
|
if (type != SLOT_TYPE_SKILL) return {};
|
||||||
|
|
||||||
|
CPythonSkill::TSkillData* skillData = nullptr;
|
||||||
|
if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData)
|
||||||
|
return {};
|
||||||
|
const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1);
|
||||||
|
CGraphicImage* image = skillData->GradeData[grade].pImage;
|
||||||
|
if (!image) image = skillData->pImage;
|
||||||
|
return image ? image->GetFileName() : std::string{};
|
||||||
|
}
|
||||||
|
|
||||||
void CameraBeginDrag(int x, int y)
|
void CameraBeginDrag(int x, int y)
|
||||||
{
|
{
|
||||||
CCameraManager& rkCmrMgr = CCameraManager::Instance();
|
CCameraManager& rkCmrMgr = CCameraManager::Instance();
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ void UIKey(int key, bool pressed);
|
|||||||
// Mobile touch & joystick controls
|
// Mobile touch & joystick controls
|
||||||
void SetMoveDirection(float angleDeg, bool moving);
|
void SetMoveDirection(float angleDeg, bool moving);
|
||||||
void SetAttackKey(bool pressed);
|
void SetAttackKey(bool pressed);
|
||||||
|
void UseLocalQuickSlot(int localSlotIndex);
|
||||||
|
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex);
|
||||||
|
std::string LocalQuickSlotSkillIcon(int localSlotIndex);
|
||||||
void CameraBeginDrag(int x, int y);
|
void CameraBeginDrag(int x, int y);
|
||||||
void CameraDrag(int x, int y);
|
void CameraDrag(int x, int y);
|
||||||
void CameraEndDrag();
|
void CameraEndDrag();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}).
|
# port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}).
|
||||||
# No godot-cpp dependency: libmtgodot links it, and it also builds on its own (top-level
|
# This library and port_platform are built directly for the SDL/Vulkan client and port tests.
|
||||||
# -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON) for targets without an extension build.
|
|
||||||
# See docs/PORT-PLAN.md batch 2A.
|
# See docs/PORT-PLAN.md batch 2A.
|
||||||
|
|
||||||
file(GLOB_RECURSE MT_PORT_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
|
file(GLOB_RECURSE MT_PORT_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
|
||||||
@@ -31,8 +30,7 @@ target_include_directories(port_logic PUBLIC ${MT_PORT_SHIMS})
|
|||||||
# Libraries 40250 links that are vendored as-is: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*>
|
# Libraries 40250 links that are vendored as-is: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*>
|
||||||
# (EterBase/cipher.h), <Python-2.7/*> (ScriptLib).
|
# (EterBase/cipher.h), <Python-2.7/*> (ScriptLib).
|
||||||
target_link_libraries(port_logic PUBLIC mt3p::minilzo mt3p::cryptopp)
|
target_link_libraries(port_logic PUBLIC mt3p::minilzo mt3p::cryptopp)
|
||||||
# common/Win32Crt.cpp converts ANSI code pages with mt_codepage. extension/CMakeLists.txt adds it for
|
# common/Win32Crt.cpp converts ANSI code pages with mt_codepage.
|
||||||
# mtnet; the port-only build (script/port_gate.sh, native_render) never reads that file.
|
|
||||||
if(NOT TARGET mt_codepage)
|
if(NOT TARGET mt_codepage)
|
||||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage)
|
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage)
|
||||||
endif()
|
endif()
|
||||||
@@ -241,8 +239,7 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
|||||||
# Locale packs in the original format (script/build_zh_locale.py packs locale/zh with it).
|
# Locale packs in the original format (script/build_zh_locale.py packs locale/zh with it).
|
||||||
add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp)
|
add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp)
|
||||||
target_link_libraries(eterpack_pack PRIVATE port_platform)
|
target_link_libraries(eterpack_pack PRIVATE port_platform)
|
||||||
# 2V2-e: the same fake server as a process, for the Godot-driven render run
|
# The same fake server as a process, for native rendering tests.
|
||||||
# (script/python_game_render_test.sh).
|
|
||||||
add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp
|
add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp)
|
||||||
|
|||||||
@@ -33,6 +33,32 @@
|
|||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <dirent.h> // PORT: resolve separately cased .eix/.epk names on case-sensitive mobile filesystems
|
||||||
|
#include <strings.h> // PORT: strcasecmp for the on-disk pack name lookup
|
||||||
|
|
||||||
|
// PORT: 40250 ships pairs such as ETC.eix and Etc.epk. Windows opens both through the
|
||||||
|
// same stem; Android must locate each file using its actual on-disk spelling.
|
||||||
|
static std::string ResolvePackFileName(const std::string& requested)
|
||||||
|
{
|
||||||
|
const std::string::size_type slash = requested.find_last_of('/');
|
||||||
|
const std::string folder = slash == std::string::npos ? "." : requested.substr(0, slash);
|
||||||
|
const std::string name = requested.substr(slash == std::string::npos ? 0 : slash + 1);
|
||||||
|
DIR* dir = opendir(folder.c_str());
|
||||||
|
if (!dir) return requested;
|
||||||
|
std::string result = requested;
|
||||||
|
while (dirent* entry = readdir(dir))
|
||||||
|
{
|
||||||
|
if (strcasecmp(entry->d_name, name.c_str()) == 0)
|
||||||
|
{
|
||||||
|
result = folder + "/" + entry->d_name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closedir(dir);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
void CMakePackLog::SetFileName(const char* c_szFileName)
|
void CMakePackLog::SetFileName(const char* c_szFileName)
|
||||||
{
|
{
|
||||||
@@ -257,6 +283,18 @@ bool CEterPack::Create(CEterFileDict& rkFileDict, const char * dbname, const cha
|
|||||||
|
|
||||||
m_stDataFileName = dbname;
|
m_stDataFileName = dbname;
|
||||||
m_stDataFileName += ".epk";
|
m_stDataFileName += ".epk";
|
||||||
|
// PORT: the two extensions may have different capitalization in the original Client/pack.
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
if (bReadOnly)
|
||||||
|
{
|
||||||
|
const std::string indexName = ResolvePackFileName(m_indexFileName);
|
||||||
|
if (indexName.size() < sizeof(m_indexFileName))
|
||||||
|
{
|
||||||
|
strcpy(m_indexFileName, indexName.c_str());
|
||||||
|
}
|
||||||
|
m_stDataFileName = ResolvePackFileName(m_stDataFileName);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
m_bReadOnly = bReadOnly;
|
m_bReadOnly = bReadOnly;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include "../EterBase/CRC32.h"
|
#include "../EterBase/CRC32.h"
|
||||||
#include "PythonWindow.h"
|
#include "PythonWindow.h"
|
||||||
#include "PythonSlotWindow.h"
|
#include "PythonSlotWindow.h"
|
||||||
@@ -1392,7 +1394,7 @@ namespace UI
|
|||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject)
|
CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject), m_bAspectFill(false)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
CExpandedImageBox::~CExpandedImageBox()
|
CExpandedImageBox::~CExpandedImageBox()
|
||||||
@@ -1400,6 +1402,73 @@ namespace UI
|
|||||||
OnDestroyInstance();
|
OnDestroyInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CExpandedImageBox::SetAspectFill(bool bEnable)
|
||||||
|
{
|
||||||
|
m_bAspectFill = bEnable;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CExpandedImageBox::IsAutoAspectFillBackground() const
|
||||||
|
{
|
||||||
|
const char* c_szFileName = "";
|
||||||
|
if (m_pImageInstance && m_pImageInstance->GetGraphicImagePointer())
|
||||||
|
c_szFileName = m_pImageInstance->GetGraphicImagePointer()->GetFileName();
|
||||||
|
if (!c_szFileName)
|
||||||
|
c_szFileName = "";
|
||||||
|
|
||||||
|
std::string lowerFile = c_szFileName;
|
||||||
|
std::transform(lowerFile.begin(), lowerFile.end(), lowerFile.begin(), ::tolower);
|
||||||
|
|
||||||
|
const std::string& name = m_strName;
|
||||||
|
std::string parentName = m_pParent ? m_pParent->GetName() : "";
|
||||||
|
|
||||||
|
// 1. Server list and login backgrounds (LoginWindow bg1 and bg2)
|
||||||
|
if (name == "bg1" || name == "bg2")
|
||||||
|
{
|
||||||
|
if (parentName == "LoginWindow")
|
||||||
|
return true;
|
||||||
|
if (lowerFile.find("serverlist") != std::string::npos || lowerFile.find("login") != std::string::npos)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Character selection & creation backgrounds (BackGround with select.sub / select.jpg)
|
||||||
|
if (name == "BackGround")
|
||||||
|
{
|
||||||
|
if (parentName == "SelectCharacterWindow" || parentName == "CreateCharacterWindow")
|
||||||
|
return true;
|
||||||
|
if (lowerFile.find("select.sub") != std::string::npos ||
|
||||||
|
lowerFile.find("select.jpg") != std::string::npos ||
|
||||||
|
lowerFile.find("select.png") != std::string::npos ||
|
||||||
|
lowerFile.find("ui/select") != std::string::npos)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Loading screen backgrounds (BackGround with Line_Pattern.tga or uiloading/*.sub)
|
||||||
|
if (name == "BackGround")
|
||||||
|
{
|
||||||
|
if (parentName == "LoadingWindow")
|
||||||
|
return true;
|
||||||
|
if (lowerFile.find("uiloading") != std::string::npos ||
|
||||||
|
lowerFile.find("line_pattern") != std::string::npos)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback check by file name directly if element name wasn't set or is generic
|
||||||
|
if (lowerFile.find("serverlist.sub") != std::string::npos ||
|
||||||
|
lowerFile.find("serverlist.jpg") != std::string::npos ||
|
||||||
|
lowerFile.find("uiloading/") != std::string::npos ||
|
||||||
|
lowerFile.find("uiloading\\") != std::string::npos)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CExpandedImageBox::IsAspectFill() const
|
||||||
|
{
|
||||||
|
return m_bAspectFill || IsAutoAspectFillBackground();
|
||||||
|
}
|
||||||
|
|
||||||
void CExpandedImageBox::OnCreateInstance()
|
void CExpandedImageBox::OnCreateInstance()
|
||||||
{
|
{
|
||||||
OnDestroyInstance();
|
OnDestroyInstance();
|
||||||
@@ -1420,9 +1489,56 @@ namespace UI
|
|||||||
if (!m_pImageInstance)
|
if (!m_pImageInstance)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (IsAspectFill())
|
||||||
|
{
|
||||||
|
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||||
|
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||||
|
int imgWidth = GetWidth();
|
||||||
|
int imgHeight = GetHeight();
|
||||||
|
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||||
|
{
|
||||||
|
float sx = float(screenWidth) / float(imgWidth);
|
||||||
|
float sy = float(screenHeight) / float(imgHeight);
|
||||||
|
float s = std::max(sx, sy);
|
||||||
|
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
|
||||||
|
CWindow::SetSize(screenWidth, screenHeight);
|
||||||
|
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||||
|
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||||
|
m_pImageInstance->SetPosition(posX, posY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(fx, fy);
|
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(fx, fy);
|
||||||
CWindow::SetSize(long(float(GetWidth())*fx), long(float(GetHeight())*fy));
|
CWindow::SetSize(long(float(GetWidth())*fx), long(float(GetHeight())*fy));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CExpandedImageBox::OnChangePosition()
|
||||||
|
{
|
||||||
|
if (!m_pImageInstance)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (IsAspectFill())
|
||||||
|
{
|
||||||
|
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||||
|
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||||
|
int imgWidth = GetWidth();
|
||||||
|
int imgHeight = GetHeight();
|
||||||
|
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||||
|
{
|
||||||
|
float sx = float(screenWidth) / float(imgWidth);
|
||||||
|
float sy = float(screenHeight) / float(imgHeight);
|
||||||
|
float s = std::max(sx, sy);
|
||||||
|
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||||
|
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||||
|
m_pImageInstance->SetPosition(posX, posY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CImageBox::OnChangePosition();
|
||||||
|
}
|
||||||
|
|
||||||
void CExpandedImageBox::SetOrigin(float fx, float fy)
|
void CExpandedImageBox::SetOrigin(float fx, float fy)
|
||||||
{
|
{
|
||||||
if (!m_pImageInstance)
|
if (!m_pImageInstance)
|
||||||
@@ -1458,6 +1574,24 @@ namespace UI
|
|||||||
if (!m_pImageInstance)
|
if (!m_pImageInstance)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (IsAspectFill())
|
||||||
|
{
|
||||||
|
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||||
|
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||||
|
int imgWidth = GetWidth();
|
||||||
|
int imgHeight = GetHeight();
|
||||||
|
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||||
|
{
|
||||||
|
float sx = float(screenWidth) / float(imgWidth);
|
||||||
|
float sy = float(screenHeight) / float(imgHeight);
|
||||||
|
float s = std::max(sx, sy);
|
||||||
|
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
|
||||||
|
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||||
|
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||||
|
m_pImageInstance->SetPosition(posX, posY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (IsShow())
|
if (IsShow())
|
||||||
m_pImageInstance->Render();
|
m_pImageInstance->Render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,6 +386,8 @@ namespace UI
|
|||||||
void SetRotation(float fRotation);
|
void SetRotation(float fRotation);
|
||||||
void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom);
|
void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom);
|
||||||
void SetRenderingMode(int iMode);
|
void SetRenderingMode(int iMode);
|
||||||
|
void SetAspectFill(bool bEnable);
|
||||||
|
bool IsAspectFill() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void OnCreateInstance();
|
void OnCreateInstance();
|
||||||
@@ -393,8 +395,14 @@ namespace UI
|
|||||||
|
|
||||||
virtual void OnUpdate();
|
virtual void OnUpdate();
|
||||||
virtual void OnRender();
|
virtual void OnRender();
|
||||||
|
virtual void OnChangePosition();
|
||||||
|
|
||||||
BOOL OnIsType(DWORD dwType);
|
BOOL OnIsType(DWORD dwType);
|
||||||
|
|
||||||
|
bool IsAutoAspectFillBackground() const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool m_bAspectFill;
|
||||||
};
|
};
|
||||||
class CAniImageBox : public CWindow
|
class CAniImageBox : public CWindow
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2012,6 +2012,27 @@ PyObject * wndImageSetScale(PyObject * poSelf, PyObject * poArgs)
|
|||||||
|
|
||||||
return Py_BuildNone();
|
return Py_BuildNone();
|
||||||
}
|
}
|
||||||
|
PyObject * wndImageSetAspectFill(PyObject * poSelf, PyObject * poArgs)
|
||||||
|
{
|
||||||
|
UI::CWindow * pWindow;
|
||||||
|
if (!PyTuple_GetWindow(poArgs, 0, &pWindow))
|
||||||
|
return Py_BuildException();
|
||||||
|
|
||||||
|
bool bEnable = true;
|
||||||
|
if (PyTuple_Size(poArgs) > 1)
|
||||||
|
{
|
||||||
|
int iEnable;
|
||||||
|
if (PyTuple_GetInteger(poArgs, 1, &iEnable))
|
||||||
|
bEnable = (iEnable != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pWindow->IsType(UI::CExpandedImageBox::Type()))
|
||||||
|
{
|
||||||
|
((UI::CExpandedImageBox*)pWindow)->SetAspectFill(bEnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
PyObject * wndImageSetOrigin(PyObject * poSelf, PyObject * poArgs)
|
PyObject * wndImageSetOrigin(PyObject * poSelf, PyObject * poArgs)
|
||||||
{
|
{
|
||||||
UI::CWindow * pWindow;
|
UI::CWindow * pWindow;
|
||||||
@@ -2454,6 +2475,7 @@ void initwndMgr()
|
|||||||
{ "GetHeight", wndImageGetHeight, METH_VARARGS },
|
{ "GetHeight", wndImageGetHeight, METH_VARARGS },
|
||||||
// ExpandedImageBox
|
// ExpandedImageBox
|
||||||
{ "SetScale", wndImageSetScale, METH_VARARGS },
|
{ "SetScale", wndImageSetScale, METH_VARARGS },
|
||||||
|
{ "SetAspectFill", wndImageSetAspectFill, METH_VARARGS },
|
||||||
{ "SetOrigin", wndImageSetOrigin, METH_VARARGS },
|
{ "SetOrigin", wndImageSetOrigin, METH_VARARGS },
|
||||||
{ "SetRotation", wndImageSetRotation, METH_VARARGS },
|
{ "SetRotation", wndImageSetRotation, METH_VARARGS },
|
||||||
{ "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS },
|
{ "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS },
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
#include "proto_node.h"
|
|
||||||
|
|
||||||
#include "../asset_io.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// szName is the Korean (CP949) development name; what 40250 shows is szLocaleName (CItemData::GetName,
|
|
||||||
// CPythonNonPlayer::GetMonsterName) in the locale code page, 1252 for locale.cfg "10002 1252 en".
|
|
||||||
String from_cp1252(const std::string &s) {
|
|
||||||
static const char32_t k80[32] = {
|
|
||||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0x017D, 0xFFFD,
|
|
||||||
0xFFFD, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0x017E, 0x0178,
|
|
||||||
};
|
|
||||||
String out;
|
|
||||||
for (unsigned char c : s) {
|
|
||||||
out += String::chr(c >= 0x80 && c < 0xA0 ? k80[c - 0x80] : static_cast<char32_t>(c));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void Metin2Proto::_bind_methods() {
|
|
||||||
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
|
|
||||||
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
|
|
||||||
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
|
|
||||||
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
|
|
||||||
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
|
|
||||||
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
|
|
||||||
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2Proto::load_item_proto(const String &path) {
|
|
||||||
std::string err;
|
|
||||||
PackedByteArray bytes = mtgodot::read_file(path);
|
|
||||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
|
||||||
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
|
|
||||||
last_error = String(err.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
m_item_ix.clear();
|
|
||||||
m_item_ix.reserve(m_item.elements);
|
|
||||||
for (uint32_t i = 0; i < m_item.elements; ++i) {
|
|
||||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
|
|
||||||
m_item_ix[r.vnum] = i;
|
|
||||||
}
|
|
||||||
last_error = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2Proto::load_mob_proto(const String &path) {
|
|
||||||
std::string err;
|
|
||||||
PackedByteArray bytes = mtgodot::read_file(path);
|
|
||||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
|
||||||
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
|
|
||||||
last_error = String(err.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
m_mob_ix.clear();
|
|
||||||
m_mob_ix.reserve(m_mob.elements);
|
|
||||||
for (uint32_t i = 0; i < m_mob.elements; ++i) {
|
|
||||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
|
|
||||||
m_mob_ix[r.vnum] = i;
|
|
||||||
}
|
|
||||||
last_error = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2Proto::item(int vnum) const {
|
|
||||||
Dictionary d;
|
|
||||||
auto it = m_item_ix.find((uint32_t)vnum);
|
|
||||||
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
|
|
||||||
d["vnum"] = (int)r.vnum;
|
|
||||||
d["vnum_range"] = (int)r.vnum_range;
|
|
||||||
d["name"] = from_cp1252(r.locale_name); // CItemData::GetName()
|
|
||||||
d["locale_name"] = from_cp1252(r.locale_name);
|
|
||||||
d["type"] = (int)r.type;
|
|
||||||
d["sub_type"] = (int)r.sub_type;
|
|
||||||
d["weight"] = (int)r.weight;
|
|
||||||
d["size"] = (int)r.size;
|
|
||||||
d["anti_flags"] = (int)r.anti_flags;
|
|
||||||
d["flags"] = (int)r.flags;
|
|
||||||
d["wear_flags"] = (int)r.wear_flags;
|
|
||||||
d["immune_flag"] = (int)r.immune_flag;
|
|
||||||
d["buy_price"] = (int)r.buy_price;
|
|
||||||
d["sell_price"] = (int)r.sell_price;
|
|
||||||
{
|
|
||||||
Array limits;
|
|
||||||
for (const mtproto::ItemLimit &limit : r.limits) {
|
|
||||||
Dictionary entry;
|
|
||||||
entry["type"] = (int)limit.type;
|
|
||||||
entry["value"] = (int)limit.value;
|
|
||||||
limits.push_back(entry);
|
|
||||||
}
|
|
||||||
d["limits"] = limits;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Array applies;
|
|
||||||
for (const mtproto::ItemApply &apply : r.applies) {
|
|
||||||
Dictionary entry;
|
|
||||||
entry["type"] = (int)apply.type;
|
|
||||||
entry["value"] = (int)apply.value;
|
|
||||||
applies.push_back(entry);
|
|
||||||
}
|
|
||||||
d["applies"] = applies;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Array vals;
|
|
||||||
for (int i = 0; i < 6; ++i) {
|
|
||||||
vals.push_back((int)r.values[i]);
|
|
||||||
}
|
|
||||||
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Array sockets;
|
|
||||||
for (int socket : r.sockets) {
|
|
||||||
sockets.push_back(socket);
|
|
||||||
}
|
|
||||||
d["sockets"] = sockets;
|
|
||||||
}
|
|
||||||
d["refined_vnum"] = (int)r.refined_vnum;
|
|
||||||
d["refine_set"] = (int)r.refine_set;
|
|
||||||
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
|
|
||||||
d["specular"] = (int)r.specular;
|
|
||||||
d["gain_socket_pct"] = (int)r.gain_socket_pct;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary Metin2Proto::mob(int vnum) const {
|
|
||||||
Dictionary d;
|
|
||||||
auto it = m_mob_ix.find((uint32_t)vnum);
|
|
||||||
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
|
|
||||||
d["vnum"] = (int)r.vnum;
|
|
||||||
d["name"] = from_cp1252(r.locale_name); // CPythonNonPlayer::GetMonsterName()
|
|
||||||
d["locale_name"] = from_cp1252(r.locale_name);
|
|
||||||
d["type"] = (int)r.type;
|
|
||||||
d["rank"] = (int)r.rank;
|
|
||||||
d["battle_type"] = (int)r.battle_type;
|
|
||||||
d["level"] = (int)r.level;
|
|
||||||
d["size"] = (int)r.size;
|
|
||||||
d["attack_speed"] = (int)r.attack_speed;
|
|
||||||
d["moving_speed"] = (int)r.moving_speed;
|
|
||||||
d["on_click_type"] = (int)r.on_click_type;
|
|
||||||
d["empire"] = (int)r.empire;
|
|
||||||
d["folder"] = String(r.folder.c_str());
|
|
||||||
d["monster_color"] = (int64_t)r.monster_color;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
|
|
||||||
//
|
|
||||||
// var proto = Metin2Proto.new()
|
|
||||||
// proto.load_item_proto("pack://locale/en/item_proto") # 40250 format, via asset_io
|
|
||||||
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
|
|
||||||
//
|
|
||||||
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
|
|
||||||
// the equip->model path.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/node.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <unordered_map>
|
|
||||||
|
|
||||||
#include "proto.h"
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
class Metin2Proto : public godot::Node {
|
|
||||||
GDCLASS(Metin2Proto, godot::Node)
|
|
||||||
|
|
||||||
public:
|
|
||||||
bool load_item_proto(const godot::String &path);
|
|
||||||
bool load_mob_proto(const godot::String &path);
|
|
||||||
|
|
||||||
godot::Dictionary item(int vnum) const;
|
|
||||||
godot::Dictionary mob(int vnum) const;
|
|
||||||
|
|
||||||
int item_count() const { return (int)m_item.elements; }
|
|
||||||
int mob_count() const { return (int)m_mob.elements; }
|
|
||||||
godot::String get_last_error() const { return last_error; }
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
|
|
||||||
private:
|
|
||||||
mtproto::Proto m_item;
|
|
||||||
mtproto::Proto m_mob;
|
|
||||||
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
|
|
||||||
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
|
|
||||||
godot::String last_error;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
#include "python_host_node.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
#include "python_stdlib.h"
|
|
||||||
|
|
||||||
#ifdef MTGODOT_HAVE_PYTHON
|
|
||||||
// Standard types only — PythonBoot.h keeps <Python-2.7/*> and the Win32 shims out of this translation
|
|
||||||
// unit, which has to stay compilable next to godot-cpp.
|
|
||||||
#include "platform/ScriptLib/PythonBoot.h"
|
|
||||||
#include "platform/EterLib/UIRenderCommands.h"
|
|
||||||
#include "platform/EterLib/RenderCommands3D.h"
|
|
||||||
#include "platform/MilesLib/AudioCommands.h"
|
|
||||||
#include "platform/PackBackend.h"
|
|
||||||
#include "../../native_render/draw_capture.h"
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
|
||||||
#include <type_traits>
|
|
||||||
#include <unordered_map>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/variant/dictionary.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
|
||||||
#include <godot_cpp/variant/string_name.hpp>
|
|
||||||
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
#ifdef MTGODOT_HAVE_PYTHON
|
|
||||||
|
|
||||||
String Metin2PythonHost::start(const String &stdlib_path) {
|
|
||||||
String path = stdlib_path;
|
|
||||||
if (path.is_empty()) {
|
|
||||||
path = Metin2Python::stdlib_path(false);
|
|
||||||
if (path.is_empty()) {
|
|
||||||
return String("python27.zip unavailable: ") + Metin2Python::last_error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::string error;
|
|
||||||
if (!PythonBoot::Start(path.utf8().get_data(), &error)) {
|
|
||||||
return String::utf8(error.c_str());
|
|
||||||
}
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2PythonHost::is_running() {
|
|
||||||
return PythonBoot::IsRunning();
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::run_main_script(const String &command_line) {
|
|
||||||
std::string error;
|
|
||||||
if (!PythonBoot::RunMainScript(command_line.utf8().get_data(), &error)) {
|
|
||||||
return String::utf8(error.c_str());
|
|
||||||
}
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::run_line(const String &source) {
|
|
||||||
std::string error;
|
|
||||||
if (!PythonBoot::RunLine(source.utf8().get_data(), &error)) {
|
|
||||||
return String::utf8(error.c_str());
|
|
||||||
}
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
|
|
||||||
// str(expression) from __main__, or "" when it raised (the error goes to the log like run_line's).
|
|
||||||
String Metin2PythonHost::evaluate(const String &expression) {
|
|
||||||
std::string result, error;
|
|
||||||
if (!PythonBoot::Evaluate(expression.utf8().get_data(), &result, &error)) {
|
|
||||||
UtilityFunctions::push_error(String::utf8(error.c_str()));
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
return String::utf8(result.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); }
|
|
||||||
|
|
||||||
void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); }
|
|
||||||
void Metin2PythonHost::ui_mouse_move(int x, int y) { PythonBoot::UIMouseMove(x, y); }
|
|
||||||
void Metin2PythonHost::ui_mouse_button(int button, bool pressed, int x, int y) {
|
|
||||||
PythonBoot::UIMouseButton(button, pressed, x, y);
|
|
||||||
}
|
|
||||||
void Metin2PythonHost::ui_mouse_wheel(int delta) { PythonBoot::UIMouseWheel(delta); }
|
|
||||||
void Metin2PythonHost::ui_key(int key, bool pressed) { PythonBoot::UIKey(key, pressed); }
|
|
||||||
void Metin2PythonHost::ui_char(int codepoint) { PythonBoot::UIChar(unsigned(codepoint)); }
|
|
||||||
void Metin2PythonHost::ui_ime_key(int vkey) { PythonBoot::UIIMEKeyDown(vkey); }
|
|
||||||
void Metin2PythonHost::ui_update() { PythonBoot::UIUpdate(); }
|
|
||||||
namespace {
|
|
||||||
Dictionary ui_command_item(const UIRenderCommand &command) {
|
|
||||||
static const StringName s_kind("kind");
|
|
||||||
static const StringName s_x1("x1");
|
|
||||||
static const StringName s_y1("y1");
|
|
||||||
static const StringName s_x2("x2");
|
|
||||||
static const StringName s_y2("y2");
|
|
||||||
static const StringName s_argb("argb");
|
|
||||||
static const StringName s_end_argb("end_argb");
|
|
||||||
static const StringName s_clip_x1("clip_x1");
|
|
||||||
static const StringName s_clip_y1("clip_y1");
|
|
||||||
static const StringName s_clip_x2("clip_x2");
|
|
||||||
static const StringName s_clip_y2("clip_y2");
|
|
||||||
static const StringName s_text("text");
|
|
||||||
static const StringName s_quad("quad");
|
|
||||||
static const StringName s_uv("uv");
|
|
||||||
static const StringName s_blend("blend");
|
|
||||||
static const StringName s_mask("mask");
|
|
||||||
static const StringName s_mask_uv("mask_uv");
|
|
||||||
static const StringName s_behind_3d("behind_3d");
|
|
||||||
|
|
||||||
static const StringName s_val_bar("bar");
|
|
||||||
static const StringName s_val_gradient_bar("gradient_bar");
|
|
||||||
static const StringName s_val_line("line");
|
|
||||||
static const StringName s_val_image("image");
|
|
||||||
static const StringName s_val_text("text");
|
|
||||||
Dictionary item;
|
|
||||||
item[s_kind] = command.kind == UIRenderCommand::GradientBar ? s_val_gradient_bar :
|
|
||||||
(command.kind == UIRenderCommand::Bar ? s_val_bar :
|
|
||||||
(command.kind == UIRenderCommand::Line ? s_val_line :
|
|
||||||
(command.kind == UIRenderCommand::Image ? s_val_image : s_val_text)));
|
|
||||||
item[s_x1] = command.x1;
|
|
||||||
item[s_y1] = command.y1;
|
|
||||||
item[s_x2] = command.x2;
|
|
||||||
item[s_y2] = command.y2;
|
|
||||||
item[s_argb] = static_cast<int64_t>(command.argb);
|
|
||||||
if (command.kind == UIRenderCommand::GradientBar)
|
|
||||||
item[s_end_argb] = static_cast<int64_t>(command.end_argb);
|
|
||||||
item[s_clip_x1] = command.clip_x1;
|
|
||||||
item[s_clip_y1] = command.clip_y1;
|
|
||||||
item[s_clip_x2] = command.clip_x2;
|
|
||||||
item[s_clip_y2] = command.clip_y2;
|
|
||||||
if (command.kind == UIRenderCommand::Text || command.kind == UIRenderCommand::Image)
|
|
||||||
item[s_text] = String::utf8(command.text.c_str());
|
|
||||||
if (command.quad) {
|
|
||||||
PackedVector2Array quad;
|
|
||||||
quad.resize(4);
|
|
||||||
Vector2 *qptr = quad.ptrw();
|
|
||||||
for (int i = 0; i < 4; ++i)
|
|
||||||
qptr[i] = Vector2(command.qx[i], command.qy[i]);
|
|
||||||
item[s_quad] = quad;
|
|
||||||
|
|
||||||
PackedVector2Array uv;
|
|
||||||
uv.resize(4);
|
|
||||||
Vector2 *uvptr = uv.ptrw();
|
|
||||||
uvptr[0] = Vector2(command.su, command.sv);
|
|
||||||
uvptr[1] = Vector2(command.eu, command.sv);
|
|
||||||
uvptr[2] = Vector2(command.su, command.ev);
|
|
||||||
uvptr[3] = Vector2(command.eu, command.ev);
|
|
||||||
item[s_uv] = uv;
|
|
||||||
item[s_blend] = command.blend;
|
|
||||||
if (!command.mask.empty()) {
|
|
||||||
item[s_mask] = String::utf8(command.mask.c_str());
|
|
||||||
PackedVector2Array mask_uv;
|
|
||||||
mask_uv.resize(4);
|
|
||||||
Vector2 *mptr = mask_uv.ptrw();
|
|
||||||
for (int i = 0; i < 4; ++i)
|
|
||||||
mptr[i] = Vector2(command.mu[i], command.mv[i]);
|
|
||||||
item[s_mask_uv] = mask_uv;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item[s_behind_3d] = command.behind_3d;
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Array Metin2PythonHost::ui_render_commands() {
|
|
||||||
PythonBoot::UIRender();
|
|
||||||
static std::uint64_t cached_frame_id = UINT64_MAX;
|
|
||||||
static Array cached_commands;
|
|
||||||
const std::uint64_t frame_id = UIRenderFrameId();
|
|
||||||
if (cached_frame_id == frame_id)
|
|
||||||
return cached_commands;
|
|
||||||
const auto &commands = UIRenderCommands();
|
|
||||||
Array out;
|
|
||||||
out.resize(static_cast<int64_t>(commands.size()));
|
|
||||||
for (size_t idx = 0; idx < commands.size(); ++idx)
|
|
||||||
out[static_cast<int64_t>(idx)] = ui_command_item(commands[idx]);
|
|
||||||
cached_frame_id = frame_id;
|
|
||||||
cached_commands = out;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
Array Metin2PythonHost::ui_render_commands_batched() {
|
|
||||||
PythonBoot::UIRender();
|
|
||||||
static std::uint64_t cached_frame_id = UINT64_MAX;
|
|
||||||
static Array cached_commands;
|
|
||||||
const std::uint64_t frame_id = UIRenderFrameId();
|
|
||||||
if (cached_frame_id == frame_id)
|
|
||||||
return cached_commands;
|
|
||||||
|
|
||||||
const auto &commands = UIRenderCommands();
|
|
||||||
const auto batchable = [](const UIRenderCommand &c) {
|
|
||||||
return c.kind == UIRenderCommand::Image && c.quad && c.text.rfind("mem:", 0) == 0 &&
|
|
||||||
c.mask.empty() && c.blend == 0 && c.x1 >= c.clip_x1 && c.y1 >= c.clip_y1 &&
|
|
||||||
c.x2 <= c.clip_x2 && c.y2 <= c.clip_y2;
|
|
||||||
};
|
|
||||||
Array out;
|
|
||||||
for (size_t i = 0; i < commands.size();) {
|
|
||||||
const UIRenderCommand &first = commands[i];
|
|
||||||
if (!batchable(first)) {
|
|
||||||
out.push_back(ui_command_item(first));
|
|
||||||
++i;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
size_t end = i + 1;
|
|
||||||
while (end < commands.size() && batchable(commands[end]) &&
|
|
||||||
commands[end].text == first.text && commands[end].behind_3d == first.behind_3d)
|
|
||||||
++end;
|
|
||||||
if (end == i + 1) {
|
|
||||||
out.push_back(ui_command_item(first));
|
|
||||||
i = end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const int64_t count = static_cast<int64_t>(end - i);
|
|
||||||
PackedVector2Array points, uvs;
|
|
||||||
PackedColorArray colors;
|
|
||||||
PackedInt32Array indices;
|
|
||||||
points.resize(count * 4);
|
|
||||||
uvs.resize(count * 4);
|
|
||||||
colors.resize(count * 4);
|
|
||||||
indices.resize(count * 6);
|
|
||||||
Vector2 *point_data = points.ptrw(), *uv_data = uvs.ptrw();
|
|
||||||
Color *color_data = colors.ptrw();
|
|
||||||
int32_t *index_data = indices.ptrw();
|
|
||||||
for (int64_t j = 0; j < count; ++j) {
|
|
||||||
const UIRenderCommand &c = commands[i + static_cast<size_t>(j)];
|
|
||||||
const int64_t v = j * 4, t = j * 6;
|
|
||||||
point_data[v] = Vector2(c.qx[0], c.qy[0]);
|
|
||||||
point_data[v + 1] = Vector2(c.qx[1], c.qy[1]);
|
|
||||||
point_data[v + 2] = Vector2(c.qx[3], c.qy[3]);
|
|
||||||
point_data[v + 3] = Vector2(c.qx[2], c.qy[2]);
|
|
||||||
uv_data[v] = Vector2(c.su, c.sv);
|
|
||||||
uv_data[v + 1] = Vector2(c.eu, c.sv);
|
|
||||||
uv_data[v + 2] = Vector2(c.eu, c.ev);
|
|
||||||
uv_data[v + 3] = Vector2(c.su, c.ev);
|
|
||||||
const Color color(((c.argb >> 16) & 255) / 255.0f, ((c.argb >> 8) & 255) / 255.0f,
|
|
||||||
(c.argb & 255) / 255.0f, ((c.argb >> 24) & 255) / 255.0f);
|
|
||||||
for (int k = 0; k < 4; ++k)
|
|
||||||
color_data[v + k] = color;
|
|
||||||
const int32_t base = static_cast<int32_t>(v);
|
|
||||||
index_data[t] = base;
|
|
||||||
index_data[t + 1] = base + 1;
|
|
||||||
index_data[t + 2] = base + 2;
|
|
||||||
index_data[t + 3] = base;
|
|
||||||
index_data[t + 4] = base + 2;
|
|
||||||
index_data[t + 5] = base + 3;
|
|
||||||
}
|
|
||||||
Dictionary item;
|
|
||||||
item["kind"] = StringName("glyph_batch");
|
|
||||||
item["text"] = String::utf8(first.text.c_str());
|
|
||||||
item["behind_3d"] = first.behind_3d;
|
|
||||||
item["points"] = points;
|
|
||||||
item["uvs"] = uvs;
|
|
||||||
item["colors"] = colors;
|
|
||||||
item["indices"] = indices;
|
|
||||||
item["glyph_count"] = count;
|
|
||||||
out.push_back(item);
|
|
||||||
i = end;
|
|
||||||
}
|
|
||||||
cached_frame_id = frame_id;
|
|
||||||
cached_commands = out;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2PythonHost::has_3d_draws() { return !Render3DDraws().empty(); }
|
|
||||||
|
|
||||||
Ref<Image> Metin2PythonHost::memory_texture(const String &name, int64_t known_revision) {
|
|
||||||
UIMemoryTexture texture;
|
|
||||||
if (!UIRenderMemoryTexture(name.utf8().get_data(), &texture) || texture.width <= 0 || texture.height <= 0)
|
|
||||||
return Ref<Image>();
|
|
||||||
if (known_revision >= 0 && texture.revision == static_cast<std::uint32_t>(known_revision))
|
|
||||||
return Ref<Image>();
|
|
||||||
PackedByteArray rgba;
|
|
||||||
rgba.resize(static_cast<int64_t>(texture.argb.size()) * 4);
|
|
||||||
uint8_t *out = rgba.ptrw();
|
|
||||||
for (std::uint32_t argb : texture.argb) {
|
|
||||||
*out++ = static_cast<uint8_t>(argb >> 16);
|
|
||||||
*out++ = static_cast<uint8_t>(argb >> 8);
|
|
||||||
*out++ = static_cast<uint8_t>(argb);
|
|
||||||
*out++ = static_cast<uint8_t>(argb >> 24);
|
|
||||||
}
|
|
||||||
return Image::create_from_data(texture.width, texture.height, false, Image::FORMAT_RGBA8, rgba);
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
PackedFloat32Array floats(const float *values, int count) {
|
|
||||||
PackedFloat32Array out;
|
|
||||||
out.resize(count);
|
|
||||||
for (int i = 0; i < count; ++i)
|
|
||||||
out[i] = values[i];
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
Color argb_color(std::uint32_t argb) {
|
|
||||||
return Color(((argb >> 16) & 255) / 255.0f, ((argb >> 8) & 255) / 255.0f, (argb & 255) / 255.0f,
|
|
||||||
((argb >> 24) & 255) / 255.0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool read_pack_texture(const std::string &vpath, std::vector<std::uint8_t> &bytes) {
|
|
||||||
if (vpath.empty() || !mtpack40250::ready())
|
|
||||||
return false;
|
|
||||||
std::string norm = vpath;
|
|
||||||
for (char &ch : norm)
|
|
||||||
if (ch == '\\')
|
|
||||||
ch = '/';
|
|
||||||
std::string stripped = norm;
|
|
||||||
if (stripped.size() >= 2 && stripped[1] == ':')
|
|
||||||
stripped = stripped.substr(2);
|
|
||||||
while (!stripped.empty() && stripped.front() == '/')
|
|
||||||
stripped.erase(stripped.begin());
|
|
||||||
auto lower = [](std::string s) {
|
|
||||||
for (char &ch : s)
|
|
||||||
if (ch >= 'A' && ch <= 'Z')
|
|
||||||
ch = static_cast<char>(ch - 'A' + 'a');
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
for (const std::string &candidate : {
|
|
||||||
norm, stripped, "d:/" + stripped,
|
|
||||||
lower(norm), lower(stripped), lower("d:/" + stripped)}) {
|
|
||||||
if (mtpack40250::read(candidate, bytes) && !bytes.empty())
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Array Metin2PythonHost::render3d_draws() {
|
|
||||||
struct CachedGeometry {
|
|
||||||
std::uint64_t revision;
|
|
||||||
std::uint64_t last_used;
|
|
||||||
Dictionary arrays;
|
|
||||||
};
|
|
||||||
static std::unordered_map<std::uint64_t, CachedGeometry> geometry_cache;
|
|
||||||
static std::uint64_t extraction = 0;
|
|
||||||
++extraction;
|
|
||||||
const auto &draws = Render3DDraws();
|
|
||||||
static bool capture_written = false;
|
|
||||||
if (!capture_written) {
|
|
||||||
const char *capture_path = std::getenv("MT_NATIVE_CAPTURE_PATH");
|
|
||||||
const char *minimum_text = std::getenv("MT_NATIVE_CAPTURE_MIN_DRAWS");
|
|
||||||
const unsigned long minimum = minimum_text ? std::strtoul(minimum_text, nullptr, 10) : 100UL;
|
|
||||||
if (capture_path && *capture_path && draws.size() >= minimum) {
|
|
||||||
capture_written = true;
|
|
||||||
try {
|
|
||||||
std::unordered_map<std::string, std::vector<std::uint8_t>> textures;
|
|
||||||
auto add_texture = [&](const std::string &name) {
|
|
||||||
if (name.empty() || textures.count(name))
|
|
||||||
return;
|
|
||||||
if (name.rfind("mem:", 0) == 0) {
|
|
||||||
UIMemoryTexture mem_tex;
|
|
||||||
if (UIRenderMemoryTexture(name, &mem_tex) && mem_tex.width > 0 && mem_tex.height > 0) {
|
|
||||||
auto mtra = native_draw_capture::encode_raw_argb_as_mtra(
|
|
||||||
static_cast<std::uint32_t>(mem_tex.width),
|
|
||||||
static_cast<std::uint32_t>(mem_tex.height),
|
|
||||||
mem_tex.argb.data());
|
|
||||||
if (!mtra.empty())
|
|
||||||
textures.emplace(name, std::move(mtra));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::vector<std::uint8_t> bytes;
|
|
||||||
if (read_pack_texture(name, bytes))
|
|
||||||
textures.emplace(name, std::move(bytes));
|
|
||||||
};
|
|
||||||
for (const Render3DDraw &draw : draws) {
|
|
||||||
add_texture(draw.texture0);
|
|
||||||
add_texture(draw.texture1);
|
|
||||||
}
|
|
||||||
const auto &ui_commands = UIRenderCommands();
|
|
||||||
for (const UIRenderCommand &cmd : ui_commands) {
|
|
||||||
if (cmd.kind == UIRenderCommand::Image) {
|
|
||||||
add_texture(cmd.text);
|
|
||||||
add_texture(cmd.mask);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsigned ui_w = 0, ui_h = 0;
|
|
||||||
UIRenderGetSize(&ui_w, &ui_h);
|
|
||||||
native_draw_capture::write(
|
|
||||||
capture_path, draws, textures,
|
|
||||||
ui_w ? ui_w : 960u, ui_h ? ui_h : 640u, ui_commands);
|
|
||||||
UtilityFunctions::print(
|
|
||||||
"native draw capture: ", capture_path,
|
|
||||||
" draws=", static_cast<int64_t>(draws.size()),
|
|
||||||
" ui_commands=", static_cast<int64_t>(ui_commands.size()),
|
|
||||||
" textures=", static_cast<int64_t>(textures.size()));
|
|
||||||
} catch (const std::exception &error) {
|
|
||||||
UtilityFunctions::printerr("native draw capture failed: ", error.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Array out;
|
|
||||||
out.resize(static_cast<int64_t>(draws.size()));
|
|
||||||
int64_t draw_index = 0;
|
|
||||||
for (const Render3DDraw &draw : draws) {
|
|
||||||
if (draw.clear_flags)
|
|
||||||
continue; // back-buffer clears are consumed by the native renderer only
|
|
||||||
Dictionary item;
|
|
||||||
item["geometry_key"] = static_cast<int64_t>(draw.geometry_key);
|
|
||||||
item["geometry_revision"] = static_cast<int64_t>(draw.geometry_revision);
|
|
||||||
item["world"] = floats(draw.world, 16);
|
|
||||||
item["view"] = floats(draw.view, 16);
|
|
||||||
item["proj"] = floats(draw.proj, 16);
|
|
||||||
item["texture0"] = String::utf8(draw.texture0.c_str());
|
|
||||||
item["texture1"] = String::utf8(draw.texture1.c_str());
|
|
||||||
item["pretransformed"] = draw.pretransformed;
|
|
||||||
item["lines"] = draw.lines;
|
|
||||||
|
|
||||||
// Stage-0 coordinates the D3D8 pipeline generates or transforms depend on per-draw matrices,
|
|
||||||
// so such draws carry them outside the geometry cache.
|
|
||||||
const bool stage0_texgen = (draw.texcoord_index[0] & 0xFFFF0000u) != 0 ||
|
|
||||||
(draw.texcoord_index[0] & 0xFFFFu) != 0 || draw.texture_transform_flags[0] != 0;
|
|
||||||
if (stage0_texgen)
|
|
||||||
item["geometry_key"] = static_cast<int64_t>(0);
|
|
||||||
Dictionary geometry;
|
|
||||||
if (draw.geometry_key != 0 && !stage0_texgen) {
|
|
||||||
auto cached = geometry_cache.find(draw.geometry_key);
|
|
||||||
if (cached != geometry_cache.end() && cached->second.revision == draw.geometry_revision) {
|
|
||||||
cached->second.last_used = extraction;
|
|
||||||
geometry = cached->second.arrays;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (geometry.is_empty()) {
|
|
||||||
PackedVector3Array positions;
|
|
||||||
positions.resize(static_cast<int64_t>(draw.positions.size() / 3));
|
|
||||||
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
|
|
||||||
if (!draw.positions.empty())
|
|
||||||
std::memcpy(positions.ptrw(), draw.positions.data(), draw.positions.size() * sizeof(float));
|
|
||||||
} else {
|
|
||||||
Vector3 *dst = positions.ptrw();
|
|
||||||
for (int64_t i = 0; i < positions.size(); ++i)
|
|
||||||
dst[i] = Vector3(draw.positions[i * 3], draw.positions[i * 3 + 1], draw.positions[i * 3 + 2]);
|
|
||||||
}
|
|
||||||
geometry["positions"] = positions;
|
|
||||||
if (!draw.rhw.empty())
|
|
||||||
geometry["rhw"] = floats(draw.rhw.data(), static_cast<int>(draw.rhw.size()));
|
|
||||||
if (!draw.normals.empty()) {
|
|
||||||
PackedVector3Array normals;
|
|
||||||
normals.resize(static_cast<int64_t>(draw.normals.size() / 3));
|
|
||||||
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
|
|
||||||
std::memcpy(normals.ptrw(), draw.normals.data(), draw.normals.size() * sizeof(float));
|
|
||||||
} else {
|
|
||||||
Vector3 *dst = normals.ptrw();
|
|
||||||
for (int64_t i = 0; i < normals.size(); ++i)
|
|
||||||
dst[i] = Vector3(draw.normals[i * 3], draw.normals[i * 3 + 1], draw.normals[i * 3 + 2]);
|
|
||||||
}
|
|
||||||
geometry["normals"] = normals;
|
|
||||||
}
|
|
||||||
auto uvs = [](const std::vector<float> &values) {
|
|
||||||
PackedVector2Array out;
|
|
||||||
out.resize(static_cast<int64_t>(values.size() / 2));
|
|
||||||
if constexpr (sizeof(Vector2) == sizeof(float) * 2 && std::is_trivially_copyable_v<Vector2>) {
|
|
||||||
if (!values.empty())
|
|
||||||
std::memcpy(out.ptrw(), values.data(), values.size() * sizeof(float));
|
|
||||||
} else {
|
|
||||||
Vector2 *dst = out.ptrw();
|
|
||||||
for (int64_t i = 0; i < out.size(); ++i)
|
|
||||||
dst[i] = Vector2(values[i * 2], values[i * 2 + 1]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
if (stage0_texgen) {
|
|
||||||
std::vector<float> stage_uv;
|
|
||||||
Render3DStageTexcoords(draw, 0, stage_uv);
|
|
||||||
geometry["uv0"] = uvs(stage_uv);
|
|
||||||
} else if (!draw.uv0.empty())
|
|
||||||
geometry["uv0"] = uvs(draw.uv0);
|
|
||||||
if (!draw.uv1.empty())
|
|
||||||
geometry["uv1"] = uvs(draw.uv1);
|
|
||||||
if (!draw.diffuse.empty()) {
|
|
||||||
PackedColorArray colors;
|
|
||||||
colors.resize(static_cast<int64_t>(draw.diffuse.size()));
|
|
||||||
for (int64_t i = 0; i < colors.size(); ++i)
|
|
||||||
colors[i] = argb_color(draw.diffuse[i]);
|
|
||||||
geometry["diffuse"] = colors;
|
|
||||||
}
|
|
||||||
PackedInt32Array indices;
|
|
||||||
indices.resize(static_cast<int64_t>(draw.indices.size()));
|
|
||||||
for (int64_t i = 0; i < indices.size(); ++i)
|
|
||||||
indices[i] = static_cast<int32_t>(draw.indices[i]);
|
|
||||||
geometry["indices"] = indices;
|
|
||||||
if (draw.geometry_key != 0 && !stage0_texgen)
|
|
||||||
geometry_cache[draw.geometry_key] = {draw.geometry_revision, extraction, geometry};
|
|
||||||
}
|
|
||||||
item.merge(geometry);
|
|
||||||
|
|
||||||
item["alpha_blend"] = static_cast<int64_t>(draw.alpha_blend);
|
|
||||||
item["src_blend"] = static_cast<int64_t>(draw.src_blend);
|
|
||||||
item["dest_blend"] = static_cast<int64_t>(draw.dest_blend);
|
|
||||||
item["alpha_test"] = static_cast<int64_t>(draw.alpha_test);
|
|
||||||
item["alpha_ref"] = static_cast<int64_t>(draw.alpha_ref);
|
|
||||||
item["alpha_func"] = static_cast<int64_t>(draw.alpha_func);
|
|
||||||
item["cull_mode"] = static_cast<int64_t>(draw.cull_mode);
|
|
||||||
item["z_enable"] = static_cast<int64_t>(draw.z_enable);
|
|
||||||
item["z_write"] = static_cast<int64_t>(draw.z_write);
|
|
||||||
item["z_func"] = static_cast<int64_t>(draw.z_func);
|
|
||||||
item["lighting"] = static_cast<int64_t>(draw.lighting);
|
|
||||||
item["texture_factor"] = static_cast<int64_t>(draw.texture_factor);
|
|
||||||
bool uses_tf = false;
|
|
||||||
for (int s = 0; s < 2; ++s) {
|
|
||||||
if (draw.color_op[s] > 1 &&
|
|
||||||
(((draw.color_arg1[s] & 0xF) == 3) || ((draw.color_arg2[s] & 0xF) == 3)))
|
|
||||||
uses_tf = true;
|
|
||||||
if (draw.alpha_op[s] > 1 &&
|
|
||||||
(((draw.alpha_arg1[s] & 0xF) == 3) || ((draw.alpha_arg2[s] & 0xF) == 3)))
|
|
||||||
uses_tf = true;
|
|
||||||
}
|
|
||||||
item["uses_tf"] = uses_tf;
|
|
||||||
item["fog_enable"] = static_cast<int64_t>(draw.fog_enable);
|
|
||||||
item["color_op"] = static_cast<int64_t>(draw.color_op[0]);
|
|
||||||
item["alpha_op"] = static_cast<int64_t>(draw.alpha_op[0]);
|
|
||||||
item["material_diffuse"] = Color(draw.material_diffuse[0], draw.material_diffuse[1],
|
|
||||||
draw.material_diffuse[2], draw.material_diffuse[3]);
|
|
||||||
item["material_ambient"] = Color(draw.material_ambient[0], draw.material_ambient[1],
|
|
||||||
draw.material_ambient[2], draw.material_ambient[3]);
|
|
||||||
item["material_emissive"] = Color(draw.material_emissive[0], draw.material_emissive[1],
|
|
||||||
draw.material_emissive[2], draw.material_emissive[3]);
|
|
||||||
item["light0"] = draw.light0;
|
|
||||||
item["light0_direction"] = Vector3(draw.light0_direction[0], draw.light0_direction[1],
|
|
||||||
draw.light0_direction[2]);
|
|
||||||
item["light0_diffuse"] = Color(draw.light0_diffuse[0], draw.light0_diffuse[1], draw.light0_diffuse[2],
|
|
||||||
draw.light0_diffuse[3]);
|
|
||||||
item["light0_ambient"] = Color(draw.light0_ambient[0], draw.light0_ambient[1], draw.light0_ambient[2],
|
|
||||||
draw.light0_ambient[3]);
|
|
||||||
item["ambient"] = argb_color(draw.ambient);
|
|
||||||
PackedFloat32Array vp;
|
|
||||||
vp.resize(4);
|
|
||||||
vp[0] = draw.viewport[0];
|
|
||||||
vp[1] = draw.viewport[1];
|
|
||||||
vp[2] = draw.viewport[2];
|
|
||||||
vp[3] = draw.viewport[3];
|
|
||||||
item["viewport"] = vp;
|
|
||||||
out[draw_index++] = item;
|
|
||||||
}
|
|
||||||
out.resize(draw_index);
|
|
||||||
if (extraction % 120 == 0) {
|
|
||||||
for (auto it = geometry_cache.begin(); it != geometry_cache.end();) {
|
|
||||||
if (extraction - it->second.last_used > 120)
|
|
||||||
it = geometry_cache.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
Array Metin2PythonHost::audio_commands() {
|
|
||||||
Array out;
|
|
||||||
for (const auto &cmd : DrainAudioCommands()) {
|
|
||||||
Dictionary item;
|
|
||||||
switch (cmd.type) {
|
|
||||||
case AudioCommand::PlaySound2D: item["type"] = "play_sound_2d"; break;
|
|
||||||
case AudioCommand::PlaySound3D: item["type"] = "play_sound_3d"; break;
|
|
||||||
case AudioCommand::StopSound3D: item["type"] = "stop_sound_3d"; break;
|
|
||||||
case AudioCommand::StopAllSound3D: item["type"] = "stop_all_sound_3d"; break;
|
|
||||||
case AudioCommand::PlayMusic: item["type"] = "play_music"; break;
|
|
||||||
case AudioCommand::FadeOutMusic: item["type"] = "fade_out_music"; break;
|
|
||||||
case AudioCommand::FadeOutAllMusic: item["type"] = "fade_out_all_music"; break;
|
|
||||||
case AudioCommand::SetSoundVolume: item["type"] = "set_sound_volume"; break;
|
|
||||||
case AudioCommand::SetMusicVolume: item["type"] = "set_music_volume"; break;
|
|
||||||
case AudioCommand::FadeInMusic: item["type"] = "fade_in_music"; break;
|
|
||||||
case AudioCommand::FadeLimitOutMusic: item["type"] = "fade_limit_out_music"; break;
|
|
||||||
case AudioCommand::SetSoundVolume3D: item["type"] = "set_sound_volume_3d"; break;
|
|
||||||
default: continue;
|
|
||||||
}
|
|
||||||
item["filename"] = String::utf8(cmd.filename.c_str());
|
|
||||||
item["x"] = cmd.x;
|
|
||||||
item["y"] = cmd.y;
|
|
||||||
item["z"] = cmd.z;
|
|
||||||
item["volume"] = cmd.volume;
|
|
||||||
item["speed"] = cmd.speed;
|
|
||||||
item["play_count"] = cmd.play_count;
|
|
||||||
item["id"] = cmd.id;
|
|
||||||
out.append(item);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::current_map_name() {
|
|
||||||
return String::utf8(PythonBoot::CurrentMapName().c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2PythonHost::stop() {
|
|
||||||
PythonBoot::Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
// Built without -DMTGODOT_EMBED_PYTHON=ON: the class is still registered so GDScript can tell "no
|
|
||||||
// interpreter in this build" apart from "the interpreter failed to start".
|
|
||||||
String Metin2PythonHost::start(const String &) {
|
|
||||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Metin2PythonHost::is_running() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::run_main_script(const String &) {
|
|
||||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::run_line(const String &) {
|
|
||||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2PythonHost::evaluate(const String &) { return String(); }
|
|
||||||
|
|
||||||
bool Metin2PythonHost::is_app_looping() { return false; }
|
|
||||||
void Metin2PythonHost::set_ui_size(int, int) {}
|
|
||||||
void Metin2PythonHost::ui_mouse_move(int, int) {}
|
|
||||||
void Metin2PythonHost::ui_mouse_button(int, bool, int, int) {}
|
|
||||||
void Metin2PythonHost::ui_mouse_wheel(int) {}
|
|
||||||
void Metin2PythonHost::ui_key(int, bool) {}
|
|
||||||
void Metin2PythonHost::ui_char(int) {}
|
|
||||||
void Metin2PythonHost::ui_ime_key(int) {}
|
|
||||||
void Metin2PythonHost::ui_update() {}
|
|
||||||
Array Metin2PythonHost::ui_render_commands() { return Array(); }
|
|
||||||
Array Metin2PythonHost::ui_render_commands_batched() { return Array(); }
|
|
||||||
bool Metin2PythonHost::has_3d_draws() { return false; }
|
|
||||||
Ref<Image> Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref<Image>(); }
|
|
||||||
Array Metin2PythonHost::render3d_draws() { return Array(); }
|
|
||||||
Array Metin2PythonHost::audio_commands() { return Array(); }
|
|
||||||
String Metin2PythonHost::current_map_name() { return String(); }
|
|
||||||
|
|
||||||
void Metin2PythonHost::stop() {
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
void Metin2PythonHost::_bind_methods() {
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"),
|
|
||||||
&Metin2PythonHost::start, DEFVAL(String()));
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("evaluate", "expression"), &Metin2PythonHost::evaluate);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"),
|
|
||||||
&Metin2PythonHost::run_main_script, DEFVAL(String()));
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_app_looping"), &Metin2PythonHost::is_app_looping);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("set_ui_size", "width", "height"), &Metin2PythonHost::set_ui_size);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_move", "x", "y"), &Metin2PythonHost::ui_mouse_move);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_button", "button", "pressed", "x", "y"), &Metin2PythonHost::ui_mouse_button);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_wheel", "delta"), &Metin2PythonHost::ui_mouse_wheel);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_key", "key", "pressed"), &Metin2PythonHost::ui_key);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_char", "codepoint"), &Metin2PythonHost::ui_char);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_ime_key", "vkey"), &Metin2PythonHost::ui_ime_key);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_update"), &Metin2PythonHost::ui_update);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands"), &Metin2PythonHost::ui_render_commands);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands_batched"), &Metin2PythonHost::ui_render_commands_batched);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("has_3d_draws"), &Metin2PythonHost::has_3d_draws);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("memory_texture", "name", "known_revision"),
|
|
||||||
&Metin2PythonHost::memory_texture, DEFVAL(-1));
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("render3d_draws"), &Metin2PythonHost::render3d_draws);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("audio_commands"), &Metin2PythonHost::audio_commands);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("current_map_name"), &Metin2PythonHost::current_map_name);
|
|
||||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Metin2PythonHost — the 40250 script layer inside the Godot process (docs/PORT-PLAN.md 批次 2P step 4).
|
|
||||||
//
|
|
||||||
// Metin2Pack.initialize(AssetRoot.client_path()) # the packs system.py is read from
|
|
||||||
// if Metin2PythonHost.start() == "":
|
|
||||||
// var err := Metin2PythonHost.run_main_script("")
|
|
||||||
//
|
|
||||||
// Both calls answer with "" on success and with the reason otherwise, so GDScript never has to guess
|
|
||||||
// from a bool. 2V0-e reaches prototype.RunApp(); until 2V0-f implements the application adapter,
|
|
||||||
// app.Create reports CREATE_DEVICE and the original script displays its error popup.
|
|
||||||
//
|
|
||||||
// This is the only unit that knows both godot-cpp and the port tree: the sequence itself lives in
|
|
||||||
// platform/ScriptLib/PythonBoot.h, which stays godot-free.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/object.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
#include <godot_cpp/variant/array.hpp>
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
class Metin2PythonHost : public godot::Object {
|
|
||||||
GDCLASS(Metin2PythonHost, godot::Object)
|
|
||||||
|
|
||||||
public:
|
|
||||||
// Stages the standard library (Metin2Python::stdlib_path) when `stdlib_path` is empty, then starts
|
|
||||||
// the interpreter. "" on success, including when it was already running.
|
|
||||||
static godot::String start(const godot::String &stdlib_path);
|
|
||||||
static bool is_running();
|
|
||||||
// __DEBUG__, __COMMAND_LINE__ and system.py out of the packs. "" when system.py ran to the end.
|
|
||||||
static godot::String run_main_script(const godot::String &command_line);
|
|
||||||
// One line of Python in the same __main__ dictionary. "" when it ran.
|
|
||||||
static godot::String run_line(const godot::String &source);
|
|
||||||
static godot::String evaluate(const godot::String &expression);
|
|
||||||
// True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process().
|
|
||||||
static bool is_app_looping();
|
|
||||||
static void set_ui_size(int width, int height);
|
|
||||||
static void ui_mouse_move(int x, int y);
|
|
||||||
static void ui_mouse_button(int button, bool pressed, int x, int y);
|
|
||||||
static void ui_mouse_wheel(int delta);
|
|
||||||
static void ui_key(int key, bool pressed);
|
|
||||||
// WM_CHAR (Unicode code point) and WM_KEYDOWN (Win32 VK code) for the 40250 IME.
|
|
||||||
static void ui_char(int codepoint);
|
|
||||||
static void ui_ime_key(int vkey);
|
|
||||||
static void ui_update();
|
|
||||||
static godot::Array ui_render_commands();
|
|
||||||
// Ordered UI commands with consecutive, unclipped font quads packed into triangle arrays.
|
|
||||||
static godot::Array ui_render_commands_batched();
|
|
||||||
static bool has_3d_draws();
|
|
||||||
// The pixels of a "mem:<id>@<revision>" image command (the CGraphicFontTexture glyph pages) as an
|
|
||||||
// RGBA8 Image; null when the texture is gone or its revision is still `known_revision`.
|
|
||||||
static godot::Ref<godot::Image> memory_texture(const godot::String &name, int64_t known_revision);
|
|
||||||
// The 3D draw calls of the last ui_update() (RenderCommands3D.h), one Dictionary each: "world",
|
|
||||||
// "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0"
|
|
||||||
// and the blend/depth/cull/lighting state.
|
|
||||||
static godot::Array render3d_draws();
|
|
||||||
static godot::Array audio_commands();
|
|
||||||
static godot::String current_map_name();
|
|
||||||
static void stop();
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#include "python_stdlib.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/dir_access.hpp>
|
|
||||||
#include <godot_cpp/classes/file_access.hpp>
|
|
||||||
#include <godot_cpp/classes/project_settings.hpp>
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
#ifdef MTGODOT_HAVE_PYTHON
|
|
||||||
#include "platform/ScriptLib/PythonHost.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Both ship in the PCK; the build copies them next to project.godot (extension/CMakeLists.txt).
|
|
||||||
constexpr const char *ZIP_RES = "res://python27.zip";
|
|
||||||
constexpr const char *SHA_RES = "res://python27.zip.sha256";
|
|
||||||
constexpr const char *ZIP_USER = "user://python27.zip";
|
|
||||||
constexpr const char *ZIP_USER_TMP = "user://python27.zip.part";
|
|
||||||
|
|
||||||
// std::string, not String: this lives at file scope and a godot::String's constructor would run at
|
|
||||||
// dlopen time, before the extension's bindings exist.
|
|
||||||
std::string g_last_error;
|
|
||||||
|
|
||||||
String fail(String *error, const String &text) {
|
|
||||||
g_last_error = text.utf8().get_data();
|
|
||||||
if (error) {
|
|
||||||
*error = text;
|
|
||||||
}
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
|
|
||||||
String expected_sha() {
|
|
||||||
if (!FileAccess::file_exists(SHA_RES)) {
|
|
||||||
return String();
|
|
||||||
}
|
|
||||||
return FileAccess::get_file_as_string(SHA_RES).strip_edges();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
String stage_python_stdlib(String *error, bool force_stage) {
|
|
||||||
g_last_error.clear();
|
|
||||||
if (error) {
|
|
||||||
*error = String();
|
|
||||||
}
|
|
||||||
if (!FileAccess::file_exists(ZIP_RES)) {
|
|
||||||
return fail(error, String("no ") + ZIP_RES +
|
|
||||||
" (build with -DMTGODOT_EMBED_PYTHON=ON, which copies it into project/)");
|
|
||||||
}
|
|
||||||
|
|
||||||
ProjectSettings *settings = ProjectSettings::get_singleton();
|
|
||||||
// Desktop and the editor: res:// is a directory on disk, so CPython can open the file in place and
|
|
||||||
// nothing is copied. In an exported game globalize_path() returns a path next to the executable
|
|
||||||
// that does not exist, which is exactly the case the staging below is for.
|
|
||||||
const String in_place = settings->globalize_path(ZIP_RES);
|
|
||||||
if (!force_stage && !in_place.begins_with("res://")) {
|
|
||||||
// FileAccess may resolve an exported PCK entry through a relative globalized path. CPython
|
|
||||||
// uses fopen(), so only return the path when libc can open a real on-disk zip there.
|
|
||||||
if (FILE *file = std::fopen(in_place.utf8().get_data(), "rb")) {
|
|
||||||
std::fclose(file);
|
|
||||||
return in_place;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const String want = expected_sha();
|
|
||||||
if (want.is_empty()) {
|
|
||||||
return fail(error, String("no ") + SHA_RES + " beside the stdlib zip");
|
|
||||||
}
|
|
||||||
|
|
||||||
// An already-staged copy is reused only when it is the zip this build ships: a new version of the
|
|
||||||
// app brings a new digest, and a first start that was killed mid-copy leaves a short file.
|
|
||||||
if (FileAccess::file_exists(ZIP_USER) && FileAccess::get_sha256(ZIP_USER) == want) {
|
|
||||||
return settings->globalize_path(ZIP_USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PackedByteArray data = FileAccess::get_file_as_bytes(ZIP_RES);
|
|
||||||
if (data.is_empty()) {
|
|
||||||
return fail(error, String("cannot read ") + ZIP_RES);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Ref<FileAccess> out = FileAccess::open(ZIP_USER_TMP, FileAccess::WRITE);
|
|
||||||
if (out.is_null()) {
|
|
||||||
return fail(error, String("cannot write ") + ZIP_USER_TMP + ": " +
|
|
||||||
itos((int) FileAccess::get_open_error()));
|
|
||||||
}
|
|
||||||
out->store_buffer(data);
|
|
||||||
out->close();
|
|
||||||
}
|
|
||||||
// Hash the file as written, not the buffer: a full disk reports itself here and not later as a
|
|
||||||
// zipimport failure three imports into system.py.
|
|
||||||
if (FileAccess::get_sha256(ZIP_USER_TMP) != want) {
|
|
||||||
DirAccess::remove_absolute(ZIP_USER_TMP);
|
|
||||||
return fail(error, String("staged ") + ZIP_USER + " does not match " + want);
|
|
||||||
}
|
|
||||||
if (FileAccess::file_exists(ZIP_USER)) {
|
|
||||||
DirAccess::remove_absolute(ZIP_USER);
|
|
||||||
}
|
|
||||||
if (DirAccess::rename_absolute(ZIP_USER_TMP, ZIP_USER) != OK) {
|
|
||||||
return fail(error, String("cannot rename ") + ZIP_USER_TMP + " to " + ZIP_USER);
|
|
||||||
}
|
|
||||||
UtilityFunctions::print("[python27] staged ", ZIP_USER, " (", data.size(), " bytes, sha256 ", want, ")");
|
|
||||||
return settings->globalize_path(ZIP_USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2Python::stdlib_path(bool force_stage) {
|
|
||||||
const String path = stage_python_stdlib(nullptr, force_stage);
|
|
||||||
#ifdef MTGODOT_HAVE_PYTHON
|
|
||||||
if (!path.is_empty()) {
|
|
||||||
PythonHost::SetDefaultStdLibPath(path.utf8().get_data());
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
String Metin2Python::last_error() {
|
|
||||||
return String::utf8(g_last_error.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void Metin2Python::_bind_methods() {
|
|
||||||
ClassDB::bind_static_method("Metin2Python", D_METHOD("stdlib_path", "force_stage"),
|
|
||||||
&Metin2Python::stdlib_path, DEFVAL(false));
|
|
||||||
ClassDB::bind_static_method("Metin2Python", D_METHOD("last_error"), &Metin2Python::last_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// python_stdlib — where the embedded interpreter's standard library is on this platform
|
|
||||||
// (docs/PORT-PLAN.md 批次 2P step 3c).
|
|
||||||
//
|
|
||||||
// CPython opens python27.zip with its own stdio, so it needs a real filesystem path. That is free on
|
|
||||||
// the desktop, where res:// is a directory on disk, but on Android and iOS res:// is inside the PCK,
|
|
||||||
// which nothing outside Godot can read. There the zip is copied once into the sandbox (user://) and
|
|
||||||
// the copy is accepted only when it hashes to the digest built beside the zip, so an interrupted
|
|
||||||
// first start or a zip replaced by a new build re-stages instead of feeding zipimport a torn file.
|
|
||||||
//
|
|
||||||
// Only this file knows about res:// / user://: PythonHost (platform/ScriptLib) stays godot-free and
|
|
||||||
// is told the path, through PythonHost::SetDefaultStdLibPath.
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/object.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// The real filesystem path of python27.zip, staging it out of res:// when res:// is not on disk.
|
|
||||||
// Empty when the zip is missing or the staged copy does not verify; the reason goes to *error.
|
|
||||||
// force_stage runs the mobile path even where res:// is readable, which is how it is tested.
|
|
||||||
godot::String stage_python_stdlib(godot::String *error = nullptr, bool force_stage = false);
|
|
||||||
|
|
||||||
class Metin2Python : public godot::Object {
|
|
||||||
GDCLASS(Metin2Python, godot::Object)
|
|
||||||
|
|
||||||
public:
|
|
||||||
// Returns the path and, when the embedded interpreter is built in, hands it to PythonHost so
|
|
||||||
// PythonHost::DefaultStdLibPath() answers with it from then on.
|
|
||||||
static godot::String stdlib_path(bool force_stage);
|
|
||||||
static godot::String last_error();
|
|
||||||
|
|
||||||
protected:
|
|
||||||
static void _bind_methods();
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#include "register_types.h"
|
|
||||||
|
|
||||||
#include <gdextension_interface.h>
|
|
||||||
#include <godot_cpp/core/defs.hpp>
|
|
||||||
#include <godot_cpp/godot.hpp>
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "metin2_anim.h"
|
|
||||||
#include "metin2_model.h"
|
|
||||||
#include "metin2_world.h"
|
|
||||||
#include "m2_material.h"
|
|
||||||
#include "net/m2_client.h"
|
|
||||||
#include "pack40250_node.h"
|
|
||||||
#include "proto/proto_node.h"
|
|
||||||
#include "python_host_node.h"
|
|
||||||
#include "python_stdlib.h"
|
|
||||||
#include "static_object.h"
|
|
||||||
#include "terrain_splat.h"
|
|
||||||
#include "tree_placeholder.h"
|
|
||||||
#include "water_builder.h"
|
|
||||||
|
|
||||||
#include <m2_tokvec.h> // fmt::set_file_reader
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
// formats/ reads every asset (maps, .msenv, .msm/.msa via textscript, .spt, …)
|
|
||||||
// through fmt::read_file. Route it through godot::FileAccess so it works from
|
|
||||||
// res:// (the PCK) on the read-only iOS/Android bundles as well as loose dev files.
|
|
||||||
static bool mt_fmt_read_file(const std::string &path, std::string &out) {
|
|
||||||
PackedByteArray b = mtgodot::read_file(String::utf8(path.c_str()));
|
|
||||||
if (b.is_empty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
out.assign(reinterpret_cast<const char *>(b.ptr()), (size_t)b.size());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
|
||||||
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fmt::set_file_reader(&mt_fmt_read_file);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2Model);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2World);
|
|
||||||
GDREGISTER_CLASS(mtgodot::M2Client);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2Proto);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2Pack);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2Python);
|
|
||||||
GDREGISTER_CLASS(mtgodot::Metin2PythonHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
|
||||||
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
|
|
||||||
fmt::set_file_reader(nullptr);
|
|
||||||
mtgodot::Metin2AnimPlayer::clear_clip_cache();
|
|
||||||
// CPythonApplication::Destroy / CResourceManager teardown releases
|
|
||||||
// model-owned decoded textures before Godot's ObjectDB is destroyed.
|
|
||||||
// Leaving this static Ref cache alive makes renderer shutdown report
|
|
||||||
// leaked ImageTexture objects after a real model/map scenario.
|
|
||||||
mtgodot::Metin2Model::clear_texture_cache();
|
|
||||||
mtgodot::cleanup_material_shaders();
|
|
||||||
mtgodot::cleanup_terrain_shader();
|
|
||||||
mtgodot::cleanup_tree_shader();
|
|
||||||
mtgodot::cleanup_static_object_cache();
|
|
||||||
mtgodot::cleanup_water_shader();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
GDExtensionBool GDE_EXPORT mtgodot_library_init(
|
|
||||||
GDExtensionInterfaceGetProcAddress p_get_proc_address,
|
|
||||||
const GDExtensionClassLibraryPtr p_library,
|
|
||||||
GDExtensionInitialization *r_initialization) {
|
|
||||||
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
|
|
||||||
init_obj.register_initializer(initialize_mtgodot_module);
|
|
||||||
init_obj.register_terminator(uninitialize_mtgodot_module);
|
|
||||||
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
|
|
||||||
return init_obj.init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
|
|
||||||
void initialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
|
|
||||||
void uninitialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#include "static_object.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "dxt.h"
|
|
||||||
#include "gr2_bridge.h"
|
|
||||||
#include "texture_util.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
#include <gr2/gr2.h>
|
|
||||||
|
|
||||||
#include <unordered_map>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::unordered_map<std::string, Ref<ImageTexture>> g_dds_cache;
|
|
||||||
|
|
||||||
// Building albedo (sRGB colour) -> mobile-ASTC-eligible via make_color_texture
|
|
||||||
// (no-op on desktop). mipmaps=false keeps the desktop result byte-identical to
|
|
||||||
// the old path (buildings had no mip chain). §F4.
|
|
||||||
Ref<ImageTexture> decode_dds_cached(const std::string &real_path) {
|
|
||||||
auto &cache = g_dds_cache;
|
|
||||||
auto it = cache.find(real_path);
|
|
||||||
if (it != cache.end())
|
|
||||||
return it->second;
|
|
||||||
Ref<ImageTexture> tex;
|
|
||||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
|
|
||||||
if (d.ok()) {
|
|
||||||
tex = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
|
|
||||||
/*mipmaps=*/false);
|
|
||||||
}
|
|
||||||
cache.emplace(real_path, tex);
|
|
||||||
return tex;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_blend, bool two_sided,
|
|
||||||
const fmt::AssetResolver &res) {
|
|
||||||
Ref<StandardMaterial3D> mat;
|
|
||||||
mat.instantiate();
|
|
||||||
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
|
|
||||||
mat->set_roughness(1.0f);
|
|
||||||
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
|
||||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
|
||||||
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
|
|
||||||
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
|
|
||||||
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
|
|
||||||
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
|
|
||||||
|
|
||||||
String t = String(tex_name.c_str()).to_lower();
|
|
||||||
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
|
|
||||||
t.find("net") != -1 || t.find("ivy") != -1 || t.find("tree") != -1 ||
|
|
||||||
t.find("branch") != -1;
|
|
||||||
if (alpha_blend) {
|
|
||||||
// EterGrnLib TYPE_BLEND_PNT:真 alpha 混合(第 2 map 作 opacity)
|
|
||||||
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA);
|
|
||||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
|
||||||
} else if (kw_alpha) {
|
|
||||||
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
|
|
||||||
mat->set_alpha_scissor_threshold(0.5f);
|
|
||||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
|
||||||
}
|
|
||||||
if (two_sided)
|
|
||||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
|
||||||
|
|
||||||
if (tex_name.empty())
|
|
||||||
return mat;
|
|
||||||
std::string rp = res.resolve(tex_name, nullptr);
|
|
||||||
if (rp.empty()) {
|
|
||||||
// gr2 里常是 .dds;有时资产用别的大小写 / 扩展。先只试原名。
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
Ref<ImageTexture> tex = decode_dds_cached(rp);
|
|
||||||
if (tex.is_valid()) {
|
|
||||||
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
|
|
||||||
mat->set_albedo(Color(1, 1, 1));
|
|
||||||
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
|
|
||||||
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
|
|
||||||
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
|
|
||||||
mat->set_emission_energy_multiplier(0.18f);
|
|
||||||
}
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
|
|
||||||
const fmt::AssetResolver &res, StaticMeshCache &cache) {
|
|
||||||
auto it = cache.by_path.find(real_gr2_path);
|
|
||||||
if (it != cache.by_path.end())
|
|
||||||
return it->second;
|
|
||||||
|
|
||||||
Ref<godot::ArrayMesh> result; // invalid until success
|
|
||||||
|
|
||||||
gr2::LoadError err;
|
|
||||||
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
|
|
||||||
if (!loaded) {
|
|
||||||
++cache.failed;
|
|
||||||
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
|
|
||||||
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
|
|
||||||
cache.by_path.emplace(real_gr2_path, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const gr2::FileInfo &fi = loaded->file_info();
|
|
||||||
|
|
||||||
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
|
|
||||||
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
|
|
||||||
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
|
|
||||||
for (const auto &m : mats) {
|
|
||||||
std::string k;
|
|
||||||
for (char c : m.diffuse_texture)
|
|
||||||
k += (char)std::tolower((unsigned char)c);
|
|
||||||
if (!k.empty())
|
|
||||||
tex_state[k] = {m.alpha_blend, m.two_sided};
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
|
|
||||||
AABB bounds;
|
|
||||||
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
|
|
||||||
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
|
|
||||||
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
|
|
||||||
const mtgodot::RenderPart &rp = parts[s];
|
|
||||||
std::string tex;
|
|
||||||
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
|
|
||||||
const auto &mt = fi.meshes[rp.mesh].material_textures;
|
|
||||||
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
|
|
||||||
tex = mt[rp.mat_index];
|
|
||||||
else if (!mt.empty())
|
|
||||||
tex = mt[0];
|
|
||||||
}
|
|
||||||
bool alpha = false, two = false;
|
|
||||||
{
|
|
||||||
std::string k;
|
|
||||||
for (char c : tex)
|
|
||||||
k += (char)std::tolower((unsigned char)c);
|
|
||||||
auto f = tex_state.find(k);
|
|
||||||
if (f != tex_state.end()) {
|
|
||||||
alpha = f->second.first;
|
|
||||||
two = f->second.second;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
|
|
||||||
}
|
|
||||||
result = mesh;
|
|
||||||
++cache.loaded;
|
|
||||||
} else {
|
|
||||||
++cache.failed;
|
|
||||||
}
|
|
||||||
cache.by_path.emplace(real_gr2_path, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cleanup_static_object_cache() { g_dds_cache.clear(); }
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
|
|
||||||
#include <map>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace fmt {
|
|
||||||
struct AssetResolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// W3 —— 静态(无骨骼)GR2 对象。用 gr2_bridge 的 build_parts/build_mesh,
|
|
||||||
// 逐 surface 从 gr2 material binding 取贴图(走 AssetResolver 定位 + dxt 解码),
|
|
||||||
// 不建 Skeleton3D、不逐帧蒙皮。Building / DungeonBlock 用。SHINSOO §9-W3。
|
|
||||||
//
|
|
||||||
// SHINSOO §9-W3「Metin2StaticModel 新节点 vs 拆 Metin2Model」的决策:
|
|
||||||
// 取轻量方案 —— 一个自由函数产出带材质的共享 ArrayMesh,调用方(Metin2World)
|
|
||||||
// 直接挂到 MeshInstance3D,跨实例共享同一份 mesh。
|
|
||||||
struct StaticMeshCache {
|
|
||||||
std::map<std::string, godot::Ref<godot::ArrayMesh>> by_path;
|
|
||||||
int loaded = 0, failed = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。
|
|
||||||
// 失败返回 invalid Ref。
|
|
||||||
godot::Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
|
|
||||||
const fmt::AssetResolver &res, StaticMeshCache &cache);
|
|
||||||
|
|
||||||
// 退出时清缓存的 Ref<Image>(别拖到 __cxa_finalize)。
|
|
||||||
void cleanup_static_object_cache();
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
#include "terrain_splat.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "dxt.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/shader.hpp>
|
|
||||||
#include <godot_cpp/classes/texture2d_array.hpp>
|
|
||||||
#include <godot_cpp/variant/color.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/typed_array.hpp>
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <unordered_map>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// 最多 16 层:A1 单区块实测最多 12 个活动图层(`000004`/`003004`),旧的 8 层上限会静默丢层。
|
|
||||||
static const int MAX_LAYERS = 16;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
const char *SRC_TERRAIN = R"(shader_type spatial;
|
|
||||||
render_mode diffuse_lambert, specular_disabled, cull_back;
|
|
||||||
|
|
||||||
uniform sampler2DArray layers : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
|
|
||||||
uniform sampler2D weights0 : filter_linear; // RGBA = layer 0..3 alpha
|
|
||||||
uniform sampler2D weights1 : filter_linear; // RGBA = layer 4..7 alpha
|
|
||||||
uniform sampler2D weights2 : filter_linear; // RGBA = layer 8..11 alpha
|
|
||||||
uniform sampler2D weights3 : filter_linear; // RGBA = layer 12..15 alpha
|
|
||||||
uniform vec4 layer_uv[16]; // xy = 每区块平铺频率 (8*Scale),zw = offset
|
|
||||||
uniform int layer_count = 0;
|
|
||||||
uniform sampler2D shadowmap : source_color, filter_linear;
|
|
||||||
uniform bool use_shadowmap = false;
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
vec3 col = vec3(0.32, 0.30, 0.24);
|
|
||||||
vec4 w[4];
|
|
||||||
w[0] = texture(weights0, UV);
|
|
||||||
w[1] = texture(weights1, UV);
|
|
||||||
w[2] = texture(weights2, UV);
|
|
||||||
w[3] = texture(weights3, UV);
|
|
||||||
for (int i = 0; i < 16; i++) {
|
|
||||||
if (i >= layer_count) { break; }
|
|
||||||
float wi = w[i >> 2][i & 3];
|
|
||||||
if (wi <= 0.003) { continue; }
|
|
||||||
vec2 tuv = UV * layer_uv[i].xy + layer_uv[i].zw;
|
|
||||||
vec3 lc = texture(layers, vec3(tuv, float(i))).rgb;
|
|
||||||
col = mix(col, lc, wi);
|
|
||||||
}
|
|
||||||
if (use_shadowmap) {
|
|
||||||
col *= texture(shadowmap, UV).rgb;
|
|
||||||
}
|
|
||||||
ALBEDO = col;
|
|
||||||
ROUGHNESS = 1.0;
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
Ref<Shader> g_terrain_shader;
|
|
||||||
Ref<Shader> terrain_shader() {
|
|
||||||
if (g_terrain_shader.is_null()) {
|
|
||||||
g_terrain_shader.instantiate();
|
|
||||||
g_terrain_shader->set_code(SRC_TERRAIN);
|
|
||||||
}
|
|
||||||
return g_terrain_shader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DDS -> RGBA8 Image,resize 到 size×size。缓存(非函数静态 —— 见 cleanup)。
|
|
||||||
std::unordered_map<std::string, Ref<godot::Image>> g_layer_cache;
|
|
||||||
std::unordered_map<std::string, std::pair<int, int>> g_layer_dimensions;
|
|
||||||
Ref<godot::Image> layer_image(const std::string &real_path, int size,
|
|
||||||
std::unordered_map<std::string, mtgodot::Image> &decoded_sources) {
|
|
||||||
auto &cache = g_layer_cache;
|
|
||||||
std::string key = real_path + "@" + std::to_string(size);
|
|
||||||
auto it = cache.find(key);
|
|
||||||
if (it != cache.end())
|
|
||||||
return it->second;
|
|
||||||
Ref<godot::Image> out;
|
|
||||||
mtgodot::Image d;
|
|
||||||
auto decoded = decoded_sources.find(real_path);
|
|
||||||
if (decoded != decoded_sources.end()) {
|
|
||||||
d = std::move(decoded->second);
|
|
||||||
decoded_sources.erase(decoded);
|
|
||||||
} else {
|
|
||||||
d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
|
|
||||||
}
|
|
||||||
if (d.ok()) {
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize((int64_t)d.rgba.size());
|
|
||||||
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
|
|
||||||
out = godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
if (out.is_valid() && (d.w != size || d.h != size))
|
|
||||||
out->resize(size, size, godot::Image::INTERPOLATE_BILINEAR);
|
|
||||||
if (out.is_valid())
|
|
||||||
out->generate_mipmaps();
|
|
||||||
}
|
|
||||||
cache.emplace(key, out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplatLayer.alpha (258²) -> 256² 的某个通道
|
|
||||||
void pack_channel(uint8_t *dst /*256*256*4*/, int ch, const std::vector<uint8_t> &alpha258) {
|
|
||||||
const int S = fmt::SPLAT_RAW_XY; // 258
|
|
||||||
for (int y = 0; y < 256; ++y)
|
|
||||||
for (int x = 0; x < 256; ++x)
|
|
||||||
dst[(y * 256 + x) * 4 + ch] = alpha258[size_t(y + 1) * S + (x + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ImageTexture> weight_tex(const std::vector<const fmt::SplatLayer *> &four) {
|
|
||||||
std::vector<uint8_t> buf(size_t(256) * 256 * 4, 0);
|
|
||||||
for (int c = 0; c < 4 && c < (int)four.size(); ++c)
|
|
||||||
if (four[c])
|
|
||||||
pack_channel(buf.data(), c, four[c]->alpha);
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize((int64_t)buf.size());
|
|
||||||
std::copy(buf.begin(), buf.end(), b.ptrw());
|
|
||||||
Ref<godot::Image> img =
|
|
||||||
godot::Image::create_from_data(256, 256, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
return ImageTexture::create_from_image(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void cleanup_terrain_shader() {
|
|
||||||
g_terrain_shader.unref();
|
|
||||||
g_layer_cache.clear(); // 释放缓存的 Ref<Image>,别拖到 __cxa_finalize(那时引擎已析构)
|
|
||||||
g_layer_dimensions.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
|
|
||||||
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
|
|
||||||
const String &shadowmap_path) {
|
|
||||||
const bool profile = std::getenv("MT_PROFILE_MAP") != nullptr;
|
|
||||||
const auto now = [] { return std::chrono::steady_clock::now(); };
|
|
||||||
const auto start = now();
|
|
||||||
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
|
|
||||||
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
|
|
||||||
int src_max = 256;
|
|
||||||
// Keep newly decoded sources only until the image pass consumes them. The size pass and image pass
|
|
||||||
// used to decode the same DDS twice on its first use.
|
|
||||||
std::unordered_map<std::string, mtgodot::Image> decoded_sources;
|
|
||||||
for (const auto &L : splat.layers) {
|
|
||||||
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
|
|
||||||
std::string rp = res.resolve(tset.layers[L.layer - 1].texture, nullptr);
|
|
||||||
if (rp.empty())
|
|
||||||
continue;
|
|
||||||
auto it = g_layer_dimensions.find(rp);
|
|
||||||
if (it == g_layer_dimensions.end()) {
|
|
||||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
|
|
||||||
it = g_layer_dimensions.emplace(rp, d.ok() ? std::make_pair(int(d.w), int(d.h))
|
|
||||||
: std::make_pair(0, 0)).first;
|
|
||||||
decoded_sources.emplace(rp, std::move(d));
|
|
||||||
}
|
|
||||||
src_max = std::max(src_max, std::max(it->second.first, it->second.second));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const int LSIZE = std::min(1024, src_max);
|
|
||||||
const auto dimensions_done = now();
|
|
||||||
|
|
||||||
int n = std::min<int>(MAX_LAYERS, (int)splat.layers.size());
|
|
||||||
if (n == 0)
|
|
||||||
return Ref<ShaderMaterial>();
|
|
||||||
|
|
||||||
// 颜色数组
|
|
||||||
TypedArray<godot::Image> imgs;
|
|
||||||
std::vector<Color> uvparm(MAX_LAYERS, Color(40, 40, 0, 0));
|
|
||||||
Ref<godot::Image> fallback;
|
|
||||||
{
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize(LSIZE * LSIZE * 4);
|
|
||||||
for (int i = 0; i < LSIZE * LSIZE * 4; i += 4) {
|
|
||||||
b[i] = 90;
|
|
||||||
b[i + 1] = 110;
|
|
||||||
b[i + 2] = 70;
|
|
||||||
b[i + 3] = 255;
|
|
||||||
}
|
|
||||||
fallback = godot::Image::create_from_data(LSIZE, LSIZE, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
fallback->generate_mipmaps();
|
|
||||||
}
|
|
||||||
for (int i = 0; i < n; ++i) {
|
|
||||||
const fmt::SplatLayer &L = splat.layers[i];
|
|
||||||
Ref<godot::Image> img;
|
|
||||||
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
|
|
||||||
const fmt::TextureLayer &tl = tset.layers[L.layer - 1];
|
|
||||||
std::string rp = res.resolve(tl.texture, nullptr);
|
|
||||||
if (!rp.empty())
|
|
||||||
img = layer_image(rp, LSIZE, decoded_sources);
|
|
||||||
// 原客户端 TextureSet.cpp:185:u' = (TexCoordBase*UScale)*vtx_cm + UOffset,
|
|
||||||
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
|
|
||||||
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
|
|
||||||
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
|
|
||||||
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
|
|
||||||
}
|
|
||||||
imgs.push_back(img.is_valid() ? img : fallback);
|
|
||||||
}
|
|
||||||
const auto images_done = now();
|
|
||||||
|
|
||||||
Ref<Texture2DArray> arr;
|
|
||||||
arr.instantiate();
|
|
||||||
arr->create_from_images(imgs);
|
|
||||||
const auto array_done = now();
|
|
||||||
|
|
||||||
// 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha)
|
|
||||||
Ref<ImageTexture> wtex[4];
|
|
||||||
for (int g = 0; g < 4; ++g) {
|
|
||||||
std::vector<const fmt::SplatLayer *> grp(4, nullptr);
|
|
||||||
for (int k = 0; k < 4; ++k) {
|
|
||||||
int li = g * 4 + k;
|
|
||||||
if (li < n)
|
|
||||||
grp[k] = &splat.layers[li];
|
|
||||||
}
|
|
||||||
wtex[g] = weight_tex(grp);
|
|
||||||
}
|
|
||||||
const auto weights_done = now();
|
|
||||||
|
|
||||||
Ref<ShaderMaterial> mat;
|
|
||||||
mat.instantiate();
|
|
||||||
mat->set_shader(terrain_shader());
|
|
||||||
mat->set_shader_parameter("layers", arr);
|
|
||||||
mat->set_shader_parameter("weights0", wtex[0]);
|
|
||||||
mat->set_shader_parameter("weights1", wtex[1]);
|
|
||||||
mat->set_shader_parameter("weights2", wtex[2]);
|
|
||||||
mat->set_shader_parameter("weights3", wtex[3]);
|
|
||||||
mat->set_shader_parameter("layer_count", n);
|
|
||||||
{
|
|
||||||
Array uva;
|
|
||||||
for (int i = 0; i < MAX_LAYERS; ++i)
|
|
||||||
uva.push_back(Plane(uvparm[i].r, uvparm[i].g, uvparm[i].b, uvparm[i].a));
|
|
||||||
mat->set_shader_parameter("layer_uv", uva);
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto shader_done = now();
|
|
||||||
if (!shadowmap_path.is_empty()) {
|
|
||||||
mtgodot::Image sm = mtgodot::dds_from_file(shadowmap_path);
|
|
||||||
if (sm.ok()) {
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize((int64_t)sm.rgba.size());
|
|
||||||
std::copy(sm.rgba.begin(), sm.rgba.end(), b.ptrw());
|
|
||||||
Ref<godot::Image> smi = godot::Image::create_from_data(
|
|
||||||
sm.w, sm.h, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
mat->set_shader_parameter("shadowmap", ImageTexture::create_from_image(smi));
|
|
||||||
mat->set_shader_parameter("use_shadowmap", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (profile) {
|
|
||||||
const auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
|
|
||||||
std::fprintf(stderr, "MATERIAL_PROFILE dimensions=%.3f images=%.3f array=%.3f weights=%.3f shader=%.3f shadow=%.3f total=%.3f layers=%d size=%d\n",
|
|
||||||
ms(start, dimensions_done), ms(dimensions_done, images_done), ms(images_done, array_done),
|
|
||||||
ms(array_done, weights_done), ms(weights_done, shader_done), ms(shader_done, now()),
|
|
||||||
ms(start, now()), n, LSIZE);
|
|
||||||
}
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/classes/shader_material.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <splat.h>
|
|
||||||
#include <texture_set.h>
|
|
||||||
|
|
||||||
namespace fmt {
|
|
||||||
struct AssetResolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// W2 —— 真正的多图层地表材质。SHINSOO §9-W2。
|
|
||||||
// - 每图层的 258→256 alpha 打进 RGBA8 权重贴图(≤2 张 = ≤8 图层)
|
|
||||||
// - 每图层的颜色 DDS 解码 + resize 256²,堆成 Texture2DArray
|
|
||||||
// - ShaderMaterial:逐图层按权重 mix,UV 按 TextureLayer.u_scale/offset 平铺
|
|
||||||
// - shadowmap.dds 作为 albedo 乘法项(有则)
|
|
||||||
// 光照交给 Godot 内置 DirectionalLight(W5 由 .msenv 驱动)。
|
|
||||||
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
|
|
||||||
const fmt::SplatSet &splat,
|
|
||||||
const fmt::TextureSet &tset,
|
|
||||||
const fmt::AssetResolver &res,
|
|
||||||
const godot::String &shadowmap_path);
|
|
||||||
|
|
||||||
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
|
|
||||||
void cleanup_terrain_shader();
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#include "texture_util.h"
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/os.hpp>
|
|
||||||
#include <godot_cpp/core/class_db.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
|
|
||||||
bool g_warned = false;
|
|
||||||
|
|
||||||
void lazy_init() {
|
|
||||||
if (g_state != -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
|
|
||||||
g_state = (e[0] == '1') ? 1 : 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No explicit override: on for mobile targets, off for desktop (keeps the
|
|
||||||
// Phase-1 parity path byte-identical).
|
|
||||||
OS *os = OS::get_singleton();
|
|
||||||
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool texcomp_enabled() {
|
|
||||||
lazy_init();
|
|
||||||
return g_state == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void texcomp_set_enabled(bool on) {
|
|
||||||
g_state = on ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
|
|
||||||
bool mipmaps, TexUse use) {
|
|
||||||
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
}
|
|
||||||
|
|
||||||
PackedByteArray bytes;
|
|
||||||
bytes.resize(int64_t(w) * h * 4);
|
|
||||||
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
|
|
||||||
|
|
||||||
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
|
|
||||||
if (img.is_null()) {
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
}
|
|
||||||
if (mipmaps) {
|
|
||||||
img->generate_mipmaps();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (use == TexUse::COLOR && texcomp_enabled()) {
|
|
||||||
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
|
|
||||||
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
|
|
||||||
Image::ASTC_FORMAT_8x8);
|
|
||||||
if (err != OK && !g_warned) {
|
|
||||||
g_warned = true;
|
|
||||||
UtilityFunctions::push_warning(
|
|
||||||
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ImageTexture::create_from_image(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// texture_util —— 统一的「RGBA8 字节 -> ImageTexture」出口,可选 GPU 压缩。
|
|
||||||
//
|
|
||||||
// 桌面默认关(与旧路径逐字节一致:create_from_data + generate_mipmaps +
|
|
||||||
// create_from_image)。移动端(OS.has_feature("mobile"))或 MTGODOT_TEXCOMP=1
|
|
||||||
// 时,对**颜色贴图**做运行时 ASTC 8x8 压缩,省 ~6–8x 显存/带宽;编码失败自动
|
|
||||||
// 回退 RGBA8。控制图 / 阴影图 / splat / 法线数据图不要走这个(用 RGBA8)。
|
|
||||||
//
|
|
||||||
// BACKLOG F4 / docs/PLATFORMS.md。
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// 颜色贴图(sRGB 视觉内容,可压)。法线/数据图另说,暂不压。
|
|
||||||
enum class TexUse { COLOR };
|
|
||||||
|
|
||||||
// 运行时压缩开关。首次调用惰性初始化:MTGODOT_TEXCOMP 环境变量优先
|
|
||||||
// ("1"/"0"),否则 OS.has_feature("mobile")。也可显式覆盖。
|
|
||||||
bool texcomp_enabled();
|
|
||||||
void texcomp_set_enabled(bool on);
|
|
||||||
|
|
||||||
// w*h*4 的 level-0 RGBA8 -> ImageTexture。mipmaps=true 时先生成 mip 链
|
|
||||||
// (ASTC 压缩前必须)。压缩仅在 texcomp_enabled() && use==COLOR 时发生。
|
|
||||||
godot::Ref<godot::ImageTexture> make_color_texture(
|
|
||||||
int w, int h, const uint8_t *rgba, size_t len, bool mipmaps,
|
|
||||||
TexUse use = TexUse::COLOR);
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,542 +0,0 @@
|
|||||||
#include "tree_placeholder.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "dxt.h"
|
|
||||||
#include "texture_util.h"
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
#include <spt.h>
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
|
||||||
#include <godot_cpp/classes/file_access.hpp>
|
|
||||||
#include <godot_cpp/classes/json.hpp>
|
|
||||||
#include <godot_cpp/classes/gltf_document.hpp>
|
|
||||||
#include <godot_cpp/classes/gltf_state.hpp>
|
|
||||||
#include <godot_cpp/classes/gltf_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/importer_mesh.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
#include <godot_cpp/classes/shader.hpp>
|
|
||||||
#include <godot_cpp/classes/shader_material.hpp>
|
|
||||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cctype>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
constexpr float kPI = 3.14159265358979323846f;
|
|
||||||
constexpr float kTAU = 2.0f * kPI;
|
|
||||||
|
|
||||||
// SpeedTree 2 的叶片是中心点 + leaf-cluster table,在顶点 shader 中展开。
|
|
||||||
// proxy 已在 CPU 侧把叶簇展开成 card;这里只保留 alpha-test 和轻微、按实例错相的风摆。
|
|
||||||
const char *SRC_LEAF = R"(shader_type spatial;
|
|
||||||
render_mode cull_disabled, diffuse_lambert, specular_disabled, depth_prepass_alpha;
|
|
||||||
uniform sampler2D leaf_tex : source_color, filter_linear_mipmap_anisotropic;
|
|
||||||
uniform float wind_strength = 1.0;
|
|
||||||
void vertex() {
|
|
||||||
vec3 wp = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
|
|
||||||
float ph = wp.x * 0.11 + wp.z * 0.13;
|
|
||||||
float h = max(VERTEX.y, 0.0);
|
|
||||||
VERTEX.x += sin(TIME * 1.3 + ph) * 0.025 * wind_strength * h;
|
|
||||||
VERTEX.z += cos(TIME * 1.05 + ph) * 0.018 * wind_strength * h;
|
|
||||||
}
|
|
||||||
void fragment() {
|
|
||||||
vec4 c = texture(leaf_tex, UV);
|
|
||||||
if (c.a < 0.38) { discard; }
|
|
||||||
ALBEDO = c.rgb;
|
|
||||||
ROUGHNESS = 1.0;
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
Ref<Shader> g_leaf_shader;
|
|
||||||
Ref<ImageTexture> g_fallback_broadleaf;
|
|
||||||
Ref<ImageTexture> g_fallback_conifer;
|
|
||||||
std::unordered_map<std::string, Ref<ImageTexture>> g_tree_texture_cache;
|
|
||||||
std::unordered_map<std::string, Ref<ArrayMesh>> g_tree_mesh_cache;
|
|
||||||
|
|
||||||
Ref<Shader> leaf_shader() {
|
|
||||||
if (g_leaf_shader.is_null()) {
|
|
||||||
g_leaf_shader.instantiate();
|
|
||||||
g_leaf_shader->set_code(SRC_LEAF);
|
|
||||||
}
|
|
||||||
return g_leaf_shader;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ImageTexture> fallback_leaf_texture(bool conifer) {
|
|
||||||
Ref<ImageTexture> &cached = conifer ? g_fallback_conifer : g_fallback_broadleaf;
|
|
||||||
if (cached.is_valid())
|
|
||||||
return cached;
|
|
||||||
const int N = 96;
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize(N * N * 4);
|
|
||||||
for (int y = 0; y < N; ++y) {
|
|
||||||
for (int x = 0; x < N; ++x) {
|
|
||||||
const float u = (x + 0.5f) / N * 2.0f - 1.0f;
|
|
||||||
const float v = (y + 0.5f) / N * 2.0f - 1.0f;
|
|
||||||
const float d = std::sqrt(u * u + v * v);
|
|
||||||
float a = 1.0f - d;
|
|
||||||
a = a <= 0 ? 0.0f : a * a * (3.0f - 2.0f * a);
|
|
||||||
const float n = 0.5f + 0.5f * std::sin(x * 0.9f) * std::sin(y * 0.7f);
|
|
||||||
const float g = conifer ? (0.28f + 0.14f * n) : (0.40f + 0.16f * n);
|
|
||||||
const float r = conifer ? (0.11f + 0.06f * n) : (0.18f + 0.10f * n);
|
|
||||||
const float bl = 0.10f + 0.06f * n;
|
|
||||||
const int o = (y * N + x) * 4;
|
|
||||||
b[o + 0] = uint8_t(std::min(255.0f, r * 255.0f));
|
|
||||||
b[o + 1] = uint8_t(std::min(255.0f, g * 255.0f));
|
|
||||||
b[o + 2] = uint8_t(std::min(255.0f, bl * 255.0f));
|
|
||||||
b[o + 3] = uint8_t(std::min(255.0f, a * 255.0f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ref<godot::Image> img =
|
|
||||||
godot::Image::create_from_data(N, N, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
img->generate_mipmaps();
|
|
||||||
cached = ImageTexture::create_from_image(img);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ImageTexture> load_dds_texture(const std::string &path) {
|
|
||||||
if (path.empty())
|
|
||||||
return Ref<ImageTexture>();
|
|
||||||
auto found = g_tree_texture_cache.find(path);
|
|
||||||
if (found != g_tree_texture_cache.end())
|
|
||||||
return found->second;
|
|
||||||
Ref<ImageTexture> result;
|
|
||||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(path.c_str()));
|
|
||||||
if (d.ok()) {
|
|
||||||
// Bark / leaf-composite albedo (sRGB) -> mobile ASTC via
|
|
||||||
// make_color_texture (no-op on desktop; keeps mipmaps). §F4.
|
|
||||||
result = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
|
|
||||||
/*mipmaps=*/true);
|
|
||||||
}
|
|
||||||
g_tree_texture_cache.emplace(path, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string lower(std::string s) {
|
|
||||||
std::transform(s.begin(), s.end(), s.begin(),
|
|
||||||
[](unsigned char c) { return (char)std::tolower(c); });
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string basename(std::string path) {
|
|
||||||
for (char &c : path)
|
|
||||||
if (c == '\\') c = '/';
|
|
||||||
const size_t slash = path.find_last_of('/');
|
|
||||||
return slash == std::string::npos ? path : path.substr(slash + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string as_dds(std::string path) {
|
|
||||||
const size_t dot = path.find_last_of('.');
|
|
||||||
if (dot != std::string::npos)
|
|
||||||
path.resize(dot);
|
|
||||||
return path + ".dds";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string resolve_sibling(const std::string &treefile, const std::string &texture,
|
|
||||||
const fmt::AssetResolver &resolver) {
|
|
||||||
if (texture.empty())
|
|
||||||
return "";
|
|
||||||
std::string parent = fmt::AssetResolver::normalize(treefile);
|
|
||||||
const size_t slash = parent.find_last_of('/');
|
|
||||||
if (slash != std::string::npos)
|
|
||||||
parent.resize(slash);
|
|
||||||
else
|
|
||||||
parent.clear();
|
|
||||||
const std::string name = as_dds(basename(texture));
|
|
||||||
return resolver.resolve(parent.empty() ? name : parent + "/" + name, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TreeTextures {
|
|
||||||
Ref<ImageTexture> bark;
|
|
||||||
Ref<ImageTexture> composite;
|
|
||||||
std::string composite_name;
|
|
||||||
};
|
|
||||||
|
|
||||||
TreeTextures resolve_tree_textures(const std::string &treefile,
|
|
||||||
const fmt::AssetResolver &resolver) {
|
|
||||||
TreeTextures out;
|
|
||||||
const std::string spt_path = resolver.resolve(treefile, nullptr);
|
|
||||||
if (spt_path.empty())
|
|
||||||
return out;
|
|
||||||
fmt::SptInfo info;
|
|
||||||
if (!fmt::sniff_spt_file(spt_path, info))
|
|
||||||
return out;
|
|
||||||
std::string branch;
|
|
||||||
for (const std::string &ref : info.texture_refs) {
|
|
||||||
if (lower(ref).find("bark") != std::string::npos) {
|
|
||||||
branch = ref;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (branch.empty() && !info.texture_refs.empty())
|
|
||||||
branch = info.texture_refs.front();
|
|
||||||
out.bark = load_dds_texture(resolve_sibling(treefile, branch, resolver));
|
|
||||||
out.composite_name = info.composite_texture;
|
|
||||||
out.composite = load_dds_texture(
|
|
||||||
resolve_sibling(treefile, info.composite_texture, resolver));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UVRect {
|
|
||||||
float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::vector<UVRect> foliage_rects(const std::string &species,
|
|
||||||
const std::string &composite, bool atlas) {
|
|
||||||
if (!atlas)
|
|
||||||
return {{0, 0, 1, 1}};
|
|
||||||
// SPT leaf-cluster UV 尚未导出;这些区域只选择各 composite atlas 中的真实叶簇,
|
|
||||||
// 不声称复原了具体树种的原始 UV。Windows exporter 接入后删除这组 proxy 布局。
|
|
||||||
const std::string s = lower(species);
|
|
||||||
const std::string c = lower(composite);
|
|
||||||
const bool fall = s.find("fall") != std::string::npos;
|
|
||||||
const bool winter = s.find("winter") != std::string::npos;
|
|
||||||
if (c.find("b1") != std::string::npos) {
|
|
||||||
if (fall) return {{0.00f, 0.05f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
|
|
||||||
return {{0.25f, 0.02f, 0.50f, 0.23f}, {0.25f, 0.18f, 0.50f, 0.36f},
|
|
||||||
{0.00f, 0.27f, 0.27f, 0.49f}};
|
|
||||||
}
|
|
||||||
if (c.find("b2") != std::string::npos) {
|
|
||||||
if (fall) return {{0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
|
|
||||||
return {{0.50f, 0.38f, 0.75f, 0.63f}, {0.50f, 0.63f, 0.75f, 0.88f},
|
|
||||||
{0.00f, 0.38f, 0.25f, 0.62f}};
|
|
||||||
}
|
|
||||||
if (c.find("b3") != std::string::npos) {
|
|
||||||
if (fall) return {{0.25f, 0.25f, 0.50f, 0.50f}};
|
|
||||||
return {{0.00f, 0.25f, 0.25f, 0.50f}, {0.00f, 0.50f, 0.25f, 0.75f},
|
|
||||||
{0.25f, 0.50f, 0.50f, 0.75f}};
|
|
||||||
}
|
|
||||||
if (c.find("n1") != std::string::npos) {
|
|
||||||
if (winter) return {{0.00f, 0.36f, 0.50f, 0.58f}, {0.25f, 0.55f, 0.52f, 0.75f}};
|
|
||||||
return {{0.00f, 0.72f, 0.28f, 0.96f}, {0.25f, 0.74f, 0.53f, 0.97f}};
|
|
||||||
}
|
|
||||||
if (c.find("n2") != std::string::npos) {
|
|
||||||
return {{0.00f, 0.48f, 0.27f, 0.75f}, {0.26f, 0.73f, 0.58f, 1.00f},
|
|
||||||
{0.75f, 0.48f, 1.00f, 0.80f}};
|
|
||||||
}
|
|
||||||
return {{0, 0, 1, 1}};
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Buf {
|
|
||||||
PackedVector3Array v, n;
|
|
||||||
PackedVector2Array uv;
|
|
||||||
PackedInt32Array idx;
|
|
||||||
|
|
||||||
void quad(const Vector3 &a, const Vector3 &b, const Vector3 &c, const Vector3 &d,
|
|
||||||
const UVRect &r, bool flip_u = false) {
|
|
||||||
const int base = v.size();
|
|
||||||
const Vector3 nn = (b - a).cross(d - a).normalized();
|
|
||||||
v.push_back(a);
|
|
||||||
v.push_back(b);
|
|
||||||
v.push_back(c);
|
|
||||||
v.push_back(d);
|
|
||||||
for (int i = 0; i < 4; ++i)
|
|
||||||
n.push_back(nn);
|
|
||||||
const float l = flip_u ? r.u1 : r.u0;
|
|
||||||
const float rr = flip_u ? r.u0 : r.u1;
|
|
||||||
uv.push_back(Vector2(l, r.v1));
|
|
||||||
uv.push_back(Vector2(rr, r.v1));
|
|
||||||
uv.push_back(Vector2(rr, r.v0));
|
|
||||||
uv.push_back(Vector2(l, r.v0));
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 1);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
idx.push_back(base + 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
void tube_quad(const Vector3 &b0, const Vector3 &b1, const Vector3 &t1,
|
|
||||||
const Vector3 &t0, const Vector3 &n0, const Vector3 &n1,
|
|
||||||
float u0, float u1, float v0, float v1) {
|
|
||||||
const int base = v.size();
|
|
||||||
v.push_back(b0);
|
|
||||||
v.push_back(b1);
|
|
||||||
v.push_back(t1);
|
|
||||||
v.push_back(t0);
|
|
||||||
n.push_back(n0);
|
|
||||||
n.push_back(n1);
|
|
||||||
n.push_back(n1);
|
|
||||||
n.push_back(n0);
|
|
||||||
uv.push_back(Vector2(u0, v0));
|
|
||||||
uv.push_back(Vector2(u1, v0));
|
|
||||||
uv.push_back(Vector2(u1, v1));
|
|
||||||
uv.push_back(Vector2(u0, v1));
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
idx.push_back(base + 1);
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 3);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
Array arrays() const {
|
|
||||||
Array a;
|
|
||||||
a.resize(Mesh::ARRAY_MAX);
|
|
||||||
a[Mesh::ARRAY_VERTEX] = v;
|
|
||||||
a[Mesh::ARRAY_NORMAL] = n;
|
|
||||||
a[Mesh::ARRAY_TEX_UV] = uv;
|
|
||||||
a[Mesh::ARRAY_INDEX] = idx;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void tube(Buf &m, const Vector3 &from, const Vector3 &to,
|
|
||||||
float r0, float r1, int seg, float bark_repeat = 1.0f) {
|
|
||||||
const Vector3 axis = (to - from).normalized();
|
|
||||||
if (axis.length_squared() < 0.5f)
|
|
||||||
return;
|
|
||||||
const Vector3 helper = std::fabs(axis.y) > 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
|
|
||||||
const Vector3 u = axis.cross(helper).normalized();
|
|
||||||
const Vector3 w = axis.cross(u).normalized();
|
|
||||||
for (int i = 0; i < seg; ++i) {
|
|
||||||
const float a0 = float(i) / seg * kTAU;
|
|
||||||
const float a1 = float(i + 1) / seg * kTAU;
|
|
||||||
const Vector3 n0 = u * std::cos(a0) + w * std::sin(a0);
|
|
||||||
const Vector3 n1 = u * std::cos(a1) + w * std::sin(a1);
|
|
||||||
m.tube_quad(from + n0 * r0, from + n1 * r0, to + n1 * r1, to + n0 * r1,
|
|
||||||
n0, n1, float(i) / seg, float(i + 1) / seg, bark_repeat, 0.0f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t hash32(uint32_t s) {
|
|
||||||
s ^= s >> 16;
|
|
||||||
s *= 0x7feb352dU;
|
|
||||||
s ^= s >> 15;
|
|
||||||
s *= 0x846ca68bU;
|
|
||||||
s ^= s >> 16;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
float hash01(uint32_t s) {
|
|
||||||
return float(hash32(s) & 0x00FFFFFFU) / float(0x01000000U);
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t species_seed(const std::string &s) {
|
|
||||||
uint32_t h = 2166136261U;
|
|
||||||
for (unsigned char c : s) {
|
|
||||||
h ^= c;
|
|
||||||
h *= 16777619U;
|
|
||||||
}
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool species_is_palm(const std::string &hint) {
|
|
||||||
const std::string h = lower(hint);
|
|
||||||
static const char *kw[] = {"palm", "banana", "aloe", "fern", "joshua"};
|
|
||||||
for (const char *k : kw)
|
|
||||||
if (h.find(k) != std::string::npos)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void add_branches(Buf &wood, const std::string &species, float H, bool conifer, bool palm) {
|
|
||||||
const float trunk_top = H * (palm ? 0.82f : (conifer ? 0.90f : 0.76f));
|
|
||||||
const float trunk_r = H * (palm ? 0.028f : 0.035f);
|
|
||||||
tube(wood, Vector3(0, 0, 0), Vector3(0, trunk_top, 0),
|
|
||||||
trunk_r * 1.35f, trunk_r * 0.42f, 9, H * 0.22f);
|
|
||||||
if (palm)
|
|
||||||
return;
|
|
||||||
const int count = conifer ? 9 : 8;
|
|
||||||
const uint32_t seed = species_seed(species);
|
|
||||||
for (int i = 0; i < count; ++i) {
|
|
||||||
const float f = (i + 1.0f) / (count + 1.0f);
|
|
||||||
const float y = H * (conifer ? (0.28f + f * 0.52f) : (0.32f + f * 0.34f));
|
|
||||||
const float angle = kTAU * (f * 1.6180339f + hash01(seed + i * 17U));
|
|
||||||
const float len = H * (conifer ? (0.24f * (1.0f - f * 0.55f)) :
|
|
||||||
(0.18f + 0.08f * hash01(seed + i * 29U)));
|
|
||||||
const Vector3 from(0, y, 0);
|
|
||||||
const Vector3 to(std::cos(angle) * len,
|
|
||||||
y + H * (conifer ? 0.06f : (0.10f + 0.06f * hash01(seed + i * 31U))),
|
|
||||||
std::sin(angle) * len);
|
|
||||||
tube(wood, from, to, trunk_r * (0.55f - 0.20f * f), trunk_r * 0.12f, 6,
|
|
||||||
H * 0.08f);
|
|
||||||
if (!conifer && (i % 2 == 0)) {
|
|
||||||
const float side = angle + (hash01(seed + i * 37U) > 0.5f ? 0.65f : -0.65f);
|
|
||||||
const Vector3 tip = to + Vector3(std::cos(side), 0.65f, std::sin(side)) * (len * 0.42f);
|
|
||||||
tube(wood, to, tip, trunk_r * 0.16f, trunk_r * 0.05f, 5, H * 0.04f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void add_leaf_cards(Buf &leaves, const std::string &species, float H,
|
|
||||||
bool conifer, bool palm, const std::vector<UVRect> &rects) {
|
|
||||||
const uint32_t seed = species_seed(species);
|
|
||||||
const int count = palm ? 16 : (conifer ? 24 : 24);
|
|
||||||
for (int i = 0; i < count; ++i) {
|
|
||||||
const float a = kTAU * (float(i) * 0.6180339f + hash01(seed + i * 101U) * 0.15f);
|
|
||||||
Vector3 center;
|
|
||||||
float width = 1.0f, height = 1.0f;
|
|
||||||
if (palm) {
|
|
||||||
const float radial = H * (0.10f + 0.18f * hash01(seed + i * 103U));
|
|
||||||
center = Vector3(std::cos(a) * radial, H * (0.78f + 0.12f * hash01(seed + i * 107U)),
|
|
||||||
std::sin(a) * radial);
|
|
||||||
width = H * 0.32f;
|
|
||||||
height = H * 0.18f;
|
|
||||||
} else if (conifer) {
|
|
||||||
const float yf = 0.30f + 0.62f * (float(i) + 0.5f) / count;
|
|
||||||
const float radial = H * 0.23f * (1.0f - yf * 0.70f) *
|
|
||||||
(0.35f + 0.65f * hash01(seed + i * 109U));
|
|
||||||
center = Vector3(std::cos(a) * radial, H * yf, std::sin(a) * radial);
|
|
||||||
width = H * (0.18f + 0.10f * (1.0f - yf));
|
|
||||||
height = H * 0.18f;
|
|
||||||
} else {
|
|
||||||
const float yf = hash01(seed + i * 109U);
|
|
||||||
const float yn = yf * 2.0f - 1.0f;
|
|
||||||
const float radial = H * 0.34f * std::sqrt(std::max(0.05f, 1.0f - yn * yn)) *
|
|
||||||
(0.25f + 0.75f * std::sqrt(hash01(seed + i * 113U)));
|
|
||||||
center = Vector3(std::cos(a) * radial, H * (0.58f + yf * 0.34f),
|
|
||||||
std::sin(a) * radial);
|
|
||||||
width = H * (0.23f + 0.10f * hash01(seed + i * 127U));
|
|
||||||
height = H * (0.15f + 0.08f * hash01(seed + i * 131U));
|
|
||||||
}
|
|
||||||
const Vector3 right(std::cos(a + kPI * 0.5f), 0, std::sin(a + kPI * 0.5f));
|
|
||||||
const Vector3 up(0, 1, 0);
|
|
||||||
const UVRect &uv = rects[size_t(i) % rects.size()];
|
|
||||||
auto card = [&](const Vector3 &r, bool flip) {
|
|
||||||
leaves.quad(center - r * (width * 0.5f) - up * (height * 0.5f),
|
|
||||||
center + r * (width * 0.5f) - up * (height * 0.5f),
|
|
||||||
center + r * (width * 0.5f) + up * (height * 0.5f),
|
|
||||||
center - r * (width * 0.5f) + up * (height * 0.5f), uv, flip);
|
|
||||||
};
|
|
||||||
card(right, (i & 1) != 0);
|
|
||||||
// 原 SpeedTree leaf cluster 始终面向相机;静态 proxy 用交叉 card 保证任意视角
|
|
||||||
// 都不会只看到一条边。离线 exporter 接入后由真实 leaf table 替代。
|
|
||||||
const Vector3 crossed(std::cos(a), 0, std::sin(a));
|
|
||||||
card(crossed, (i & 1) == 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
|
|
||||||
const TreeTextures &textures) {
|
|
||||||
const String hint(species.c_str());
|
|
||||||
const bool conifer = species_is_conifer(hint);
|
|
||||||
const bool palm = species_is_palm(species);
|
|
||||||
const float H = std::max(2.0f, height_m);
|
|
||||||
const bool atlas = textures.composite.is_valid();
|
|
||||||
const std::vector<UVRect> rects = foliage_rects(species, textures.composite_name, atlas);
|
|
||||||
|
|
||||||
Buf wood, leaves;
|
|
||||||
add_branches(wood, species, H, conifer, palm);
|
|
||||||
add_leaf_cards(leaves, species, H, conifer, palm, rects);
|
|
||||||
|
|
||||||
Ref<ArrayMesh> mesh;
|
|
||||||
mesh.instantiate();
|
|
||||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays());
|
|
||||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays());
|
|
||||||
|
|
||||||
Ref<StandardMaterial3D> bark;
|
|
||||||
bark.instantiate();
|
|
||||||
bark->set_albedo(textures.bark.is_valid() ? Color(1, 1, 1) : Color(0.30f, 0.21f, 0.13f));
|
|
||||||
bark->set_roughness(1.0f);
|
|
||||||
bark->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
|
||||||
if (textures.bark.is_valid())
|
|
||||||
bark->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, textures.bark);
|
|
||||||
mesh->surface_set_material(0, bark);
|
|
||||||
|
|
||||||
Ref<ShaderMaterial> leaf;
|
|
||||||
leaf.instantiate();
|
|
||||||
leaf->set_shader(leaf_shader());
|
|
||||||
leaf->set_shader_parameter("leaf_tex",
|
|
||||||
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
|
|
||||||
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
|
|
||||||
// Environment_Load does not read a wind token from .msenv.
|
|
||||||
leaf->set_shader_parameter("wind_strength", 0.2f);
|
|
||||||
mesh->surface_set_material(1, leaf);
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void cleanup_tree_shader() {
|
|
||||||
g_tree_mesh_cache.clear();
|
|
||||||
g_tree_texture_cache.clear();
|
|
||||||
g_fallback_broadleaf.unref();
|
|
||||||
g_fallback_conifer.unref();
|
|
||||||
g_leaf_shader.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool species_is_conifer(const String &hint) {
|
|
||||||
const String h = hint.to_lower();
|
|
||||||
static const char *kw[] = {"cedar", "cypress", "pine", "fir", "spruce", "conifer", "juniper",
|
|
||||||
"christmastree"};
|
|
||||||
for (const char *k : kw)
|
|
||||||
if (h.find(k) != -1)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m) {
|
|
||||||
TreeTextures empty;
|
|
||||||
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
|
|
||||||
const String source(resolver.resolve(treefile, nullptr).c_str());
|
|
||||||
if (source.is_empty()) return {};
|
|
||||||
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
|
|
||||||
const String manifest_path = directory.path_join("manifest.json");
|
|
||||||
if (!FileAccess::file_exists(manifest_path)) return {};
|
|
||||||
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
|
|
||||||
if (parsed.get_type() != Variant::DICTIONARY) return {};
|
|
||||||
const Dictionary manifest = parsed;
|
|
||||||
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
|
|
||||||
const Dictionary trees = manifest.get("trees", Dictionary());
|
|
||||||
const String source_hash = FileAccess::get_sha256(source);
|
|
||||||
if (!trees.has(source_hash)) return {};
|
|
||||||
const Dictionary record = trees[source_hash];
|
|
||||||
const String filename = record.get("glb", "");
|
|
||||||
if (filename != source_hash + String(".glb")) return {};
|
|
||||||
const String path = directory.path_join(filename);
|
|
||||||
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
|
|
||||||
Ref<GLTFDocument> document;
|
|
||||||
document.instantiate();
|
|
||||||
Ref<GLTFState> state;
|
|
||||||
state.instantiate();
|
|
||||||
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
|
|
||||||
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
|
|
||||||
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
|
|
||||||
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
|
|
||||||
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
|
|
||||||
mesh->set_meta("tree_geometry", "native_spt");
|
|
||||||
mesh->set_meta("source_sha256", source_hash);
|
|
||||||
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
|
|
||||||
mesh->set_meta("leaf_mode", "static_extracted_cards");
|
|
||||||
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ref<ArrayMesh> get_tree_mesh(const std::string &treefile,
|
|
||||||
const fmt::AssetResolver &resolver, float height_m) {
|
|
||||||
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
|
|
||||||
"#" + std::to_string(height_m);
|
|
||||||
auto found = g_tree_mesh_cache.find(key);
|
|
||||||
if (found != g_tree_mesh_cache.end())
|
|
||||||
return found->second;
|
|
||||||
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
|
|
||||||
if (native.is_valid()) {
|
|
||||||
g_tree_mesh_cache.emplace(key, native);
|
|
||||||
return native;
|
|
||||||
}
|
|
||||||
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
|
|
||||||
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
|
|
||||||
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
|
|
||||||
mesh->set_meta("tree_geometry", "proxy");
|
|
||||||
g_tree_mesh_cache.emplace(key, mesh);
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
#include <godot_cpp/variant/string.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace fmt {
|
|
||||||
struct AssetResolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
|
|
||||||
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
|
|
||||||
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
// species_hint = treefile 名(判针叶/阔叶)。height_m ≈ 期望树高(米)。
|
|
||||||
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
|
|
||||||
float height_m = 12.0f);
|
|
||||||
|
|
||||||
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
|
|
||||||
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
|
|
||||||
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
|
|
||||||
// implementation is an explicitly reported fallback for missing/invalid trees.
|
|
||||||
godot::Ref<godot::ArrayMesh> get_tree_mesh(const std::string &treefile,
|
|
||||||
const fmt::AssetResolver &resolver, float height_m = 12.0f);
|
|
||||||
|
|
||||||
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
|
|
||||||
bool species_is_conifer(const godot::String &species_hint);
|
|
||||||
|
|
||||||
// 退出时清 shader、DDS 和 treefile->mesh 缓存。
|
|
||||||
void cleanup_tree_shader();
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
#include "water_builder.h"
|
|
||||||
#include "water_motion.h"
|
|
||||||
|
|
||||||
#include "asset_io.h"
|
|
||||||
#include "dxt.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/image.hpp>
|
|
||||||
#include <godot_cpp/classes/shader.hpp>
|
|
||||||
#include <godot_cpp/classes/shader_material.hpp>
|
|
||||||
#include <godot_cpp/classes/texture2d_array.hpp>
|
|
||||||
#include <godot_cpp/classes/time.hpp>
|
|
||||||
#include <godot_cpp/variant/utility_functions.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
|
||||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
|
||||||
#include <godot_cpp/variant/typed_array.hpp>
|
|
||||||
|
|
||||||
#include <asset_resolver.h>
|
|
||||||
#include <m2_coord.h>
|
|
||||||
#include <terrain_mesh.h> // terrain_height_at
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
using namespace godot;
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
|
|
||||||
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
|
|
||||||
const char *SRC_WATER = R"(shader_type spatial;
|
|
||||||
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
|
|
||||||
|
|
||||||
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
|
|
||||||
uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4)
|
|
||||||
uniform float height_offset = 0.0; // Shared reference water translation, metres.
|
|
||||||
|
|
||||||
void vertex() {
|
|
||||||
VERTEX.y += height_offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
|
|
||||||
vec2 uv = UV * uv_per_meter;
|
|
||||||
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
|
|
||||||
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
|
|
||||||
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
|
|
||||||
ALBEDO = tex;
|
|
||||||
ALPHA = COLOR.a;
|
|
||||||
}
|
|
||||||
)";
|
|
||||||
|
|
||||||
Ref<Shader> g_water_shader;
|
|
||||||
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
|
|
||||||
WaterMotion g_water_motion;
|
|
||||||
|
|
||||||
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
|
|
||||||
if (g_water_mat.is_valid())
|
|
||||||
return g_water_mat;
|
|
||||||
if (g_water_shader.is_null()) {
|
|
||||||
g_water_shader.instantiate();
|
|
||||||
g_water_shader->set_code(SRC_WATER);
|
|
||||||
}
|
|
||||||
Ref<ShaderMaterial> m;
|
|
||||||
m.instantiate();
|
|
||||||
m->set_shader(g_water_shader);
|
|
||||||
|
|
||||||
// special/water/01..30.dds
|
|
||||||
TypedArray<godot::Image> imgs;
|
|
||||||
int W = 0, H = 0;
|
|
||||||
for (int i = 1; i <= 30; ++i) {
|
|
||||||
char nm[64];
|
|
||||||
std::snprintf(nm, sizeof(nm), "d:/ymir work/special/water/%02d.dds", i);
|
|
||||||
std::string rp = res.resolve(nm, nullptr);
|
|
||||||
mtgodot::Image d = rp.empty() ? mtgodot::Image{} : mtgodot::dds_from_file(godot::String(rp.c_str()));
|
|
||||||
if (!d.ok())
|
|
||||||
break;
|
|
||||||
if (W == 0) {
|
|
||||||
W = d.w;
|
|
||||||
H = d.h;
|
|
||||||
}
|
|
||||||
PackedByteArray b;
|
|
||||||
b.resize((int64_t)d.rgba.size());
|
|
||||||
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
|
|
||||||
Ref<godot::Image> img =
|
|
||||||
godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
|
|
||||||
if (img.is_valid() && (d.w != W || d.h != H))
|
|
||||||
img->resize(W, H, godot::Image::INTERPOLATE_BILINEAR);
|
|
||||||
if (img.is_valid())
|
|
||||||
img->generate_mipmaps();
|
|
||||||
imgs.push_back(img);
|
|
||||||
}
|
|
||||||
if (imgs.size() == 30) {
|
|
||||||
Ref<Texture2DArray> arr;
|
|
||||||
arr.instantiate();
|
|
||||||
arr->create_from_images(imgs);
|
|
||||||
m->set_shader_parameter("frames", arr);
|
|
||||||
}
|
|
||||||
g_water_mat = m;
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void cleanup_water_shader() {
|
|
||||||
g_water_shader.unref();
|
|
||||||
g_water_mat.unref();
|
|
||||||
g_water_motion = WaterMotion{};
|
|
||||||
}
|
|
||||||
|
|
||||||
void update_water_animation() {
|
|
||||||
if (g_water_mat.is_null()) return;
|
|
||||||
const uint64_t now = Time::get_singleton()->get_ticks_msec();
|
|
||||||
uint64_t duration = 1000;
|
|
||||||
double depth = 0;
|
|
||||||
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
|
|
||||||
duration = UtilityFunctions::randi_range(1000, 3000);
|
|
||||||
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
|
|
||||||
}
|
|
||||||
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
|
|
||||||
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res) {
|
|
||||||
std::vector<WaterPiece> out;
|
|
||||||
if (wm.layer_count == 0 || wm.ids.size() != size_t(fmt::WATERMAP_XY) * fmt::WATERMAP_XY)
|
|
||||||
return out;
|
|
||||||
|
|
||||||
const int W = fmt::WATERMAP_XY; // 128
|
|
||||||
const double CELL_M = double(fmt::m2coord::CELLSCALE) * fmt::m2coord::CM_TO_M; // 2m/texel
|
|
||||||
const double X0 = double(tile_x) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
|
|
||||||
const double Z0 = double(tile_y) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
|
|
||||||
|
|
||||||
auto depth_alpha = [&](int tex_x, int tex_y, double water_h_cm) -> float {
|
|
||||||
// 水 texel (tex_x,tex_y) 对应的区块本地 cm(texel = 1 格 = CELLSCALE)
|
|
||||||
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
|
|
||||||
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
|
|
||||||
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
|
|
||||||
double depth_cm = water_h_cm - th;
|
|
||||||
// AreaTerrain uses RAW height differences, before HeightScale, with
|
|
||||||
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
|
|
||||||
return water_depth_alpha(depth_cm, height_scale);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (int layer = 0; layer < wm.layer_count; ++layer) {
|
|
||||||
double h_cm = double(layer < (int)wm.heights.size() ? wm.heights[layer] : 0) * height_scale;
|
|
||||||
float gy = float(h_cm * fmt::m2coord::CM_TO_M);
|
|
||||||
|
|
||||||
PackedVector3Array v;
|
|
||||||
PackedVector3Array n;
|
|
||||||
PackedVector2Array uv;
|
|
||||||
PackedColorArray col;
|
|
||||||
PackedInt32Array idx;
|
|
||||||
for (int y = 0; y < W; ++y) {
|
|
||||||
int x = 0;
|
|
||||||
while (x < W) {
|
|
||||||
if (wm.ids[y * W + x] != layer) {
|
|
||||||
++x;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int xs = x;
|
|
||||||
// Keep each cell's four shoreline samples. Merging a whole row
|
|
||||||
// discarded interior terrain peaks and bridged dry banks with water.
|
|
||||||
++x;
|
|
||||||
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
|
|
||||||
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
|
|
||||||
// AreaTerrain skips the quad when every diffuse alpha is zero.
|
|
||||||
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
|
|
||||||
continue;
|
|
||||||
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
|
|
||||||
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
|
|
||||||
int base = v.size();
|
|
||||||
v.push_back(Vector3(x0, gy, z0));
|
|
||||||
v.push_back(Vector3(x1, gy, z0));
|
|
||||||
v.push_back(Vector3(x1, gy, z1));
|
|
||||||
v.push_back(Vector3(x0, gy, z1));
|
|
||||||
for (int k = 0; k < 4; ++k)
|
|
||||||
n.push_back(Vector3(0, 1, 0));
|
|
||||||
uv.push_back(Vector2(float(x0), float(z0)));
|
|
||||||
uv.push_back(Vector2(float(x1), float(z0)));
|
|
||||||
uv.push_back(Vector2(float(x1), float(z1)));
|
|
||||||
uv.push_back(Vector2(float(x0), float(z1)));
|
|
||||||
col.push_back(Color(1, 1, 1, aLL));
|
|
||||||
col.push_back(Color(1, 1, 1, aLR));
|
|
||||||
col.push_back(Color(1, 1, 1, aUR));
|
|
||||||
col.push_back(Color(1, 1, 1, aUL));
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
idx.push_back(base + 1);
|
|
||||||
idx.push_back(base);
|
|
||||||
idx.push_back(base + 3);
|
|
||||||
idx.push_back(base + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (v.is_empty())
|
|
||||||
continue;
|
|
||||||
|
|
||||||
Array arr;
|
|
||||||
arr.resize(Mesh::ARRAY_MAX);
|
|
||||||
arr[Mesh::ARRAY_VERTEX] = v;
|
|
||||||
arr[Mesh::ARRAY_NORMAL] = n;
|
|
||||||
arr[Mesh::ARRAY_TEX_UV] = uv;
|
|
||||||
arr[Mesh::ARRAY_COLOR] = col;
|
|
||||||
arr[Mesh::ARRAY_INDEX] = idx;
|
|
||||||
|
|
||||||
Ref<godot::ArrayMesh> mesh;
|
|
||||||
mesh.instantiate();
|
|
||||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
|
|
||||||
mesh->surface_set_material(0, water_material(res));
|
|
||||||
out.push_back({mesh, gy});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "water_depth.h"
|
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
|
||||||
#include <godot_cpp/classes/ref.hpp>
|
|
||||||
|
|
||||||
#include <terrain_files.h>
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace fmt {
|
|
||||||
struct AssetResolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
// W7 / PARITY §4 —— water.wtr -> 每层水面网格(texel 掩膜)+ 共享水材质。
|
|
||||||
// 水材质用原客户端资产:`special/water/01..30.dds` 30 帧序列(`MapOutdoorWater.cpp:14/43`,
|
|
||||||
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
|
|
||||||
namespace mtgodot {
|
|
||||||
|
|
||||||
|
|
||||||
struct WaterPiece {
|
|
||||||
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
|
|
||||||
float godot_y = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。
|
|
||||||
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
|
|
||||||
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
|
|
||||||
|
|
||||||
void cleanup_water_shader();
|
|
||||||
void update_water_animation();
|
|
||||||
|
|
||||||
} // namespace mtgodot
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <cmath>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
|
|
||||||
inline float water_depth_alpha(double depth_cm, double height_scale) {
|
|
||||||
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
|
|
||||||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
|
|
||||||
double alpha = depth_cm / (400.0 * height_scale);
|
|
||||||
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
namespace mtgodot {
|
|
||||||
// MapOutdoorWater: one shared vertical translation, alternating a random
|
|
||||||
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
|
|
||||||
struct WaterMotion {
|
|
||||||
uint64_t start_ms = 0, duration_ms = 300;
|
|
||||||
double begin = 0, end = 0, current = 0;
|
|
||||||
bool initialized = false;
|
|
||||||
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
|
|
||||||
if (!initialized) { start_ms = now; initialized = true; }
|
|
||||||
if (now - start_ms > duration_ms) {
|
|
||||||
begin = current;
|
|
||||||
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
|
|
||||||
start_ms = now;
|
|
||||||
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
|
|
||||||
}
|
|
||||||
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,8 @@ Bytes header(uint32_t bits, uint32_t r, uint32_t g, uint32_t b, uint32_t a, size
|
|||||||
put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a);
|
put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
mtgodot::Image decode(const Bytes& b) { return mtgodot::load_dds(b.data(), b.size()); }
|
mtimage::Image decode(const Bytes& b) { return mtimage::load_dds(b.data(), b.size()); }
|
||||||
void pixel(const mtgodot::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
|
void pixel(const mtimage::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
|
||||||
assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a);
|
assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a);
|
||||||
}
|
}
|
||||||
int main() {
|
int main() {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
#include "water_depth.h"
|
|
||||||
#include <cassert>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <limits>
|
|
||||||
#include <initializer_list>
|
|
||||||
int main() {
|
|
||||||
for (double scale : {0.25, 0.5, 1.0, 2.0}) {
|
|
||||||
assert(mtgodot::water_depth_alpha(-1, scale) == 0);
|
|
||||||
assert(mtgodot::water_depth_alpha(0, scale) == 0);
|
|
||||||
assert(std::abs(mtgodot::water_depth_alpha(100 * scale, scale) - 0.25) < 1e-6);
|
|
||||||
assert(std::abs(mtgodot::water_depth_alpha(320 * scale, scale) - 0.8) < 1e-6);
|
|
||||||
assert(std::abs(mtgodot::water_depth_alpha(1000 * scale, scale) - 0.8) < 1e-6);
|
|
||||||
}
|
|
||||||
assert(mtgodot::water_depth_alpha(100, 0) == 0);
|
|
||||||
assert(mtgodot::water_depth_alpha(std::numeric_limits<double>::quiet_NaN(), 1) == 0);
|
|
||||||
std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)");
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#include "water_motion.h"
|
|
||||||
#include <cassert>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdio>
|
|
||||||
int main() {
|
|
||||||
mtgodot::WaterMotion m;
|
|
||||||
assert(m.sample(100, 2000, 15) == 0);
|
|
||||||
assert(m.sample(400, 2000, 15) == 0);
|
|
||||||
assert(m.sample(401, 2000, 15) == 0);
|
|
||||||
assert(std::abs(m.sample(1401, 2000, 15) + .075) < 1e-9);
|
|
||||||
assert(std::abs(m.sample(2401, 2000, 15) + .15) < 1e-9);
|
|
||||||
assert(std::abs(m.sample(2402, 1000, 15) + .15) < 1e-9);
|
|
||||||
assert(std::abs(m.sample(2902, 1000, 15) + .075) < 1e-9);
|
|
||||||
assert(m.sample(3402, 1000, 15) == 0);
|
|
||||||
// Large frame gaps start at the last rendered height, without overshoot.
|
|
||||||
for (uint64_t t = 3403; t < 200000; t += 137) {
|
|
||||||
const double y = m.sample(t, 1700, 9);
|
|
||||||
assert(y <= 0 && y >= -.15);
|
|
||||||
}
|
|
||||||
std::puts("PASS: shared linear water motion, endpoints, timing, no overshoot");
|
|
||||||
}
|
|
||||||
Vendored
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
# extension/third_party — vendored native dependencies for the GDExtension.
|
# Vendored native dependencies for the 40250 port and SDL/Vulkan client.
|
||||||
#
|
#
|
||||||
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
|
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
|
||||||
# exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG
|
# exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG
|
||||||
# F1/F2) needs them built from source as part of our own build. All three are
|
# F1/F2) needs them built from source as part of our own build. All three are
|
||||||
# pinned here and produce static libs that link into libmtgodot:
|
# pinned here and produce static libraries for port_platform:
|
||||||
#
|
#
|
||||||
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
|
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
|
||||||
# libzstd_static <- facebook/zstd submodule, its own build/cmake project
|
# libzstd_static <- facebook/zstd submodule, its own build/cmake project
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
# GPL: fine for this internal, non-published project — same footing as
|
# GPL: fine for this internal, non-published project — same footing as
|
||||||
# libgr2/src/oodle1.c — but must be swapped or re-licensed before any release.
|
# libgr2/src/oodle1.c — but must be swapped or re-licensed before any release.
|
||||||
|
|
||||||
# Everything here is archived into the SHARED libmtgodot, so it must be PIC.
|
# Android links these static libraries into libmain.so, so they must be PIC.
|
||||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
# --- libsodium ------------------------------------------------------------------
|
# --- libsodium ------------------------------------------------------------------
|
||||||
@@ -57,7 +57,7 @@ if(WIN32)
|
|||||||
else()
|
else()
|
||||||
set(MT_EMBED_PYTHON_DEFAULT ON)
|
set(MT_EMBED_PYTHON_DEFAULT ON)
|
||||||
endif()
|
endif()
|
||||||
option(MTGODOT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT})
|
option(MT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT})
|
||||||
if(MTGODOT_EMBED_PYTHON)
|
if(MT_EMBED_PYTHON)
|
||||||
add_subdirectory(cpython-2.7.18)
|
add_subdirectory(cpython-2.7.18)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Generate the Android debug keystore Godot's editor settings point at:
|
|
||||||
# ~/Library/Application Support/Godot/keystores/debug.keystore
|
|
||||||
# (editor_settings-4.7.tres: export/android/debug_keystore, pass "android").
|
|
||||||
# Standard well-known debug credentials — never used for release signing.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
KS_DIR="$HOME/Library/Application Support/Godot/keystores"
|
|
||||||
KS="$KS_DIR/debug.keystore"
|
|
||||||
|
|
||||||
if [ -f "$KS" ]; then
|
|
||||||
echo "already exists: $KS"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/}keytool"
|
|
||||||
command -v "$KEYTOOL" >/dev/null 2>&1 || KEYTOOL=keytool
|
|
||||||
|
|
||||||
mkdir -p "$KS_DIR"
|
|
||||||
"$KEYTOOL" -genkeypair -v \
|
|
||||||
-keystore "$KS" \
|
|
||||||
-storepass android -keypass android \
|
|
||||||
-alias androiddebugkey \
|
|
||||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
|
||||||
-dname "CN=Android Debug,O=Android,C=US"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "created: $KS"
|
|
||||||
echo "Godot editor settings already reference it (export/android/debug_keystore)."
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
+5
-14
@@ -1,6 +1,6 @@
|
|||||||
# Native Vulkan renderer prototype
|
# Native Vulkan client
|
||||||
|
|
||||||
This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. The existing Godot client remains the default playable path.
|
This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. This is the active macOS and Android client path.
|
||||||
|
|
||||||
## Build and run on macOS
|
## Build and run on macOS
|
||||||
|
|
||||||
@@ -9,18 +9,9 @@ Install Vulkan headers/loader, MoltenVK, SDL3 and `glslc` (shaderc). With Homebr
|
|||||||
```sh
|
```sh
|
||||||
brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc
|
brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc
|
||||||
|
|
||||||
# 1. Standalone replay build (without port_platform)
|
# Optimized Release build (includes port_platform, embedded Python and FakeLoginServer)
|
||||||
cmake -S . -B build-native-render -DMTGODOT_BUILD_EXTENSION=OFF \
|
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON \
|
||||||
-DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
-DMT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
||||||
cmake --build build-native-render --target mt_native_render -j8
|
|
||||||
|
|
||||||
# 2. Full live-client build (links port_platform + mtpython + FakeLoginServer)
|
|
||||||
cmake -S . -B build -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
|
||||||
cmake --build build --target mtgodot mt_native_render port_fake_login_server -j8
|
|
||||||
|
|
||||||
# 3. Optimized Release (-O3) live-client build
|
|
||||||
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON \
|
|
||||||
-DMTGODOT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
|
||||||
cmake --build build-release --target mt_native_render -j8
|
cmake --build build-release --target mt_native_render -j8
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+147
-25
@@ -9,6 +9,7 @@
|
|||||||
#include "platform/PackBackend.h"
|
#include "platform/PackBackend.h"
|
||||||
#include "platform/ScriptLib/PythonBoot.h"
|
#include "platform/ScriptLib/PythonBoot.h"
|
||||||
#include "platform/UserInterface/ServerClock.h"
|
#include "platform/UserInterface/ServerClock.h"
|
||||||
|
#include "platform/EterBase/TraceErrorObserver.h"
|
||||||
#include "../tests/port_login_flow_server.h"
|
#include "../tests/port_login_flow_server.h"
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#endif
|
#endif
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
#ifdef __APPLE__
|
#ifdef __APPLE__
|
||||||
#include <AudioToolbox/AudioToolbox.h>
|
#include <AudioToolbox/AudioToolbox.h>
|
||||||
|
#include <TargetConditionals.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -230,8 +232,8 @@ std::uint64_t geometry_hash(const Render3DDraw& draw) {
|
|||||||
return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t));
|
return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t));
|
||||||
}
|
}
|
||||||
|
|
||||||
mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
|
mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size) {
|
||||||
mtgodot::Image out;
|
mtimage::Image out;
|
||||||
if (size < 18) return out;
|
if (size < 18) return out;
|
||||||
const std::uint8_t id_len = data[0];
|
const std::uint8_t id_len = data[0];
|
||||||
const std::uint8_t cmap_type = data[1];
|
const std::uint8_t cmap_type = data[1];
|
||||||
@@ -306,7 +308,7 @@ mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
|
mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
|
||||||
if (!data || size < 12) return {};
|
if (!data || size < 12) return {};
|
||||||
if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') {
|
if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') {
|
||||||
std::uint32_t w = 0, h = 0;
|
std::uint32_t w = 0, h = 0;
|
||||||
@@ -314,7 +316,7 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
|
|||||||
std::memcpy(&h, data + 8, 4);
|
std::memcpy(&h, data + 8, 4);
|
||||||
const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4;
|
const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4;
|
||||||
if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) {
|
if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) {
|
||||||
mtgodot::Image out;
|
mtimage::Image out;
|
||||||
out.w = static_cast<std::uint16_t>(w);
|
out.w = static_cast<std::uint16_t>(w);
|
||||||
out.h = static_cast<std::uint16_t>(h);
|
out.h = static_cast<std::uint16_t>(h);
|
||||||
out.rgba.assign(data + 12, data + 12 + bytes);
|
out.rgba.assign(data + 12, data + 12 + bytes);
|
||||||
@@ -323,14 +325,14 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ')
|
if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ')
|
||||||
return mtgodot::load_dds(data, size);
|
return mtimage::load_dds(data, size);
|
||||||
auto tga = decode_tga(data, size);
|
auto tga = decode_tga(data, size);
|
||||||
if (tga.ok()) return tga;
|
if (tga.ok()) return tga;
|
||||||
if (size > static_cast<std::size_t>(INT_MAX)) return {};
|
if (size > static_cast<std::size_t>(INT_MAX)) return {};
|
||||||
int w = 0, h = 0, channels = 0;
|
int w = 0, h = 0, channels = 0;
|
||||||
stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4);
|
stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4);
|
||||||
if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) {
|
if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) {
|
||||||
mtgodot::Image out;
|
mtimage::Image out;
|
||||||
out.w = static_cast<std::uint16_t>(w);
|
out.w = static_cast<std::uint16_t>(w);
|
||||||
out.h = static_cast<std::uint16_t>(h);
|
out.h = static_cast<std::uint16_t>(h);
|
||||||
out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4);
|
out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4);
|
||||||
@@ -847,6 +849,7 @@ public:
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
|
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
|
||||||
|
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
|
||||||
window_ = SDL_CreateWindow(
|
window_ = SDL_CreateWindow(
|
||||||
"Metin2 Native Vulkan Client",
|
"Metin2 Native Vulkan Client",
|
||||||
std::max(init_width, 320),
|
std::max(init_width, 320),
|
||||||
@@ -979,6 +982,7 @@ public:
|
|||||||
screenshot_path_ = std::move(path);
|
screenshot_path_ = std::move(path);
|
||||||
screenshot_frame_ = frame;
|
screenshot_frame_ = frame;
|
||||||
}
|
}
|
||||||
|
std::uint64_t frame_number() const { return frame_number_; }
|
||||||
|
|
||||||
int msaa_samples() const { return int(samples_); }
|
int msaa_samples() const { return int(samples_); }
|
||||||
std::uint32_t width() const { return extent_.width; }
|
std::uint32_t width() const { return extent_.width; }
|
||||||
@@ -986,11 +990,19 @@ public:
|
|||||||
std::uint32_t logical_width() const {
|
std::uint32_t logical_width() const {
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
if (window_) SDL_GetWindowSize(window_, &w, &h);
|
if (window_) SDL_GetWindowSize(window_, &w, &h);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
// Keep the 40250 UI at the same height as the macOS 1280x800 window,
|
||||||
|
// while retaining the device aspect ratio on wider phone displays.
|
||||||
|
if (w > 0 && h > 0) return std::max(1, int(std::lround(double(w) * 800.0 / h)));
|
||||||
|
#endif
|
||||||
return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width;
|
return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width;
|
||||||
}
|
}
|
||||||
std::uint32_t logical_height() const {
|
std::uint32_t logical_height() const {
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
if (window_) SDL_GetWindowSize(window_, &w, &h);
|
if (window_) SDL_GetWindowSize(window_, &w, &h);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
if (w > 0 && h > 0) return 800;
|
||||||
|
#endif
|
||||||
return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height;
|
return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1192,10 +1204,10 @@ public:
|
|||||||
if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) {
|
if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) {
|
||||||
int ew = 0, eh = 0;
|
int ew = 0, eh = 0;
|
||||||
SDL_GetWindowSize(window_, &ew, &eh);
|
SDL_GetWindowSize(window_, &ew, &eh);
|
||||||
touch_controller.update_screen_size(ew, eh);
|
touch_controller.update_screen_size(int(logical_width()), int(logical_height()));
|
||||||
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
if (forward_to_live_client && ew > 0 && eh > 0) {
|
if (forward_to_live_client && ew > 0 && eh > 0) {
|
||||||
PythonBoot::SetUISize(ew, eh);
|
PythonBoot::SetUISize(int(logical_width()), int(logical_height()));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
recreate_swapchain();
|
recreate_swapchain();
|
||||||
@@ -1658,9 +1670,12 @@ public:
|
|||||||
present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_;
|
present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_;
|
||||||
present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index;
|
present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index;
|
||||||
const auto presented = vkQueuePresentKHR(queue_, &present);
|
const auto presented = vkQueuePresentKHR(queue_, &present);
|
||||||
if (presented == VK_ERROR_OUT_OF_DATE_KHR || presented == VK_SUBOPTIMAL_KHR) {
|
// An Android surface can continuously report SUBOPTIMAL while the app deliberately
|
||||||
|
// uses the identity transform. The image is still presentable; rebuilding every frame
|
||||||
|
// stalls rendering without changing that status.
|
||||||
|
if (presented == VK_ERROR_OUT_OF_DATE_KHR) {
|
||||||
recreate_swapchain();
|
recreate_swapchain();
|
||||||
} else if (presented != VK_SUCCESS) {
|
} else if (presented != VK_SUCCESS && presented != VK_SUBOPTIMAL_KHR) {
|
||||||
check(presented, "vkQueuePresentKHR");
|
check(presented, "vkQueuePresentKHR");
|
||||||
}
|
}
|
||||||
const auto presented_at = std::chrono::steady_clock::now();
|
const auto presented_at = std::chrono::steady_clock::now();
|
||||||
@@ -2027,7 +2042,17 @@ private:
|
|||||||
info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_;
|
info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_;
|
||||||
info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
|
info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
|
||||||
(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
|
(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
|
||||||
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; info.preTransform = capabilities.currentTransform;
|
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||||
|
// Android surfaces can report a quarter-turn transform even when SDL's window is
|
||||||
|
// already landscape. Rendering in SDL window coordinates and requesting that transform
|
||||||
|
// again rotates the entire game image on presentation.
|
||||||
|
info.preTransform = (capabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
|
||||||
|
? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR : capabilities.currentTransform;
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("swapchain: window=%dx%d extent=%ux%u currentTransform=%u preTransform=%u",
|
||||||
|
width, height, extent_.width, extent_.height,
|
||||||
|
unsigned(capabilities.currentTransform), unsigned(info.preTransform));
|
||||||
|
#endif
|
||||||
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_;
|
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_;
|
||||||
info.clipped = VK_TRUE;
|
info.clipped = VK_TRUE;
|
||||||
check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR");
|
check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR");
|
||||||
@@ -2617,7 +2642,7 @@ private:
|
|||||||
}
|
}
|
||||||
it->second.last_decode_attempt_frame = frame_number_;
|
it->second.last_decode_attempt_frame = frame_number_;
|
||||||
|
|
||||||
mtgodot::Image decoded;
|
mtimage::Image decoded;
|
||||||
if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) {
|
if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) {
|
||||||
decoded = decode_texture_bytes(raw->second.data(), raw->second.size());
|
decoded = decode_texture_bytes(raw->second.data(), raw->second.size());
|
||||||
}
|
}
|
||||||
@@ -3187,6 +3212,32 @@ bool py_eval_true(const char* expr) {
|
|||||||
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
|
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
bool hide_mobile_taskbar_quickslots() {
|
||||||
|
return py_exec(
|
||||||
|
"_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n"
|
||||||
|
"for _mobile_quickslot in _mobile_taskbar.quickslot:\n"
|
||||||
|
" _mobile_quickslot.Hide()\n"
|
||||||
|
"_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(__APPLE__) && TARGET_OS_OSX
|
||||||
|
bool configure_macos_numeric_quickslots() {
|
||||||
|
return py_exec(
|
||||||
|
"_mac_game = _stream.curPhaseWindow\n"
|
||||||
|
"_mac_game.onPressKeyDict[app.DIK_5] = lambda: _mac_game._GameWindow__PressQuickSlot(4)\n"
|
||||||
|
"_mac_game.onPressKeyDict[app.DIK_6] = lambda: _mac_game._GameWindow__PressQuickSlot(5)\n"
|
||||||
|
"_mac_game.onPressKeyDict[app.DIK_7] = lambda: _mac_game._GameWindow__PressQuickSlot(6)\n"
|
||||||
|
"_mac_game.onPressKeyDict[app.DIK_8] = lambda: _mac_game._GameWindow__PressQuickSlot(7)\n"
|
||||||
|
"for _mac_key in (app.DIK_F1, app.DIK_F2, app.DIK_F3, app.DIK_F4):\n"
|
||||||
|
" del _mac_game.onPressKeyDict[_mac_key]\n"
|
||||||
|
"_mac_taskbar = _mac_game.interface.wndTaskBar\n"
|
||||||
|
"for _mac_slot_number in range(5, 9):\n"
|
||||||
|
" _mac_taskbar.GetChild('slot_%d' % _mac_slot_number).LoadImage('d:/ymir work/ui/game/taskbar/%d.sub' % _mac_slot_number)\n");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) {
|
bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) {
|
||||||
const auto p1 = spec.find(':');
|
const auto p1 = spec.find(':');
|
||||||
if (p1 == std::string::npos) return false;
|
if (p1 == std::string::npos) return false;
|
||||||
@@ -3206,15 +3257,25 @@ int run_live_client(
|
|||||||
bool gpu_skinning,
|
bool gpu_skinning,
|
||||||
bool native_terrain,
|
bool native_terrain,
|
||||||
bool login_screen,
|
bool login_screen,
|
||||||
|
bool selection_screen,
|
||||||
const std::string& live_server_spec,
|
const std::string& live_server_spec,
|
||||||
const std::string& capture_out) {
|
const std::string& capture_out,
|
||||||
|
const std::string& screenshot_out) {
|
||||||
if (fake_mobs > 0) {
|
if (fake_mobs > 0) {
|
||||||
const std::string mobs_str = std::to_string(fake_mobs);
|
const std::string mobs_str = std::to_string(fake_mobs);
|
||||||
setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1);
|
setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1);
|
||||||
}
|
}
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SetTraceErrorObserver([](const char* line) {
|
||||||
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "40250 SYSERR: %s", line);
|
||||||
|
});
|
||||||
|
#endif
|
||||||
char resolved_client_dir[4096] = {};
|
char resolved_client_dir[4096] = {};
|
||||||
const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir;
|
const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir;
|
||||||
setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1);
|
setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: pack init %s", abs_client_dir.c_str());
|
||||||
|
#endif
|
||||||
if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) {
|
if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) {
|
||||||
throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir);
|
throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir);
|
||||||
}
|
}
|
||||||
@@ -3226,6 +3287,9 @@ int run_live_client(
|
|||||||
SetNativeTerrainRenderEnabled(native_terrain);
|
SetNativeTerrainRenderEnabled(native_terrain);
|
||||||
SetGpuSkinningEnabled(gpu_skinning);
|
SetGpuSkinningEnabled(gpu_skinning);
|
||||||
NativeAudioEngine audio;
|
NativeAudioEngine audio;
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: audio initialized");
|
||||||
|
#endif
|
||||||
|
|
||||||
std::string server_host = "127.0.0.1";
|
std::string server_host = "127.0.0.1";
|
||||||
int auth_port = 0;
|
int auth_port = 0;
|
||||||
@@ -3271,11 +3335,17 @@ int run_live_client(
|
|||||||
#endif
|
#endif
|
||||||
if (!PythonBoot::Start(stdlib.c_str(), &error))
|
if (!PythonBoot::Start(stdlib.c_str(), &error))
|
||||||
throw std::runtime_error("PythonBoot::Start: " + error);
|
throw std::runtime_error("PythonBoot::Start: " + error);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: PythonBoot started");
|
||||||
|
#endif
|
||||||
|
|
||||||
SetPlatformServerTime(123456789);
|
SetPlatformServerTime(123456789);
|
||||||
PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height()));
|
PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height()));
|
||||||
if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping())
|
if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping())
|
||||||
throw std::runtime_error("PythonBoot::RunMainScript: " + error);
|
throw std::runtime_error("PythonBoot::RunMainScript: " + error);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: main script started");
|
||||||
|
#endif
|
||||||
|
|
||||||
const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures;
|
const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures;
|
||||||
auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool {
|
auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool {
|
||||||
@@ -3301,6 +3371,9 @@ int run_live_client(
|
|||||||
|
|
||||||
if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }))
|
if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }))
|
||||||
throw std::runtime_error("timed out waiting for LoginWindow");
|
throw std::runtime_error("timed out waiting for LoginWindow");
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: LoginWindow open");
|
||||||
|
#endif
|
||||||
|
|
||||||
if (login_screen) {
|
if (login_screen) {
|
||||||
char conn_cmd[512];
|
char conn_cmd[512];
|
||||||
@@ -3338,18 +3411,28 @@ int run_live_client(
|
|||||||
auth_port,
|
auth_port,
|
||||||
FakeLoginServer::kLogin,
|
FakeLoginServer::kLogin,
|
||||||
FakeLoginServer::kPassword);
|
FakeLoginServer::kPassword);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: submitting fake login");
|
||||||
|
#endif
|
||||||
if (!py_exec(login_cmd))
|
if (!py_exec(login_cmd))
|
||||||
throw std::runtime_error("failed to submit login credentials");
|
throw std::runtime_error("failed to submit login credentials");
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: fake login submitted");
|
||||||
|
#endif
|
||||||
|
|
||||||
if (!pump_until(20.0, 5, [&] {
|
if (!pump_until(20.0, 5, [&] {
|
||||||
return (use_external_server || server.Has("game1:login2")) &&
|
return (use_external_server || server.Has("game1:login2")) &&
|
||||||
py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)");
|
py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)");
|
||||||
}))
|
}))
|
||||||
throw std::runtime_error("timed out waiting for SelectCharacterWindow");
|
throw std::runtime_error("timed out waiting for SelectCharacterWindow");
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: SelectCharacterWindow open");
|
||||||
|
#endif
|
||||||
|
|
||||||
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
|
if (!selection_screen) {
|
||||||
"_stream.curPhaseWindow.StartGame()"))
|
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
|
||||||
throw std::runtime_error("failed to start game from SelectCharacterWindow");
|
"_stream.curPhaseWindow.StartGame()"))
|
||||||
|
throw std::runtime_error("failed to start game from SelectCharacterWindow");
|
||||||
|
|
||||||
if (!use_external_server) {
|
if (!use_external_server) {
|
||||||
if (!pump_until(30.0, 5, [&] { return server.Has("game2:client_version"); }))
|
if (!pump_until(30.0, 5, [&] { return server.Has("game2:client_version"); }))
|
||||||
@@ -3362,12 +3445,22 @@ int run_live_client(
|
|||||||
}))
|
}))
|
||||||
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
|
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
|
||||||
|
|
||||||
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
|
#ifdef __ANDROID__
|
||||||
for (int i = 0; i < warmup_frames; ++i) {
|
if (!hide_mobile_taskbar_quickslots())
|
||||||
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
|
throw std::runtime_error("failed to hide the desktop quick-slot bar on Android");
|
||||||
PythonBoot::UIUpdate();
|
#endif
|
||||||
renderer.sync_game_cursor();
|
#if defined(__APPLE__) && TARGET_OS_OSX
|
||||||
audio.pump();
|
if (!configure_macos_numeric_quickslots())
|
||||||
|
throw std::runtime_error("failed to configure numeric quick slots on macOS");
|
||||||
|
#endif
|
||||||
|
|
||||||
|
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
|
||||||
|
for (int i = 0; i < warmup_frames; ++i) {
|
||||||
|
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
|
||||||
|
PythonBoot::UIUpdate();
|
||||||
|
renderer.sync_game_cursor();
|
||||||
|
audio.pump();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3417,6 +3510,10 @@ int run_live_client(
|
|||||||
native_draw_capture::write(capture_out, Render3DDraws(), cap_textures, ui_w, ui_h, cap_ui);
|
native_draw_capture::write(capture_out, Render3DDraws(), cap_textures, ui_w, ui_h, cap_ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!screenshot_out.empty() && frames > 0) {
|
||||||
|
renderer.request_screenshot(screenshot_out, renderer.frame_number() + static_cast<std::uint64_t>(frames));
|
||||||
|
}
|
||||||
|
|
||||||
renderer.reset_timings();
|
renderer.reset_timings();
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
double update_ms = 0.0;
|
double update_ms = 0.0;
|
||||||
@@ -3442,10 +3539,16 @@ int run_live_client(
|
|||||||
++completed;
|
++completed;
|
||||||
}
|
}
|
||||||
renderer.finish_gpu_timings();
|
renderer.finish_gpu_timings();
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: render loop complete (%d frames)", completed);
|
||||||
|
#endif
|
||||||
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||||
print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms));
|
print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms));
|
||||||
|
|
||||||
PythonBoot::Stop();
|
PythonBoot::Stop();
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("live stage: PythonBoot stopped");
|
||||||
|
#endif
|
||||||
if (!use_external_server)
|
if (!use_external_server)
|
||||||
server.Stop();
|
server.Stop();
|
||||||
return (frames == 0 || completed == frames) ? 0 : 2;
|
return (frames == 0 || completed == frames) ? 0 : 2;
|
||||||
@@ -3461,6 +3564,7 @@ int main(int argc, char** argv) {
|
|||||||
bool synthetic_specified = false;
|
bool synthetic_specified = false;
|
||||||
bool interactive = false;
|
bool interactive = false;
|
||||||
bool login_screen = false;
|
bool login_screen = false;
|
||||||
|
bool selection_screen = false;
|
||||||
int init_width = 1280;
|
int init_width = 1280;
|
||||||
int init_height = 800;
|
int init_height = 800;
|
||||||
std::size_t draw_count = 64;
|
std::size_t draw_count = 64;
|
||||||
@@ -3489,6 +3593,9 @@ int main(int argc, char** argv) {
|
|||||||
else if (arg == "--login-screen") {
|
else if (arg == "--login-screen") {
|
||||||
login_screen = true;
|
login_screen = true;
|
||||||
interactive = true;
|
interactive = true;
|
||||||
|
} else if (arg == "--selection-screen") {
|
||||||
|
selection_screen = true;
|
||||||
|
interactive = true;
|
||||||
} else if (arg == "--live-server" && i+1 < argc) {
|
} else if (arg == "--live-server" && i+1 < argc) {
|
||||||
live_server_spec = argv[++i];
|
live_server_spec = argv[++i];
|
||||||
interactive = true;
|
interactive = true;
|
||||||
@@ -3525,7 +3632,7 @@ int main(int argc, char** argv) {
|
|||||||
else if (arg == "--no-terrain") native_terrain = false;
|
else if (arg == "--no-terrain") native_terrain = false;
|
||||||
else if (arg == "--mobile") mobile_mode = true;
|
else if (arg == "--mobile") mobile_mode = true;
|
||||||
else throw std::runtime_error(
|
else throw std::runtime_error(
|
||||||
"usage: mt_native_render [--frames N] [--interactive] [--login-screen] "
|
"usage: mt_native_render [--frames N] [--interactive] [--login-screen|--selection-screen] "
|
||||||
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
|
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
|
||||||
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
|
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
|
||||||
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
|
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
|
||||||
@@ -3558,6 +3665,12 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (interactive && !frames_specified) frames = 0;
|
if (interactive && !frames_specified) frames = 0;
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("native startup: argc=%d live_client=%s selection=%d frames=%d", argc,
|
||||||
|
live_client_dir.c_str(), selection_screen, frames);
|
||||||
|
#endif
|
||||||
|
if (login_screen && selection_screen)
|
||||||
|
throw std::runtime_error("--login-screen and --selection-screen are mutually exclusive");
|
||||||
if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 ||
|
if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 ||
|
||||||
triangles_per_draw == 0 || triangles_per_draw > 10000)
|
triangles_per_draw == 0 || triangles_per_draw > 10000)
|
||||||
throw std::runtime_error("invalid frame/draw/triangle count");
|
throw std::runtime_error("invalid frame/draw/triangle count");
|
||||||
@@ -3569,7 +3682,7 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
if (!live_client_dir.empty()) {
|
if (!live_client_dir.empty()) {
|
||||||
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
return run_live_client(
|
const int result = run_live_client(
|
||||||
renderer,
|
renderer,
|
||||||
live_client_dir,
|
live_client_dir,
|
||||||
frames,
|
frames,
|
||||||
@@ -3577,8 +3690,14 @@ int main(int argc, char** argv) {
|
|||||||
gpu_skinning,
|
gpu_skinning,
|
||||||
native_terrain,
|
native_terrain,
|
||||||
login_screen,
|
login_screen,
|
||||||
|
selection_screen,
|
||||||
live_server_spec,
|
live_server_spec,
|
||||||
capture_out);
|
capture_out,
|
||||||
|
screenshot_out);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_Log("native live client exit: %d", result);
|
||||||
|
#endif
|
||||||
|
return result;
|
||||||
#else
|
#else
|
||||||
throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)");
|
throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)");
|
||||||
#endif
|
#endif
|
||||||
@@ -3620,6 +3739,9 @@ int main(int argc, char** argv) {
|
|||||||
return (frames == 0 || completed == frames) ? 0 : 2;
|
return (frames == 0 || completed == frames) ? 0 : 2;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "native renderer: " << error.what() << '\n';
|
std::cerr << "native renderer: " << error.what() << '\n';
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "native renderer: %s", error.what());
|
||||||
|
#endif
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-211
@@ -17,9 +17,7 @@
|
|||||||
class TouchController {
|
class TouchController {
|
||||||
public:
|
public:
|
||||||
struct ButtonDef {
|
struct ButtonDef {
|
||||||
int id; // 1 = ATK, 2 = S1, 3 = S2, 4 = S3, 5 = POT, 6 = PICK
|
int id; // 1 = attack, 2..7 = quick slots 1..6
|
||||||
// 99 = MENU (toggle drawer)
|
|
||||||
// 10 = BAG (DIK_I), 11 = CHAR (DIK_C), 12 = SKILL (DIK_V), 13 = QUEST (DIK_N), 14 = COMM (DIK_M), 15 = SET (DIK_ESCAPE)
|
|
||||||
int dik;
|
int dik;
|
||||||
float x, y, radius;
|
float x, y, radius;
|
||||||
const char* label;
|
const char* label;
|
||||||
@@ -53,6 +51,14 @@ public:
|
|||||||
// Called once per frame to maintain continuous analog movement
|
// Called once per frame to maintain continuous analog movement
|
||||||
void update() {
|
void update() {
|
||||||
if (!enabled_) return;
|
if (!enabled_) return;
|
||||||
|
const bool controls_visible = gameplay_controls_visible();
|
||||||
|
if (!controls_visible) {
|
||||||
|
if (controls_were_visible_)
|
||||||
|
release_gameplay_controls();
|
||||||
|
controls_were_visible_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controls_were_visible_ = true;
|
||||||
if (joystick_active_) {
|
if (joystick_active_) {
|
||||||
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
PythonBoot::SetMoveDirection(move_angle_, true);
|
PythonBoot::SetMoveDirection(move_angle_, true);
|
||||||
@@ -65,23 +71,25 @@ public:
|
|||||||
if (!enabled_) return false;
|
if (!enabled_) return false;
|
||||||
const float px = norm_x * float(screen_w_);
|
const float px = norm_x * float(screen_w_);
|
||||||
const float py = norm_y * float(screen_h_);
|
const float py = norm_y * float(screen_h_);
|
||||||
|
const bool controls_visible = gameplay_controls_visible();
|
||||||
|
|
||||||
// 1. Check HUD buttons (Action cluster & Drawer menu)
|
// The mobile overlay owns only the movement and combat control regions.
|
||||||
int btn_idx = find_button(px, py);
|
if (controls_visible) {
|
||||||
if (btn_idx >= 0) {
|
const int btn_idx = find_button(px, py);
|
||||||
auto& btn = buttons_[btn_idx];
|
if (btn_idx >= 0) {
|
||||||
if (btn.id == 99) { // MENU toggle button
|
auto& btn = buttons_[btn_idx];
|
||||||
drawer_open_ = !drawer_open_;
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
layout_buttons();
|
if (btn.id >= 2 && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2))
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
btn.pressed = true;
|
||||||
|
btn.finger_id = finger_id;
|
||||||
|
trigger_button(btn.dik, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
btn.pressed = true;
|
|
||||||
btn.finger_id = finger_id;
|
|
||||||
trigger_button(btn.dik, true);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Intelligent UI Hit-Testing: check if touch lands inside an open in-game UI window
|
// Everything else uses the unchanged 40250 UI and its normal mouse path.
|
||||||
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) {
|
if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) {
|
||||||
ui_touch_finger_id_ = finger_id;
|
ui_touch_finger_id_ = finger_id;
|
||||||
@@ -91,8 +99,8 @@ public:
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 3. Left side: virtual joystick (lower-left quadrant)
|
// Start movement only in the gameplay phase.
|
||||||
if (norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) {
|
if (controls_visible && norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) {
|
||||||
joystick_active_ = true;
|
joystick_active_ = true;
|
||||||
joystick_finger_id_ = finger_id;
|
joystick_finger_id_ = finger_id;
|
||||||
joystick_base_x_ = px;
|
joystick_base_x_ = px;
|
||||||
@@ -103,7 +111,10 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 3D Game World: camera rotation, pinch zoom, or tap to target
|
if (!controls_visible)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// The remaining gameplay area controls camera and world selection.
|
||||||
if (camera_finger_id_ < 0) {
|
if (camera_finger_id_ < 0) {
|
||||||
camera_finger_id_ = finger_id;
|
camera_finger_id_ = finger_id;
|
||||||
camera_last_x_ = px;
|
camera_last_x_ = px;
|
||||||
@@ -265,80 +276,12 @@ public:
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render virtual joystick, buttons, drawer menu, and player status bar
|
// Overlay only the controls that have no practical desktop-UI touch equivalent.
|
||||||
void append_ui_commands(std::vector<UIRenderCommand>& commands) const {
|
void append_ui_commands(std::vector<UIRenderCommand>& commands) const {
|
||||||
if (!enabled_) return;
|
if (!enabled_ || !gameplay_controls_visible()) return;
|
||||||
|
|
||||||
const float W = float(screen_w_);
|
// Right-side attack and quick-slot buttons.
|
||||||
|
|
||||||
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
|
||||||
// --- 1. Top-Left Player Status Bar (Mobile HUD) ---
|
|
||||||
const auto status = PythonBoot::GetPlayerStatusInfo();
|
|
||||||
if (status.max_hp > 0) {
|
|
||||||
const float bar_x = 20.0f;
|
|
||||||
const float bar_y = 16.0f;
|
|
||||||
const float bar_w = 205.0f;
|
|
||||||
const float bar_h = 44.0f;
|
|
||||||
|
|
||||||
// Background plate
|
|
||||||
draw_rect_bar(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x90101824);
|
|
||||||
draw_rect_lines(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x60405870);
|
|
||||||
|
|
||||||
// Avatar circle badge
|
|
||||||
const float av_cx = bar_x + 22.0f;
|
|
||||||
const float av_cy = bar_y + 22.0f;
|
|
||||||
const float av_r = 15.0f;
|
|
||||||
draw_filled_disc(commands, av_cx, av_cy, av_r, 0xB0203040);
|
|
||||||
draw_circle(commands, av_cx, av_cy, av_r, 0xE0FFD700, 16);
|
|
||||||
// "Lv" tick inside avatar
|
|
||||||
draw_line(commands, av_cx - 4.0f, av_cy - 4.0f, av_cx - 4.0f, av_cy + 3.0f, 0xFFFFFFFF);
|
|
||||||
draw_line(commands, av_cx - 4.0f, av_cy + 3.0f, av_cx - 1.0f, av_cy + 3.0f, 0xFFFFFFFF);
|
|
||||||
draw_line(commands, av_cx + 1.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
|
|
||||||
draw_line(commands, av_cx + 5.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
|
|
||||||
|
|
||||||
// Gauges
|
|
||||||
const float gx1 = bar_x + 44.0f;
|
|
||||||
const float gx2 = bar_x + bar_w - 10.0f;
|
|
||||||
const float gw = gx2 - gx1;
|
|
||||||
|
|
||||||
// HP Gauge
|
|
||||||
const float hp_y1 = bar_y + 10.0f;
|
|
||||||
const float hp_y2 = hp_y1 + 10.0f;
|
|
||||||
const float hp_ratio = std::clamp(float(status.hp) / float(status.max_hp), 0.0f, 1.0f);
|
|
||||||
draw_rect_bar(commands, gx1, hp_y1, gx2, hp_y2, 0x70301010);
|
|
||||||
draw_rect_bar(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y2, 0xE5D82424);
|
|
||||||
draw_line(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y1, 0x60FFFFFF); // Gloss line
|
|
||||||
draw_rect_lines(commands, gx1, hp_y1, gx2, hp_y2, 0x80502020);
|
|
||||||
|
|
||||||
// MP Gauge
|
|
||||||
const float mp_y1 = bar_y + 24.0f;
|
|
||||||
const float mp_y2 = mp_y1 + 8.0f;
|
|
||||||
const float mp_ratio = status.max_sp > 0 ? std::clamp(float(status.sp) / float(status.max_sp), 0.0f, 1.0f) : 0.0f;
|
|
||||||
draw_rect_bar(commands, gx1, mp_y1, gx2, mp_y2, 0x70102038);
|
|
||||||
draw_rect_bar(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y2, 0xE52068E0);
|
|
||||||
draw_line(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y1, 0x60FFFFFF); // Gloss line
|
|
||||||
draw_rect_lines(commands, gx1, mp_y1, gx2, mp_y2, 0x80204060);
|
|
||||||
|
|
||||||
// EXP bar (bottom trim line)
|
|
||||||
if (status.max_exp > 0) {
|
|
||||||
const float exp_ratio = std::clamp(float(status.exp) / float(status.max_exp), 0.0f, 1.0f);
|
|
||||||
draw_rect_bar(commands, bar_x, bar_y + bar_h - 2.0f, bar_x + bar_w * exp_ratio, bar_y + bar_h, 0xD0E0B020);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// --- 2. Top-Right Drawer Menu Bar ---
|
|
||||||
if (drawer_open_) {
|
|
||||||
// Background capsule tray behind drawer buttons
|
|
||||||
draw_rect_bar(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x90101824);
|
|
||||||
draw_rect_lines(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x60506880);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3. Buttons Rendering (Combat wheel & Drawer items) ---
|
|
||||||
for (const auto& btn : buttons_) {
|
for (const auto& btn : buttons_) {
|
||||||
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle;
|
const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle;
|
||||||
draw_filled_disc(commands, btn.x, btn.y, btn.radius, col);
|
draw_filled_disc(commands, btn.x, btn.y, btn.radius, col);
|
||||||
draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16);
|
draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16);
|
||||||
@@ -346,6 +289,24 @@ public:
|
|||||||
const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF;
|
const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF;
|
||||||
const float r = btn.radius;
|
const float r = btn.radius;
|
||||||
|
|
||||||
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
|
if (btn.id >= 2) {
|
||||||
|
const std::string skill_icon = PythonBoot::LocalQuickSlotSkillIcon(btn.id - 2);
|
||||||
|
if (!skill_icon.empty()) {
|
||||||
|
UIRenderCommand icon{};
|
||||||
|
icon.kind = UIRenderCommand::Image;
|
||||||
|
icon.x1 = btn.x - r * 0.68f;
|
||||||
|
icon.y1 = btn.y - r * 0.68f;
|
||||||
|
icon.x2 = btn.x + r * 0.68f;
|
||||||
|
icon.y2 = btn.y + r * 0.68f;
|
||||||
|
icon.argb = icon_col;
|
||||||
|
icon.text = skill_icon;
|
||||||
|
commands.push_back(std::move(icon));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
switch (btn.id) {
|
switch (btn.id) {
|
||||||
case 1: { // ATK: crossed swords
|
case 1: { // ATK: crossed swords
|
||||||
const float s = r * 0.35f;
|
const float s = r * 0.35f;
|
||||||
@@ -376,69 +337,30 @@ public:
|
|||||||
draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col);
|
draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 5: { // POT: +
|
case 5: { // S4: IV
|
||||||
const float p = r * 0.4f;
|
const float h = r * 0.35f;
|
||||||
draw_line(commands, btn.x - p, btn.y, btn.x + p, btn.y, icon_col);
|
draw_line(commands, btn.x - 7.0f, btn.y - h, btn.x - 7.0f, btn.y + h, icon_col);
|
||||||
draw_line(commands, btn.x, btn.y - p, btn.x, btn.y + p, icon_col);
|
draw_line(commands, btn.x, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
|
||||||
|
draw_line(commands, btn.x + 14.0f, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 6: { // PICK: Downward arrow
|
case 6: { // S5: V
|
||||||
const float a = r * 0.35f;
|
const float h = r * 0.35f;
|
||||||
draw_line(commands, btn.x - a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col);
|
draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
|
||||||
draw_line(commands, btn.x + a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col);
|
draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
|
||||||
draw_line(commands, btn.x, btn.y - a * 0.7f, btn.x, btn.y + a * 0.6f, icon_col);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 99: { // MENU: ☰ hamburger icon
|
case 7: { // S6: VI
|
||||||
draw_line(commands, btn.x - 7.0f, btn.y - 5.0f, btn.x + 7.0f, btn.y - 5.0f, icon_col);
|
const float h = r * 0.35f;
|
||||||
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col);
|
draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
|
||||||
draw_line(commands, btn.x - 7.0f, btn.y + 5.0f, btn.x + 7.0f, btn.y + 5.0f, icon_col);
|
draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
|
||||||
break;
|
draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col);
|
||||||
}
|
|
||||||
case 10: { // BAG: Backpack
|
|
||||||
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 4.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y - 4.0f, btn.x, btn.y - 7.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x, btn.y - 7.0f, btn.x + 3.0f, btn.y - 4.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 6.0f, btn.y, btn.x + 6.0f, btn.y, icon_col);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 11: { // CHAR: Head + Shoulders
|
|
||||||
draw_circle(commands, btn.x, btn.y - 3.0f, 4.0f, icon_col, 12);
|
|
||||||
draw_line(commands, btn.x - 6.0f, btn.y + 6.0f, btn.x - 3.0f, btn.y + 2.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 3.0f, btn.y + 2.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x + 3.0f, btn.y + 2.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 12: { // SKILL: Lightning
|
|
||||||
draw_line(commands, btn.x + 2.0f, btn.y - 7.0f, btn.x - 3.0f, btn.y - 1.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y - 1.0f, btn.x + 1.0f, btn.y - 1.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x + 1.0f, btn.y - 1.0f, btn.x - 2.0f, btn.y + 7.0f, icon_col);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 13: { // QUEST: Scroll
|
|
||||||
draw_rect_lines(commands, btn.x - 5.0f, btn.y - 6.0f, btn.x + 5.0f, btn.y + 6.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y - 2.0f, btn.x + 3.0f, btn.y - 2.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 1.0f, btn.y + 2.0f, icon_col);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 14: { // COMM: Chat bubble
|
|
||||||
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 5.0f, btn.x + 6.0f, btn.y + 3.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 3.0f, btn.y + 3.0f, btn.x - 5.0f, btn.y + 6.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 5.0f, btn.y + 6.0f, btn.x, btn.y + 3.0f, icon_col);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 15: { // SET: Gear / Close
|
|
||||||
draw_circle(commands, btn.x, btn.y, 4.0f, icon_col, 10);
|
|
||||||
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col);
|
|
||||||
draw_line(commands, btn.x, btn.y - 7.0f, btn.x, btn.y + 7.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 5.0f, btn.y - 5.0f, btn.x + 5.0f, btn.y + 5.0f, icon_col);
|
|
||||||
draw_line(commands, btn.x - 5.0f, btn.y + 5.0f, btn.x + 5.0f, btn.y - 5.0f, icon_col);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. Virtual Joystick ---
|
// Left-side movement joystick.
|
||||||
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24);
|
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24);
|
||||||
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16);
|
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16);
|
||||||
draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_,
|
draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_,
|
||||||
@@ -494,26 +416,52 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
bool gameplay_controls_visible() const {
|
||||||
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
|
return !PythonBoot::CurrentMapName().empty();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void release_gameplay_controls() {
|
||||||
|
if (joystick_active_) {
|
||||||
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
|
PythonBoot::SetMoveDirection(0.0f, false);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
joystick_active_ = false;
|
||||||
|
joystick_finger_id_ = -1;
|
||||||
|
joystick_knob_x_ = joystick_base_x_;
|
||||||
|
joystick_knob_y_ = joystick_base_y_;
|
||||||
|
for (auto& btn : buttons_) {
|
||||||
|
if (btn.pressed)
|
||||||
|
trigger_button(btn.dik, false);
|
||||||
|
btn.pressed = false;
|
||||||
|
btn.finger_id = -1;
|
||||||
|
}
|
||||||
|
if (camera_dragged_) {
|
||||||
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
||||||
|
PythonBoot::CameraEndDrag();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
camera_finger_id_ = -1;
|
||||||
|
camera_dragged_ = false;
|
||||||
|
pinch_finger1_ = -1;
|
||||||
|
pinch_finger2_ = -1;
|
||||||
|
pinch_last_dist_ = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
void init_buttons() {
|
void init_buttons() {
|
||||||
buttons_.clear();
|
buttons_.clear();
|
||||||
// Combat Action Wheel (Lower-Right)
|
// Lower-right combat controls. All menus and status UI remain the original 40250 UI.
|
||||||
buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040});
|
buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040});
|
||||||
buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF});
|
buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF});
|
||||||
buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0});
|
buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0});
|
||||||
buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080});
|
buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080});
|
||||||
buttons_.push_back({5, 0x05, 0, 0, 22.0f, "POT", 0x90A03030, 0xD0FF5050});
|
buttons_.push_back({5, 0x05, 0, 0, 26.0f, "S4", 0x80906030, 0xD0D08050});
|
||||||
buttons_.push_back({6, 0x2c, 0, 0, 22.0f, "PICK", 0x80208080, 0xD040B0B0});
|
buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0});
|
||||||
|
buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0});
|
||||||
// Top-Right Drawer Menu Toggle
|
|
||||||
buttons_.push_back({99, 0, 0, 0, 20.0f, "MENU", 0x90283848, 0xD0FFB040});
|
|
||||||
|
|
||||||
// Drawer Menu Buttons
|
|
||||||
buttons_.push_back({10, 0x17, 0, 0, 18.0f, "BAG", 0x90D09020, 0xD0FFB040}); // DIK_I
|
|
||||||
buttons_.push_back({11, 0x2e, 0, 0, 18.0f, "CHAR", 0x903060B0, 0xD05080FF}); // DIK_C
|
|
||||||
buttons_.push_back({12, 0x2f, 0, 0, 18.0f, "SKILL", 0x90803090, 0xD0D050D0}); // DIK_V
|
|
||||||
buttons_.push_back({13, 0x31, 0, 0, 18.0f, "QUEST", 0x90308050, 0xD050D070}); // DIK_N
|
|
||||||
buttons_.push_back({14, 0x32, 0, 0, 18.0f, "COMM", 0x90905020, 0xD0E07030}); // DIK_M
|
|
||||||
buttons_.push_back({15, 0x01, 0, 0, 18.0f, "SET", 0x90506070, 0xD08090A0}); // DIK_ESCAPE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void layout_buttons() {
|
void layout_buttons() {
|
||||||
@@ -522,70 +470,40 @@ private:
|
|||||||
for (auto& btn : buttons_) {
|
for (auto& btn : buttons_) {
|
||||||
switch (btn.id) {
|
switch (btn.id) {
|
||||||
case 1:
|
case 1:
|
||||||
btn.x = W - 85.0f;
|
btn.x = W - 75.0f;
|
||||||
btn.y = H - 85.0f;
|
btn.y = H - 85.0f;
|
||||||
btn.radius = std::min(46.0f, H * 0.12f);
|
btn.radius = std::min(46.0f, H * 0.12f);
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
|
btn.x = W - 360.0f;
|
||||||
|
btn.y = H - 75.0f;
|
||||||
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
btn.x = W - 295.0f;
|
||||||
|
btn.y = H - 75.0f;
|
||||||
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
btn.x = W - 230.0f;
|
||||||
|
btn.y = H - 75.0f;
|
||||||
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
btn.x = W - 165.0f;
|
btn.x = W - 165.0f;
|
||||||
btn.y = H - 85.0f;
|
btn.y = H - 85.0f;
|
||||||
btn.radius = std::min(28.0f, H * 0.08f);
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 6:
|
||||||
btn.x = W - 145.0f;
|
btn.x = W - 125.0f;
|
||||||
btn.y = H - 155.0f;
|
btn.y = H - 155.0f;
|
||||||
btn.radius = std::min(28.0f, H * 0.08f);
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 7:
|
||||||
btn.x = W - 85.0f;
|
btn.x = W - 75.0f;
|
||||||
btn.y = H - 175.0f;
|
btn.y = H - 225.0f;
|
||||||
btn.radius = std::min(28.0f, H * 0.08f);
|
btn.radius = std::min(28.0f, H * 0.08f);
|
||||||
break;
|
break;
|
||||||
case 5:
|
|
||||||
btn.x = W - 225.0f;
|
|
||||||
btn.y = H - 75.0f;
|
|
||||||
btn.radius = std::min(24.0f, H * 0.065f);
|
|
||||||
break;
|
|
||||||
case 6:
|
|
||||||
btn.x = W - 85.0f;
|
|
||||||
btn.y = H - 235.0f;
|
|
||||||
btn.radius = std::min(24.0f, H * 0.065f);
|
|
||||||
break;
|
|
||||||
case 99: // MENU toggle button (to the left of MiniMap)
|
|
||||||
btn.x = W - 170.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 20.0f;
|
|
||||||
break;
|
|
||||||
case 10: // BAG
|
|
||||||
btn.x = W - 220.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
case 11: // CHAR
|
|
||||||
btn.x = W - 265.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
case 12: // SKILL
|
|
||||||
btn.x = W - 310.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
case 13: // QUEST
|
|
||||||
btn.x = W - 355.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
case 14: // COMM
|
|
||||||
btn.x = W - 400.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
case 15: // SET
|
|
||||||
btn.x = W - 445.0f;
|
|
||||||
btn.y = 35.0f;
|
|
||||||
btn.radius = 18.0f;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,8 +511,6 @@ private:
|
|||||||
int find_button(float x, float y) {
|
int find_button(float x, float y) {
|
||||||
for (size_t i = 0; i < buttons_.size(); ++i) {
|
for (size_t i = 0; i < buttons_.size(); ++i) {
|
||||||
const auto& btn = buttons_[i];
|
const auto& btn = buttons_[i];
|
||||||
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
|
|
||||||
continue;
|
|
||||||
const float dist = std::hypot(x - btn.x, y - btn.y);
|
const float dist = std::hypot(x - btn.x, y - btn.y);
|
||||||
if (dist <= btn.radius * 1.25f) {
|
if (dist <= btn.radius * 1.25f) {
|
||||||
return int(i);
|
return int(i);
|
||||||
@@ -608,6 +524,11 @@ private:
|
|||||||
if (dik == 0x39) {
|
if (dik == 0x39) {
|
||||||
PythonBoot::SetAttackKey(pressed);
|
PythonBoot::SetAttackKey(pressed);
|
||||||
}
|
}
|
||||||
|
if (dik >= 0x02 && dik <= 0x07) {
|
||||||
|
if (pressed)
|
||||||
|
PythonBoot::UseLocalQuickSlot(dik - 0x02);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (dik != 0) {
|
if (dik != 0) {
|
||||||
PythonBoot::UIKey(dik, pressed);
|
PythonBoot::UIKey(dik, pressed);
|
||||||
}
|
}
|
||||||
@@ -703,7 +624,7 @@ private:
|
|||||||
int screen_w_ = 1280;
|
int screen_w_ = 1280;
|
||||||
int screen_h_ = 720;
|
int screen_h_ = 720;
|
||||||
|
|
||||||
bool drawer_open_ = false;
|
bool controls_were_visible_ = false;
|
||||||
int64_t ui_touch_finger_id_ = -1;
|
int64_t ui_touch_finger_id_ = -1;
|
||||||
|
|
||||||
bool joystick_active_ = false;
|
bool joystick_active_ = false;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。
|
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。
|
||||||
# 前置:oracle/build-wine.sh 已产出 oracle.exe;WINEPREFIX 已 init(见 RUNBOOK.md);
|
# 前置:oracle/build-wine.sh 已产出 oracle.exe;WINEPREFIX 已 init(见 RUNBOOK.md);
|
||||||
# oracle_diff 已构建(cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON)。
|
# oracle_diff 已构建(cmake -B build-tools -DMT_BUILD_TOOLS=ON)。
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
REPO="$(pwd)"
|
REPO="$(pwd)"
|
||||||
@@ -11,7 +11,7 @@ DIFF=""
|
|||||||
for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do
|
for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do
|
||||||
[ -x "$c" ] && DIFF="$c" && break
|
[ -x "$c" ] && DIFF="$c" && break
|
||||||
done
|
done
|
||||||
[ -n "$DIFF" ] || { echo "oracle_diff not built — run: cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON && cmake --build build-tools"; exit 2; }
|
[ -n "$DIFF" ] || { echo "oracle_diff not built — run: cmake -B build-tools -DMT_BUILD_TOOLS=ON && cmake --build build-tools"; exit 2; }
|
||||||
mkdir -p "$DUMPS"
|
mkdir -p "$DUMPS"
|
||||||
|
|
||||||
WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}"
|
WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}"
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Pack the Metin2 asset tree into build/export/assets.zip for the mobile builds.
|
|
||||||
#
|
|
||||||
# The APK/IPA is code-only (assets are gitignored and can't go in res://). The
|
|
||||||
# zip is mounted at runtime by AssetPack.ensure() -> res://assets/... and read
|
|
||||||
# through godot::FileAccess (all C++ asset IO now goes through it).
|
|
||||||
#
|
|
||||||
# ./pack-assets.sh # bake index + zip -0 (store, no compression)
|
|
||||||
#
|
|
||||||
# Deploy:
|
|
||||||
# Android: adb push build/export/assets.zip /sdcard/Android/data/org.internal.mtgodotpoc/files/
|
|
||||||
# iOS: ios-deploy --bundle_id org.internal.mtgodotpoc --upload build/export/assets.zip --to Documents/
|
|
||||||
# desktop: not needed (loose assets/ is used directly)
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
GODOT="${GODOT:-godot}"
|
|
||||||
OUT="build/export/assets.zip"
|
|
||||||
|
|
||||||
if [ ! -d assets ]; then
|
|
||||||
echo "no ./assets — nothing to pack" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. refresh the AssetResolver index (baked; device can't std::filesystem-scan a pack)
|
|
||||||
"$GODOT" --headless --path project --script bake_asset_index.gd || {
|
|
||||||
echo "asset_index bake failed" >&2; exit 1; }
|
|
||||||
|
|
||||||
# 2. zip -0 (store): DDS/gr2 barely compress and we want fast random access from
|
|
||||||
# FileAccess; -X drops extra file attrs. Paths stay assets/... bgm/... so they
|
|
||||||
# mount as res://assets/... res://bgm/...
|
|
||||||
mkdir -p build/export
|
|
||||||
rm -f "$OUT"
|
|
||||||
zip -0 -r -X "$OUT" assets bgm \
|
|
||||||
-x '*/.git/*' -x '*.DS_Store' -x 'assets/.gdignore'
|
|
||||||
|
|
||||||
SZ=$(du -h "$OUT" | cut -f1)
|
|
||||||
N=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
|
|
||||||
echo
|
|
||||||
echo ">> $OUT ($SZ, $N entries)"
|
|
||||||
echo ">> Android: adb push $OUT /sdcard/Android/data/org.internal.mtgodotpoc/files/"
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# AssetPack —— 把 Metin2 资源(assets/ + bgm/ + asset_index.txt 打成的 assets.zip)
|
|
||||||
# 挂载到 res://,供 AssetRoot / C++ 的 FileAccess 读取。
|
|
||||||
#
|
|
||||||
# 桌面开发:仓库旁的散 assets/ 直接可用 -> ensure() 什么都不做。
|
|
||||||
# 移动端:APK/IPA 里没有资源;assets.zip 由外部提供:
|
|
||||||
# Android —— adb push build/export/assets.zip /sdcard/Android/data/<pkg>/files/
|
|
||||||
# iOS —— ios-deploy --upload assets.zip --to Documents/
|
|
||||||
# 找到就 ProjectSettings.load_resource_pack() 挂到 res://。
|
|
||||||
#
|
|
||||||
# 候选路径:MT_ASSETS_ZIP 环境变量 -> user://assets.zip ->
|
|
||||||
# <可执行文件目录>/assets.zip -> res://../assets.zip(开发)
|
|
||||||
#
|
|
||||||
# FUTURE(下载版):_find_local_zip() 失败时,改为 HTTPRequest 从 ASSET_PACK_URL
|
|
||||||
# 下载到 user://assets.zip(断点续传 + sha256 校验 + 进度 UI),再走同一个 mount。
|
|
||||||
class_name AssetPack
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
const ZIP_NAME := "assets.zip"
|
|
||||||
# const ASSET_PACK_URL := "" # FUTURE: 下载版填这里
|
|
||||||
|
|
||||||
static var mounted := false
|
|
||||||
static var last_source := ""
|
|
||||||
|
|
||||||
# 返回 true = 资源可用(散文件 或 已挂载 zip)。同步;本地文件场景不阻塞。
|
|
||||||
# force_zip=true:即使散 assets/ 可用也去挂 zip(测试 / 验证移动端路径用)。
|
|
||||||
static func ensure(force_zip := false) -> bool:
|
|
||||||
if mounted:
|
|
||||||
return true
|
|
||||||
if not force_zip and AssetRoot.available(): # 散 assets/(桌面开发)
|
|
||||||
return true
|
|
||||||
var zip := _find_local_zip()
|
|
||||||
if zip == "":
|
|
||||||
# FUTURE: 这里改成下载 ASSET_PACK_URL -> user://assets.zip -> 继续
|
|
||||||
push_warning("[AssetPack] 找不到 %s(Android: adb push 到 files/;iOS: 上传到 Documents/)" % ZIP_NAME)
|
|
||||||
return false
|
|
||||||
if ProjectSettings.load_resource_pack(zip, false):
|
|
||||||
mounted = true
|
|
||||||
last_source = zip
|
|
||||||
print("[AssetPack] mounted ", zip)
|
|
||||||
return true
|
|
||||||
push_warning("[AssetPack] load_resource_pack 失败: " + zip)
|
|
||||||
return false
|
|
||||||
|
|
||||||
static func _find_local_zip() -> String:
|
|
||||||
var cands: Array[String] = []
|
|
||||||
var env := OS.get_environment("MT_ASSETS_ZIP")
|
|
||||||
if env != "":
|
|
||||||
cands.append(env)
|
|
||||||
cands.append("user://" + ZIP_NAME)
|
|
||||||
var data_dir := OS.get_user_data_dir()
|
|
||||||
if data_dir != "":
|
|
||||||
cands.append(data_dir.path_join(ZIP_NAME))
|
|
||||||
var exe := OS.get_executable_path()
|
|
||||||
if exe != "":
|
|
||||||
cands.append(exe.get_base_dir().path_join(ZIP_NAME))
|
|
||||||
cands.append("res://../" + ZIP_NAME) # 开发:仓库根旁
|
|
||||||
for c in cands:
|
|
||||||
if FileAccess.file_exists(c):
|
|
||||||
return c
|
|
||||||
return ""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://f260rrsoxa0p
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# AssetRoot —— Metin2 资源根目录的唯一解析点。
|
|
||||||
#
|
|
||||||
# 资源(`.gr2` / `.dds` / `locale` / `OutdoorA1` / `uiscript` …)不进版本库。
|
|
||||||
# 解析顺序:
|
|
||||||
# 1. 环境变量 MT_ASSETS(绝对路径)
|
|
||||||
# 2. res://../assets —— 开发树(仓库根的 assets/)
|
|
||||||
# 3. <可执行文件>/../Resources/assets —— 打包进 .app 内
|
|
||||||
# 4. <.app 同级>/assets —— .app 旁边放一份
|
|
||||||
# 都没有就返回 res://../assets(调用方自行 warn)。
|
|
||||||
#
|
|
||||||
# var root := AssetRoot.path()
|
|
||||||
# var il := AssetRoot.sub("locale/locale/common/item_list.txt")
|
|
||||||
# if AssetRoot.available(): ...
|
|
||||||
class_name AssetRoot
|
|
||||||
extends RefCounted
|
|
||||||
|
|
||||||
static func _candidates() -> Array:
|
|
||||||
var out: Array = []
|
|
||||||
var env := OS.get_environment("MT_ASSETS")
|
|
||||||
if env != "":
|
|
||||||
out.append(env)
|
|
||||||
# 挂载的 assets.zip(AssetPack)把资源放到 res://assets —— 移动端主路径。
|
|
||||||
# 用 asset_index.txt 作为「pack 已挂载」信号(空的 project/assets/ 占位目录不算)。
|
|
||||||
if FileAccess.file_exists("res://assets/asset_index.txt"):
|
|
||||||
out.append("res://assets")
|
|
||||||
out.append(ProjectSettings.globalize_path("res://../assets"))
|
|
||||||
var exe := OS.get_executable_path()
|
|
||||||
if exe != "":
|
|
||||||
var exe_dir := exe.get_base_dir() # .../mtgodot-poc.app/Contents/MacOS
|
|
||||||
out.append(exe_dir.path_join("../Resources/assets").simplify_path())
|
|
||||||
out.append(exe_dir.path_join("../../../assets").simplify_path()) # 与 .app 同级
|
|
||||||
out.append(exe_dir.path_join("assets")) # 与裸可执行文件同级
|
|
||||||
return out
|
|
||||||
|
|
||||||
static func path() -> String:
|
|
||||||
for c in _candidates():
|
|
||||||
if DirAccess.dir_exists_absolute(c):
|
|
||||||
return c
|
|
||||||
return ProjectSettings.globalize_path("res://../assets")
|
|
||||||
|
|
||||||
# 资源根下的子路径(用 path_join,跨平台)。
|
|
||||||
static func sub(rel: String) -> String:
|
|
||||||
return path().path_join(rel)
|
|
||||||
|
|
||||||
# 资源目录是否真的存在(缺资源的测试据此跳过)。
|
|
||||||
static func available() -> bool:
|
|
||||||
return DirAccess.dir_exists_absolute(path())
|
|
||||||
|
|
||||||
# --- 40250 client (pack/Index + .eix/.epk), docs/PORT-PLAN.md 2R item 6 / 2D ---
|
|
||||||
# 解析顺序:显式覆盖、macOS .app 内的自包含资源、开发树。
|
|
||||||
static func client_path() -> String:
|
|
||||||
var env := OS.get_environment("MT_40250_CLIENT")
|
|
||||||
if env != "":
|
|
||||||
return env
|
|
||||||
var exe := OS.get_executable_path()
|
|
||||||
if exe != "":
|
|
||||||
var bundled := exe.get_base_dir().path_join("../Resources/40250/Client").simplify_path()
|
|
||||||
if FileAccess.file_exists(bundled.path_join("pack/Index")):
|
|
||||||
return bundled
|
|
||||||
var repo_client := ProjectSettings.globalize_path("res://../Client").simplify_path()
|
|
||||||
if FileAccess.file_exists(repo_client.path_join("pack/Index")):
|
|
||||||
return repo_client
|
|
||||||
return ProjectSettings.globalize_path("res://../../40250/Server Client TMP4/Client").simplify_path()
|
|
||||||
|
|
||||||
# 按 40250 PackInitialize("pack") 注册 <client>/pack;只注册一次,之后返回首次结果。
|
|
||||||
static func pack_ready() -> bool:
|
|
||||||
if not ClassDB.class_exists("Metin2Pack"):
|
|
||||||
return false
|
|
||||||
return bool(ClassDB.class_call_static("Metin2Pack", "initialize", client_path()))
|
|
||||||
|
|
||||||
# 40250 的 locale 文件(CPythonApplication::Create: "%s/item_proto" % LocalePath,LocalePath = "locale/<lang>"),
|
|
||||||
# 走 pack://;该语言没有时回退 en。pack 没就绪返回 ""。
|
|
||||||
static func locale_file(name: String, lang: String = "en") -> String:
|
|
||||||
if not pack_ready():
|
|
||||||
return ""
|
|
||||||
for l in [lang, "en"]:
|
|
||||||
var p := "pack://locale/%s/%s" % [l, name]
|
|
||||||
if bool(ClassDB.class_call_static("Metin2Pack", "exists", "locale/%s/%s" % [l, name])):
|
|
||||||
return p
|
|
||||||
return ""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://debiskaw74tdw
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# audio_driver.gd —— 桥接 40250 Python/MilesLib 音频调用至 Godot 引擎。
|
|
||||||
# 每帧通过 Metin2PythonHost.audio_commands() 拉取原生端产生的音频播放/音量指令并执行。
|
|
||||||
class_name AudioDriver
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
const AssetRoot = preload("res://asset_root.gd")
|
|
||||||
|
|
||||||
var _cache := {} # 绝对路径 -> AudioStream
|
|
||||||
var _bgm_root: String
|
|
||||||
var _sound_roots: PackedStringArray = []
|
|
||||||
|
|
||||||
var _bgm: Array[AudioStreamPlayer] = [] # 40250 CSoundManagerStream has three music slots.
|
|
||||||
var _bgm_names: Array[String] = []
|
|
||||||
var _bgm_volumes: Array[float] = []
|
|
||||||
var _bgm_fades := {} # slot -> {target, speed, mode}
|
|
||||||
|
|
||||||
var _ui_pool: Array[AudioStreamPlayer] = []
|
|
||||||
var _ui_next := 0
|
|
||||||
|
|
||||||
var master_sound := 1.0
|
|
||||||
var master_music := 1.0
|
|
||||||
|
|
||||||
# 3D 循环音效实例:id -> AudioStreamPlayer3D;所有 3D 播放器用于 StopAll。
|
|
||||||
var _active_3d := {}
|
|
||||||
var _all_3d: Array[AudioStreamPlayer3D] = []
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
_init_roots()
|
|
||||||
_init_players()
|
|
||||||
|
|
||||||
func _init_roots() -> void:
|
|
||||||
var client := AssetRoot.client_path()
|
|
||||||
var assets := AssetRoot.path()
|
|
||||||
|
|
||||||
# BGM 根目录:优先 Client/BGM,次选 assets/BGM
|
|
||||||
if DirAccess.dir_exists_absolute(client.path_join("BGM")):
|
|
||||||
_bgm_root = client.path_join("BGM")
|
|
||||||
elif DirAccess.dir_exists_absolute(assets.path_join("BGM")):
|
|
||||||
_bgm_root = assets.path_join("BGM")
|
|
||||||
else:
|
|
||||||
_bgm_root = client.path_join("BGM")
|
|
||||||
|
|
||||||
# 音效查找根目录
|
|
||||||
var candidate_roots := [
|
|
||||||
client.path_join("sound"),
|
|
||||||
client.path_join("Sound/sound"),
|
|
||||||
assets.path_join("Sound/sound"),
|
|
||||||
assets.path_join("sound2/sound"),
|
|
||||||
assets.path_join("sound_m/sound"),
|
|
||||||
assets.path_join("metin2_patch_w20_sound/sound"),
|
|
||||||
assets.path_join("Sound"),
|
|
||||||
assets,
|
|
||||||
client,
|
|
||||||
]
|
|
||||||
for r in candidate_roots:
|
|
||||||
if DirAccess.dir_exists_absolute(r) and not _sound_roots.has(r):
|
|
||||||
_sound_roots.append(r)
|
|
||||||
|
|
||||||
func _init_players() -> void:
|
|
||||||
for i in 3:
|
|
||||||
var pl := AudioStreamPlayer.new()
|
|
||||||
pl.bus = "Master"
|
|
||||||
pl.name = "BgmPlayer%d" % i
|
|
||||||
add_child(pl)
|
|
||||||
_bgm.append(pl)
|
|
||||||
_bgm_names.append("")
|
|
||||||
_bgm_volumes.append(0.0)
|
|
||||||
|
|
||||||
# 16 个 2D 音效池播放器
|
|
||||||
for i in 16:
|
|
||||||
var pl := AudioStreamPlayer.new()
|
|
||||||
pl.bus = "Master"
|
|
||||||
pl.name = "UiPlayer%d" % i
|
|
||||||
add_child(pl)
|
|
||||||
_ui_pool.append(pl)
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
|
||||||
if not ClassDB.class_exists("Metin2PythonHost") or not Metin2PythonHost.is_running():
|
|
||||||
return
|
|
||||||
|
|
||||||
var commands: Array = Metin2PythonHost.audio_commands()
|
|
||||||
for cmd in commands:
|
|
||||||
var type: String = cmd.get("type", "")
|
|
||||||
match type:
|
|
||||||
"play_sound_2d":
|
|
||||||
play_sound_2d(cmd.get("filename", ""), cmd.get("volume", 1.0))
|
|
||||||
"play_sound_3d":
|
|
||||||
play_sound_3d(cmd.get("filename", ""), Vector3(cmd.get("x", 0.0) / 100.0, cmd.get("z", 0.0) / 100.0, -cmd.get("y", 0.0) / 100.0), cmd.get("volume", 1.0), cmd.get("id", 0), cmd.get("play_count", 1))
|
|
||||||
"stop_sound_3d":
|
|
||||||
stop_sound_3d(cmd.get("id", 0))
|
|
||||||
"stop_all_sound_3d":
|
|
||||||
stop_all_sound_3d()
|
|
||||||
"set_sound_volume_3d":
|
|
||||||
set_sound_volume_3d(cmd.get("id", 0), cmd.get("volume", 1.0))
|
|
||||||
"play_music":
|
|
||||||
play_music(cmd.get("filename", ""), cmd.get("volume", 1.0))
|
|
||||||
"fade_in_music":
|
|
||||||
fade_in_music(cmd.get("filename", ""), cmd.get("volume", 1.0), cmd.get("speed", 0.016))
|
|
||||||
"fade_out_music":
|
|
||||||
fade_out_music(cmd.get("filename", ""), cmd.get("speed", 0.016))
|
|
||||||
"fade_limit_out_music":
|
|
||||||
fade_limit_out_music(cmd.get("filename", ""), cmd.get("volume", 0.0), cmd.get("speed", 0.016))
|
|
||||||
"fade_out_all_music":
|
|
||||||
fade_out_all_music()
|
|
||||||
"set_sound_volume":
|
|
||||||
set_sound_volume(cmd.get("volume", 1.0))
|
|
||||||
"set_music_volume":
|
|
||||||
set_music_volume(cmd.get("volume", 1.0))
|
|
||||||
_update_music_fades()
|
|
||||||
|
|
||||||
# --- 音效路径检索与加载 ---------------------------------------------------
|
|
||||||
|
|
||||||
func _find_sound(rel: String) -> String:
|
|
||||||
if rel.is_empty():
|
|
||||||
return ""
|
|
||||||
rel = rel.replace("\\", "/").strip_edges()
|
|
||||||
if rel.begins_with("d:/ymir work/") or rel.begins_with("D:/ymir work/"):
|
|
||||||
rel = rel.substr(13)
|
|
||||||
elif rel.begins_with("d:/") or rel.begins_with("D:/"):
|
|
||||||
rel = rel.substr(3)
|
|
||||||
|
|
||||||
# 补全扩展名候选
|
|
||||||
var cands := PackedStringArray([rel])
|
|
||||||
if rel.get_extension().is_empty():
|
|
||||||
cands = PackedStringArray([rel + ".wav", rel + ".mp3", rel + ".ogg"])
|
|
||||||
|
|
||||||
# 40250's CEterPackManager is the authority for sounds, as it is for models and UI images.
|
|
||||||
if ClassDB.class_exists("Metin2Pack") and Metin2Pack.is_ready():
|
|
||||||
for c in cands:
|
|
||||||
if Metin2Pack.exists(c):
|
|
||||||
return "pack://" + c
|
|
||||||
if not c.begins_with("sound/") and Metin2Pack.exists("sound/" + c):
|
|
||||||
return "pack://sound/" + c
|
|
||||||
|
|
||||||
if FileAccess.file_exists(rel):
|
|
||||||
return rel
|
|
||||||
|
|
||||||
# 开发期散文件兜底。
|
|
||||||
for root in _sound_roots:
|
|
||||||
for c in cands:
|
|
||||||
var p := root.path_join(c)
|
|
||||||
if FileAccess.file_exists(p):
|
|
||||||
return p
|
|
||||||
# 若路径带 sound/,剥掉后再试一次(防止 root 自身已带 sound/)
|
|
||||||
if c.begins_with("sound/"):
|
|
||||||
var p_sub := root.path_join(c.substr(6))
|
|
||||||
if FileAccess.file_exists(p_sub):
|
|
||||||
return p_sub
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
func _load_stream(path: String) -> AudioStream:
|
|
||||||
if _cache.has(path):
|
|
||||||
return _cache[path]
|
|
||||||
var s: AudioStream = null
|
|
||||||
var ext := path.get_extension().to_lower()
|
|
||||||
var bytes: PackedByteArray
|
|
||||||
if path.begins_with("pack://"):
|
|
||||||
bytes = Metin2Pack.get_bytes(path.substr(7))
|
|
||||||
else:
|
|
||||||
bytes = FileAccess.get_file_as_bytes(path)
|
|
||||||
if bytes.is_empty():
|
|
||||||
return null
|
|
||||||
if ext == "mp3":
|
|
||||||
var m := AudioStreamMP3.new()
|
|
||||||
m.data = bytes
|
|
||||||
s = m if m.data.size() > 0 else null
|
|
||||||
elif ext == "ogg":
|
|
||||||
s = AudioStreamOggVorbis.load_from_buffer(bytes)
|
|
||||||
elif ext == "wav":
|
|
||||||
s = AudioStreamWAV.load_from_buffer(bytes)
|
|
||||||
_cache[path] = s
|
|
||||||
return s
|
|
||||||
|
|
||||||
# --- 2D 音效播放 -----------------------------------------------------------
|
|
||||||
|
|
||||||
func play_sound_2d(rel: String, volume_scale := 1.0) -> void:
|
|
||||||
var path := _find_sound(rel)
|
|
||||||
if path.is_empty():
|
|
||||||
return
|
|
||||||
var stream := _load_stream(path)
|
|
||||||
if stream == null:
|
|
||||||
return
|
|
||||||
if _ui_pool.is_empty():
|
|
||||||
return
|
|
||||||
var pl := _ui_pool[_ui_next]
|
|
||||||
_ui_next = (_ui_next + 1) % _ui_pool.size()
|
|
||||||
pl.stream = stream
|
|
||||||
# SoundManager already applied the current master volume to this command.
|
|
||||||
var vol: float = clampf(volume_scale, 0.0, 1.0)
|
|
||||||
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
|
|
||||||
pl.play()
|
|
||||||
|
|
||||||
# --- 3D 空间音效播放 -------------------------------------------------------
|
|
||||||
|
|
||||||
func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0, play_count := 1) -> void:
|
|
||||||
var path := _find_sound(rel)
|
|
||||||
if path.is_empty():
|
|
||||||
return
|
|
||||||
var stream := _load_stream(path)
|
|
||||||
if stream == null:
|
|
||||||
return
|
|
||||||
var pl := AudioStreamPlayer3D.new()
|
|
||||||
pl.stream = stream
|
|
||||||
pl.unit_size = 8.0
|
|
||||||
pl.max_distance = 80.0
|
|
||||||
var vol: float = clampf(volume_scale, 0.0, 1.0)
|
|
||||||
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
|
|
||||||
add_child(pl)
|
|
||||||
_all_3d.append(pl)
|
|
||||||
pl.global_position = pos
|
|
||||||
pl.play()
|
|
||||||
if sound_id > 0:
|
|
||||||
_active_3d[sound_id] = pl
|
|
||||||
var state := {"remaining": play_count}
|
|
||||||
pl.finished.connect(func():
|
|
||||||
if state.remaining == 0 or state.remaining > 1:
|
|
||||||
if state.remaining > 1:
|
|
||||||
state.remaining -= 1
|
|
||||||
pl.play()
|
|
||||||
return
|
|
||||||
_all_3d.erase(pl)
|
|
||||||
if sound_id > 0 and _active_3d.get(sound_id) == pl:
|
|
||||||
_active_3d.erase(sound_id)
|
|
||||||
pl.queue_free())
|
|
||||||
|
|
||||||
func set_sound_volume_3d(sound_id: int, volume: float) -> void:
|
|
||||||
var pl: AudioStreamPlayer3D = _active_3d.get(sound_id)
|
|
||||||
if pl != null and is_instance_valid(pl):
|
|
||||||
var level := clampf(volume, 0.0, 1.0)
|
|
||||||
pl.volume_db = linear_to_db(level) if level > 0.001 else -80.0
|
|
||||||
|
|
||||||
func stop_sound_3d(sound_id: int) -> void:
|
|
||||||
if _active_3d.has(sound_id):
|
|
||||||
var pl: AudioStreamPlayer3D = _active_3d[sound_id]
|
|
||||||
if is_instance_valid(pl):
|
|
||||||
pl.stop()
|
|
||||||
_all_3d.erase(pl)
|
|
||||||
pl.queue_free()
|
|
||||||
_active_3d.erase(sound_id)
|
|
||||||
|
|
||||||
func stop_all_sound_3d() -> void:
|
|
||||||
for pl in _all_3d:
|
|
||||||
if is_instance_valid(pl):
|
|
||||||
pl.stop()
|
|
||||||
pl.queue_free()
|
|
||||||
_all_3d.clear()
|
|
||||||
_active_3d.clear()
|
|
||||||
|
|
||||||
# --- BGM 背景音乐播放 -----------------------------------------------------
|
|
||||||
|
|
||||||
func _music_stream(name: String) -> AudioStream:
|
|
||||||
var path := name.replace("\\", "/")
|
|
||||||
if not path.is_absolute_path():
|
|
||||||
var direct := _bgm_root.path_join(name)
|
|
||||||
if FileAccess.file_exists(direct):
|
|
||||||
path = direct
|
|
||||||
elif FileAccess.file_exists(direct + ".mp3"):
|
|
||||||
path = direct + ".mp3"
|
|
||||||
else:
|
|
||||||
path = _find_sound(name)
|
|
||||||
return _load_stream(path)
|
|
||||||
|
|
||||||
func _music_slot(name: String) -> int:
|
|
||||||
var normalized := name.replace("\\", "/").to_lower()
|
|
||||||
for i in _bgm_names.size():
|
|
||||||
if _bgm_names[i].replace("\\", "/").to_lower() == normalized and _bgm[i].playing:
|
|
||||||
return i
|
|
||||||
return -1
|
|
||||||
|
|
||||||
func _set_music_volume(slot: int, volume: float) -> void:
|
|
||||||
_bgm_volumes[slot] = clampf(volume, 0.0, 1.0)
|
|
||||||
_bgm[slot].volume_db = linear_to_db(_bgm_volumes[slot]) if _bgm_volumes[slot] > 0.001 else -80.0
|
|
||||||
|
|
||||||
func _start_music(slot: int, name: String, volume: float) -> bool:
|
|
||||||
var stream := _music_stream(name)
|
|
||||||
if stream == null:
|
|
||||||
return false
|
|
||||||
if stream is AudioStreamMP3:
|
|
||||||
stream.loop = true
|
|
||||||
_bgm[slot].stop()
|
|
||||||
_bgm[slot].stream = stream
|
|
||||||
_bgm_names[slot] = name
|
|
||||||
_set_music_volume(slot, volume)
|
|
||||||
_bgm[slot].play()
|
|
||||||
return true
|
|
||||||
|
|
||||||
func play_music(name: String, volume_scale := 1.0) -> void:
|
|
||||||
if name.is_empty():
|
|
||||||
return
|
|
||||||
# 40250 PlayMusic(name) always replaces stream slot 0.
|
|
||||||
_bgm_fades.erase(0)
|
|
||||||
_start_music(0, name, volume_scale)
|
|
||||||
|
|
||||||
func fade_in_music(name: String, target_volume := 1.0, speed := 0.016) -> void:
|
|
||||||
if name.is_empty():
|
|
||||||
return
|
|
||||||
var slot := _music_slot(name)
|
|
||||||
if slot < 0:
|
|
||||||
fade_out_all_music()
|
|
||||||
for i in _bgm.size():
|
|
||||||
if not _bgm[i].playing:
|
|
||||||
slot = i
|
|
||||||
break
|
|
||||||
if slot < 0 or not _start_music(slot, name, 0.0):
|
|
||||||
return
|
|
||||||
_bgm_fades[slot] = {"target": clampf(target_volume, 0.0, 1.0), "speed": speed, "mode": "in"}
|
|
||||||
|
|
||||||
func fade_out_music(name: String, speed := 0.016) -> void:
|
|
||||||
var slot := _music_slot(name)
|
|
||||||
if slot >= 0:
|
|
||||||
_bgm_fades[slot] = {"target": 0.0, "speed": speed, "mode": "out"}
|
|
||||||
|
|
||||||
func fade_limit_out_music(name: String, limit: float, speed := 0.016) -> void:
|
|
||||||
var slot := _music_slot(name)
|
|
||||||
if slot >= 0:
|
|
||||||
_bgm_fades[slot] = {"target": clampf(limit, 0.0, 1.0), "speed": speed, "mode": "limit"}
|
|
||||||
|
|
||||||
func fade_out_all_music() -> void:
|
|
||||||
for i in _bgm.size():
|
|
||||||
if _bgm[i].playing:
|
|
||||||
_bgm_fades[i] = {"target": 0.0, "speed": 0.01, "mode": "out"}
|
|
||||||
|
|
||||||
func _update_music_fades() -> void:
|
|
||||||
# 40250 advances fVolume by fVolumeSpeed once per SoundManager::Update frame.
|
|
||||||
for slot in _bgm_fades.keys():
|
|
||||||
if not _bgm[slot].playing:
|
|
||||||
_bgm_fades.erase(slot)
|
|
||||||
_bgm_names[slot] = ""
|
|
||||||
continue
|
|
||||||
var fade: Dictionary = _bgm_fades[slot]
|
|
||||||
var target: float = fade.target
|
|
||||||
var step: float = fade.speed
|
|
||||||
var volume: float = _bgm_volumes[slot]
|
|
||||||
match fade.mode:
|
|
||||||
"in":
|
|
||||||
volume = minf(target, volume + step)
|
|
||||||
"limit":
|
|
||||||
volume = maxf(target, volume - step)
|
|
||||||
"out":
|
|
||||||
volume = maxf(0.0, volume - step)
|
|
||||||
_set_music_volume(slot, volume)
|
|
||||||
if (fade.mode == "in" and volume >= target) or (fade.mode != "in" and volume <= target):
|
|
||||||
if fade.mode == "out":
|
|
||||||
_bgm[slot].stop()
|
|
||||||
_bgm_names[slot] = ""
|
|
||||||
_bgm_fades.erase(slot)
|
|
||||||
|
|
||||||
# --- 音量控制 -------------------------------------------------------------
|
|
||||||
|
|
||||||
func set_sound_volume(v: float) -> void:
|
|
||||||
master_sound = clampf(v, 0.0, 1.0)
|
|
||||||
|
|
||||||
func set_music_volume(v: float) -> void:
|
|
||||||
master_music = clampf(v, 0.0, 1.0)
|
|
||||||
for i in _bgm.size():
|
|
||||||
if _bgm[i].playing and not (_bgm_fades.has(i) and _bgm_fades[i].mode == "out"):
|
|
||||||
_set_music_volume(i, master_music)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://vqw8iy17y4m0
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
var failures := 0
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("_run")
|
|
||||||
|
|
||||||
func _check(ok: bool, detail: String) -> void:
|
|
||||||
if not ok:
|
|
||||||
failures += 1
|
|
||||||
printerr("FAIL: " + detail)
|
|
||||||
|
|
||||||
func _run() -> void:
|
|
||||||
_check(AssetRoot.pack_ready(), "40250 pack unavailable")
|
|
||||||
var driver: Node = preload("res://audio_driver.gd").new()
|
|
||||||
root.add_child(driver)
|
|
||||||
var sound := "sound/ambience/waterfall.wav"
|
|
||||||
var path: String = driver._find_sound(sound)
|
|
||||||
_check(path == "pack://" + sound, "sound must resolve through 40250 pack")
|
|
||||||
var stream: AudioStream = driver._load_stream(path)
|
|
||||||
_check(stream != null, "packed WAV must decode")
|
|
||||||
if stream != null:
|
|
||||||
driver.set_sound_volume(0.5)
|
|
||||||
driver.play_sound_2d(sound, 0.5)
|
|
||||||
var player: AudioStreamPlayer = driver._ui_pool[0]
|
|
||||||
_check(absf(player.volume_db - linear_to_db(0.5)) < 0.01,
|
|
||||||
"sound volume was applied more than once")
|
|
||||||
driver.play_sound_3d(sound, Vector3.ZERO, 0.5)
|
|
||||||
_check(driver._all_3d.size() == 1, "3D sound not tracked")
|
|
||||||
driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 17, 0)
|
|
||||||
driver.set_sound_volume_3d(17, 0.25)
|
|
||||||
_check(absf(driver._active_3d[17].volume_db - linear_to_db(0.25)) < 0.01,
|
|
||||||
"indexed 3D sound volume did not update")
|
|
||||||
_check(driver._all_3d.size() == 2, "looping 3D sound not tracked")
|
|
||||||
driver._active_3d[17].finished.emit()
|
|
||||||
_check(driver._active_3d.has(17), "zero-loop-count ambience did not restart")
|
|
||||||
driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 18, 2)
|
|
||||||
var twice: AudioStreamPlayer3D = driver._active_3d[18]
|
|
||||||
twice.finished.emit()
|
|
||||||
_check(driver._active_3d.has(18), "two-play ambience stopped after first play")
|
|
||||||
twice.finished.emit()
|
|
||||||
_check(not driver._active_3d.has(18), "two-play ambience did not finish")
|
|
||||||
driver.stop_sound_3d(17)
|
|
||||||
_check(not driver._active_3d.has(17), "StopSound3D left indexed sound running")
|
|
||||||
driver.stop_all_sound_3d()
|
|
||||||
_check(driver._all_3d.is_empty(), "StopAllSound3D left a player running")
|
|
||||||
driver.play_music("login_window.mp3", 0.8)
|
|
||||||
_check(driver._music_slot("login_window.mp3") == 0, "PlayMusic did not use slot 0")
|
|
||||||
driver.fade_limit_out_music("login_window.mp3", 0.2, 0.1)
|
|
||||||
for i in 8:
|
|
||||||
driver._update_music_fades()
|
|
||||||
_check(absf(driver._bgm_volumes[0] - 0.2) < 0.001,
|
|
||||||
"FadeLimitOutMusic did not retain the requested limit")
|
|
||||||
driver.fade_limit_out_music("login_window.mp3", 0.7, 0.1)
|
|
||||||
driver._update_music_fades()
|
|
||||||
_check(absf(driver._bgm_volumes[0] - 0.7) < 0.001,
|
|
||||||
"40250 limit fade must clamp immediately when already below the limit")
|
|
||||||
driver.fade_limit_out_music("login_window.mp3", 0.2, 0.5)
|
|
||||||
driver._update_music_fades()
|
|
||||||
driver.fade_out_music("another_track.mp3", 0.1)
|
|
||||||
_check(not driver._bgm_fades.has(1), "FadeOutMusic affected an unrelated track")
|
|
||||||
driver.fade_out_music("login_window.mp3", 0.1)
|
|
||||||
for i in 2:
|
|
||||||
driver._update_music_fades()
|
|
||||||
_check(not driver._bgm[0].playing, "FadeOutMusic did not stop named track")
|
|
||||||
driver.fade_in_music("characterselect.mp3", 0.3, 0.1)
|
|
||||||
for i in 3:
|
|
||||||
driver._update_music_fades()
|
|
||||||
_check(absf(driver._bgm_volumes[0] - 0.3) < 0.001,
|
|
||||||
"FadeInMusic did not advance by the requested frame step")
|
|
||||||
for player in driver._bgm:
|
|
||||||
player.stop()
|
|
||||||
player.stream = null
|
|
||||||
for player in driver._ui_pool:
|
|
||||||
player.stop()
|
|
||||||
player.stream = null
|
|
||||||
driver._cache.clear()
|
|
||||||
driver.free()
|
|
||||||
await process_frame
|
|
||||||
print("PASS: audio_driver_test" if failures == 0 else "FAIL: audio_driver_test")
|
|
||||||
quit(0 if failures == 0 else 1)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dfbxpg1q8a0q8
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# Bounded, read-only release probe on the same native 40250 path as normal play.
|
|
||||||
# Credentials stay in the process environment and are never written to the report.
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
var _cases: Array[Dictionary] = []
|
|
||||||
var _failed := false
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
call_deferred("_run")
|
|
||||||
|
|
||||||
func _check(ok: bool, name: String) -> bool:
|
|
||||||
_cases.append({"id": name, "status": "PASS" if ok else "FAIL"})
|
|
||||||
if not ok:
|
|
||||||
_failed = true
|
|
||||||
printerr("BETA_PROBE: FAIL " + name)
|
|
||||||
return ok
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await get_tree().process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func _key_state(keycode: Key, unicode: int, pressed: bool) -> void:
|
|
||||||
var event := InputEventKey.new()
|
|
||||||
event.keycode = keycode
|
|
||||||
event.physical_keycode = keycode
|
|
||||||
event.unicode = unicode
|
|
||||||
event.pressed = pressed
|
|
||||||
get_tree().root.push_input(event)
|
|
||||||
|
|
||||||
func _key(keycode: Key, unicode: int) -> void:
|
|
||||||
_key_state(keycode, unicode, true)
|
|
||||||
_key_state(keycode, unicode, false)
|
|
||||||
|
|
||||||
func _type(value: String) -> void:
|
|
||||||
for i in value.length():
|
|
||||||
var codepoint := value.unicode_at(i)
|
|
||||||
_key(OS.find_keycode_from_string(value[i].to_upper()), codepoint)
|
|
||||||
|
|
||||||
func _finish(output: String, report: Dictionary) -> void:
|
|
||||||
report["cases"] = _cases
|
|
||||||
report["status"] = "FAIL" if _failed else "PASS"
|
|
||||||
var file := FileAccess.open(output.path_join("client-report.json"), FileAccess.WRITE)
|
|
||||||
if file == null:
|
|
||||||
printerr("BETA_PROBE: cannot write client report")
|
|
||||||
get_tree().quit(1)
|
|
||||||
return
|
|
||||||
file.store_string(JSON.stringify(report, " "))
|
|
||||||
file.close()
|
|
||||||
print("BETA_PROBE: " + report["status"])
|
|
||||||
if not _py("import app\napp.Exit()"):
|
|
||||||
Metin2PythonHost.stop()
|
|
||||||
get_tree().quit(1)
|
|
||||||
return
|
|
||||||
# PythonUISurface observes app.Exit, stops the interpreter and ends this process.
|
|
||||||
|
|
||||||
func _run() -> void:
|
|
||||||
printerr("BETA_STAGE: start")
|
|
||||||
var output := OS.get_environment("MT_BETA_OUTPUT")
|
|
||||||
if output.is_empty() or not output.is_absolute_path():
|
|
||||||
printerr("BETA_PROBE: MT_BETA_OUTPUT must be an absolute directory")
|
|
||||||
get_tree().quit(2)
|
|
||||||
return
|
|
||||||
DirAccess.make_dir_recursive_absolute(output)
|
|
||||||
var report := {"schema_version": 1, "route": "native_40250", "map": "", "draw_count": 0}
|
|
||||||
var host := OS.get_environment("MT_LIVE_HOST")
|
|
||||||
var login := OS.get_environment("MT_LIVE_LOGIN")
|
|
||||||
var password := OS.get_environment("MT_LIVE_PASSWORD")
|
|
||||||
if not _check(not host.is_empty() and not login.is_empty() and not password.is_empty(), "credentials_present"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
if OS.get_environment("MT_BETA_REQUIRE_BUNDLE") == "1":
|
|
||||||
var bundle := OS.get_executable_path().get_base_dir().path_join("../Resources/40250/Client").simplify_path()
|
|
||||||
if not _check(AssetRoot.client_path() == bundle, "bundled_client_selected"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
if not _check(AssetRoot.pack_ready() and Metin2PythonHost.is_app_looping(), "bundled_pack_and_python_loop"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
if not _check(await _until(20, func(): return _py(
|
|
||||||
"import __main__, networkModule, introLogin, introSelect, game\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]\n"
|
|
||||||
+ "assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)")), "login_window"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
printerr("BETA_STAGE: login_window")
|
|
||||||
if not _check(_py("import os\n"
|
|
||||||
+ "_env = os.environ\n"
|
|
||||||
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
|
|
||||||
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
|
|
||||||
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
|
|
||||||
+ "_w = _stream.curPhaseWindow\n"
|
|
||||||
+ "_w._LoginWindow__OpenLoginBoard()\n"
|
|
||||||
+ "_w.idEditLine.SetText('')\n"
|
|
||||||
+ "_w.pwdEditLine.SetText('')\n"
|
|
||||||
+ "_w.idEditLine.SetFocus()"), "login_board"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
_type(login)
|
|
||||||
_key(KEY_TAB, 0)
|
|
||||||
_type(password)
|
|
||||||
if not _check(_py("assert _w.idEditLine.GetText() == _env['MT_LIVE_LOGIN']\n"
|
|
||||||
+ "assert _w.pwdEditLine.GetText() == _env['MT_LIVE_PASSWORD']\n"
|
|
||||||
+ "del _env, _w"), "keyboard_input"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
_key(KEY_ENTER, 0)
|
|
||||||
printerr("BETA_STAGE: login_submitted")
|
|
||||||
var selecting := await _until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
|
|
||||||
if not _check(selecting, "character_select"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
printerr("BETA_STAGE: character_select")
|
|
||||||
if not _check(_py("import net\n"
|
|
||||||
+ "assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
|
|
||||||
+ "_stream.curPhaseWindow.SelectSlot(_slot)\n"
|
|
||||||
+ "_stream.curPhaseWindow.StartGame()"), "selected_character"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
var in_game := await _until(70, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
|
|
||||||
if not _check(in_game, "game_window"):
|
|
||||||
_finish(output, report)
|
|
||||||
return
|
|
||||||
printerr("BETA_STAGE: game_window")
|
|
||||||
var world: Node = get_parent().get_node("Python3DSurface")
|
|
||||||
_check(await _until(10, func(): return world.draw_count > 0), "character_draws")
|
|
||||||
_check(not Metin2PythonHost.current_map_name().is_empty(), "map_selected")
|
|
||||||
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0, "terrain_textured")
|
|
||||||
_check(_py("assert net.IsConnect()"), "network_connected")
|
|
||||||
if _check(_py("import player\n_beta_start_pos = player.GetMainCharacterPosition()"), "movement_start_position"):
|
|
||||||
printerr("BETA_STAGE: move_start")
|
|
||||||
_key_state(KEY_UP, 0, true)
|
|
||||||
await get_tree().create_timer(1.5).timeout
|
|
||||||
_key_state(KEY_UP, 0, false)
|
|
||||||
await get_tree().create_timer(0.5).timeout
|
|
||||||
_check(_py("_beta_end_pos = player.GetMainCharacterPosition()\n"
|
|
||||||
+ "assert abs(_beta_end_pos[0] - _beta_start_pos[0])"
|
|
||||||
+ " + abs(_beta_end_pos[1] - _beta_start_pos[1]) > 20"), "movement_position_changed")
|
|
||||||
printerr("BETA_STAGE: move_end")
|
|
||||||
# Let the loading fade finish before assessing what a player actually sees.
|
|
||||||
await get_tree().create_timer(2.0).timeout
|
|
||||||
report["draw_count"] = world.draw_count
|
|
||||||
report["map"] = Metin2PythonHost.current_map_name()
|
|
||||||
report["terrain_chunks"] = int(world.terrain_report.get("chunks_splatted", 0))
|
|
||||||
if DisplayServer.get_name() != "headless":
|
|
||||||
await RenderingServer.frame_post_draw
|
|
||||||
var screenshot := get_viewport().get_texture().get_image()
|
|
||||||
_check(screenshot.save_png(output.path_join("game.png")) == OK, "screenshot")
|
|
||||||
var lit := 0
|
|
||||||
for y in range(screenshot.get_height() / 4, screenshot.get_height() * 3 / 4, 12):
|
|
||||||
for x in range(screenshot.get_width() / 4, screenshot.get_width() * 3 / 4, 12):
|
|
||||||
if screenshot.get_pixel(x, y).get_luminance() > 0.05:
|
|
||||||
lit += 1
|
|
||||||
report["centre_lit_samples"] = lit
|
|
||||||
_check(lit > 30, "scene_visible_after_loading")
|
|
||||||
_finish(output, report)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b0y8dh6cv657q
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
[configuration]
|
|
||||||
|
|
||||||
entry_symbol = "mtgodot_library_init"
|
|
||||||
compatibility_minimum = "4.7"
|
|
||||||
reloadable = true
|
|
||||||
|
|
||||||
[libraries]
|
|
||||||
|
|
||||||
macos.debug = "res://bin/libmtgodot.macos.template_debug.dylib"
|
|
||||||
macos.release = "res://bin/libmtgodot.macos.template_release.dylib"
|
|
||||||
macos.editor = "res://bin/libmtgodot.macos.template_debug.dylib"
|
|
||||||
macos.editor.arm64 = "res://bin/libmtgodot.macos.template_debug.dylib"
|
|
||||||
|
|
||||||
ios.debug = "res://bin/libmtgodot.ios.template_debug.a"
|
|
||||||
ios.release = "res://bin/libmtgodot.ios.template_release.a"
|
|
||||||
|
|
||||||
android.debug.arm64 = "res://bin/libmtgodot.android.template_debug.arm64.so"
|
|
||||||
android.release.arm64 = "res://bin/libmtgodot.android.template_release.arm64.so"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
|
|
||||||
; iOS can't dlopen: the extension archive above is linked into the app at export
|
|
||||||
; time, and it needs every static lib it was built against on the link line.
|
|
||||||
; `./build-ios.sh` stages these into project/bin/ios/. See docs/PLATFORMS.md (F1).
|
|
||||||
ios.debug = {
|
|
||||||
"res://bin/ios/libgodot-cpp.ios.template_debug.arm64.a" : "",
|
|
||||||
"res://bin/ios/liblibgr2.a" : "",
|
|
||||||
"res://bin/ios/libxr_formats.a" : "",
|
|
||||||
"res://bin/ios/libmtnet.a" : "",
|
|
||||||
"res://bin/ios/libsodium.a" : ""
|
|
||||||
}
|
|
||||||
ios.release = {
|
|
||||||
"res://bin/ios/libgodot-cpp.ios.template_release.arm64.a" : "",
|
|
||||||
"res://bin/ios/liblibgr2.a" : "",
|
|
||||||
"res://bin/ios/libxr_formats.a" : "",
|
|
||||||
"res://bin/ios/libmtnet.a" : "",
|
|
||||||
"res://bin/ios/libsodium.a" : ""
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://hc7fxxrjvpr8
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# client_main —— 打包客户端的入口场景根(main_scene = res://client_main.tscn)。
|
|
||||||
#
|
|
||||||
# 走 40250 原生 port 路径:40250 system.py 在内嵌解释器里跑,界面画在
|
|
||||||
# python_ui_surface.gd、RenderGame 的 3D 绘制画在 python_3d_surface.gd。
|
|
||||||
# 方向键/鼠标点地走动走 40250 原路径(2V3);app.Exit() 退出进程。
|
|
||||||
# 资源目录由 AssetRoot 决定:40250 Client/pack 资源包。
|
|
||||||
extends Node
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
if OS.get_environment("MT_TEST_MODE") == "particle_exit":
|
|
||||||
add_child(preload("res://particle_exit_test.gd").new())
|
|
||||||
return
|
|
||||||
# MT_TEST_MODE=python:在 app 进程里起内嵌解释器跑 system.py(PORT-PLAN 批次 2P step 4)。
|
|
||||||
# 导出模板不接受 --script,这是在真机 APK/IPA 里跑这条检查的唯一入口。
|
|
||||||
if OS.get_environment("MT_TEST_MODE") == "python":
|
|
||||||
AssetPack.ensure()
|
|
||||||
var fail: int = preload("res://python_host_check.gd").new().run()
|
|
||||||
if fail == 0:
|
|
||||||
print("PASS: python_host_check (app 进程内 system.py)")
|
|
||||||
else:
|
|
||||||
printerr("%d check(s) failed" % fail)
|
|
||||||
get_tree().quit(1 if fail else 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 原生 port 路径:40250 system.py 的界面直接画在 Godot Canvas 上(PORT-PLAN 2V0-f),
|
|
||||||
# Logo → 登录 → 选人 → 进游戏,RenderGame 的 3D 绘制由 python_3d_surface.gd 画出(2V2-e),
|
|
||||||
# 方向键/鼠标点地走动走 40250 原路径(2V3);app.Exit() 退出进程。
|
|
||||||
if OS.has_environment("MT_PORT_PATH") and OS.get_environment("MT_PORT_PATH") == "legacy":
|
|
||||||
push_warning("[client] MT_PORT_PATH=legacy 已废弃并移除,40250 原生 port 路径已接管主循环")
|
|
||||||
|
|
||||||
_start_port_route()
|
|
||||||
|
|
||||||
# 启动 40250 原生 port 路径
|
|
||||||
func _start_port_route() -> void:
|
|
||||||
if not (ClassDB.class_exists("Metin2PythonHost") and AssetRoot.pack_ready()):
|
|
||||||
printerr("[client] port 路由缺少 Metin2PythonHost 或 40250 Client/pack(%s)" % AssetRoot.client_path())
|
|
||||||
get_tree().quit(1)
|
|
||||||
return
|
|
||||||
var surface: Control = preload("res://python_ui_surface.gd").new()
|
|
||||||
surface.name = "PythonUISurface"
|
|
||||||
add_child(surface)
|
|
||||||
# The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads that
|
|
||||||
# frame's draws after it, so it comes second in the tree.
|
|
||||||
var world: Node3D = preload("res://python_3d_surface.gd").new()
|
|
||||||
world.name = "Python3DSurface"
|
|
||||||
add_child(world)
|
|
||||||
var audio: Node = preload("res://audio_driver.gd").new()
|
|
||||||
audio.name = "AudioDriver"
|
|
||||||
add_child(audio)
|
|
||||||
var err: String = surface.run_app()
|
|
||||||
if err != "":
|
|
||||||
printerr("port: " + err)
|
|
||||||
get_tree().quit(1)
|
|
||||||
return
|
|
||||||
if OS.get_environment("MT_TEST_MODE") == "beta_probe":
|
|
||||||
var probe: Node = preload("res://beta_live_probe.gd").new()
|
|
||||||
probe.name = "BetaLiveProbe"
|
|
||||||
add_child(probe)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d384dorgv24yv
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[gd_scene load_steps=2 format=3 uid="uid://b0mtgdclientmain0"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://client_main.gd" id="1_client"]
|
|
||||||
|
|
||||||
[node name="ClientMain" type="Node"]
|
|
||||||
script = ExtResource("1_client")
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
|
||||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
|
||||||
|
|
||||||
var ui: Control
|
|
||||||
var world: Node3D
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("run")
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func run() -> void:
|
|
||||||
root.size = Vector2i(1024, 768)
|
|
||||||
world = Python3DSurface.new()
|
|
||||||
world.name = "Python3DSurface"
|
|
||||||
ui = PythonUISurface.new()
|
|
||||||
ui.name = "PythonUISurface"
|
|
||||||
root.add_child(ui)
|
|
||||||
root.add_child(world)
|
|
||||||
|
|
||||||
var err: String = ui.run_app()
|
|
||||||
if err != "":
|
|
||||||
print("ERROR: run_app: ", err)
|
|
||||||
quit(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
await _pump_until(15, func(): return _py(
|
|
||||||
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
|
|
||||||
))
|
|
||||||
|
|
||||||
await _pump_until(20, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
var host = OS.get_environment("MT_LIVE_HOST")
|
|
||||||
var login = OS.get_environment("MT_LIVE_LOGIN")
|
|
||||||
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
|
|
||||||
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
|
|
||||||
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
|
|
||||||
|
|
||||||
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
|
|
||||||
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
|
|
||||||
))
|
|
||||||
|
|
||||||
for i in 60:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
print("=== CAMERA WORLD & HEIGHT PROFILE ===")
|
|
||||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
|
||||||
if not draws.is_empty():
|
|
||||||
var vm: PackedFloat32Array = draws[0]["view"]
|
|
||||||
var cam_inv: Transform3D = Python3DSurface.d3d_transform(vm).affine_inverse()
|
|
||||||
print("Camera world origin: ", cam_inv.origin)
|
|
||||||
print("Camera world basis: ", cam_inv.basis)
|
|
||||||
print("Camera looking direction: ", -cam_inv.basis.z)
|
|
||||||
|
|
||||||
for y in range(68200, 66200, -100):
|
|
||||||
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(59700.0, %f))" % float(y))
|
|
||||||
var terrain_h = float(world.terrain.call("sample_height", 597.0, float(y) / 100.0)) * 100.0 if world.terrain else 0.0
|
|
||||||
print("Profile y=%d: bg_h=%s terrain_h=%.2f" % [y, bg_h, terrain_h])
|
|
||||||
|
|
||||||
print("Total draws: ", draws.size())
|
|
||||||
|
|
||||||
for d in draws:
|
|
||||||
var tex: String = d.get("texture0", "")
|
|
||||||
if tex.contains("warrior") or tex.contains("stray_dog") or tex.contains("goods"):
|
|
||||||
var wm: PackedFloat32Array = d["world"]
|
|
||||||
var vm: PackedFloat32Array = d["view"]
|
|
||||||
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
|
|
||||||
var cam_xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
|
|
||||||
|
|
||||||
var min_z := 1e9
|
|
||||||
var max_z := -1e9
|
|
||||||
var min_local_z := 1e9
|
|
||||||
var max_local_z := -1e9
|
|
||||||
for pos in d["positions"]:
|
|
||||||
min_local_z = minf(min_local_z, pos.z)
|
|
||||||
max_local_z = maxf(max_local_z, pos.z)
|
|
||||||
var wp: Vector3 = xform * pos
|
|
||||||
min_z = minf(min_z, wp.z)
|
|
||||||
max_z = maxf(max_z, wp.z)
|
|
||||||
|
|
||||||
# Map position: (xform.origin.x, -xform.origin.y)
|
|
||||||
var map_x := xform.origin.x
|
|
||||||
var map_y := -xform.origin.y
|
|
||||||
var actor_z := xform.origin.z
|
|
||||||
|
|
||||||
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y])
|
|
||||||
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
|
|
||||||
|
|
||||||
print("Actor [%s]:" % tex.get_file())
|
|
||||||
print(" World origin: ", xform.origin, " (map_x=%.1f, map_y=%.1f, origin_z=%.1f)" % [map_x, map_y, actor_z])
|
|
||||||
print(" Local vert Z range: [%.2f, %.2f]" % [min_local_z, max_local_z])
|
|
||||||
print(" World vert Z range: [%.2f, %.2f]" % [min_z, max_z])
|
|
||||||
print(" Background Height: %s, Terrain Height: %.2f" % [bg_h, terrain_h])
|
|
||||||
print(" Vert_Z min vs Height: diff = %.2f cm" % (min_z - float(bg_h)))
|
|
||||||
print(" Origin_Z vs Height: diff = %.2f cm" % (actor_z - float(bg_h)))
|
|
||||||
print(" Camera-space origin: ", cam_xform.origin)
|
|
||||||
|
|
||||||
if tex.contains("stray_dog"):
|
|
||||||
print("--- DOG TERRAIN DETAIL ---")
|
|
||||||
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100), Vector3(0, -100, 0)), Vector3.ZERO)
|
|
||||||
var dog_world := xform.origin # (59700, -66400, 19851.5)
|
|
||||||
print("Dog 40250 world: ", dog_world)
|
|
||||||
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
|
|
||||||
var mi := child as MeshInstance3D
|
|
||||||
if mi.mesh is ArrayMesh:
|
|
||||||
var am := mi.mesh as ArrayMesh
|
|
||||||
var arrays := am.surface_get_arrays(0)
|
|
||||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
||||||
for v in verts:
|
|
||||||
var world_v: Vector3 = from_map * (mi.transform * v)
|
|
||||||
if abs(world_v.x - dog_world.x) < 300.0 and abs(world_v.y - dog_world.y) < 300.0:
|
|
||||||
print("Terrain local vert: ", v, " -> mi.xform*v: ", mi.transform * v, " -> from_map: ", world_v)
|
|
||||||
print("Diff Z (terrain world Z - dog world Z): ", world_v.z - dog_world.z)
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
quit(0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b5vx3h3lipsuf
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
|
||||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
|
||||||
|
|
||||||
var ui: Control
|
|
||||||
var world: Node3D
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("run")
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func run() -> void:
|
|
||||||
root.size = Vector2i(1024, 768)
|
|
||||||
world = Python3DSurface.new()
|
|
||||||
ui = PythonUISurface.new()
|
|
||||||
root.add_child(ui)
|
|
||||||
root.add_child(world)
|
|
||||||
|
|
||||||
ui.run_app()
|
|
||||||
|
|
||||||
await _pump_until(15, func(): return _py(
|
|
||||||
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
|
|
||||||
))
|
|
||||||
|
|
||||||
await _pump_until(20, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
var host = OS.get_environment("MT_LIVE_HOST")
|
|
||||||
var login = OS.get_environment("MT_LIVE_LOGIN")
|
|
||||||
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
|
|
||||||
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
|
|
||||||
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
|
|
||||||
|
|
||||||
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
|
|
||||||
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
|
|
||||||
))
|
|
||||||
|
|
||||||
for i in 60:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
print("=== INVESTIGATING ALL 518 ACTORS ===")
|
|
||||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
|
||||||
var actor_draws := {}
|
|
||||||
for d in draws:
|
|
||||||
var wm: PackedFloat32Array = d["world"]
|
|
||||||
var key := "%.1f,%.1f,%.1f" % [wm[12], wm[13], wm[14]]
|
|
||||||
if not actor_draws.has(key):
|
|
||||||
actor_draws[key] = []
|
|
||||||
actor_draws[key].append(d)
|
|
||||||
|
|
||||||
print("Unique actor positions: ", actor_draws.size())
|
|
||||||
|
|
||||||
var anomalies := []
|
|
||||||
for key in actor_draws:
|
|
||||||
var ds: Array = actor_draws[key]
|
|
||||||
var wm: PackedFloat32Array = ds[0]["world"]
|
|
||||||
var map_x := wm[12]
|
|
||||||
var map_y := -wm[13]
|
|
||||||
var actor_z := wm[14]
|
|
||||||
|
|
||||||
var bg_h = float(Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y]))
|
|
||||||
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
|
|
||||||
|
|
||||||
var diff_bg = actor_z - bg_h
|
|
||||||
var diff_terrain = actor_z - terrain_h
|
|
||||||
|
|
||||||
# Find textures
|
|
||||||
var texs := []
|
|
||||||
var min_vert_z := 1e9
|
|
||||||
for d in ds:
|
|
||||||
var t: String = d.get("texture0", "")
|
|
||||||
if not t.is_empty():
|
|
||||||
texs.append(t.get_file())
|
|
||||||
for p in d["positions"]:
|
|
||||||
min_vert_z = minf(min_vert_z, actor_z + p.z)
|
|
||||||
|
|
||||||
if absf(diff_bg) > 5.0 or absf(diff_terrain) > 5.0:
|
|
||||||
anomalies.append({
|
|
||||||
"pos": [map_x, map_y, actor_z],
|
|
||||||
"bg_h": bg_h,
|
|
||||||
"terrain_h": terrain_h,
|
|
||||||
"diff_bg": diff_bg,
|
|
||||||
"diff_terrain": diff_terrain,
|
|
||||||
"vert_min_diff": min_vert_z - bg_h,
|
|
||||||
"textures": texs
|
|
||||||
})
|
|
||||||
|
|
||||||
print("Found %d anomalies with gap > 5cm!" % anomalies.size())
|
|
||||||
for a in anomalies.slice(0, 30):
|
|
||||||
print("Anomaly: pos=(%.1f, %.1f, %.1f), bg_h=%.1f, terrain_h=%.1f, diff_bg=%.1f, diff_terrain=%.1f, vert_min_diff=%.1f, tex=%s" % [
|
|
||||||
a["pos"][0], a["pos"][1], a["pos"][2], a["bg_h"], a["terrain_h"], a["diff_bg"], a["diff_terrain"], a["vert_min_diff"], a["textures"]
|
|
||||||
])
|
|
||||||
|
|
||||||
quit(0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://xn0yhjq7h8d
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
|
||||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
|
||||||
|
|
||||||
var ui: Control
|
|
||||||
var world: Node3D
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("run")
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _key(keycode: Key, unicode: int, pressed: bool) -> void:
|
|
||||||
var event := InputEventKey.new()
|
|
||||||
event.keycode = keycode
|
|
||||||
event.physical_keycode = keycode
|
|
||||||
event.unicode = unicode
|
|
||||||
event.pressed = pressed
|
|
||||||
root.push_input(event)
|
|
||||||
|
|
||||||
func _press(keycode: Key) -> void:
|
|
||||||
_key(keycode, 0, true)
|
|
||||||
_key(keycode, 0, false)
|
|
||||||
|
|
||||||
func _type(text: String) -> void:
|
|
||||||
for i in text.length():
|
|
||||||
var ch := text.unicode_at(i)
|
|
||||||
var keycode := OS.find_keycode_from_string(text[i].to_upper())
|
|
||||||
_key(keycode, ch, true)
|
|
||||||
_key(keycode, ch, false)
|
|
||||||
|
|
||||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func run() -> void:
|
|
||||||
root.size = Vector2i(1024, 768)
|
|
||||||
|
|
||||||
world = Python3DSurface.new()
|
|
||||||
world.name = "Python3DSurface"
|
|
||||||
ui = PythonUISurface.new()
|
|
||||||
ui.name = "PythonUISurface"
|
|
||||||
root.add_child(ui)
|
|
||||||
root.add_child(world)
|
|
||||||
|
|
||||||
var err: String = ui.run_app()
|
|
||||||
if err != "":
|
|
||||||
print("ERROR: run_app failed: ", err)
|
|
||||||
quit(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
await _pump_until(15, func(): return _py(
|
|
||||||
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
|
|
||||||
))
|
|
||||||
|
|
||||||
await _pump_until(20, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
_py(
|
|
||||||
"_env = os.environ\n"
|
|
||||||
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
|
|
||||||
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
|
|
||||||
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
|
|
||||||
+ "_w = _stream.curPhaseWindow\n"
|
|
||||||
+ "_w._LoginWindow__OpenLoginBoard()\n"
|
|
||||||
+ "_w.idEditLine.SetText('')\n"
|
|
||||||
+ "_w.pwdEditLine.SetText('')\n"
|
|
||||||
+ "_w.idEditLine.SetFocus()"
|
|
||||||
)
|
|
||||||
_type(OS.get_environment("MT_LIVE_LOGIN"))
|
|
||||||
_press(KEY_TAB)
|
|
||||||
_type(OS.get_environment("MT_LIVE_PASSWORD"))
|
|
||||||
_py("del _env, _w")
|
|
||||||
_press(KEY_ENTER)
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()")
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
|
|
||||||
))
|
|
||||||
|
|
||||||
for i in 30:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
print("=== DEBUG HEIGHT REPORT ===")
|
|
||||||
var test_points = [
|
|
||||||
Vector2(59700, 68200),
|
|
||||||
Vector2(59700, 66400),
|
|
||||||
Vector2(60100, 66400),
|
|
||||||
Vector2(50000, 50000),
|
|
||||||
Vector2(30000, 30000),
|
|
||||||
Vector2(70000, 70000),
|
|
||||||
]
|
|
||||||
for pt in test_points:
|
|
||||||
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [pt.x, pt.y])
|
|
||||||
var tm_h = float(world.terrain.call("sample_height", pt.x / 100.0, pt.y / 100.0)) * 100.0 if world.terrain else 0.0
|
|
||||||
print("Point (%.0f, %.0f): 40250_bg_h=%s, Metin2World_h=%.1f, diff=%.1f" % [
|
|
||||||
pt.x, pt.y, bg_h, tm_h, float(bg_h) - tm_h
|
|
||||||
])
|
|
||||||
|
|
||||||
# Inspect terrain mesh triangles around dog
|
|
||||||
print("=== TERRAIN MESH VERTICES AROUND DOG (59700, -66400) ===")
|
|
||||||
# In camera space (since Camera3D is at origin):
|
|
||||||
# Dog world pos in 40250: (59700, -66400, 19851.5)
|
|
||||||
# What is dog in camera space?
|
|
||||||
var draws = Metin2PythonHost.render3d_draws()
|
|
||||||
for i in draws.size():
|
|
||||||
var d = draws[i]
|
|
||||||
var tex: String = d.get("texture0", "")
|
|
||||||
if tex.contains("stray_dog"):
|
|
||||||
var wm: PackedFloat32Array = d.get("world", PackedFloat32Array())
|
|
||||||
var vm: PackedFloat32Array = d.get("view", PackedFloat32Array())
|
|
||||||
var xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
|
|
||||||
print("DOG camera-space transform origin: ", xform.origin)
|
|
||||||
|
|
||||||
# Now let's check terrain mesh global transform and vertices:
|
|
||||||
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
|
|
||||||
var mi := child as MeshInstance3D
|
|
||||||
if mi.mesh is ArrayMesh:
|
|
||||||
var am := mi.mesh as ArrayMesh
|
|
||||||
var arrays := am.surface_get_arrays(0)
|
|
||||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
||||||
for v in verts:
|
|
||||||
var gv: Vector3 = mi.global_transform * v
|
|
||||||
# Dog in camera space is around xform.origin. Let's find terrain vertices within 200cm:
|
|
||||||
if abs(gv.x - xform.origin.x) < 200.0 and abs(gv.z - xform.origin.z) < 200.0:
|
|
||||||
print("Terrain vertex near dog in camera-space: ", gv, " dog origin: ", xform.origin, " diff_y (height in cam space): ", gv.y - xform.origin.y)
|
|
||||||
break
|
|
||||||
break
|
|
||||||
|
|
||||||
quit(0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://mpcddwfiipkc
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
|
||||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
|
||||||
|
|
||||||
var ui: Control
|
|
||||||
var world: Node3D
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("run")
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func run() -> void:
|
|
||||||
root.size = Vector2i(1024, 768)
|
|
||||||
world = Python3DSurface.new()
|
|
||||||
world.name = "Python3DSurface"
|
|
||||||
ui = PythonUISurface.new()
|
|
||||||
ui.name = "PythonUISurface"
|
|
||||||
root.add_child(ui)
|
|
||||||
root.add_child(world)
|
|
||||||
|
|
||||||
var err: String = ui.run_app()
|
|
||||||
if err != "":
|
|
||||||
print("ERROR: run_app: ", err)
|
|
||||||
quit(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
await _pump_until(15, func(): return _py(
|
|
||||||
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
|
|
||||||
))
|
|
||||||
|
|
||||||
await _pump_until(20, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
var host = OS.get_environment("MT_LIVE_HOST")
|
|
||||||
var login = OS.get_environment("MT_LIVE_LOGIN")
|
|
||||||
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
|
|
||||||
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
|
|
||||||
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
|
|
||||||
|
|
||||||
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
|
|
||||||
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
|
|
||||||
|
|
||||||
await _pump_until(30, func(): return _py(
|
|
||||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
|
|
||||||
))
|
|
||||||
|
|
||||||
print("=== IN INTRO SELECT PHASE ===")
|
|
||||||
for i in 60:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
var cmds: Array = Metin2PythonHost.ui_render_commands()
|
|
||||||
print("UI commands count: ", cmds.size())
|
|
||||||
var file = FileAccess.open("/tmp/select_ui_cmds.json", FileAccess.WRITE)
|
|
||||||
file.store_string(JSON.stringify(cmds, " "))
|
|
||||||
file.close()
|
|
||||||
print("Saved /tmp/select_ui_cmds.json")
|
|
||||||
|
|
||||||
quit(0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://c0k854rmu55u7
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
|
||||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
call_deferred("run")
|
|
||||||
|
|
||||||
func _py(source: String) -> bool:
|
|
||||||
return Metin2PythonHost.run_line(source) == ""
|
|
||||||
|
|
||||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
|
||||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
|
||||||
while Time.get_ticks_msec() < deadline:
|
|
||||||
if condition.call():
|
|
||||||
return true
|
|
||||||
await process_frame
|
|
||||||
return condition.call()
|
|
||||||
|
|
||||||
func run() -> void:
|
|
||||||
root.size = Vector2i(1024, 768)
|
|
||||||
var world = Python3DSurface.new()
|
|
||||||
var ui = PythonUISurface.new()
|
|
||||||
root.add_child(ui)
|
|
||||||
root.add_child(world)
|
|
||||||
|
|
||||||
ui.run_app()
|
|
||||||
|
|
||||||
await _pump_until(15, func(): return _py(
|
|
||||||
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
|
|
||||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
|
|
||||||
))
|
|
||||||
await _pump_until(20, func(): return _py("assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"))
|
|
||||||
|
|
||||||
var host = OS.get_environment("MT_LIVE_HOST")
|
|
||||||
var login = OS.get_environment("MT_LIVE_LOGIN")
|
|
||||||
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
|
|
||||||
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
|
|
||||||
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
|
|
||||||
|
|
||||||
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n_stream.curPhaseWindow.Connect('%s', '%s')" % [host, game_port, host, auth_port, login, pwd])
|
|
||||||
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
|
|
||||||
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
|
|
||||||
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
|
|
||||||
|
|
||||||
for i in 60:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
print("=== INSPECTING STONE TILES AROUND PLAYER (59700, 68200) ===")
|
|
||||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
|
||||||
var player_z := 19851.5
|
|
||||||
|
|
||||||
for d in draws:
|
|
||||||
var tex: String = d.get("texture0", "")
|
|
||||||
if tex.contains("stone") or tex.contains("floor") or tex.contains("tile") or tex.contains("sign"):
|
|
||||||
var wm: PackedFloat32Array = d["world"]
|
|
||||||
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
|
|
||||||
# check if this object is near player (within 2000cm):
|
|
||||||
if absf(xform.origin.x - 59700.0) < 3000.0 and absf(-xform.origin.y - 68200.0) < 3000.0:
|
|
||||||
var min_z := 1e9
|
|
||||||
var max_z := -1e9
|
|
||||||
for p in d["positions"]:
|
|
||||||
var wp: Vector3 = xform * p
|
|
||||||
min_z = minf(min_z, wp.z)
|
|
||||||
max_z = maxf(max_z, wp.z)
|
|
||||||
print("Object [%s] at (%.1f, %.1f, %.1f), Z range: [%.2f, %.2f], diff vs player_z(19851.5): min=%.2f, max=%.2f" % [
|
|
||||||
tex.get_file(), xform.origin.x, -xform.origin.y, xform.origin.z, min_z, max_z, min_z - player_z, max_z - player_z
|
|
||||||
])
|
|
||||||
|
|
||||||
quit(0)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user