OCC 星球漫游 · 部署与发布
两条互不相干的路:Windows 本机常驻(开发机自用)和 Linux 生产服务器(对外)。
核对基线:2026-10-15,scripts/svc.py、deploy/目录实测。
1. 本地开发机常驻
1.1 命令
python scripts/svc.py start # 启动(已健康则跳过;端口被占但探活失败会先清场)
python scripts/svc.py status # 状态 + 进程链 + HTTP 探活
python scripts/svc.py logs [n] # 生命周期日志 + 服务日志(默认末 40 行)
python scripts/svc.py stop # 停止(杀到进程链根)
python scripts/svc.py restart
关键常量:端口 8790、计划任务名 occ-planet、健康检查 http://127.0.0.1:8790/api/health。
1.2 为什么必须用计划任务
普通子进程活不过一次工具调用:工具沙箱给进程树套了 Job Object(KILL_ON_JOB_CLOSE),会话一结束整棵树被回收。 唯一出路是让任务计划服务(svchost)来创建进程。
启动链:
计划任务 occ-planet
→ wscript.exe //B deploy/_svc_occgame.vbs (窗口样式 0,隐藏)
→ deploy/_svc_occgame.bat (chcp 65001 → cd /d D:\OCC-Game → node server\server.js >> _logs\occgame.log)
→ node server/server.js
几个刻意的设计:
| 设计 | 原因 |
|---|---|
| 任务触发时间设在 2099/12/31 | 避免到点自动触发第二次(会清空日志并造成端口冲突);实际靠 schtasks /Run 手动拉起 |
stop 要杀到进程链根 | 只杀监听 PID 会留一个 cmd.exe 复活服务。用 Toolhelp32 快照向上追链(wmic 被安全策略拦截) |
定位 PID 用 netstat -ano | 不依赖额外工具 |
| 健康检查最长等 35s | 冷启动要烘焙贴图 |
1.3 脚本文件必须纯 ASCII + CRLF
_svc_occgame.bat / .vbs 要保持纯 ASCII + CRLF。 本机按 ANSI/OEM 解码这两类文件:中文注释若行末非 ASCII 字节数为奇数,会把换行吃掉,下一行被并进本行——若是代码就静默失效。
规范化用:
python scripts/fix_line_endings.py
先验证 UTF-8 解码、再验证 ASCII 编码,全部通过才写回。
2. 生产服务器
2.1 服务器侧布局
| 项 | 值 |
|---|---|
| 代码目录 | /opt/occ-game(该机约定是 /opt/<项目名>/,没有 /var/www/) |
| 应用监听 | 127.0.0.1:8790(只绑回环,不直接对外) |
| 对外入口 | nginx :8090 反代(配置见 deploy/prod/nginx-occ-game.conf) |
| 进程守护 | PM2(deploy/prod/ecosystem.config.cjs),单实例 fork 模式 |
| 应用日志 | /opt/occ-game/_logs/pm2-out.log、pm2-error.log |
| nginx 日志 | /var/log/nginx/occ-game.access.log、occ-game.error.log |
2.2 PM2 配置要点
| 项 | 值 | 原因 |
|---|---|---|
instances | 1 | 世界状态(被打碎的物件、在线玩家)是进程内缓存,多实例会各存一份 |
exec_mode | fork | 同上 |
max_memory_restart | 500M | 机器内存有限 |
NODE_ENV | production | |
HOST | 127.0.0.1 | 只绑回环 |
| 文件后缀 | 必须 .cjs | package.json 有 "type": "module",叫 .js 会被 PM2 按 CommonJS 读 → File malformated |
2.3 nginx 要点
| 项 | 做法 |
|---|---|
| 部署位置 | /etc/nginx/conf.d/occ-game.conf(必须 .conf 结尾;sites-enabled/ 是空目录且没被 include) |
| 监听 | 8090 http2(IPv4 + IPv6) |
| 上游 | upstream occ_game { server 127.0.0.1:8790; keepalive 16; },全站反代 |
| WebSocket | 必须有 map $http_upgrade $connection_upgrade 升级头,否则 /ws 一直 400 |
| 缓存 | = / 与 = /index.html → no-cache, must-revalidate;/vendor/ → 30 天 immutable;其余(含 assets)交给 Node 自己设头,不覆盖 |
| gzip | 开(level 5),类型含 js/css/json/svg;不含 GLB(内部已是压缩贴图,实测只省 2.3%) |
| 未启用 | brotli(需模块)、gzip_static(需预生成 .gz) |
🔴 如果这台机器上还有别的站点,先确认哪个 server 块是自己的,不要整体重启别人的配置。
2.4 发布:push.sh
bash deploy/prod/push.sh # 全量发布
OCC_SSH_TARGET=other-host bash deploy/prod/push.sh # 指定别的机器
OCC_SKIP_INSTALL=1 bash deploy/prod/push.sh # 依赖没变时跳过 npm install
OCC_SYNC_NGINX=1 bash deploy/prod/push.sh # 顺带覆盖服务器上的 nginx 站点配置
默认 SSH 目标别名是 occ-server,要在本机 ~/.ssh/config 里配好:
Host occ-server
HostName <服务器 IP>
User root
IdentityFile ~/.ssh/<你的密钥>
IdentitiesOnly yes
刻意用别名而不是写死 IP —— 免得生产机地址被提交进公开仓库。
2.5 流程(set -euo pipefail)
| 步 | 动作 |
|---|---|
| 0 | 连通性检查(远端 hostname / node / pm2 / nginx 版本) |
| 1 | 本地打包:tar -C <repo> <EXCLUDES> --owner=root --group=root -I 'gzip -1' -cf <tmp>.tgz . |
| 2 | scp 到 <目标>:/tmp/occ-game-release.tgz |
| 3 | 远端 tar -xzf 到 /opt/occ-game(原地覆盖,不动 _logs),删临时包 |
| 4 | 远端 npm install --omit=dev --no-audit --no-fund(可 OCC_SKIP_INSTALL=1 跳过) |
| 5 | 同步 PM2 配置:install -m 644 deploy/prod/ecosystem.config.cjs /opt/occ-game/ecosystem.config.cjs(PM2 读的是这份,不是包里的那份) |
| 6 | 可选 nginx 同步(OCC_SYNC_NGINX=1):先备份 .bak.<时间戳> → 覆盖 → nginx -t 失败自动回滚 |
| 7 | pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save |
| 8 | nginx -t && systemctl reload nginx |
| 9 | 三项探活:8790/api/health、8090/api/health、8090/(nginx 侧非 200 即报错Sign out) |
2.6 打包排除清单(硬编码)
固定排除:
./node_modules
./_logs
./_data
./.git
./.workbuddy
./.deploy-tmp
./tools/e2e/node_modules
./tools/blender
条件排除(未设 OCC_FULL_ASSETS=1 时追加,即"资产瘦身"清单): assets/mecha/img、assets/mecha/models、assets/mecha/model/bang、assets/mecha/model/blender、 未减面的机甲 GLB(aegis07_voxel_pbr.glb、aegis07_voxel_shaded.glb)、 旧减面版(r0015、r01、r004.glb、r004.flat.glb)、 以及 assets/mecha/ 与 assets/buildings/ 下的一批展示用 HTML(index.html、voxel.html、vehicles.html、preview.html、parts_viewer.html、character.html 等)。
2026-10-15 追加(新增的建筑 / 矿产 / 无人机 / 图标资产):
./assets/buildings/img # 34MB 设计参考图
./assets/buildings/ref # 35MB
./assets/buildings/*.html
./assets/buildings/models/*/preview_*.png # 建模预览渲染
./assets/buildings/models/*/*_texture_emissive.png
./assets/resources/img # 11MB
./assets/resources/ref # 11MB
./assets/resources/*.html
./assets/drones/img # 4MB
./assets/drones/ref # 4MB
./assets/drones/*.html
./assets/icons/hd # 11MB 高清母版(作品内用 game/ 下的 128px)
./assets/buildings/models/*/*_final.glb # 原始 GLB(约 203MB)—— 只发瘦身版
./assets/resources/models/*/*_final.glb # 原始 GLB(约 61MB)
./assets/drones/models/*/*_final.glb # 原始 GLB(约 26MB)
🔴 这个清单是硬编码的:新增素材目录时必须手动加一条--exclude,否则整个目录会被打进发布包。 判断标准是"作品运行时到底需不需要"。上面的排除项逐条 grep 全项目确认过没有任何运行时引用。 最后三条是 2026-10-16 加的:这批模型原来的发布包是 314MB,其中约 245MB 是原始 GLB。 瘦身后(node tools/slim_assets.mjs,243MB → 42MB,↓82.6%)只发_final.opt.glb, 客户端build.js/resources.js/logistics.js的加载顺序是 opt → 失败退回原始版 ⇒ 原始版只在本地开发用到。_final.glb不会匹配*_final.opt.glb(后者结尾是.opt.glb),别怕误伤。 瘦身原理与"量化 + 改几何"那个坑见 美术资产管线 §5.2 / §5.3。
实测发布包:314MB → 约 108MB(2026-10-16 瘦身后)。
push 之后建议跟一条清理
push.sh 是 tar 原地覆盖,不删远端已有的文件 —— 每收紧一次排除规则 (瘦身 GLB、剔除开发素材、换模型版本…),上一版传上去的那批就永远留在服务器上。 实测攒了 273MB(assets/mecha 的设定图 49MB + 未减面 GLB / blender 渲染图 226MB)。
bash deploy/prod/prune-remote.sh # 只报告(默认 dry)
OCC_APPLY=1 bash deploy/prod/prune-remote.sh # 真的删
- 规则:只删"本地也有、但按当前
EXCLUDES不会被发上去"的文件 ——
远端有、本地却没有的(老版本改过名 / 本地已删)只列出来不删,那种得人来判断。
- 排除规则从
push.sh里现取(EXCLUDES=(和缩进的EXCLUDES+=(两处都要取),
不另写一份,免得两处漂移。
- ⚠️
ssh ... <<HEREDOC < list是两个重定向、后写的赢 —— 远端会把文件清单当脚本读,
症状是"说删了 N 个、体积一点没变"。清单要内联进远端脚本再 bash -s。
- 实测:
/opt/occ-game460M → 187M(删 82 个文件),机甲运行时必需的四个文件都在。
运行时真正需要的机甲资产只有 4 个:
| 文件 | Purpose |
|---|---|
assets/mecha/model/rig.js | 骨架 / 蒙皮 |
assets/mecha/model/anim.js | 动作库 |
assets/mecha/model/legmath.js | 腿部 IK |
assets/mecha/model/reduced/aegis07_voxel_pbr.r004.flat.opt.glb | 减面 + 瘦身后的模型(约 0.55MB) |
2.7 可用的环境变量
| 变量 | 作用 |
|---|---|
OCC_SSH_TARGET | SSH 目标别名(默认 occ-server) |
OCC_REMOTE_DIR | 远端目录(默认 /opt/occ-game) |
OCC_NGINX_PORT | 对外端口(默认 8090) |
OCC_APP_PORT | 应用端口(默认 8790) |
OCC_NGINX_CONF_DIR | nginx 配置目录(默认 /etc/nginx/conf.d) |
OCC_FULL_ASSETS=1 | 连大体积素材一起发布 |
OCC_SKIP_INSTALL=1 | 跳过 npm install |
OCC_SYNC_NGINX=1 | 顺带覆盖 nginx 站点配置 |
3. 硬约束(踩过的)
| 约束 | 说明 |
|---|---|
| 服务器上没有 git / rsync / zip / unzip | 只有 scp / tar / gzip。别试图改成 git clone |
PM2 配置必须是 .cjs | 见 §2.2 |
新增进程后必须 pm2 save | 否则机器重启不会自动拉起 |
instances 保持 1 | 见 §2.2 |
nginx 配置必须在 conf.d/ 且 .conf 结尾 | 停用某个站点要改名成不带 .conf 的扩展名 |
| 云厂商安全组要单独放行对外端口 | 机内 firewalld / iptables 是空的,拦不住也不代表外部能通 |
| 发布后如果改了 nginx 配置 | 加 OCC_SYNC_NGINX=1,否则服务器上还是旧配置 |
| 新素材目录 | 手动加 --exclude(清单是硬编码的) |
4. 发布后自检
# 三项探活(push.sh 已自动做,手工复核用)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8790/api/health
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/health
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/
# PM2 状态与日志
pm2 list && pm2 logs occ-game --lines 50
浏览器端再确认三件事:
- 作品能进(不是白屏 / 卡在加载页)。
- 浏览器控制台没有
xxx is not a function之类的报错(有就说明会冻屏)。 - 硬刷新后拿到的还是新版本(
/与/index.html是no-cache,/public/是 5 分钟协商缓存)。