简介:这份源码资源面向希望将传统AVG文字冒险游戏迁移到Android平台的开发者,核心围绕吉里吉里(Krkr2)与NS(Nscripter)两款引擎的移动端实现展开。包内共291个文件,以198个class与34个java源码为主体,辅以21个png界面素材、9个xml配置、9个so本地库及4个exe工具,另有ks脚本、properties与prefs等配置项,压缩包约4.25MB,结构紧凑便于按模块研读。资源重点解决触屏操作适配、移动端性能优化、多分辨率资源管理与Android权限控制等移植难点,并涉及NSTools打包解包与脚本转换思路。已有1198人学习下载,适合具备一定Android与脚本基础的中高级开发者参考,可据此理解引擎改造、资源加载与兼容性调试的完整链路,为自有AVG项目移植提供实践蓝本。
1. 从 PC 到 Android:一套 AVG 移植框架到底解决了什么
很多做视觉小说或文字冒险游戏的朋友都遇到过同一个尴尬:PC 上跑得好好的项目,想搬到 Android 手机上就卡住了。不是渲染黑屏,就是脚本解析报错,再不然就是触摸事件对不上、存档路径找不到。一套成熟的 AVG 游戏 Android 移植框架源码,本质上就是把这些重复造轮子的活提前干完了——它把吉里吉里(KiriKiri)和 NS(NScripter)这两套经典 AVG 引擎的运行时逻辑,重新用适合移动端的架构组织了一遍,让开发者能把精力放回剧情、立绘和演出上,而不是跟 JNI 和 SurfaceView 死磕。
这份源码适合三类人:手里有 PC 端 AVG 项目想低成本上 Android 的独立开发者;想研究移动端 AVG 引擎架构的学生或爱好者;以及需要给自家文字游戏快速搭一个可运行壳子的团队。它不承诺一键打包,但把最脏最累的引擎适配层、资源加载策略和输入映射都摆在了明面上,照着改比从零写省太多事。
2. 引擎适配层拆解:吉里吉里与 NS 在 Android 上怎么跑起来
2.1 两套引擎的运行时差异与统一抽象
吉里吉里和 NS 虽然都是 AVG 引擎,但底层设计思路差得很远。吉里吉里以 TJS 脚本为核心,渲染层依赖图层合成,事件系统是消息驱动;NS 则更偏向命令式脚本,渲染相对直接,但扩展性弱一些。移植到 Android 时,如果分别写两套渲染循环和脚本解释器,代码量会爆炸。这套框架的做法是抽出一个EngineAdapter接口,把「初始化」「执行脚本行」「渲染一帧」「处理输入」四个核心动作统一掉,具体引擎各自实现。
常见做法是让吉里吉里走SurfaceView+ 独立渲染线程,NS 走TextureView+ 主线程回调,但框架里为了减少差异,统一用了SurfaceView加双缓冲。这样做的代价是 NS 那边需要额外做一层纹理上传,但换来了输入事件和生命周期管理的一致性。如果你只打算支持其中一套引擎,可以把另一套的适配代码直接删掉,能省下不少包体积。
2.2 脚本解释器的 Android 化改造要点
PC 上的脚本解释器大量依赖文件系统直接读取和同步阻塞调用,搬到 Android 后必须改成异步加载,否则主线程一卡,ANR 就来了。框架里把脚本解析拆成了「预编译」和「运行时执行」两个阶段:预编译在后台线程把.ks或.nscript转成中间字节码,运行时只做轻量级的指令分发。
// ScriptPreloader.java public class ScriptPreloader { // 后台线程预编译脚本,避免主线程 IO public static void preload(Context ctx, String scriptPath, PreloadCallback cb) { new Thread(() -> { try { InputStream is = ctx.getAssets().open(scriptPath); // 将脚本转为中间字节码,具体实现依赖引擎 byte[] bytecode = ScriptCompiler.compile(is); // 回调切回主线程 new Handler(Looper.getMainLooper()).post(() -> cb.onDone(bytecode)); } catch (IOException e) { // 资源缺失时给出明确日志,方便排查 Log.e("ScriptPreloader", "preload failed: " + scriptPath, e); cb.onError(e); } }).start(); } }这段代码的关键参数是scriptPath,它必须是 assets 目录下的相对路径,不能带前导斜杠。PreloadCallback的onDone会在主线程触发,可以直接把字节码交给引擎实例。注意ScriptCompiler.compile是框架预留的扩展点,吉里吉里和 NS 各自实现不同的编译逻辑,但对外接口一致。如果你发现预编译后脚本执行报「未知操作码」,大概率是编译器和运行时版本不匹配,检查两边用的引擎分支是否一致。
2.3 资源加载策略:assets 与外部存储的取舍
AVG 游戏资源量大,立绘、背景、音频动辄几百 MB。全塞进 APK 的 assets 里会导致安装包巨大,而且 Android 对单个 assets 文件有 1MB 左右的读取限制(虽然可以用AssetManager.openFd绕过,但压缩格式又受限)。框架默认策略是:脚本和小体积配置文件放 assets,大资源放外部存储或 OBB 扩展包。
# 把大资源推送到应用外部存储目录的示例 adb push game_res/ /sdcard/Android/data/com.example.avgport/files/res/推送后需要在代码里把资源根路径指向getExternalFilesDir("res")。注意 Android 10 以后对外部存储的访问收紧,getExternalFilesDir不需要额外权限,但路径会随包名变化。如果你在模拟器上调试,adb push的目标路径要换成模拟器实际挂载的路径,否则会提示目录不存在。另一个坑是文件名大小写:PC 上 Windows 不区分,Android 的 ext4 分区区分,Bg01.png和bg01.png会当成两个文件,移植前最好统一改成小写。
3. 从源码到可运行 APK:编译、配置与触摸事件对接
3.1 工程结构与 Gradle 配置要点
拿到源码后先别急着点编译,花五分钟看目录结构能省后面半小时的报错排查。典型的工程根目录下会有app模块、engine-krkr2和engine-ns两个库模块,以及一个native目录放 C++ 层的桥接代码。settings.gradle里必须把这三个模块都 include 进去,漏一个就会在链接阶段报「找不到符号」。
// app/build.gradle 关键片段 android { compileSdk 34 defaultConfig { minSdk 21 // 低于 21 会缺很多现代 API targetSdk 34 ndk { // 只保留 arm64-v8a 和 armeabi-v7a,减少包体积 abiFilters 'arm64-v8a', 'armeabi-v7a' } externalNativeBuild { cmake { // 指定 C++ 标准,引擎桥接层用了 C++17 特性 cppFlags '-std=c++17' } } } externalNativeBuild { cmake { path 'src/main/cpp/CMakeLists.txt' } } }minSdk设 21 是底线,再低的话SurfaceView的双缓冲行为和Assets的异步读取会有兼容问题。abiFilters里如果加了x86,模拟器能跑但真机包会变大,按需取舍。cppFlags里的-std=c++17不能省,桥接层用了std::optional和结构化绑定,用 C++14 编译会直接报错。常见翻车点是 NDK 版本:框架的CMakeLists.txt里指定了cmake_minimum_required(VERSION 3.18.1),如果你本地 NDK 太老,Gradle 会提示找不到匹配的 CMake,去 SDK Manager 里装一个 3.18.1 以上版本即可。
3.2 触摸事件映射:从屏幕坐标到游戏内坐标
AVG 游戏在 PC 上靠鼠标点击推进剧情,Android 上要映射成触摸事件。难点在于坐标系转换:屏幕触摸点是物理像素,游戏内逻辑坐标可能是 1280x720 或 1920x1080 的虚拟分辨率。框架里用了一个TouchMapper类做缩放和偏移计算。
// TouchMapper.java public class TouchMapper { private float scaleX, scaleY; private float offsetX, offsetY; // 根据游戏虚拟分辨率和 View 实际尺寸计算映射参数 public void update(int viewW, int viewH, int gameW, int gameH) { // 保持宽高比,取较小的缩放系数 scaleX = (float) viewW / gameW; scaleY = (float) viewH / gameH; float scale = Math.min(scaleX, scaleY); // 计算黑边偏移,让游戏画面居中 offsetX = (viewW - gameW * scale) / 2f; offsetY = (viewH - gameH * scale) / 2f; scaleX = scaleY = scale; } // 把触摸点转成游戏内坐标 public float[] map(float touchX, float touchY) { float gx = (touchX - offsetX) / scaleX; float gy = (touchY - offsetY) / scaleY; return new float[]{gx, gy}; } }update方法在SurfaceView尺寸变化时调用,比如横竖屏切换或分屏模式。map返回的坐标如果超出gameW和gameH范围,说明触摸点落在黑边上,引擎层应该忽略这次事件。参数gameW和gameH必须和脚本里设定的虚拟分辨率一致,否则点击位置会偏移。我见过有人把gameW写成屏幕实际宽度,结果立绘上的按钮怎么点都不对,排查了半天才发现是这里配错了。
3.3 存档与读档:Android 沙箱下的路径处理
PC 版 AVG 通常把存档写在游戏目录下的save文件夹,Android 上不能这么干,必须写到应用私有目录。框架里用getFilesDir()作为存档根路径,再按引擎类型分子目录。吉里吉里的存档格式是二进制加校验,NS 是文本加 Base64,两者不能混用。
// SaveManager.java public class SaveManager { // 获取当前引擎对应的存档目录 public static File getSaveDir(Context ctx, EngineType type) { // 私有目录无需权限,卸载应用后自动清理 File base = ctx.getFilesDir(); File dir = new File(base, type == EngineType.KRKR2 ? "save_krkr" : "save_ns"); if (!dir.exists()) { // mkdirs 失败通常是因为父目录权限异常,极少见 dir.mkdirs(); } return dir; } }EngineType是个枚举,在引擎初始化时确定。存档目录建好后,读写操作和 PC 上逻辑一致,只是路径换了。注意 Android 的getFilesDir()路径会带包名,比如/data/data/com.example.avgport/files/save_krkr,在日志里看到这个路径不要慌,是正常的。如果你需要让用户手动备份存档,可以用FileProvider把目录暴露出去,但记得在AndroidManifest.xml里配好provider节点,否则会抛FileUriExposedException。
4. 避坑与排查:移植过程中最容易翻车的五个点
4.1 黑屏但音频正常
现象:启动后屏幕全黑,但背景音乐和音效都能听到,触摸也有反应。原因通常是渲染线程启动了但纹理没上传成功,或者SurfaceView的SurfaceHolder回调顺序不对。解决:先检查surfaceCreated里是否调用了引擎的onSurfaceReady,再确认资源路径下立绘文件确实存在且格式是 PNG 或 JPG。如果用的是 ETC2 压缩纹理,还要确认 GPU 支持情况,部分老机型不支持 ETC2 会静默失败。
4.2 脚本执行到某一行卡死
现象:剧情推进到特定位置后不再继续,日志里没有异常。原因多半是脚本里用了 PC 端特有的同步等待命令,比如wait或sleep的阻塞版本,在 Android 上被主线程调度卡住了。解决:把阻塞等待改成基于Handler的延时回调,或者用框架提供的AsyncWaiter工具类。排查时可以在脚本解释器的指令分发处加一行日志,看最后执行的是哪条指令。
4.3 触摸点击位置偏移
现象:按钮能点,但实际响应区域和视觉位置差了一截。原因通常是TouchMapper的gameW和gameH与脚本设定的虚拟分辨率不一致,或者SurfaceView的尺寸变化后没有重新调用update。解决:在onSizeChanged里强制刷新映射参数,并打印一次scaleX、offsetX的值,和预期对比。如果偏移是固定值,多半是offset算错了;如果偏移随点击位置变化,那是scale的问题。
4.4 存档写入失败
现象:点存档没反应,或者提示「保存失败」。原因可能是外部存储权限没给,但更常见的是存档目录被写成了只读,或者文件名里带了非法字符。解决:确认存档路径用的是getFilesDir()而不是getExternalFilesDir(),后者在部分机型上挂载为只读。另外检查存档文件名是否包含:、*、?这些字符,Android 文件系统不允许。
4.5 编译时 NDK 报错「undefined reference」
现象:Gradle 编译到externalNativeBuild阶段报链接错误,提示某个 C++ 函数未定义。原因通常是CMakeLists.txt里漏了某个源文件,或者abiFilters和预编译库的架构不匹配。解决:打开CMakeLists.txt确认add_library里列出的.cpp文件齐全,再检查native目录下是否有对应架构的.so文件。如果只保留了arm64-v8a,但预编译库只有armeabi-v7a,链接就会失败,补上对应的 ABI 或换库即可。
5. 进阶技巧:用日志钩子快速定位引擎层异常
框架本身带了一个轻量级的日志系统,但默认只输出到 Logcat,排查跨线程问题时不够用。我一般会加一个文件日志钩子,把引擎层的关键事件按时间戳写到应用私有目录,出问题时直接拉文件,比在 Logcat 里翻滚屏高效得多。
// FileLogHook.java public class FileLogHook implements EngineLogHook { private BufferedWriter writer; public FileLogHook(File logFile) throws IOException { // 追加模式,避免每次启动覆盖旧日志 writer = new BufferedWriter(new FileWriter(logFile, true)); } @Override public void onLog(String tag, String msg) { try { // 格式:时间戳 线程名 标签 消息 String line = System.currentTimeMillis() + " " + Thread.currentThread().getName() + " " + tag + " " + msg; writer.write(line); writer.newLine(); writer.flush(); // 实时刷盘,崩溃时也能保留最后几条 } catch (IOException e) { // 日志写入失败不能影响游戏运行,直接忽略 } } }把这个钩子在引擎初始化时注册进去,EngineLogHook是框架预留的接口,吉里吉里和 NS 的实现都会回调onLog。flush()每行都调是为了防止崩溃时缓冲区丢日志,代价是频繁 IO,如果性能敏感可以改成每 10 行刷一次。日志文件放在getFilesDir()/engine.log,用adb pull拉出来即可。
验证方法很简单:启动游戏,随便点几下,然后看日志里有没有surfaceCreated、scriptExec、touchEvent这几类标签。如果某个标签一直不出现,说明对应模块没被触发,问题就锁定在那条链路上。我习惯在每次移植新项目时先跑一遍这个日志流程,确认引擎生命周期完整,再开始调脚本和资源。从那以后,每次拿到新框架我都强制走一遍日志钩子加文件输出的流程,省下的排查时间远比写这几行代码多。希望帮到你。
本文还有配套的精品资源,点击获取