Refine NPC interactions and runtime item generation
This commit is contained in:
@@ -1,72 +1,164 @@
|
||||
type AffinityLevelMeta = {
|
||||
value: number;
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = {
|
||||
value: 0,
|
||||
label: '戒备',
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
type AffinityProgressMarker = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const AFFINITY_PROGRESS_MIN = -40;
|
||||
const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
DEFAULT_AFFINITY_LEVEL,
|
||||
{
|
||||
value: 15,
|
||||
label: '敌对',
|
||||
minAffinity: Number.NEGATIVE_INFINITY,
|
||||
nextAffinity: 0,
|
||||
description:
|
||||
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
|
||||
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
|
||||
},
|
||||
{
|
||||
label: '戒备',
|
||||
minAffinity: 0,
|
||||
nextAffinity: 15,
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
},
|
||||
{
|
||||
label: '缓和',
|
||||
minAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
},
|
||||
{
|
||||
value: 30,
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
label: '深交',
|
||||
minAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
|
||||
|
||||
const AFFINITY_PROGRESS_MARKERS: AffinityProgressMarker[] = [
|
||||
{ value: -40, label: '敌对' },
|
||||
{ value: 0, label: '戒备' },
|
||||
{ value: 15, label: '缓和' },
|
||||
{ value: 30, label: '友善' },
|
||||
{ value: 60, label: '信任' },
|
||||
{ value: 90, label: '深交' },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getAffinityLevelMeta(affinity: number) {
|
||||
return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL;
|
||||
return (
|
||||
[...AFFINITY_LEVELS]
|
||||
.reverse()
|
||||
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
function getNextAffinityLevelMeta(affinity: number) {
|
||||
return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityLevelMeta(affinity);
|
||||
const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1;
|
||||
const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity));
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
return (
|
||||
AFFINITY_PROGRESS_MARKERS.find(
|
||||
(marker) => marker.value === currentLevel.nextAffinity,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getAffinityProgressRatio(value: number) {
|
||||
return clamp(
|
||||
(value - AFFINITY_PROGRESS_MIN) /
|
||||
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function getAnchorTransform(ratio: number) {
|
||||
if (ratio <= 0.02) return 'translateX(0)';
|
||||
if (ratio >= 0.98) return 'translateX(-100%)';
|
||||
return 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
||||
if (marker.value < 0) {
|
||||
return affinity < 0;
|
||||
}
|
||||
|
||||
return affinity >= marker.value;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityMarker(affinity);
|
||||
const currentRatio = getAffinityProgressRatio(affinity);
|
||||
const zeroRatio = getAffinityProgressRatio(0);
|
||||
const activeMarkerValue =
|
||||
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
|
||||
? AFFINITY_PROGRESS_MIN
|
||||
: currentLevel.minAffinity;
|
||||
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
|
||||
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
|
||||
const fillWidthPercent =
|
||||
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
|
||||
const currentPointerTone =
|
||||
affinity < 0
|
||||
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
|
||||
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
|
||||
const fillGradient =
|
||||
affinity < 0
|
||||
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感等级</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感等级
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">当前好感 {affinity}</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
当前好感 {affinity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{nextLevel ? (
|
||||
<>
|
||||
@@ -83,76 +175,117 @@ export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{currentLevel.description}</p>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{currentLevel.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感进度</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">节点数值表示进入对应等级所需的好感度。</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感进度
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
||||
0 是战斗分界线,低于 0
|
||||
会直接进入对战;其余节点表示进入对应阶段所需的最低好感。
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 pt-1">
|
||||
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-white/18 to-transparent" />
|
||||
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
|
||||
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
|
||||
<div
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-full bg-gradient-to-r from-sky-300 via-amber-300 to-rose-300 shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{width: `${progress * 100}%`}}
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
|
||||
style={{ width: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
|
||||
style={{
|
||||
left: `${zeroRatio * 100}%`,
|
||||
width: `${(1 - zeroRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
|
||||
style={{ left: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{
|
||||
left: `${fillLeftRatio * 100}%`,
|
||||
width: fillWidthPercent,
|
||||
background: fillGradient,
|
||||
}}
|
||||
/>
|
||||
|
||||
{AFFINITY_LEVELS.map((level, index) => {
|
||||
const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0;
|
||||
const isReached = affinity >= level.value;
|
||||
const isCurrent = currentLevel.value === level.value;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === AFFINITY_LEVELS.length - 1;
|
||||
<div
|
||||
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
|
||||
style={{
|
||||
left: `${currentRatio * 100}%`,
|
||||
transform: getAnchorTransform(currentRatio),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-full rounded-full border ${currentPointerTone}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
|
||||
const markerRatio = getAffinityProgressRatio(marker.value);
|
||||
const isReached = isMarkerReached(marker, affinity);
|
||||
const isActive = marker.value === activeMarkerValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`affinity-level-${level.value}`}
|
||||
key={`affinity-marker-${marker.value}`}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: `${ratio * 100}%`,
|
||||
transform: isFirst ? 'translateX(0)' : isLast ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
left: `${markerRatio * 100}%`,
|
||||
transform: getAnchorTransform(markerRatio),
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-9 w-4 items-end justify-center sm:h-11 sm:w-5">
|
||||
{isCurrent ? (
|
||||
<div className="absolute bottom-0 h-8 w-3 rounded-full bg-sky-300/20 blur-[6px] sm:h-10 sm:w-4" />
|
||||
) : null}
|
||||
{isReached && !isCurrent ? (
|
||||
<div className="absolute bottom-0 h-6 w-2.5 rounded-full bg-amber-300/10 blur-[4px] sm:h-7" />
|
||||
) : null}
|
||||
<div className="flex w-12 flex-col items-center text-center sm:w-16">
|
||||
<div className="relative flex h-9 items-end justify-center sm:h-10">
|
||||
{isActive ? (
|
||||
<div
|
||||
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
|
||||
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative rounded-full border transition-all duration-300 ${
|
||||
isActive
|
||||
? marker.value < 0
|
||||
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
|
||||
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
|
||||
: isReached
|
||||
? marker.value < 0
|
||||
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
|
||||
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-full border transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'h-8 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-10 sm:w-2.5'
|
||||
: isReached
|
||||
? 'h-6 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-7 sm:w-2'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5 sm:w-2'
|
||||
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
|
||||
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
|
||||
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-5 gap-1 pt-10 sm:gap-2 sm:pt-12">
|
||||
{AFFINITY_LEVELS.map(level => {
|
||||
const isReached = affinity >= level.value;
|
||||
|
||||
return (
|
||||
<div key={`affinity-label-${level.value}`} className="text-center">
|
||||
<div className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${isReached ? 'text-zinc-100' : 'text-zinc-500'}`}>
|
||||
{level.label}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${isReached ? 'text-zinc-300' : 'text-zinc-600'}`}>
|
||||
{level.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user