storytellurx-v1/app/components/chat/chat-metrics.tsx
Elias Rhouzlane cfe3526f6b fix: localize
2025-07-14 14:08:57 +02:00

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}
/>
)}
</>
);
};