Files
Kokorone/apps/api/src/tachie/expression-resolver.service.ts
T
JefferyandClaude Sonnet 5 0f3d16059c 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>
2026-08-13 17:46:50 +08:00

128 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}
}