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
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user