634 lines
21 KiB
TypeScript
634 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useNarrative } from "../../game-engine/narrative-provider.tsx";
|
|
import FisheyeEffect from "../utils/fish-eye.tsx";
|
|
import type { GameState } from "../../game-engine/game-schemas.ts";
|
|
import { useId, useMemo } from "react";
|
|
import NumberTicker from "../ui/number-ticker.tsx";
|
|
import BreathingText from "../ui/breathing-text.tsx";
|
|
import { type IAudioBuffer, linearPath } from "../../utils/waveform-path.ts";
|
|
import { cva } from "class-variance-authority";
|
|
import cn from "@bureaudouble/outils/cn.ts";
|
|
import { useClientLocale } from "../../game-engine/locale-provider.tsx";
|
|
|
|
type ArousalLevel = "low" | "medium" | "high";
|
|
type ConsentLevel = "stable" | "waning" | "critical";
|
|
|
|
const getArousalLevel = (arousal: number): ArousalLevel => {
|
|
if (arousal > 0.75) return "high";
|
|
if (arousal > 0.4) return "medium";
|
|
return "low";
|
|
};
|
|
|
|
const getConsentLevel = (consent: number): ConsentLevel => {
|
|
if (consent > 0.7) return "stable";
|
|
if (consent > 0.4) return "waning";
|
|
return "critical";
|
|
};
|
|
|
|
interface ChatMetricProps {
|
|
gameState: GameState;
|
|
arousalLevel: ArousalLevel;
|
|
consentLevel: ConsentLevel;
|
|
style?: React.CSSProperties;
|
|
}
|
|
|
|
const mapArousalToNoteGlyph = (
|
|
arousal: number,
|
|
): { noteGlyphCode: number; octave: number } => {
|
|
const noteGlyphs = [
|
|
0x1D15D,
|
|
0x1D15E,
|
|
0x1D15F,
|
|
0x1D160,
|
|
0x1D161,
|
|
];
|
|
const clampedArousal = Math.max(0, Math.min(arousal, 1));
|
|
const noteIndex = Math.floor(clampedArousal * (noteGlyphs.length - 0.01));
|
|
const noteGlyphCode = noteGlyphs[noteIndex];
|
|
const octave = 4 + Math.floor(clampedArousal * 2.99);
|
|
|
|
return { noteGlyphCode, octave };
|
|
};
|
|
|
|
const lerp = (a: number, b: number, alpha: number) => a + alpha * (b - a);
|
|
|
|
const createMockAudioBuffer = (
|
|
options: {
|
|
channels?: number;
|
|
duration?: number;
|
|
sampleRate?: number;
|
|
amplitude?: number;
|
|
frequency?: number;
|
|
} = {},
|
|
): IAudioBuffer => {
|
|
const {
|
|
channels = 1,
|
|
duration = 2.5,
|
|
sampleRate = 11025,
|
|
amplitude = 0.5,
|
|
frequency = 10,
|
|
} = options;
|
|
const totalSamples: number = duration * sampleRate;
|
|
const mockBuffer: IAudioBuffer = {
|
|
sampleRate: sampleRate,
|
|
duration: duration,
|
|
length: totalSamples,
|
|
numberOfChannels: channels,
|
|
getChannelData: (channel: number): Float32Array => {
|
|
if (channel >= channels) {
|
|
console.error("Invalid channel index");
|
|
return new Float32Array();
|
|
}
|
|
const audioData = new Float32Array(totalSamples);
|
|
for (let i = 0; i < totalSamples; i++) {
|
|
const time = i / sampleRate;
|
|
// Combine multiple sine waves for a more organic look
|
|
const mainWave = Math.sin(time * frequency * Math.PI * 2);
|
|
const fineWave = Math.sin(time * frequency * 3.5 * Math.PI * 2) * 0.3;
|
|
// Apply an envelope to fade in/out slightly at the ends of the loop
|
|
const envelope = Math.sin((time / duration) * Math.PI);
|
|
audioData[i] = (mainWave + fineWave) * amplitude * envelope;
|
|
}
|
|
return audioData;
|
|
},
|
|
};
|
|
return mockBuffer;
|
|
};
|
|
|
|
const ChatMetricSamantha69: React.FC<ChatMetricProps> = (
|
|
{ gameState, style },
|
|
) => {
|
|
const { t } = useClientLocale();
|
|
return (
|
|
<div className="w-full transition-all duration-500 ease-in-out z-20 h-px pointer-events-none">
|
|
<div
|
|
className="px-4 pt-2 flex items-center justify-between w-full"
|
|
style={style}
|
|
>
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
<span className="text-xs">{t("consent")}</span>
|
|
<FisheyeEffect
|
|
scale={Math.max(0, Math.min(gameState.aiConsentLevel ?? 1, 1)) * 30}
|
|
className="animate-[pulse-bg_1s_infinite] size-10 rounded-full border-2 border-destructive transition-opacity duration-300 opacity-[calc(var(--ai-consent-level)*0.5+0.5)]"
|
|
>
|
|
<div className="size-full rounded-full bg-background/80 transition-transform duration-300 scale-(--ai-consent-level)" />
|
|
</FisheyeEffect>
|
|
</div>
|
|
<div className="flex flex-col items-center flex-grow max-w-32">
|
|
<span className="text-xs">{t("intensity")}</span>
|
|
<FisheyeEffect
|
|
scale={Math.max(
|
|
0,
|
|
Math.min(gameState.arousalPeakPotential ?? 0, 1),
|
|
) *
|
|
30}
|
|
className="h-4 w-full border-y-2 border-x-1 border-destructive bg-secondary rounded-full overflow-hidden"
|
|
>
|
|
<div className="h-full bg-background transition-all duration-300 w-[calc(var(--arousal-peak-potential)*100%)]" />
|
|
</FisheyeEffect>
|
|
</div>
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-xs">{t("peak")}</span>
|
|
<FisheyeEffect
|
|
scale={Math.max(
|
|
0,
|
|
Math.min(gameState.arousalPeakPotential ?? 0, 1),
|
|
) *
|
|
30}
|
|
className="w-10 h-7 flex items-center justify-center text-center bg-secondary/50 rounded-full border-2 border-destructive transition-opacity duration-300 opacity-[calc(var(--ai-consent-level)*0.5+0.5)]"
|
|
>
|
|
{((gameState.arousalPeakPotential ?? 0) * 10).toFixed(1)}
|
|
</FisheyeEffect>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatMetricPolaire: React.FC<ChatMetricProps> = (
|
|
{ gameState, ...props },
|
|
) => {
|
|
const { t } = useClientLocale();
|
|
return (
|
|
<div
|
|
className="grid grid-cols-3 gap-2 items-center text-xs p-2 font-mono text-primary bg-black/30 z-20 pointer-events-none"
|
|
{...props}
|
|
>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span>{t("peakPotential")}</span>
|
|
<div className="relative size-8 flex items-center justify-center">
|
|
<div className="animate-pulse size-[calc(10px_+_var(--arousal-peak-potential,_0)_*_1000px)] absolute bg-[radial-gradient(ellipse_21.30%_21.30%_at_50.00%_50.00%,_#FF0000_15%,_rgba(255,_180.08,_180.08,_0.29)_82%,_rgba(255,_255,_255,_0)_100%)] rounded-full" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span>{t("intimacySync")}</span>
|
|
<div className="w-full h-8 flex items-center justify-center border border-primary/50 p-1">
|
|
<div className="text-lg tracking-widest">
|
|
{((gameState.intimacyLevel ?? 0) * 100).toFixed(0)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span>{t("consentIntegrity")}</span>
|
|
<div className="w-full h-8 border border-primary/50 p-0.5 flex gap-0.5">
|
|
{Array.from({ length: 10 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-grow bg-primary transition-all duration-200 last:animate-pulse last:[animation-delay:400ms]"
|
|
style={{
|
|
opacity: ((gameState.aiConsentLevel ?? 1) * 10) > i ? 1 : 0.2,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatMetricCybelle: React.FC<ChatMetricProps> = ({ gameState }) => {
|
|
const { t } = useClientLocale();
|
|
const intensity = gameState.arousalPeakPotential ?? 0;
|
|
const numDots = Math.floor(intensity * 30);
|
|
|
|
const dots = useMemo(() => {
|
|
const colors = [
|
|
"bg-[var(--confetti-1)]",
|
|
"bg-[var(--confetti-2)]",
|
|
"bg-[var(--confetti-3)]",
|
|
"bg-[var(--confetti-4)]",
|
|
];
|
|
return Array.from({ length: numDots }).map((_, i) => ({
|
|
id: i,
|
|
color: colors[i % colors.length],
|
|
style: {
|
|
top: `${Math.random() * 80 + 10}%`,
|
|
left: `${Math.random() * 95 + 2.5}%`,
|
|
animation: `jiggle ${
|
|
Math.random() * 1.5 + 0.5
|
|
}s infinite alternate ease-in-out`,
|
|
animationDelay: `${Math.random() * -2}s`,
|
|
},
|
|
}));
|
|
}, [numDots]);
|
|
|
|
return (
|
|
<div className="relative w-full flex justify-between items-center text-xs px-2 pt-1 pb-4 pointer-events-none z-20 h-px">
|
|
<div className="flex items-center gap-1">
|
|
<span>{t("peak")}</span>
|
|
<span className="font-bold">
|
|
{Math.round(intensity * 10)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span>
|
|
{t("intensity")} {Math.round(intensity * 100)}
|
|
</span>
|
|
<div className="h-30 inset-0 -top-12 absolute">
|
|
{dots.map((dot) => (
|
|
<div
|
|
key={dot.id}
|
|
className={`absolute rounded-full size-4 ${dot.color}`}
|
|
style={dot.style}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span>{t("consent")}</span>
|
|
<span className="font-bold">
|
|
{(gameState.aiConsentLevel ?? 1).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatMetricXixi: React.FC<ChatMetricProps> = ({ gameState, ...props }) => {
|
|
const { t } = useClientLocale();
|
|
const trustLevel = Math.round((gameState.arousalPeakPotential ?? 0) * 100);
|
|
|
|
return (
|
|
<div
|
|
className="flex justify-between items-center p-2 text-xs font-mono text-primary/70 z-20"
|
|
{...props}
|
|
>
|
|
<div className="flex gap-1 items-center">
|
|
<BreathingText
|
|
staggerDuration={0.08}
|
|
fromFontVariationSettings="'wght' 100, 'slnt' 0, 'SRFF' 0.5"
|
|
toFontVariationSettings="'wght' 800, 'slnt' 0, 'SRFF' 0.0816"
|
|
>
|
|
{t("peakPotential")}
|
|
</BreathingText>
|
|
<NumberTicker
|
|
key={gameState.arousalPeakPotential}
|
|
from={0}
|
|
target={trustLevel}
|
|
autoStart
|
|
/>%
|
|
</div>
|
|
<div className="flex gap-1 items-center">
|
|
<BreathingText
|
|
staggerDuration={0.08}
|
|
fromFontVariationSettings="'wght' 100, 'slnt' 0, 'SRFF' 0.5"
|
|
toFontVariationSettings="'wght' 800, 'slnt' 0, 'SRFF' 0.0816"
|
|
>
|
|
{t("consentMeter")}
|
|
</BreathingText>
|
|
<div className="w-24 h-3.5 bg-secondary border border-border relative">
|
|
<div className="h-full bg-[var(--kintsugi-gold)] blur-sm transition-all duration-500 starting:w-0 w-[calc(var(--ai-consent-level)_*_100%)] duration-3000" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatMetricNelly: React.FC<ChatMetricProps> = ({ gameState }) => {
|
|
const { t } = useClientLocale();
|
|
const uniqueId = useId();
|
|
const intensity = gameState.arousalPeakPotential ?? 0.1;
|
|
const consent = gameState.aiConsentLevel ?? 1;
|
|
const syncLevel = gameState.intimacyLevel ?? 0;
|
|
|
|
const consentNoise = (1 - consent) * 0.15;
|
|
const displacement = (1 - consent) * 10;
|
|
const { noteGlyphCode, octave } = mapArousalToNoteGlyph(intensity);
|
|
const animationDuration = 2.54;
|
|
|
|
const { mainWavePath, syncWavePath } = useMemo(() => {
|
|
const mainAmplitude = lerp(0.2, 1.0, intensity);
|
|
const mainFrequency = lerp(8, 25, intensity);
|
|
|
|
const mainAudioBuffer = createMockAudioBuffer({
|
|
duration: animationDuration,
|
|
sampleRate: 11025,
|
|
amplitude: mainAmplitude,
|
|
frequency: mainFrequency,
|
|
});
|
|
|
|
const mainPath = linearPath(mainAudioBuffer, {
|
|
samples: 120,
|
|
width: 300,
|
|
height: 60,
|
|
animation: true,
|
|
animationframes: 24,
|
|
type: "mirror",
|
|
paths: [{ d: "Q", sx: 0, sy: 0, x: 50, y: 100, ex: 100, ey: 0 }],
|
|
});
|
|
|
|
// The sync wave's intensity is a blend based on syncLevel
|
|
const syncIntensity = lerp(intensity * 0.3, intensity, syncLevel);
|
|
const syncAmplitude = lerp(0.2, 1.0, syncIntensity);
|
|
const syncFrequency = lerp(8, 25, syncIntensity);
|
|
|
|
const syncAudioBuffer = createMockAudioBuffer({
|
|
duration: animationDuration,
|
|
sampleRate: 11025,
|
|
amplitude: syncAmplitude,
|
|
frequency: syncFrequency,
|
|
});
|
|
|
|
const syncPath = linearPath(syncAudioBuffer, {
|
|
samples: 120,
|
|
width: 300,
|
|
height: 60,
|
|
animation: true,
|
|
animationframes: 24,
|
|
type: "mirror",
|
|
paths: [{ d: "Q", sx: 0, sy: 0, x: 50, y: 100, ex: 100, ey: 0 }],
|
|
});
|
|
|
|
return { mainWavePath: mainPath, syncWavePath: syncPath };
|
|
}, [intensity, syncLevel]);
|
|
|
|
return (
|
|
<div className="w-full flex flex-col items-center gap-1 text-xs text-primary z-20 pointer-events-none p-2">
|
|
<div className="w-full flex justify-between px-2 font-mono uppercase text-primary/70">
|
|
<BreathingText
|
|
staggerDuration={0.1}
|
|
fromFontVariationSettings="'wght' 200, 'slnt' -5, 'SRFF' 0.01"
|
|
toFontVariationSettings="'wght' 500, 'slnt' 0, 'SRFF' 0.01"
|
|
>
|
|
{t("resonance")}
|
|
</BreathingText>
|
|
<BreathingText
|
|
staggerDuration={0.1}
|
|
staggerFrom="last"
|
|
fromFontVariationSettings="'wght' 200, 'slnt' -5, 'SRFF' 0.01"
|
|
toFontVariationSettings="'wght' 500, 'slnt' 0, 'SRFF' 0.01"
|
|
>
|
|
{t("clarity")}
|
|
</BreathingText>
|
|
</div>
|
|
<svg
|
|
width="100%"
|
|
height="50"
|
|
viewBox="0 0 300 60"
|
|
preserveAspectRatio="none"
|
|
className="-mt-2"
|
|
>
|
|
<defs>
|
|
<filter id={`watery-${uniqueId}`}>
|
|
<feTurbulence
|
|
type="fractalNoise"
|
|
baseFrequency={`0.04 ${consentNoise}`}
|
|
numOctaves="2"
|
|
result="noise"
|
|
/>
|
|
<feDisplacementMap
|
|
in="SourceGraphic"
|
|
in2="noise"
|
|
scale={displacement}
|
|
xChannelSelector="R"
|
|
yChannelSelector="G"
|
|
/>
|
|
</filter>
|
|
</defs>
|
|
<path
|
|
stroke="var(--primary)"
|
|
strokeOpacity="0.4"
|
|
fill="none"
|
|
strokeWidth="1"
|
|
className="transition-all duration-1000 ease-out"
|
|
>
|
|
<animate
|
|
attributeName="d"
|
|
dur={`${animationDuration}s`}
|
|
repeatCount="indefinite"
|
|
calcMode="linear"
|
|
values={syncWavePath}
|
|
/>
|
|
</path>
|
|
<path
|
|
stroke="var(--primary)"
|
|
fill="none"
|
|
strokeWidth="1.5"
|
|
filter={`url(#watery-${uniqueId})`}
|
|
className="transition-all duration-500 ease-out"
|
|
>
|
|
<animate
|
|
attributeName="d"
|
|
dur={`${animationDuration}s`}
|
|
repeatCount="indefinite"
|
|
calcMode="linear"
|
|
values={mainWavePath}
|
|
/>
|
|
</path>
|
|
</svg>
|
|
<div className="w-full flex justify-between items-center px-2 text-primary/80 -mt-2">
|
|
<div className="flex items-baseline gap-0.5 text-lg">
|
|
<span className="font-display text-xs uppercase font-mono mr-1">
|
|
{t("pitch")}
|
|
</span>
|
|
<span className="font-music text-lg leading-none">
|
|
{String.fromCodePoint(noteGlyphCode)}
|
|
</span>
|
|
<NumberTicker
|
|
className="text-sm font-mono"
|
|
key={octave}
|
|
from={4}
|
|
target={octave}
|
|
transition={{ duration: 1, ease: "easeOut" }}
|
|
autoStart
|
|
/>
|
|
</div>
|
|
<div className="font-mono text-xs uppercase">
|
|
{t("sync")}{" "}
|
|
<NumberTicker
|
|
key={syncLevel}
|
|
from={0}
|
|
target={syncLevel * 100}
|
|
transition={{ duration: 2 }}
|
|
autoStart
|
|
/>%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatMetricHisoka: React.FC<ChatMetricProps> = ({ gameState }) => {
|
|
const { t } = useClientLocale();
|
|
|
|
const power = (gameState.arousalPeakPotential ?? 0) * 100;
|
|
const stamina = (gameState.aiConsentLevel ?? 1) * 100;
|
|
const sync = (gameState.intimacyLevel ?? 0) * 100;
|
|
|
|
return (
|
|
<div className="grid grid-cols-3 gap-2 items-center text-xs p-2 font-mono text-primary bg-black/30 z-20 pointer-events-none uppercase tracking-wider">
|
|
<div className="flex flex-col items-start gap-1">
|
|
<span>{t("power")}</span>
|
|
<div className="w-full h-2 bg-secondary relative">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300 ease-out"
|
|
style={{ width: `${power}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-foreground text-sm font-bold">
|
|
<NumberTicker
|
|
target={power}
|
|
from={0}
|
|
transition={{ duration: 1 }}
|
|
autoStart
|
|
/>%
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-start gap-1">
|
|
<span>{t("stamina")}</span>
|
|
<div className="w-full h-2 bg-secondary relative">
|
|
<div
|
|
className="h-full bg-primary/70 transition-all duration-300 ease-out"
|
|
style={{ width: `${stamina}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-foreground text-sm font-bold">
|
|
<NumberTicker
|
|
target={stamina}
|
|
from={100}
|
|
transition={{ duration: 1 }}
|
|
autoStart
|
|
/>%
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-start gap-1">
|
|
<span>{t("sync")}</span>
|
|
<div className="w-full h-2 bg-secondary relative">
|
|
<div
|
|
className="h-full bg-primary/50 transition-all duration-300 ease-out"
|
|
style={{ width: `${sync}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-foreground text-sm font-bold">
|
|
<NumberTicker
|
|
target={sync}
|
|
from={0}
|
|
transition={{ duration: 1 }}
|
|
autoStart
|
|
/>%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const galateeBorderVariants = cva("border rounded-full", {
|
|
variants: {
|
|
arousalPeakPotential: {
|
|
low: "border-green-500/50",
|
|
medium: "border-yellow-500/50",
|
|
high: "border-red-500/50",
|
|
},
|
|
},
|
|
});
|
|
|
|
const ChatMetricGalatee: React.FC<ChatMetricProps> = (
|
|
{ gameState, arousalLevel },
|
|
) => {
|
|
const { t } = useClientLocale();
|
|
const intensity = gameState.arousalPeakPotential ?? 0;
|
|
const consent = gameState.aiConsentLevel ?? 1;
|
|
|
|
return (
|
|
<div className="w-full flex flex-col font-display z-20 pointer-events-none p-2 text-primary">
|
|
<div className="flex justify-between items-center gap-2">
|
|
<div className="text-center">
|
|
<div className="text-xs text-left">{t("peak")}</div>
|
|
<div
|
|
className={cn(
|
|
"w-16 h-3 flex items-center justify-center",
|
|
galateeBorderVariants({ arousalPeakPotential: arousalLevel }),
|
|
)}
|
|
>
|
|
<div
|
|
className="w-full h-1 bg-gradient-to-r from-transparent to-primary rounded-full"
|
|
style={{
|
|
clipPath: `inset(0 ${100 - (intensity * 10 * 10)}% 0 0)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-grow text-center">
|
|
<div className="text-xs">{t("intensity")}</div>
|
|
<div
|
|
className={cn(
|
|
"w-full h-3 border-y relative flex items-center justify-center overflow-hidden",
|
|
galateeBorderVariants({ arousalPeakPotential: arousalLevel }),
|
|
)}
|
|
>
|
|
<div className="absolute inset-0 bg-gradient-to-r from-green-300 via-yellow-300 to-red-400 rounded-full" />
|
|
<div
|
|
className="absolute w-2 h-8 bg-background/70 border-x border-foreground/50"
|
|
style={{ left: `calc(${intensity * 5 * 100}% - 4px)` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-xs text-right">{t("consent")}</div>
|
|
<div
|
|
className={cn(
|
|
"w-16 h-3 flex items-center justify-center",
|
|
galateeBorderVariants({ arousalPeakPotential: arousalLevel }),
|
|
)}
|
|
>
|
|
<div
|
|
className="w-full h-full bg-primary rounded-full"
|
|
style={{ opacity: consent }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default () => {
|
|
const { gameState } = useNarrative();
|
|
|
|
if (!gameState || !gameState.personaId) {
|
|
return <div className="h-10" />;
|
|
}
|
|
|
|
const arousal = gameState.arousalPeakPotential ?? 0;
|
|
const consent = gameState.aiConsentLevel ?? 1;
|
|
|
|
const arousalLevel = getArousalLevel(arousal);
|
|
const consentLevel = getConsentLevel(consent);
|
|
|
|
const personaComponents: Record<
|
|
string,
|
|
React.FC<ChatMetricProps>
|
|
> = {
|
|
samantha69: ChatMetricSamantha69,
|
|
polaire: ChatMetricPolaire,
|
|
cybelle: ChatMetricCybelle,
|
|
xixi: ChatMetricXixi,
|
|
nelly: ChatMetricNelly,
|
|
hisoka: ChatMetricHisoka,
|
|
galatee: ChatMetricGalatee,
|
|
};
|
|
|
|
const Component = personaComponents[gameState.personaId];
|
|
const props = { gameState, arousalLevel, consentLevel };
|
|
|
|
return (
|
|
<>
|
|
{Component && (
|
|
<Component
|
|
{...props}
|
|
style={{
|
|
"--arousal-peak-potential": Math.min(Math.max(arousal ?? 0, 0), 1),
|
|
"--ai-consent-level": Math.min(Math.max(consent ?? 1, 0), 1),
|
|
"--intimacy-level": Math.min(
|
|
Math.max(gameState.intimacyLevel ?? 0, 0),
|
|
1,
|
|
),
|
|
} as React.CSSProperties}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|