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
+2
View File
@@ -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],
})
+45
View File
@@ -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<EmotionTag, ExpressionTemplate> = {
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; // 情緒強度達此值以上的低落,才夠格被視為「哭臉」候選
@@ -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<EmotionState, "characterId" | "updatedAt"> {
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<ExpressionState> {
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,
};
}
}
+42
View File
@@ -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<TachieManifest> {
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,
};
}
}
+31
View File
@@ -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",
});
}
}
+15
View File
@@ -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 {}
+15 -11
View File
@@ -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<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(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]}
</span>
<div className="mb-6 flex flex-col items-center gap-2">
<div
className="relative h-[190px] w-[120px] rounded-t-[60px] rounded-b-[24px] opacity-85"
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet))" }}
>
<div
className="absolute -top-11 left-1/2 h-21 w-21 -translate-x-1/2 rounded-full"
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet) 140%)" }}
/>
</div>
<TachieStage expression={expression} />
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
立繪(表情差分即時變化)
</p>
+162
View File
@@ -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<void>;
applyExpression(expression: TachieExpression): void;
destroy(): void;
}
const EFFECT_GLYPH: Record<string, string> = {
臉紅: "◕‸◕", 汗: "💧", 淚: "😢", 青筋: "💢", 音符: "♪",
};
// 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<void> {
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<HTMLDivElement>(null);
const rendererRef = useRef<PixiTachieRenderer | null>(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 <div ref={containerRef} className="h-[260px] w-[200px]" />;
}
+25
View File
@@ -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<TachieExpressionResponse> {
const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`);
if (!res.ok) {
throw new Error(`表情查詢失敗(${res.status})`);
}
return res.json();
}
+1
View File
@@ -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"
},
+102
View File
@@ -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",
+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 } });
}
+14 -6
View File
@@ -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. 語音子系統