後端(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>
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|