From 0f3d16059ccaab4804eb66ebfb9894c1ce832f45 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 17:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20O=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E7=AB=8B=E7=B9=AA=E5=AD=90=E7=B3=BB?= =?UTF-8?q?=E7=B5=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端(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 --- apps/api/src/app.module.ts | 2 + apps/api/src/tachie/constants.ts | 45 ++++ .../src/tachie/expression-resolver.service.ts | 127 +++++++++++ apps/api/src/tachie/manifest.service.ts | 42 ++++ apps/api/src/tachie/tachie.controller.ts | 31 +++ apps/api/src/tachie/tachie.module.ts | 15 ++ apps/web/app/chat/ChatView.tsx | 26 ++- apps/web/components/TachieStage.tsx | 162 ++++++++++++++ apps/web/lib/api.ts | 25 +++ apps/web/package.json | 1 + package-lock.json | 102 +++++++++ scripts/smoke/O.mjs | 199 ++++++++++++++++++ todo.md | 20 +- 13 files changed, 780 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/tachie/constants.ts create mode 100644 apps/api/src/tachie/expression-resolver.service.ts create mode 100644 apps/api/src/tachie/manifest.service.ts create mode 100644 apps/api/src/tachie/tachie.controller.ts create mode 100644 apps/api/src/tachie/tachie.module.ts create mode 100644 apps/web/components/TachieStage.tsx create mode 100644 scripts/smoke/O.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5809d39..fed7fc9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,7 @@ import { RoomModule } from "./room/room.module.js"; import { RomanceModule } from "./romance/romance.module.js"; import { CanonModule } from "./canon/canon.module.js"; import { EpilogueModule } from "./epilogue/epilogue.module.js"; +import { TachieModule } from "./tachie/tachie.module.js"; @Module({ imports: [ @@ -31,6 +32,7 @@ import { EpilogueModule } from "./epilogue/epilogue.module.js"; RomanceModule, CanonModule, EpilogueModule, + TachieModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/tachie/constants.ts b/apps/api/src/tachie/constants.ts new file mode 100644 index 0000000..e5bd64c --- /dev/null +++ b/apps/api/src/tachie/constants.ts @@ -0,0 +1,45 @@ +import type { EmotionTag } from "@kokorone/shared"; + +// O-1 立繪分層合成資產目錄(差分=可替換部件)。實際美術資產尚未存在,這裡先定義規範與佔位用的 +// 部件識別字串——之後接上真的差分素材時,只要讓對應識別字串指到真實檔案即可,呼叫端(前端渲染器) +// 完全不用改。 +export const BODY_POSE_OPTIONS = ["預設站姿", "抱胸", "前傾", "側身", "肩膀微垂"] as const; +export const COSTUME_DEFAULT_OPTIONS = ["制服", "便服"] as const; // M-1 CostumeCatalogEntry 有登錄時優先使用那份目錄 +export const EYEBROW_OPTIONS = ["自然", "舒展", "八字眉", "微皺", "八字微皺", "上挑"] as const; +export const EYE_OPTIONS = ["平視", "彎月眼", "半垂視線", "直視", "視線飄開", "閉眼撇開"] as const; +export const MOUTH_OPTIONS = ["微閉", "上揚", "抿嘴", "抿直", "小口欲言又止", "嘟嘴"] as const; +export const EFFECT_OPTIONS = ["臉紅", "汗", "淚", "青筋", "音符"] as const; +export const MICRO_MOTIONS = ["眨眼", "呼吸"] as const; // 常駐,不受情緒/親密度節制 + +export interface ExpressionTemplate { + eyebrow: string; + eye: string; + mouth: string; + effect: string | null; + pose: string; +} + +// O-4 情緒 → 表情對照表:情緒狀態機直接驅動表情差分,六狀態各自的眉/眼/口/效果層/姿勢。 +export const EMOTION_EXPRESSION_TABLE: Record = { + CALM: { eyebrow: "自然", eye: "平視", mouth: "微閉", effect: null, pose: "預設站姿" }, + JOY: { eyebrow: "舒展", eye: "彎月眼", mouth: "上揚", effect: "音符", pose: "前傾" }, + SAD: { eyebrow: "八字眉", eye: "半垂視線", mouth: "抿嘴", effect: null, pose: "肩膀微垂" }, + ALERT: { eyebrow: "微皺", eye: "直視", mouth: "抿直", effect: null, pose: "抱胸" }, + SHY: { eyebrow: "八字微皺", eye: "視線飄開", mouth: "小口欲言又止", effect: "臉紅", pose: "側身" }, + GRUMPY: { eyebrow: "上挑", eye: "閉眼撇開", mouth: "嘟嘴", effect: "汗", pose: "抱胸別過頭" }, +}; + +export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL"; + +// O-3 親密度與外觀解鎖:陌生~認識/朋友/摯友/羈絆對應既有的 RelationshipStage 四個可觀察分層 +// (STRANGER/ACQUAINTANCE 合併視為「陌生~認識」)。哭臉(極度低落時的淚光特效)放在最深層, +// 非 BONDED 不可用——即使情緒引擎判定她該哭,親密度不到就是不會在你面前哭。 +export const BASIC_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY"]; +export const FRIEND_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT"]; +export const CLOSE_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"]; + +export const FRIEND_TIER_COSTUMES = ["制服", "便服"]; +export const CLOSE_TIER_COSTUMES = ["制服", "便服", "居家服"]; + +export const CRYING_EFFECT = "淚"; +export const CRYING_INTENSITY_THRESHOLD = 0.75; // 情緒強度達此值以上的低落,才夠格被視為「哭臉」候選 diff --git a/apps/api/src/tachie/expression-resolver.service.ts b/apps/api/src/tachie/expression-resolver.service.ts new file mode 100644 index 0000000..46738ea --- /dev/null +++ b/apps/api/src/tachie/expression-resolver.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from "@nestjs/common"; +import type { EmotionTag, EmotionState } from "@kokorone/shared"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { EmotionService, dominantState } from "../emotion/emotion.service.js"; +import { RelationshipService } from "../relationship/relationship.service.js"; +import { getArchetypeParams } from "../personality/archetype-params.js"; +import { + EMOTION_EXPRESSION_TABLE, + BASIC_TIER_EMOTIONS, + FRIEND_TIER_EMOTIONS, + CLOSE_TIER_EMOTIONS, + FRIEND_TIER_COSTUMES, + CLOSE_TIER_COSTUMES, + CRYING_EFFECT, + CRYING_INTENSITY_THRESHOLD, + type GazeDirection, +} from "./constants.js"; + +export interface ResolveExpressionOptions { + now?: Date; + isThinking?: boolean; // O-2 視線:思考時視線飄上 + isDeflecting?: boolean; // O-2 視線:說謊/迴避時視線撇開(跟 SHY 的視線飄開共用同一個方向) +} + +export interface ExpressionState { + emotionTag: EmotionTag; // 實際套用的表情(可能因親密度未解鎖而降級) + rawEmotionTag: EmotionTag; // 情緒引擎真實判定的狀態,不受親密度解鎖影響——立繪與文字可以故意不一致 + eyebrow: string; + eye: string; + mouth: string; + effect: string | null; + pose: string; + magnitude: number; // O-4:0~1,外顯度 × 情緒強度——同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動 + gaze: GazeDirection; + availableCostumes: string[]; + lockedFallbackApplied: boolean; // O-3:真實情緒不在目前親密度階段的解鎖範圍內,被降級成基本表情 + usedCryingVariant: boolean; // O-3:哭臉——只有 BONDED 階段的深度低落才會出現 +} + +function fieldFor(tag: EmotionTag): keyof Omit { + return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy"); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function resolveGaze(emotionTag: EmotionTag, options: ResolveExpressionOptions): GazeDirection { + if (options.isThinking) { + return "UP"; + } + if (options.isDeflecting || emotionTag === "SHY") { + return "AWAY"; + } + if (emotionTag === "ALERT") { + return "DIRECT"; + } + return "NEUTRAL"; +} + +// O-2/O-3/O-4 表情解析:情緒引擎直接驅動表情差分,套用親密度解鎖與性格外顯度縮放。 +// 這裡回傳的「表情」永遠反映真實情緒狀態(rawEmotionTag/magnitude 都不看對話文字內容), +// 文字生成走另一條完全獨立的路徑(MockProvider)——兩者不一致本身就是「微反差演出」, +// 不需要額外寫「故意不一致」的特殊邏輯,是兩條獨立資料流的自然結果。 +@Injectable() +export class ExpressionResolverService { + constructor( + private readonly prisma: PrismaService, + private readonly emotion: EmotionService, + private readonly relationship: RelationshipService, + ) {} + + async resolveExpression( + characterId: string, + userId: string, + options: ResolveExpressionOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); + const archetypeParams = getArchetypeParams(character.personalityArchetype); + + const emotionState = await this.emotion.getState(characterId, now); + const rawEmotionTag = dominantState(emotionState); + const intensityNormalized = clamp01((emotionState[fieldFor(rawEmotionTag)] as number) / 100); + + const { relationship } = await this.relationship.getState(characterId, userId, now); + const stage = relationship.stage; + + const allowedEmotions = + stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" + ? CLOSE_TIER_EMOTIONS + : stage === "FRIEND" + ? FRIEND_TIER_EMOTIONS + : BASIC_TIER_EMOTIONS; + + const lockedFallbackApplied = !allowedEmotions.includes(rawEmotionTag); + const emotionTag = lockedFallbackApplied ? "CALM" : rawEmotionTag; + const template = EMOTION_EXPRESSION_TABLE[emotionTag]; + + let effect = template.effect; + let usedCryingVariant = false; + if (emotionTag === "SAD" && intensityNormalized >= CRYING_INTENSITY_THRESHOLD && stage === "BONDED") { + effect = CRYING_EFFECT; + usedCryingVariant = true; + } + + const magnitude = clamp01(archetypeParams.expressiveness * intensityNormalized); + const gaze = resolveGaze(emotionTag, options); + const availableCostumes = + stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" ? CLOSE_TIER_COSTUMES : stage === "FRIEND" ? FRIEND_TIER_COSTUMES : ["制服"]; + + return { + emotionTag, + rawEmotionTag, + eyebrow: template.eyebrow, + eye: template.eye, + mouth: template.mouth, + effect, + pose: template.pose, + magnitude, + gaze, + availableCostumes, + lockedFallbackApplied, + usedCryingVariant, + }; + } +} diff --git a/apps/api/src/tachie/manifest.service.ts b/apps/api/src/tachie/manifest.service.ts new file mode 100644 index 0000000..0220d86 --- /dev/null +++ b/apps/api/src/tachie/manifest.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { + BODY_POSE_OPTIONS, + COSTUME_DEFAULT_OPTIONS, + EYEBROW_OPTIONS, + EYE_OPTIONS, + MOUTH_OPTIONS, + EFFECT_OPTIONS, + MICRO_MOTIONS, +} from "./constants.js"; + +export interface TachieManifest { + characterId: string; + bodyPoses: readonly string[]; + costumes: string[]; // 有 M-1 服裝目錄時優先使用那份(依角色而異),否則退回預設兩套 + expressionParts: { eyebrows: readonly string[]; eyes: readonly string[]; mouths: readonly string[] }; + effects: readonly string[]; + microMotions: readonly string[]; +} + +// O-1 差分資產結構:定義立繪 manifest 與資產目錄規範。這裡只登錄「有哪些部件可用」, +// 不含實際美術檔案(美術資產尚未存在,前端目前用佔位色塊渲染)——manifest 的形狀就是未來接上 +// 真實素材時,資產檔名/URL 要對齊的目錄結構。 +@Injectable() +export class ManifestService { + constructor(private readonly prisma: PrismaService) {} + + async getManifest(characterId: string): Promise { + const catalogEntries = await this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } }); + const costumes = catalogEntries.length > 0 ? [...new Set(catalogEntries.map((entry) => entry.label))] : [...COSTUME_DEFAULT_OPTIONS]; + + return { + characterId, + bodyPoses: BODY_POSE_OPTIONS, + costumes, + expressionParts: { eyebrows: EYEBROW_OPTIONS, eyes: EYE_OPTIONS, mouths: MOUTH_OPTIONS }, + effects: EFFECT_OPTIONS, + microMotions: MICRO_MOTIONS, + }; + } +} diff --git a/apps/api/src/tachie/tachie.controller.ts b/apps/api/src/tachie/tachie.controller.ts new file mode 100644 index 0000000..09de9f7 --- /dev/null +++ b/apps/api/src/tachie/tachie.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { ManifestService } from "./manifest.service.js"; +import { ExpressionResolverService } from "./expression-resolver.service.js"; + +@Controller("tachie") +export class TachieController { + constructor( + private readonly manifest: ManifestService, + private readonly expression: ExpressionResolverService, + ) {} + + @Get(":characterId/manifest") + async getManifest(@Param("characterId") characterId: string) { + return this.manifest.getManifest(characterId); + } + + @Get(":characterId/:userId/expression") + async getExpression( + @Param("characterId") characterId: string, + @Param("userId") userId: string, + @Query("now") now?: string, + @Query("isThinking") isThinking?: string, + @Query("isDeflecting") isDeflecting?: string, + ) { + return this.expression.resolveExpression(characterId, userId, { + now: now ? new Date(now) : undefined, + isThinking: isThinking === "true", + isDeflecting: isDeflecting === "true", + }); + } +} diff --git a/apps/api/src/tachie/tachie.module.ts b/apps/api/src/tachie/tachie.module.ts new file mode 100644 index 0000000..83b28ae --- /dev/null +++ b/apps/api/src/tachie/tachie.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module.js"; +import { EmotionModule } from "../emotion/emotion.module.js"; +import { RelationshipModule } from "../relationship/relationship.module.js"; +import { ManifestService } from "./manifest.service.js"; +import { ExpressionResolverService } from "./expression-resolver.service.js"; +import { TachieController } from "./tachie.controller.js"; + +@Module({ + imports: [PrismaModule, EmotionModule, RelationshipModule], + controllers: [TachieController], + providers: [ManifestService, ExpressionResolverService], + exports: [ManifestService, ExpressionResolverService], +}) +export class TachieModule {} diff --git a/apps/web/app/chat/ChatView.tsx b/apps/web/app/chat/ChatView.tsx index f82d836..dbdf36a 100644 --- a/apps/web/app/chat/ChatView.tsx +++ b/apps/web/app/chat/ChatView.tsx @@ -1,12 +1,16 @@ "use client"; import { useState } from "react"; -import { sendChatMessage } from "../../lib/api"; +import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api"; import { EMOTION_LABELS } from "../../lib/emotion-labels"; import { ChatBubble } from "../../components/ChatBubble"; import { HeartbeatWave } from "../../components/HeartbeatWave"; +import { TachieStage } from "../../components/TachieStage"; import type { EmotionTag } from "@kokorone/shared"; +// O-2 表情演出時機:表情差分要在文字回覆出現前先變化——順序要對才有真實感。 +const EXPRESSION_LEAD_MS = 220; + const CHARACTER_ID = "seed-character-genki"; const USER_ID = "seed-user-primary"; @@ -22,6 +26,7 @@ export function ChatView() { const [sending, setSending] = useState(false); const [dominant, setDominant] = useState("CALM"); const [intimacy, setIntimacy] = useState(0); + const [expression, setExpression] = useState(null); const [error, setError] = useState(null); async function handleSend() { @@ -40,11 +45,18 @@ export function ChatView() { // I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。 return; } - setMessages((prev) => [...prev, { role: "character", text: result.text }]); if (result.context) { setDominant(result.context.emotion.dominant); setIntimacy(result.context.relationship.intimacy); } + // O-2:情緒引擎在文字生成前就已經更新完畢,這裡先套用新表情,文字訊息延後一小段時間才出現。 + try { + setExpression(await fetchExpression(CHARACTER_ID, USER_ID)); + } catch { + // 表情查詢失敗不影響對話本身。 + } + await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS)); + setMessages((prev) => [...prev, { role: "character", text: result.text }]); } catch { setError("訊息傳送失敗,請確認 api 服務是否已啟動"); } finally { @@ -68,15 +80,7 @@ export function ChatView() { {EMOTION_LABELS[dominant]}
-
-
-
+

立繪(表情差分即時變化)

diff --git a/apps/web/components/TachieStage.tsx b/apps/web/components/TachieStage.tsx new file mode 100644 index 0000000..0bdb100 --- /dev/null +++ b/apps/web/components/TachieStage.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Application, Container, Graphics, Text } from "pixi.js"; +import type { EmotionTag } from "@kokorone/shared"; + +export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL"; + +export interface TachieExpression { + emotionTag: EmotionTag; + eyebrow: string; + eye: string; + mouth: string; + effect: string | null; + pose: string; + magnitude: number; + gaze: GazeDirection; +} + +// O-5 前端渲染介面:目前由 PixiJS 以佔位色塊實作。若日後要接上真正的 Live2D Cubism SDK +// (授權需人工確認),只需要另外寫一個同樣實作這個介面的 Live2DTachieRenderer,呼叫端 +// (TachieStage 元件)完全不需要改動。 +export interface TachieRenderer { + mount(container: HTMLElement): Promise; + applyExpression(expression: TachieExpression): void; + destroy(): void; +} + +const EFFECT_GLYPH: Record = { + 臉紅: "◕‸◕", 汗: "💧", 淚: "😢", 青筋: "💢", 音符: "♪", +}; + +// O-1/O-4 分層合成(由下而上):身體姿勢 → 服裝層 → 表情層(眉/眼/口) → 效果層 → 動態層(眨眼/呼吸,常駐)。 +export class PixiTachieRenderer implements TachieRenderer { + private app: Application | null = null; + private body: Container | null = null; + private face: Container | null = null; + private effectLayer: Container | null = null; + private eyeGraphics: Graphics[] = []; + private blinkClock = 0; + private breathPhase = 0; + + async mount(container: HTMLElement): Promise { + const app = new Application(); + await app.init({ width: 200, height: 260, backgroundAlpha: 0, antialias: true }); + container.replaceChildren(app.canvas); + this.app = app; + + const body = new Container(); + body.x = 100; + body.y = 40; + app.stage.addChild(body); + this.body = body; + + // 底層:身體姿勢(佔位色塊) + body.addChild(new Graphics().roundRect(-55, 0, 110, 180, 24).fill(0xffb3c6)); + // 服裝層(佔位色塊) + body.addChild(new Graphics().rect(-55, 95, 110, 85).fill(0x9b8cff)); + + const face = new Container(); + face.y = -8; + body.addChild(face); + this.face = face; + + const effectLayer = new Container(); + app.stage.addChild(effectLayer); + this.effectLayer = effectLayer; + + // 動態層:眨眼/呼吸——常駐運作,不受情緒或親密度節制。 + app.ticker.add((ticker) => { + this.breathPhase += ticker.deltaTime * 0.03; + body.scale.y = 1 + Math.sin(this.breathPhase) * 0.015; + + this.blinkClock += ticker.deltaTime; + const cycle = this.blinkClock % 180; // 約每 3 秒眨一次眼 + const blinking = cycle > 172; + for (const eye of this.eyeGraphics) { + eye.scale.y = blinking ? 0.1 : 1; + } + }); + } + + applyExpression(expression: TachieExpression): void { + const face = this.face; + const effectLayer = this.effectLayer; + if (!face || !effectLayer || !this.app) { + return; + } + face.removeChildren(); + this.eyeGraphics = []; + + // O-4 外顯度縮放:magnitude 越小,差分動作幅度越小(三無角色只有嘴角微動); + // 佔位資產仍保留一點最低幅度,確保肉眼看得出「有變化」。 + const magnitude = Math.max(0.12, expression.magnitude); + + const eyebrowY = -26 - 6 * magnitude; + face.addChild(new Graphics().rect(-40, eyebrowY, 40, 4).fill(0x4a3b3b)); + face.addChild(new Graphics().rect(0, eyebrowY, 40, 4).fill(0x4a3b3b)); + + const gazeDx = expression.gaze === "AWAY" ? -8 : 0; + const gazeDy = expression.gaze === "UP" ? -5 : 0; + const eyeHeight = 6 + 6 * magnitude; + for (const dx of [-20, 20]) { + const eye = new Graphics().ellipse(0, 0, 6, eyeHeight).fill(0x2f2f2f); + eye.x = dx + gazeDx; + eye.y = -10 + gazeDy; + face.addChild(eye); + this.eyeGraphics.push(eye); + } + + const isFrown = expression.emotionTag === "SAD" || expression.emotionTag === "GRUMPY"; + const curve = 10 * magnitude * (isFrown ? 1 : -1); + face.addChild( + new Graphics().moveTo(-12, 6).quadraticCurveTo(0, 6 + curve, 12, 6).stroke({ width: 3, color: 0x8a4a4a }), + ); + + effectLayer.removeChildren(); + if (expression.effect) { + const glyph = EFFECT_GLYPH[expression.effect] ?? expression.effect; + const text = new Text({ text: glyph, style: { fontSize: 20 } }); + text.x = 140; + text.y = 30; + effectLayer.addChild(text); + } + } + + destroy(): void { + this.app?.destroy(true, { children: true }); + this.app = null; + } +} + +export function TachieStage({ expression }: { expression: TachieExpression | null }) { + const containerRef = useRef(null); + const rendererRef = useRef(null); + + useEffect(() => { + const renderer = new PixiTachieRenderer(); + rendererRef.current = renderer; + let disposed = false; + if (containerRef.current) { + void renderer.mount(containerRef.current).then(() => { + if (!disposed && expression) { + renderer.applyExpression(expression); + } + }); + } + return () => { + disposed = true; + renderer.destroy(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (expression) { + rendererRef.current?.applyExpression(expression); + } + }, [expression]); + + return
; +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7a12da8..1dad7c5 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -49,3 +49,28 @@ export async function endChatSession(sessionId: string, characterId: string): Pr body: JSON.stringify({ characterId }), }); } + +export interface TachieExpressionResponse { + emotionTag: EmotionTag; + rawEmotionTag: EmotionTag; + eyebrow: string; + eye: string; + mouth: string; + effect: string | null; + pose: string; + magnitude: number; + gaze: "UP" | "AWAY" | "DIRECT" | "NEUTRAL"; + availableCostumes: string[]; + lockedFallbackApplied: boolean; + usedCryingVariant: boolean; +} + +// O-2 表情演出:情緒引擎已經在對話文字生成之前更新完畢,這裡獨立查詢目前的表情狀態, +// 讓前端可以在文字訊息出現前先套用新的表情差分。 +export async function fetchExpression(characterId: string, userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`); + if (!res.ok) { + throw new Error(`表情查詢失敗(${res.status})`); + } + return res.json(); +} diff --git a/apps/web/package.json b/apps/web/package.json index 0ffc097..2ac6909 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "dependencies": { "@kokorone/shared": "*", "next": "16.3.0", + "pixi.js": "^8.19.0", "react": "19.2.8", "react-dom": "19.2.8" }, diff --git a/package-lock.json b/package-lock.json index cd23ad1..f9a56af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "dependencies": { "@kokorone/shared": "*", "next": "16.3.0", + "pixi.js": "^8.19.0", "react": "19.2.8", "react-dom": "19.2.8" }, @@ -1614,6 +1615,12 @@ "node": ">=12.4.0" } }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, "node_modules/@prisma/adapter-better-sqlite3": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.9.1.tgz", @@ -2443,6 +2450,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3403,6 +3416,21 @@ "node": ">=12" } }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", + "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4619,6 +4647,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5614,6 +5648,12 @@ "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -6126,6 +6166,15 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/giget": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", @@ -6924,6 +6973,12 @@ "devOptional": true, "license": "ISC" }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -6961,6 +7016,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8140,6 +8201,12 @@ "node": ">=6" } }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8219,6 +8286,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixi.js": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz", + "integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.13", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -9559,6 +9652,15 @@ "node": ">=6" } }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/scripts/smoke/O.mjs b/scripts/smoke/O.mjs new file mode 100644 index 0000000..1dc5c54 --- /dev/null +++ b/scripts/smoke/O.mjs @@ -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 } }); +} diff --git a/todo.md b/todo.md index 8bfef60..c91b2e7 100644 --- a/todo.md +++ b/todo.md @@ -370,12 +370,20 @@ flowchart TB ### O. 立繪子系統 -- [ ] **O-1 差分資產結構(S)**:定義立繪 manifest(身體姿勢 3~5、服裝 2~4、表情眉 4×眼 5×口 5、效果層 臉紅/汗/淚/青筋/音符、微動態 眨眼/呼吸)與資產目錄規範。驗收:以佔位資產可組出一張完整立繪。依據:§立繪的分層合成表格。 -- [ ] **O-2 表情演出細節(S)**:表情在聽到關鍵字當下就切換(早於文字回覆)、視線方向表達心理狀態、文字與立繪可故意不一致(嘴上說沒事但表情低落)。驗收:回應送出前表情已先變化。依據:§立繪的動態演出。 -- [ ] **O-3 親密度解鎖(S)**:依 陌生~認識/朋友/摯友/羈絆 分層解鎖表情與服裝差分,哭臉列為最深層。驗收:低親密度時稀有差分不可用。依據:§親密度與外觀解鎖。 -- [ ] **O-4 情緒→表情對照(M)**:實作平靜/愉悅/低落/警戒/害羞/彆扭 六狀態的眉眼口+效果層+姿勢對照,並依角色外顯度縮放變化幅度(三無角色僅嘴角微動)。驗收:外顯度低的角色差分幅度明顯小於元氣角色。依據:§情緒 → 表情對照、「表情也吃性格參數」。 -- [ ] **O-5 前端渲染(M)**:以 PixiJS 實作分層合成與微動態(眨眼、呼吸),並預留 Live2D Cubism 介面(授權需人工確認);接上 H-5 的立繪區取代佔位圖形。驗收:對話中表情隨情緒即時變化、常駐微動態運作。依據:技術選型「Live2D / PixiJS:立繪差分渲染」。 -- [ ] **O-V 階段驗證(S)**:`npm run restart && npm run smoke -- O`(O.mjs:情緒切換後 manifest 選中的差分正確、解鎖規則生效),並實際開啟畫面確認表情變化。 +- [x] **O-1 差分資產結構(S)**:定義立繪 manifest(身體姿勢 3~5、服裝 2~4、表情眉 4×眼 5×口 5、效果層 臉紅/汗/淚/青筋/音符、微動態 眨眼/呼吸)與資產目錄規範。驗收:以佔位資產可組出一張完整立繪。依據:§立繪的分層合成表格。 +- [x] **O-2 表情演出細節(S)**:表情在聽到關鍵字當下就切換(早於文字回覆)、視線方向表達心理狀態、文字與立繪可故意不一致(嘴上說沒事但表情低落)。驗收:回應送出前表情已先變化。依據:§立繪的動態演出。 +- [x] **O-3 親密度解鎖(S)**:依 陌生~認識/朋友/摯友/羈絆 分層解鎖表情與服裝差分,哭臉列為最深層。驗收:低親密度時稀有差分不可用。依據:§親密度與外觀解鎖。 +- [x] **O-4 情緒→表情對照(M)**:實作平靜/愉悅/低落/警戒/害羞/彆扭 六狀態的眉眼口+效果層+姿勢對照,並依角色外顯度縮放變化幅度(三無角色僅嘴角微動)。驗收:外顯度低的角色差分幅度明顯小於元氣角色。依據:§情緒 → 表情對照、「表情也吃性格參數」。 +- [x] **O-5 前端渲染(M)**:以 PixiJS 實作分層合成與微動態(眨眼、呼吸),並預留 Live2D Cubism 介面(授權需人工確認);接上 H-5 的立繪區取代佔位圖形。驗收:對話中表情隨情緒即時變化、常駐微動態運作。依據:技術選型「Live2D / PixiJS:立繪差分渲染」。 +- [x] **O-V 階段驗證(S)**:`npm run restart && npm run smoke -- O`(O.mjs:情緒切換後 manifest 選中的差分正確、解鎖規則生效),並實際開啟畫面確認表情變化。 + +> **實作記錄(O 群組)**: +> - 新增後端模組 `apps/api/src/tachie/`(manifest/表情解析)與前端元件 `apps/web/components/TachieStage.tsx`(PixiJS 渲染)。**決策點的分工原則**:差分「選哪一個」(情緒對照、親密度解鎖、外顯度縮放)是後端邏輯——跟性格參數、關係狀態一樣屬於角色引擎的一部分,前端只負責「把後端算好的選擇畫出來」,不重複判斷規則。這跟其他所有子系統的分工原則一致(引擎算、前端演)。 +> - **O-3 親密度解鎖直接重用既有的 `RelationshipStage`**(陌生/認識/朋友/摯友/羈絆),沒有另開新 enum——`STRANGER`/`ACQUAINTANCE` 合併對應文件的「陌生~認識」層級。`ExpressionResolverService` 回傳的 `emotionTag` 是「實際套用的表情」(可能因未解鎖而降級成 CALM),`rawEmotionTag` 才是情緒引擎的真實判定——**這個雙欄位設計同時實現了 O-2 的「文字與立繪可故意不一致」**:生成文字的 `MockProvider` 完全不知道有沒有解鎖這件事,只看真實情緒/親密度/原型,跟表情解析是兩條獨立的資料流,兩者不一致是自然結果,不需要特別寫「故意不一致」的邏輯。哭臉(`CRYING_EFFECT`)額外多加一個「情緒強度 ≥ 0.75 且 stage=BONDED」的雙重條件,只有這裡才會把低落的效果層從「無」升級成「淚」,具體實作了「哭臉是最深層差分」。 +> - **O-4 外顯度縮放不是換一套差分素材,而是同一組差分部件套上不同的數值幅度**:`magnitude = 性格外顯度(expressiveness) × 情緒強度`,回傳給前端後由 PixiJS 用這個數值去縮放眉毛抬起高度、眼睛開合、嘴角彎曲弧度——「同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動」在這裡具體變成同一個 `mouth: "上揚"` 標籤配上完全不同的 `magnitude`(元氣約 0.9、三無約 0.045,測試裡驗證兩者相差超過 3 倍)。這個設計選擇是刻意的:**離散的差分種類(眉/眼/口選哪一個)由情緒決定,連續的幅度由性格決定,兩個維度互不干涉**,之後接上真的美術資產時,`magnitude` 可以直接對應到差分圖層的透明度或位移量。 +> - **O-2「表情早於文字」在目前非串流架構下的忠實妥協**:`DialogueService.handleMessage` 本來就是「先更新情緒引擎,再生成文字」(沿續 D/F 群組既有順序,這裡沒有改動),所以聊天 API 回應完成的那一刻,情緒狀態已經是新的了;`GET /tachie/:characterId/:userId/expression` 是一個完全獨立於對話生成的查詢端點,讀到的永遠是「當下」的情緒。前端(`ChatView.tsx`)在收到聊天回應後,**先呼叫這個端點更新表情、等待約 220ms、才把文字訊息加進對話列表**——這是目前系統唯一能做到「玩家先看到表情變化,才看到文字出現」的方式。**如果之後要做到文件描述的「聽到關鍵字的當下」(比使用者送出後、伺服器算完更早),需要真正的串流(SSE/WebSocket)架構,那是超出本群組(甚至超出目前所有已完成群組)範疇的基礎設施,留給 R 群組評估。** +> - **O-5 前端渲染是本群組唯一真正做瀏覽器驗證的部分**:本環境沒有 `chromium-cli` 這個工具(`run` skill 文件提到的指令在這裡不存在),改用在 scratchpad 暫裝 `playwright-core` + `npx playwright-core install chromium` 的方式驗證(沒有把這個相依性留在專案 `package.json` 裡)——這次幸運地沒有像 H 群組那樣遇到缺 `libasound.so.2` 的問題(`ldd` 檢查沒有回報缺任何動態函式庫),可能是環境已經有安裝或這次剛好夠用。**實測過程中第一版驗證腳本有隨機性失敗(`page.click()` 在 React hydration 完成前就送出點擊,事件處理器還沒掛上,訊息送不出去)**——修正方式是先 `waitForSelector` 等一個確定是 client-side render 才會出現的元素(心跳波形 SVG)再互動,這個坑值得記錄:**headless 瀏覽器自動化測試在互動前一定要等到明確的 client-hydration 完成訊號,不能只等 `networkidle`**。實測截圖確認:送出「謝謝你,我今天好開心!」後,親密度/情緒標籤即時更新為「愉悅」,立繪同步切換成上揚嘴角+彎月眼+音符特效;連續四次取樣 canvas 畫面內容互不相同,證實眨眼/呼吸的常駐動態確實在跑。 +> - **Live2D Cubism 介面預留**:`TachieRenderer`(`mount`/`applyExpression`/`destroy`)是渲染器的抽象介面,`PixiTachieRenderer` 是目前唯一實作;程式碼註解明確標註「日後若要接上真正的 Live2D Cubism SDK(授權需人工確認),只需要另外寫一個同樣實作這個介面的 `Live2DTachieRenderer`」——**完全沒有引入任何 Live2D SDK 或其授權條款**,這條路徑目前純粹是架構上的佔位,尚未有任何後續動作,授權議題留給之後真的要導入時人工確認。 ### P. 語音子系統