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:
co-authored by
Claude Sonnet 5
parent
eccdcba45c
commit
0f3d16059c
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user