feat: 完成 O 群組 — 立繪子系統

後端(apps/api/src/tachie/):立繪 manifest(身體姿勢/服裝/表情眉眼口/
效果層/微動態的資產目錄,服裝目錄有 M-1 登錄時優先採用);表情解析依
情緒引擎六狀態對照眉/眼/口/效果/姿勢,依 RelationshipStage 分層解鎖
(陌生~認識/朋友/摯友/羈絆,哭臉限羈絆才會出現),依角色外顯度縮放
差分幅度(magnitude = expressiveness × 情緒強度);emotionTag(實際
套用)與 rawEmotionTag(情緒引擎真實判定)分離,讓文字生成與表情解析
維持兩條獨立資料流,「文字與立繪故意不一致」是這個設計的自然結果,
不需要額外邏輯;視線方向依思考/迴避/警戒切換。

前端(apps/web/components/TachieStage.tsx):以 PixiJS 實作分層合成
與眨眼/呼吸微動態,預留 TachieRenderer 介面供未來 Live2D Cubism SDK
實作(未引入任何 Live2D 相依或授權,純架構佔位);接上 ChatView 取代
H 群組的佔位立繪,聊天回應後先查詢表情、延遲約 220ms 才顯示文字,
在非串流架構下盡力模擬「表情先變化、文字後出現」。

以 scratchpad 暫裝 playwright-core 實測瀏覽器行為(本環境無
chromium-cli),確認情緒變化即時反映在立繪、微動態持續運作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 17:46:50 +08:00
co-authored by Claude Sonnet 5
parent eccdcba45c
commit 0f3d16059c
13 changed files with 780 additions and 17 deletions
+199
View File
@@ -0,0 +1,199 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "seed-user-primary";
const GENKI_ID = "smoke-o-genki"; // 元氣:外顯度高
const COOL_ID = "smoke-o-cool"; // 三無:外顯度極低
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function createCharacter(id, archetype, intimacy, stage) {
await post("/personality/characters", {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: id,
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: archetype,
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy, trust: intimacy },
});
if (stage) {
await prisma.relationship.update({
where: { characterId_userId: { characterId: id, userId: USER_ID } },
data: { stage },
});
}
}
async function setEmotion(characterId, tag, value) {
const dims = { calm: 0, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 };
dims[tag.toLowerCase()] = value;
await prisma.emotionState.update({ where: { characterId }, data: dims });
}
export default async function smokeO() {
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, COOL_ID] } } });
await createCharacter(GENKI_ID, "元氣", 10, "STRANGER");
await createCharacter(COOL_ID, "三無", 10, "STRANGER");
// O-1 差分資產結構:manifest 應該能以佔位資產組出完整立繪需要的所有圖層。
const manifest = await get(`/tachie/${GENKI_ID}/manifest`);
if (manifest.bodyPoses.length < 3 || manifest.bodyPoses.length > 5) {
throw new Error("身體姿勢差分數量應在 3~5 種之間");
}
if (manifest.costumes.length < 2) {
throw new Error("服裝差分至少應有 2 套");
}
if (
manifest.expressionParts.eyebrows.length === 0 ||
manifest.expressionParts.eyes.length === 0 ||
manifest.expressionParts.mouths.length === 0
) {
throw new Error("表情差分(眉/眼/口)應該都有可用選項");
}
if (manifest.effects.length === 0 || manifest.microMotions.length < 2) {
throw new Error("效果層與微動態(眨眼/呼吸)應該都有定義");
}
// M-1 服裝目錄若已登錄,manifest 應該優先採用那份目錄(而不是通用預設兩套)——立繪服裝層
// 只能從目錄取用、不可自創,manifest 回傳的內容就是這條規則的具體體現。
const work = await prisma.work.create({ data: { title: "服裝目錄測試作品" } });
await post("/canon/illustrations", {
workId: work.id,
volume: 1,
type: "BW_ILLUSTRATION",
description: "測試插圖",
costumeLabels: [{ characterId: GENKI_ID, label: "特殊活動服" }],
});
const manifestWithCatalog = await get(`/tachie/${GENKI_ID}/manifest`);
if (!manifestWithCatalog.costumes.includes("特殊活動服") || manifestWithCatalog.costumes.length !== 1) {
throw new Error("已登錄 M-1 服裝目錄時,manifest 應該優先採用那份目錄,而不是退回通用預設");
}
// O-4 情緒 → 表情對照:六狀態應該各自對應到不同的眉/眼/口/姿勢組合。
const emotionTags = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
await prisma.relationship.update({
where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } },
data: { stage: "BONDED", intimacy: 90 },
});
const templates = new Map();
for (const tag of emotionTags) {
await setEmotion(GENKI_ID, tag, 80);
const expr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (expr.rawEmotionTag !== tag) {
throw new Error(`情緒設為 ${tag} 時,表情解析應該反映相同的情緒`);
}
templates.set(tag, `${expr.eyebrow}/${expr.eye}/${expr.mouth}/${expr.pose}`);
}
if (new Set(templates.values()).size !== emotionTags.length) {
throw new Error("六種情緒狀態應該各自對應到不同的表情差分組合");
}
// O-4 外顯度縮放:同樣的愉悅情緒,元氣角色的差分幅度應該遠大於三無角色。
await prisma.relationship.update({
where: { characterId_userId: { characterId: COOL_ID, userId: USER_ID } },
data: { stage: "BONDED", intimacy: 90 },
});
await setEmotion(GENKI_ID, "JOY", 90);
await setEmotion(COOL_ID, "JOY", 90);
const genkiExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
const coolExpr = await get(`/tachie/${COOL_ID}/${USER_ID}/expression`);
if (!(genkiExpr.magnitude > coolExpr.magnitude * 3)) {
throw new Error(
`外顯度低的角色差分幅度應該明顯小於元氣角色,實際為 元氣=${genkiExpr.magnitude} 三無=${coolExpr.magnitude}`,
);
}
// O-3 親密度解鎖:低親密度時,稀有情緒差分(例如彆扭)不可用,應該降級為基本表情。
await prisma.relationship.update({
where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } },
data: { stage: "STRANGER", intimacy: 5 },
});
await setEmotion(GENKI_ID, "GRUMPY", 80);
const lockedExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (!lockedExpr.lockedFallbackApplied || lockedExpr.emotionTag !== "CALM" || lockedExpr.rawEmotionTag !== "GRUMPY") {
throw new Error("陌生階段時,彆扭表情應該被降級為基本表情,但情緒引擎的真實判定仍應可查(故意不一致)");
}
await prisma.relationship.update({
where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } },
data: { stage: "CLOSE_AMBIGUOUS", intimacy: 65 },
});
const unlockedExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (unlockedExpr.lockedFallbackApplied || unlockedExpr.emotionTag !== "GRUMPY") {
throw new Error("達到摯友階段後,彆扭表情應該被解鎖");
}
// O-3 哭臉列為最深層:即使情緒引擎判定深度低落,未達羈絆階段也不會出現哭臉特效。
await setEmotion(GENKI_ID, "SAD", 90);
const closeSad = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (closeSad.usedCryingVariant) {
throw new Error("哭臉是羈絆階段才解鎖的最深層差分,摯友階段不應該出現");
}
await prisma.relationship.update({
where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } },
data: { stage: "BONDED", intimacy: 90 },
});
const bondedSad = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (!bondedSad.usedCryingVariant || bondedSad.effect !== "淚") {
throw new Error("羈絆階段的深度低落應該解鎖哭臉特效");
}
// O-2 視線方向:思考時視線飄上、迴避/害羞時視線撇開、警戒時直視。
const thinkingGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression?isThinking=true`);
if (thinkingGaze.gaze !== "UP") {
throw new Error("思考時視線應該飄上");
}
const deflectingGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression?isDeflecting=true`);
if (deflectingGaze.gaze !== "AWAY") {
throw new Error("迴避時視線應該撇開");
}
await setEmotion(GENKI_ID, "ALERT", 80);
const alertGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (alertGaze.gaze !== "DIRECT") {
throw new Error("警戒時視線應該直視");
}
// O-2 表情早於文字:情緒引擎的更新(emotion.processInput)發生在文字生成之前,
// 因此聊天訊息送出後,表情查詢應該已經反映這一輪的新情緒,不需要等文字回覆完成才更新。
await prisma.relationship.update({
where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } },
data: { stage: "BONDED", intimacy: 90 },
});
await setEmotion(GENKI_ID, "CALM", 100);
const chatResult = await post(`/chat/${GENKI_ID}`, {
userId: USER_ID,
sessionId: "smoke-o-session",
text: "謝謝你,我今天好開心!",
});
const exprAfterChat = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
if (chatResult.context?.emotion.dominant !== "JOY" || exprAfterChat.emotionTag !== "JOY") {
throw new Error("聊天訊息觸發的情緒轉變,應該同時反映在對話上下文與獨立查詢的表情狀態上");
}
// 清理本次測試建立的角色與作品(cascade 會一併清掉關係、情緒狀態、插圖索引等)。
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, COOL_ID] } } });
await prisma.work.delete({ where: { id: work.id } });
}