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"
},