事情起因很俗:右边半个屏幕常年空着,我不想让它空着。
成品是一个两兆出头的单文件 exe(前端全部打包在内,旁边没有 dll),双击就有一个 1320×820 的窗口,里面是座小岛,岛外的浅水环里养着四十多条鱼,一条礁鲨沿最外圈慢慢蹭,岸上蹲着一只猫。鼠标点一下水面就撒一把饲料,鱼会掉头游过来吃。想看得更近,按 1 是随便转随便缩的全岛视角,按 2 跟着鱼群走,按 3 跟着猫走。
写完回头看,真正有意思的地方全不在"鱼缸"这两个字上,而在几个挺琐碎的决定里。下面按我踩的顺序讲。
代码挂在 GitHub 和 Gitee 两边,clone 下来跑 build.ps1 就行(需要 VS2022 的 C++ 工作负载加 vcpkg,WebView2 的 SDK 靠 vcpkg 拉):
- GitHub:https://github.com/Emsoro/FishTank
- Gitee(国内快一些):https://gitee.com/masonwu21/FishTank
为什么没上 Electron
也没什么高级理由,就是不忍心为一个桌面摆件常驻三百多兆内存,而且打包出来一个 node_modules 规模的安装目录我看着难受。
Windows 上机器里本来就躺着 Edge WebView2 运行时,那就用它。后端直接是 C++:一个 Win32 窗口,把 WebView2 塞进去,页面从内存里的虚拟文件系统读,不落地。
这套配方我叫它 TauriCPP(项目里 TauriCPP/ 那个目录,是照着 maze 那份抄过来的壳)。前后端之间就两个方向:
cpp
bridge.RegisterCommand("feed", [](const json& args) -> json {
g_tank.Feed(fx, fz, count); // 真正干活的是 C++ 那边
j["state"] = g_tank.Snapshot();
return j;
});
app.GetBridge().Emit("tick", snapshot); // 每 50ms 推一份全量状态
前端拿到的是 window.__tauricpp__.invoke('feed', {...}) 和一个 listen('tick', ...)。整个壳(窗口、桥、虚拟文件系统)加起来不到两千行,比我在两个进程间手写 stdio 协议省了一万个心眼。
前端那些 js/css/png 是编译期被 TauriCPP/tools/pack_resources.py 遍历目录塞进 resources.rc 的。这条后来坑了我一次:改了 js 不重新 build,页面一点反应没有,我还以为自己写的逻辑被狗吃了。
建模不是我干的,我认
第一版我是自己写的"鱼缸"。鱼是几块蓝色三角板拼的,游起来像拖把在水里涮,帧率低得难看,截图发给自己都嫌寒碜。
第二版我换了个思路:之前手上有一份现成的小岛场景(一个两百来行的 Three.js 建模脚本,海风之屿,卡通描边、沙滩、灯塔、木栈桥、水上小屋里那种),我拿 tools/extract_island.py 把 js 从 html 里抠出来改名成 frontend/js/island.js,一行没改直接渲染。
进度立刻从 0 变成九成。剩下要做的只是往这个场景里塞活物:水面上加鱼群、岸边加寄居蟹、岛上加一只猫。
所以丑话说在前面:这项目里好看的部分不是我做的。但物理、鱼的行为、猫、还有下面那一堆坑,是我一行行写的,这点底气还是有的。
鱼在哪算:C++ 算,前端只管插值
src/tank.cpp 里是个纯逻辑的 Tank:不碰窗口、不碰渲染、不依赖 Three.js,Step(dt) 推进 50 毫秒,然后 Snapshot() 出一份全量 JSON:
cpp
std::thread([&app]() {
while (g_running) {
std::this_thread::sleep_for(std::chrono::milliseconds(kTickMs)); // 50
{
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_paused) g_tank.Step(dt);
snapshot = g_tank.Snapshot();
}
app.GetBridge().Emit("tick", snapshot);
}
}).detach();
前端每帧收到的就是一堆 {id, s, x, y, z, yaw, sz},只负责往上报的位置插值,一点逻辑都不算。这么分的好处很实在:
- 能单测 。
test/tank_test.cpp里一千多项断言,喂多少粒饲料、多少粒被吃了、鲨鱼不吃食、猫抓走的鱼六秒后补回同种的一条、鱼会不会穿到沙里,全是纯函数级别的断言,跑一次一秒钟。这些逻辑要是长在 js 的渲染循环里,我根本没法测。 - 前端换渲染方案不用动后端(我第一版和第二版之间就换了个彻底)。
代价是每秒二十次全量 JSON。四十多条鱼也就十几 KB 的量级,实测这点流量连眉毛都不皱一下。
单条鱼受的是几股力:分离/对齐/聚合(同种之间权重更高,所以真会分出小群)、自己的偏好水层 prefY、wander 噪声、看到饲料时的觅食冲动(hunger 越高看得越远),以及看到鲨鱼的惊慌。鲨鱼自己不合群,只沿着外圈切线蹭。
吃的动作我一开始写成"嘴对准饲料才算吃",看着很做作------鱼会摆出一个尴尬的精确走位。改成用鱼身半径去撞,判定通过就少一粒,视觉上反而是"游过去一蹭,没了",比动画糊弄得多。
点水面喂鱼,其实是个反向投影
js
ndc.set((clientX / innerWidth) * 2 - 1, -(clientY / innerHeight) * 2 + 1);
raycaster.setFromCamera(ndc, I.camera);
const hits = raycaster.intersectObject(I.sea, false); // 水面是 y=0.105 那块平面
拿到世界坐标还得夹一下,不然一点击撒到沙滩上:
js
function ringPoint(x, z) {
const a = Math.atan2(z, x);
const shore = I.coast(a) * 1.02; // 岸线
const r = Math.min(Math.max(Math.hypot(x, z), shore + 0.10), 7.9);
return { x: Math.cos(a) * r, z: Math.sin(a) * r };
}
coast(a) 是个三角函数叠出来的不规则海岸线(6.45 + .35sin(3a+.4) + .27cos(5a-1) + .18sin(7a)),C++ 那边有一份一模一样的实现。两边同一套几何常量 ------这是这个项目的命门:坐标一旦漂移,鱼会游到沙里、猫会踩进水里、饲料会撒到岸上。我在 tank.hpp 顶上把这条写成了注释,算是给以后的自己留的话。
性能的几处,都很土
- 一种鱼一个
InstancedMesh,四十多条鱼在渲染端只占十来个 draw call。 - 材质全共享,卡通渐变就一张 4 档的
DataTexture。 - 鱼身的花纹不是贴图文件,是每种鱼一个
paint(g, W, H)用 Canvas2D 现画,转成CanvasTexture。想要新花色改十行 js。 renderer.shadowMap.autoUpdate = false,静态场景的阴影烘一次;会动的东西(鱼、猫、旗、船)一律挂一片假接触阴影。这一条把帧率抬到稳 60。
Euler 记得设 'YXZ',不然偏航会被前倾带跑,鱼会转歪。
岛上的猫是我写过最啰嗦的状态机
猫整个在前端跑。本来想放后端,但它的骨骼姿态和场景贴得太死,放后端就得每帧把姿态推过来,不值。
行为环是这样:
屋边趴着休息 → 走到岸边蹲守(肚皮贴地往前蹭,尾巴先摇,瞄够时间才扑)→ 按住鱼搏斗(0.9~3.5 秒随机,鱼在爪子底下扑腾)→ 叼到屋子靠海那侧随便一块地吃完 → 出来舔毛 → 趴着歇一两分钟 → 在岛上走走跑跑(三成概率小跑)→ 隔两三分钟才再抓一次
一开始它十几秒抓一条,看两分钟就烦了;抓完固定在楼梯底下吃饭,像个定点上班的白领。现在 yardSpot() 在房子周围随机找一块不撞墙、不踩水、走得通的空地吃饭歇脚,样子活了。
骨头就是几个 Group 套起来逐帧摆:四条腿是髋关节绕 x 轴,正方向是往后(我一开始把负方向当进步,猫一直倒着走还不自知)。身上的虎斑也返工过------用三片浮在背上的深色椭圆,从上往下看像长了四片鳍;换成贴着身体截面放大的开口圆柱环,才像长在毛上。
抓到的鱼通过 catch_fish 交给后端真删掉,六秒后补一条同种的,鱼群总数不变。鲨鱼抓不动------Catchable() 直接挡掉,猫扑空一次就蹲回去,这个失败分支比成功分支有意思。
图标是画出来的,不是找的
tools/make_icon_src.py 用 PIL 画:天空到海水的圆角渐变、太阳、浅水圈、沙滩、草、小房子、带红顶的灯塔、一棵棕榈(ribbon() 画的贝塞尔带,宽度从根部到梢收掉)、几块礁石、几道浪、一条很小的橙色鱼。512 的源图缩成 src/icon.png,再让 TauriCPP/tools/make_icon.py 出多尺寸 src/app.ico,CMake 生成的 rc 里就一行:
1 ICON "D:/Cplusplus/FishTank/src/app.ico"
HUD 左上角那个小图标是同一张图缩到 128 存进 frontend/img/logo.png,靠虚拟文件系统当 image/png 发出来。桌面图标和窗口里的 logo 是同一个岛,这种小事看着很舒服。
第一版画完,太阳糊成一团灰,棕榈像刚发芽,我盯着看了十秒决定重画。
镜头:我到最后才想明白该"不管"
Three.js 的 OrbitControls 有阻尼,很好用;但如果每帧都把 camera.position 往预设机位 lerp,玩家拖动就是在跟代码打架,而且必输。
全岛/俯视好解决------玩家一动手就把控制权交出去:
js
I.controls.addEventListener('start', () => {
if (state.view === 'island' || state.view === 'top') state.view = 'free';
});
跟鱼、跟猫麻烦一点:既要跟着主体走,又要让玩家自己定高度和远近。我的做法是每帧只把 target 往主体挪,然后把相机平移同样的量,于是"相机 - 目标"这个偏移原样保留,方向、高低、远近就还是玩家定的:
js
const px = C.target.x, py = C.target.y, pz = C.target.z;
C.target.lerp(followPt, 1 - Math.exp(-dt * 6));
cam.position.x += C.target.x - px;
cam.position.y += C.target.y - py;
cam.position.z += C.target.z - pz;
跟鱼那边还顺手治了个老毛病:镜头在两个鱼群之间来回甩。原因是"选身边鱼最多的那条"每帧重算,两个群数量一样时就一会儿跟东一会儿跟西。现在盯上的鱼有黏性,只有当自己明显更冷落时才换群。
顺手记几个坑,都是真的浪费过时间的
- CDP 的
Input.dispatchMouseEvent会真的把指针打进去。 我用它做自动化验证的时候,正在写的文档里鼠标自己飘走了。现在验证脚本全部改成在页面里派发PointerEvent/WheelEvent------顺带要桩掉setPointerCapture,合成事件的pointerId不是真实指针,OrbitControls 会抛异常。 mouseReleased必须带clickCount=1,否则 Chrome 不给页面派发pointerup,OrbitControls 卡在拖动状态里:滚轮失灵,鼠标一动镜头还继续转。我前前后后怀疑过阻尼系数、怀疑过自己每帧覆盖相机,白折腾很久。.\build.ps1只能单独一条命令跑,跟管道、&&拼一起会让 vcvarsall 炸(报的是 StandardOutputEncoding 那种莫名其妙的错)。- 链接报
LNK1104打不开 exe,就是程序还开着。Stop-Process -Name FishTank -ErrorAction SilentlyContinue有次静默没杀掉,得按 PID 强杀。 - 前端资源是编译期打进 exe 的(前面说过一遍,因为它真的又骗了我一次)。
验证:把浏览器当被测件
启动时带上调试端口:
powershell
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"
.\build\Release\FishTank.exe
然后 python 连 websockets 走 CDP:Runtime.evaluate 读页面里挂出来的 AQ 调试对象,Page.captureScreenshot 出图。tools/ 里那几个脚本全是这么来的------verify_feed.py 会在点一次"喂食"之后连续采样十二次,算最近三条鱼离饲料的距离、两单位内有几条鱼围过来,最后判"鱼确实凑上来吃了";cam_probe.py 用页面内合成事件验证四个视角的拖动、缩放、跟随;cat_watch.py 蹲猫的行为环,能看它蹲、蹭、扑、搏斗、找地方吃饭。前端在 window 上留了 AQ(状态、机位、猫、投喂、切视角),平时不参与渲染,纯粹是给脚本留的手。
这么做的收益不在"看起来专业",而在于我改猫的状态机时不用手动把玩五分钟才确认它还会不会走到吃饭那步。
目录长这样
| 位置 | 内容 |
|---|---|
src/tank.cpp tank.hpp |
鱼群模拟:群游、觅食、饲料、快照 JSON、猫抓鱼 |
src/main.cpp |
窗口 + 命令注册 + 50ms 推状态的线程 |
TauriCPP/ |
Win32 + WebView2 的壳,pack_resources.py / make_icon.py |
frontend/js/island.js |
小岛场景(原样搬来,没改) |
frontend/js/creatures.js |
鱼种定义与 Canvas2D 花纹、实例化鱼群、饲料、寄居蟹、水波 |
frontend/js/cat.js |
猫:骨骼与行为状态机 |
frontend/js/app.js |
主控:状态插值、机位、点击投喂 |
test/tank_test.cpp |
后端纯逻辑断言 |
tools/*.py |
画图标、扒场景、验证、截图、压测 |
src/main.cpp 里注册了八个命令,其中 new_tank / set_stock / set_paused / window.set_title 是壳模板留下的,HUD 上现在没有对应按钮,暂时没人调------留着不亏,反正一行的事。
最后
严格说这东西没有任何"必要",它就是一坨会动的像素,放在屏幕右边让我打字的时候想骂人的次数少一点。但它确实把我想要的几个技术点串起来了:C++ 单 exe、不装运行时、后端能单测、前端能实例化、还能自动验证。
要继续玩也容易:水母、水下光斑、把猫换成水獭、鱼群密度做成可拖的滑块。哪个都行,反正建模我已经不打算自己写了。
仓库在这儿,觉得有点意思给个 star:
- GitHub --- https://github.com/Emsoro/FishTank
- Gitee --- https://gitee.com/masonwu21/FishTank