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 {}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" />;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+102
@@ -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",
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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. 語音子系統
|
||||
|
||||
|
||||
Reference in New Issue
Block a user