Appearance
我的世界 AI 伙伴「夕瑟」· 保姆级项目详解
这是一篇"读到能自己改"的深度讲解。建议配合源码阅读,顺序:
bot.mjs → perception.mjs → tools.mjs → brain.mjs → persona.mjs。 面向:会一点 JS、想学"怎么把大模型接进游戏/系统"的人。
0. 先理解一个核心思想:把"大脑"和"身体"分开
绝大多数"AI 玩游戏"的项目之所以难,是因为想让一个模型同时理解世界又实时操作。这是矛盾的:
- 大模型聪明,但慢(一次几秒到几十秒),做不了实时反应。
- 游戏需要毫秒级的反应(被打要还手、看到岩浆要躲)。
本项目的解法是分层:
| 层 | 谁负责 | 特点 |
|---|---|---|
| 反射层(身体本能) | 纯代码 setInterval | 毫秒级、不经过模型:跟随、被打还手、躲苦力怕、自动吃 |
| 决策层(大脑) | 大模型 | 秒级:理解你说的人话、决定用哪个工具 |
| 执行层(手) | 纯代码工具 | 毫秒级:真正去挖、去合成、去丢东西 |
| 表达层(嘴) | 本地小模型 + TTS | 闲聊用 3B(快),说话用绯莎音 |
一句话:模型只负责"想",代码负责"动"。 这也是为什么 6GB 显存的笔记本也能跑出一个"像样"的伙伴。
1. 系统全景
┌───────────────────────────────────────────┐
│ 你的 Windows 电脑 │
│ │
Minecraft 客户端 ────┤─ 单机世界 · 对局域网开放 (127.0.0.1:端口) │
│ ↑ mineflayer (TCP) │
│ ┌────┴─────┐ │
│ │ bot.mjs │ 身体 + 反射层 + 总控 │
│ └──┬───┬───┘ │
│ │ │ │
Ollama (11434) ◄────┤──────┘ └────────────► GPT-SoVITS (9880) │
闲聊 3B │ brain.mjs 决策 绯莎语音合成 │
│ │ │
opencode-api (8787) ┤◄─────┘ │
→ opencode run │ │
→ 免费大模型 │ listen.ps1 (麦克风) / tts.ps1 (兜底语音) │
└───────────────────────────────────────────┘所有服务都用 127.0.0.1(本机回环) 通信,因此全部要在同一台 Windows 上跑(这也是为什么把它迁到 WSL 会出问题)。
2. 一键启动:launcher.mjs
用户只双击 启动夕瑟.cmd(内容就是 node launcher.mjs)。launcher 按顺序做五件事:
- 问端口:读
config.json里上次的端口作为默认,写回。 - 拉起 Ollama:探测
http://127.0.0.1:11434/api/version;没起就spawn ollama serve;然后预热qwen2.5:3b(keep_alive:-1让它常驻显存),并每 3 分钟 ping 一次保活。 - 拉起大脑代理 opencode-api:探测
http://127.0.0.1:8787/health;没起就spawn node server.mjs。 - 拉起 GPT-SoVITS 语音服务:探测
/docs;没起就用config.voice.gptsovits.serverDir里的runtime\python.exe api_v2.py …启动,等它就绪(最多 120s),再调用/set_gpt_weights和/set_sovits_weights换上绯莎权重。 - 拉起麦克风:
spawn powershell -File listen.ps1(stdio:"inherit",识别到的文字会显示在同一窗口)。 - 启动 bot:
await import("./bot.mjs")——注意是同一进程内导入,所以关窗时 bot 也随之结束。
关键实现(并发与生命周期):
js
const children = [];
function launch(cmd, args, cwd, inherit = false) {
const child = spawn(cmd, args, { cwd, detached: false, stdio: inherit ? "inherit" : "ignore",
windowsHide: true, env: { ...process.env, OLLAMA_KEEP_ALIVE: "-1" } });
children.push(child);
return child;
}
// 关窗/退出时,把所有子进程一起杀掉(GPT-SoVITS 释放显存)
process.on("exit", killChildren);
process.on("SIGINT", () => { killChildren(); process.exit(0); });
detached:false是刻意选的:让子进程跟启动窗口同生共死,避免"关了窗口但服务还在后台占显存"。
3. 身体与反射层:bot.mjs
这是最大的文件,承担四件事:连接游戏、跑反射循环、接入语音、处理聊天。
3.1 连接与事件
js
bot = mineflayer.createBot({ host, port, username, auth: "offline", hideErrors: true });
bot.loadPlugin(pathfinder);
bot.loadPlugin(pvpPlugin);
bot.once("spawn", () => { /* 初始化 Movements、tools、默认跟随 */ });建筑保护在初始化寻路器时设置:
js
const movements = new Movements(bot);
movements.canDig = false; // 赶路不挖方块 → 不会拆你的房子
movements.canOpenDoors = true; // 会开门
movements.allow1by1towers = false;
bot.pathfinder.setMovements(movements);注意:这只约束寻路。主动挖矿走的是
bot.dig(),不受影响——这正是我们想要的分工。
事件监听里比较重要的是死亡/复活:
js
bot.on("death", () => { pushEvent("我死了"); mood = Math.max(5, mood - 10); });
bot.on("spawn", () => {
spawnCount++;
if (spawnCount <= 1) return; // 首次由 once("spawn") 处理
pushEvent("我复活了,回主人身边");
if (cfg.followByDefault !== false && owner) followTarget = owner;
sayFlavor("你刚复活,正在回到主人身边", "主人,我又回来了,马上到你那儿。");
});以及"主人死亡/回归"——它监听的是系统消息 messagestr(不是 chat),因为死亡提示是系统消息:
js
bot.on("messagestr", (raw) => {
const t = String(raw || "");
if (!owner || !t.includes(owner)) return;
if (canEvent("ownerDeath", 15000) && /(被杀|杀死了|死亡|died|slain|虚空|溺死|烧死|饿死|炸死|摔死)/.test(t)) {
const ent = playerEntity(bot, owner);
if (ent) ownerDeathPos = { x: 取整, y, z }; // 记住你死亡的位置
pushEvent("主人死了");
pendingConfirm = { kind: "collect", pos: ownerDeathPos, at: Date.now() };
say("主人你挂了……要我去你倒下的地方把东西捡回来吗?");
}
// 主人(重新)加入
if (canEvent("ownerJoin", 15000) && /(加入了游戏|joined the game)/.test(t)) { … }
});3.2 反射循环:四个定时器
js
function startLoops() {
setInterval(followTick, 1000); // 跟随(顺手捡掉落物)
setInterval(guardTick, 1000); // 威胁评估 / 自保 / 护主 / 撤退
setInterval(eatTick, 3000); // 低血自动吃
setInterval(brainTick, 6000); // 夜间回家 / 主动搭话 / 恢复被打断的任务
}这就是"身体本能":不经过任何模型,每秒都在跑。
followTick:跟随 + 顺路捡物
js
function followTick() {
if (!followTarget || !bot.entity) return;
if (homeTrip) return; // 回家优先级高于跟随
if ((suspect && Date.now() < suspect.until) || nearestHostile(bot, 10)) return; // 反击高于跟随
const drop = nearestItem(bot, 3); // 顺路捡起附近掉落物
if (drop) { bot.pathfinder.setGoal(new GoalNear(drop.position…, 0)); return; }
const t = playerEntity(bot, followTarget);
if (t) bot.pathfinder.setGoal(new GoalFollow(t, cfg.followDistance), true);
}优先级就藏在这几行里:回家 > 反击 > 跟随。
guardTick:威胁评估 → 撤退 / 护主 / 攻击
它先做"打不过判断":
js
function shouldFlee() {
const h = bot.health ?? 20;
const { score, count, creeper } = assessThreat();
if (creeper) return "creeper"; // 苦力怕贴身 → 一律避开
if (有监守者/凋灵/末影龙) return "boss"; // 直接跑
if (h < 4) return "dying"; // 濒死
if (count >= 3 && score > 7 * mult * s) return "outnumbered"; // 寡不敌众
if (score > 14 * mult * s) return "toostrong"; // 威胁远超自身战力
return null; // 否则能打就打
}assessThreat() 把附近 16 格内每个怪物按"种类权重 × 距离"累加成 score,myStrength() 综合血量/武器/护甲算战力,mult 来自 persona.courage(胆量)。低血本身不跑,只有真正打不过才跑(用户的明确要求)。
撤退时用"锁定期"避免刷屏:
js
if (fleeReason) {
if (Date.now() >= fleeUntil) { // 进入一次撤退
fleeUntil = Date.now() + 9000; // 锁定 9 秒
fleeAnnounced = false;
bot.pvp?.stop();
pushEvent(`我撤退了(${fleeReason})`);
}
if (!fleeAnnounced) { fleeAnnounced = true; sayFlavor("情况不妙,正在撤退", "打不过,先撤!"); }
// 持续朝主人/家跑
return;
}护主 / 陪猎在最前面(不需要"保护"开关):
js
if (ownerAttacker && Date.now() < ownerAttacker.until) { bot.pvp.attack(bot.entities[ownerAttacker.id]); return; }
if (huntAssist && Date.now() < huntAssist.until) { bot.pvp.attack(bot.entities[huntAssist.id]); return; }ownerAttacker / huntAssist 是在 entityHurt 事件里设置的:主人受伤 → 找最近的非被动怪锁定;被动动物在主人附近受伤 → 陪打。
一个易错点:PvP 插件靠寻路接近目标,如果同时
followTick每秒覆盖寻路目标,就打不到怪。所以战斗中followTick会让位。
eatTick:低血/饥饿自动进食
js
if (bot.food >= 18 && (bot.health ?? 20) >= 16) return;
const food = items.find(i => /cooked_/.test(i.name)) || items.find(i => /apple|bread|beef|porkchop…/);
if (food) { await bot.equip(food, "hand"); await bot.consume(); }brainTick:夜间回家 / 主动搭话 / 续任务
- 夜间回家且可放弃手头工作:天黑且设过家 →
homeTrip = true,并清掉pendingTask/pendingConfirm、置abortRequested(正在挖矿的循环会看到并中止)。 - 到家整理:到家后
homeTidy()打开附近箱子:把"不用的"存进去(保留武器/工具/护甲/食物/火把),并从箱子里"补缺的"(剑/镐/斧/锹/火把/食物)。这就是"每次回家自动补"。 - 天亮了恢复跟随:
waitingHome && !isNight→ 重新followTarget = owner。 - 主动搭话:调用
proactive(),结合"最近发生的事"说一句。
3.3 聊天处理:onChat(二次确认的核心)
处理一条消息的顺序:
- 首次认主:
owner = username,并写入owner.txt,memory.txt记一条。 - 记忆命令:
记住 X→ 写rules.txt;清空对话/忘记一切。 - 记忆查询:
你记得什么→ 回显事实 + 规矩 + 好感度。 - 自动记个人信息:含"我叫/我住/我喜欢/我讨厌…"的短句自动进
memory.txt。 - 命令二次确认:见下。
- 编号选项:模型拿不准时给
1)… 2)… 3) 取消。 - 夜间先问:晚上让它外出的采集类命令 → 先问"还要我去吗"。
- 存箱子模式:字面出现"放箱子/放家里/存起来" →
storeMode = true。 - 安全指令即时:
停下 / 别跟 / 别打→ 立即执行,不确认。 - 快速通道:制作/给/采集/配方 → 直接由正则识别,但先
proposeConfirm。 - 交给大脑:
think()决定这是"闲聊"还是"命令"。
二次确认的实现(这是用户最新要求的行为):
js
function proposeConfirm(summary, thunk) {
lookAtOwner();
say(`你是要我做「${summary}」吗?回「是」我就去做。`);
pendingExec = { summary, thunk, at: Date.now() }; // 把"要做的事"存起来,等确认
}- 是命令(模型给出
actions)→proposeConfirm(describeActions(actions), () => runPlan(plan, username, message))。 - 是闲聊 → 直接
say(plan.say)。 - 下一条消息若匹配
/^(是|对|好|嗯|可以|确认|执行|去吧|行|开始|ok)/→ 执行pendingExec.thunk();匹配"不/算了/取消" → 放弃;否则当新消息。
主动行为(护主/反击/夜里回家/复活回身边/就近捡物)不经过
onChat,所以不受"二次确认"影响——这正是用户要的"被动的都要确认,主动的不用"。
3.4 语音接入(详见第 6 节)
say(text, voice=true) 同时做三件事:打日志(写 bot.log)、如果要发声就 ttsSpeak()、以及 bot.chat() 发到游戏聊天栏。
4. 眼睛:perception.mjs
sense(bot, owner) 把游戏状态整理成一个对象,describe(s) 再把它变成一段中文文本喂给模型。
js
export function sense(bot, owner) {
return {
health, food, position, dimension, timeOfDay, isNight, held,
inventory, // "oak_log×6、dirt×12、…"
hostiles, // 附近 20 格的敌对怪(名字+距离)
animals, // 附近 24 格的动物
items, // 附近 16 格的掉落物
players, // 附近 32 格的玩家
nearbyBlocks, // 附近 12 格的工作台/熔炉/箱子/水/岩浆/床/火把
owner, // 主人的距离/血量/位置
};
}describe() 输出形如:
血量 20/20,饥饿 18/20,白天,维度 minecraft:overworld
坐标 123,64,-45,手持 wooden_sword
背包:oak_log×6、dirt×12、bread×3
附近敌对生物:zombie(7m)
附近动物:cow(9m)
附近设施:crafting_table(4m)、chest(6m)
主人 mhd 距离5格 血量20为什么要给模型"眼睛"? 因为大模型看不到游戏画面。把这些结构化数据塞进 prompt,它才能"看着世界"做决定("附近没树""背包没石头"这类判断就靠它)。
isHostileName / isPassiveName / MEAT_ANIMALS 等集合定义了"什么算敌人/动物/可打的肉源"。
5. 手:tools.mjs(全部动作)
5.1 工具是怎么"暴露"给模型的
createTools(bot, ctx) 返回一个 { 工具名: async (args) => 结果 } 的字典;TOOL_SPEC 是一份"工具名 + 参数示意 + 中文说明"的清单,会被 brain.mjs 拼进 prompt。模型只输出 {tool, args},程序查字典调用。
加动作的成本:写一个函数 + 在
TOOL_SPEC加一行。不用改大脑。
5.2 采集安全:digNames
所有采集都走它,它在"动手前"做安全检查:
js
for (let i = 0; i < count; i++) {
if (ctx?.abort?.() || ctx?.underAttack?.() || nearestHostile(bot, 8)) { // 被中止/被袭击
ctx.interrupted = true;
break; // 停手,交给反射层应战
}
const block = findBlockByName(bot, names, 64);
if (!block) break;
await approach(bot, block, 1, 12000); // 走到旁边(GoalGetToBlock:站到方块旁,不会站它顶上)
await bot.dig(block); // 从侧面挖 → 天然"不竖直向下挖"
}"永不竖直向下挖"不是一句提示,而是靠 GoalGetToBlock(站到方块旁边再挖)+ 只挖目标方块实现的。
5.3 合成:ensureItem 递归补材料
这是最"聪明"的一段。目标:说"做个木剑",自动把原木 → 木板 → 木棍 → 剑全补齐。
js
async function ensureItem(bot, name, qty, depth = 4) {
if (已有数量 >= qty) return true;
const recipes = bot.recipesFor(itemId, …, table);
for (const recipe of recipes) {
for (const d of recipe.delta) { // delta:负数是消耗,正数是产出
if (d.count < 0) {
// 递归:先把每个原料补齐
if (!(await ensureItem(bot, ingredientName, -d.count * crafts, depth - 1))) { ok = false; break; }
}
}
if (ok) { await bot.craft(recipe, crafts, table); return 已够; }
}
}如果某个配方需要工作台而附近没有,ensureCraftingTable() 会先造一个工作台、放到地上、再用它。
craft_kit 更进一步:一次做一整套(剑/镐/斧/锹/锄),然后走到主人身边丢给他。
5.4 放置与"堵你正看的缺口":placeBlockAt + ownerViewTarget
js
async function placeBlockAt(bot, pos, materialName) {
// 1) 目标格必须是空的 2) 找一个相邻的实心方块当"参照" 3) 太远就先走近 4) 放
}
function ownerViewTarget(bot, owner, maxDist = 6) {
const o = bot.players[owner]?.entity;
const eyes = o.position.offset(0, (o.height||1.8)*0.9, 0);
const dir = { x: -sin(yaw)*cos(pitch), y: -sin(pitch), z: cos(yaw)*cos(pitch) }; // 主人视线方向
// 沿视线步进,返回"命中实心方块之前的那个空气格" = 缺口
}于是"用土块把这里堵上"会:读你的朝向 → 射线找到你看的缺口 → 走过去 → 放下土块。
5.5 采集通用化:GATHER_MAP
把中文资源名映射到"挖哪些方块/打哪些动物":
js
const GATHER_MAP = [
{ match: /木头|原木|圆木|树|木/, blocks: LOG_NAMES, tool: "axe" },
{ match: /石头|石/, blocks: ["stone","cobblestone"], tool: "pickaxe" },
{ match: /泥土|土/, blocks: ["dirt","grass_block"], tool: "shovel" },
{ match: /煤/, blocks: ["coal_ore","deepslate_coal_ore"], tool: "pickaxe" },
…
{ match: /羊毛|毛/, hunt: ["sheep"], drop: /wool|mutton/ },
{ match: /肉/, hunt: ["cow","pig","chicken","sheep","rabbit"], drop: /beef|…/ },
];采集数量按物品个数算:砍12个圆木 → 挖 12 个原木。
5.6 其他值得一提的工具
equipWeapon/equipTool:按动作自动换装备(砍树换斧、挖矿换镐、打怪换剑/斧)。没工具就空手(fetchToolFromChest已被移除,符合"生存前期没有工具就用手"的要求)。store/withdraw:箱子存取。recipe:用minecraft-data的配方数据回答"X 要什么材料",不靠模型猜。survey_shelter:扫描家附近,报告"照明/门/床/工作台/熔炉/箱子/屋顶"缺什么(观察类工具,触发"观察→再决定")。feed/craft_kit/gather:高层组合动作。
6. 大脑:brain.mjs
6.1 只输出 JSON
系统提示词要求模型输出:
json
{"say":"要说的话","actions":[{"tool":"chop","args":{"count":4}}]}parsePlan(text) 负责把它解析出来,而且很结实:
- 用"括号配对"从任意文本里抠出第一个 JSON;
- 解析失败时,用正则从
"say":"…"里抠出话,避免把原始 JSON 念出来; - 只保留
TOOL_NAMES里存在的工具(模型编的假工具会被丢弃)。
6.2 路由:闲聊 vs 做事
needsAction(message) 用一组强指令词判断"像不像命令"。
- 不像命令(闲聊) → 走本地 3B,用短提示词(不含工具清单),快。
- 像命令 → 走"3B 先试,升级大模型":先让 3B 用完整工具提示词试一次,出不了合法动作再依次试
brain.model和fallbacks。全失败才回"这个我还不会"。
为什么动作不直接用大模型?因为大模型每次要 spawn
opencode run、约 10–30s;3B 常在 1–2s 内就选对常见动作。二者结合=又快又稳。
6.3 流式闲聊:callOpenAIStream
闲聊用 Ollama 的 stream:true,边收边按句子(遇 。!?;… 切分)回调 onSentence。这样她能"边想边说",首句约 2 秒就开始出声。
6.4 事件即兴台词:flavor
挨打、天黑、你受伤等场景,不再用写死台词,而是让 3B 按情境即兴说一句(并明确要求"不要用口头禅、不要重复上一句");失败才用兜底句。
6.5 长期记忆:summarize
每隔约 20 轮,让大模型把旧对话浓缩成 1–3 条长期事实,追加到 memory.txt——所以聊得再多,早期的重要信息也不会丢。
7. 人格与记忆:persona.mjs
createMemory(here) 管四样东西:
| 文件 | 内容 |
|---|---|
memory.txt | 长期事实("主人叫 mhd""主人喜欢…") |
rules.txt | 规矩("以后有怪先保护我"),每次对话强制注入 |
affinity.json | 好感度,影响称呼与语气 |
history.json | 短期对话历史(存 40 条,发给模型只取最近 12 条) |
personaPrompt(cfg, mem, mood) 生成人格描述:"你是夕瑟,女仆,和主人玩 Java 版…性格…你们关系…你现在心情…"。relationWord / moodWord 把数值转成自然语言。
8. 语音系统(TTS / STT / 缓存)
8.1 她说(TTS)
say() → ttsSpeak(text):
- 若
voice_cache/index.json里正好有这句话 → 直接playWav()播放缓存的 wav(瞬时、省 GPU); - 否则把这句话塞进
ttsQueue,由ttsWorker依次调 GPT-SoVITS 的/tts接口合成(gptsovitsSpeak):GET 附带ref_audio_path(绯莎参考音频)+prompt_text+ 待合成文本,拿回 wav,写到临时文件后SoundPlayer播放; - 若 GPT-SoVITS 不可用 → 退回系统语音
tts.ps1(Huihui)。
voice_cache.mjs 预先批量合成一批常用固定台词(问候、确认、报错…)存进 voice_cache/,命中即播。生成语音缓存.cmd 就是跑它。
8.2 你说话(STT)
listen.ps1 用 Windows 自带 System.Speech 连续识别(RecognizeAsync),识别到一句就 POST 到 http://127.0.0.1:8790/say;bot.mjs 里的 ensureVoiceInput() 起了个小 HTTP 服务接收,转成 onChat(owner, text)。
- 唤醒词机制:配置
wakeWord(默认"喂喂喂")。喊一次后进入 60 秒"会话窗口"(每说一句续期),窗口内免唤醒;listen.ps1会把唤醒词从文本里去掉再发。 - 回音风险:免唤醒词 + 外放 → 她的声音会被麦克风再识别一遍 → 回环;所以建议戴耳机。
8.3 为什么第一次说话很慢
GPT-SoVITS 首次要加载模型(30–60s),之后每句合成仅 1–2s。启动器负责等待并设置权重,所以"启动时慢一下,玩起来就快"。
9. 配置:config.json(逐项)
| 字段 | 说明 |
|---|---|
host / port | 连的服务器(单机局域网:127.0.0.1 + 端口) |
username | 机器人名(也是皮肤站绑定名) |
brain.* | 决策大模型(apiBase 指向 opencode-api;model + fallbacks 组成回退链) |
chat.* | 闲聊模型(本地 Ollama 3B) |
followByDefault | 进服/复活是否自动跟随 |
historyMax / historySend | 历史"存多少 / 发给模型多少" |
voice.* | 语音总开关、引擎(gptsovits/sapi)、唤醒词、会话窗口、麦克风端口、GPT-SoVITS 各路径 |
persona.* | 名字、性格、口头禅、胆量、主动搭话间隔 |
改动后要重启才生效(bot.mjs 启动时读一次)。
10. 如何自己扩展
- 加一个动作:在
tools.mjs的返回对象里加async xxx(args){…},并在TOOL_SPEC加一行说明。模型即可调用。 - 加一条快速通道:在
bot.mjs#onChat的"快速通道"区加一段正则,命中就proposeConfirm(…, () => runFast([…]))。 - 换大脑模型:改
config.json的brain.model(用opencode models里存在的名字)。 - 加语音台词到缓存:把句子加进
voice-cache.mjs的PHRASES,重新双击生成语音缓存.cmd。 - 改人格:改
config.json#persona或persona.mjs#personaPrompt。 - 加"自主"行为:在
bot.mjs#brainTick里加逻辑,但记得遵守"只做简单/安全/就近的事"这一原则。
11. 设计取舍与踩坑
- 为什么不用大模型做实时战斗:模型延迟秒级,实时反应必须用代码反射层。
- 为什么动作先给 3B:省时省钱;大模型只在必要时兜底。
- 为什么工具要"安全优先":
canDig=false保护建筑;GoalGetToBlock防竖挖;采集在受袭时立即中断交给反射层。 - 二次确认为什么必要:大模型会误解指令;确认一步能挡住大部分"帮倒忙"。
- 为什么别迁 WSL:所有服务走
127.0.0.1,而 WSL2 与 Windows 网络隔离,机器人连不上 Windows 上开的游戏;GPT-SoVITS 也是 Windows 形态。 owner.txt/home.json:把"主人是谁""家在哪"持久化,重启不忘。
12. 结语:这个项目最值得学的三件事
- 分层架构:反射(代码)/决策(模型)/执行(工具)/表达(模型+TTS)各司其职,才可能既实时又聪明。
- 给模型"眼睛和手":结构化感知(
sense)+ 可调用工具(TOOL_SPEC)+ 严格 JSON 协议,是把 LLM 变成"能干活"的关键。 - 渐进式信任:从"全自动"退回到"二次确认",让 AI 的能力和你的控制权取得平衡。
建议阅读顺序:
bot.mjs的 tick 循环 →perception.mjs的sense→tools.mjs的ensureItem/digNames→brain.mjs的think。这四处吃透,你就能随意改造她。