634 lines
24 KiB
TypeScript
634 lines
24 KiB
TypeScript
import {motion} from 'motion/react';
|
|
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
import {getCharacterById} from '../../data/characterPresets';
|
|
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
|
import {RESOLVED_ENTITY_X_METERS} from '../../data/sceneEncounterPreviews';
|
|
import {
|
|
AnimationState,
|
|
type Character,
|
|
type CompanionRenderState,
|
|
type Encounter,
|
|
type SceneHostileNpc,
|
|
type ScenePresetInfo,
|
|
type WorldType,
|
|
} from '../../types';
|
|
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
|
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
|
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
|
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
|
import {
|
|
buildCombatFeedbackEvents,
|
|
type CombatFeedbackEvent,
|
|
type CombatFeedbackHealthSample,
|
|
} from './combatFeedback';
|
|
import {
|
|
CHARACTER_COMBAT_HP_TOP_PX,
|
|
DialogueBubbleIcon,
|
|
type GameCanvasEntitySelection,
|
|
GENERIC_NPC_SCENE_SCALE,
|
|
getCompanionSlotOffset,
|
|
getEncounterCharacterBottomOffsetPx,
|
|
getEncounterCharacterOpponentBottom,
|
|
getHostileNpcSceneBottomOffsetPx,
|
|
getMonsterWorldLeft,
|
|
getNpcCombatHpTop,
|
|
getSceneEntityZIndex,
|
|
getSceneNpcVisualBottomOffsetPx,
|
|
HpBar,
|
|
mapHostileNpcAnimationToCharacterState,
|
|
MONSTER_RENDER_OFFSETS,
|
|
ROLE_CHARACTER_FRAME_CLASS,
|
|
RoleCharacterSprite,
|
|
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
|
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
|
|
SceneEncounterNpcSprite,
|
|
SceneEntityButton,
|
|
} from './GameCanvasShared';
|
|
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
|
|
|
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
|
|
|
interface GameCanvasEntityLayerProps {
|
|
companions: CompanionRenderState[];
|
|
currentScenePreset: ScenePresetInfo | null;
|
|
sceneTransitionToken: number;
|
|
isSceneTransitionEntering: boolean;
|
|
isSceneTransitionExiting: boolean;
|
|
transitionSweepPx: number;
|
|
sceneTransitionExitDurationS: number;
|
|
sceneTransitionEntryDurationS: number;
|
|
companionAnchorLeft: string;
|
|
companionAnchorBottom: string;
|
|
playerBottomOffsetPx: number;
|
|
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
|
inBattle: boolean;
|
|
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
|
playerLeft: string;
|
|
playerCharacter: Character | null;
|
|
playerHp: number;
|
|
playerMaxHp: number;
|
|
effectivePlayerFacing: 'left' | 'right';
|
|
effectivePlayerAnimationState: AnimationState;
|
|
shouldShowPlayerDialogueIcon: boolean;
|
|
dialogueIndicator?: {
|
|
showPlayer: boolean;
|
|
showEncounter: boolean;
|
|
activeSpeaker?: 'player' | 'npc' | null;
|
|
} | null;
|
|
npcAffinityEffect?: {
|
|
eventId: string;
|
|
npcId: string;
|
|
delta: number;
|
|
} | null;
|
|
sceneCombatants: SceneHostileNpc[];
|
|
monsters: MonsterSpriteConfig[];
|
|
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
|
groundBottom: string;
|
|
stageLiftPx: number;
|
|
encounter: Encounter | null;
|
|
sideAnchor: string;
|
|
cameraAnchorX: number;
|
|
monsterAnchorMeters: number;
|
|
playerX: number;
|
|
}
|
|
|
|
function CombatFloatingNumber({
|
|
event,
|
|
onDone,
|
|
}: {
|
|
event: CombatFeedbackEvent;
|
|
onDone: (eventId: string) => void;
|
|
}) {
|
|
const isHealing = event.delta > 0;
|
|
const deltaText = `${isHealing ? '+' : ''}${event.delta}`;
|
|
const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200';
|
|
const glowClass = isHealing
|
|
? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]'
|
|
: 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]';
|
|
|
|
return (
|
|
<motion.div
|
|
key={event.id}
|
|
initial={{opacity: 0, y: 10, scale: 0.76}}
|
|
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
|
|
transition={{duration: 0.92, ease: 'easeOut'}}
|
|
onAnimationComplete={() => onDone(event.id)}
|
|
className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`}
|
|
data-testid={`combat-feedback-${event.targetKey}`}
|
|
aria-label={`战斗数值 ${deltaText}`}
|
|
>
|
|
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
|
|
{deltaText}
|
|
</span>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function CombatFeedbackNumbers({
|
|
events,
|
|
onDone,
|
|
}: {
|
|
events: CombatFeedbackEvent[];
|
|
onDone: (eventId: string) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
{events.map(event => (
|
|
<CombatFloatingNumber key={event.id} event={event} onDone={onDone} />
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function getLatestDamageFeedback(events: CombatFeedbackEvent[]) {
|
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
const event = events[index];
|
|
if (event?.delta && event.delta < 0) return event;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function CombatReactiveSpriteFrame({
|
|
events,
|
|
facing,
|
|
className = ROLE_CHARACTER_FRAME_CLASS,
|
|
children,
|
|
}: {
|
|
events: CombatFeedbackEvent[];
|
|
facing: 'left' | 'right';
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const latestDamage = getLatestDamageFeedback(events);
|
|
const retreatX = facing === 'right' ? -12 : 12;
|
|
|
|
return (
|
|
<motion.div
|
|
className={className}
|
|
animate={latestDamage ? {x: [0, retreatX, 0]} : {x: 0}}
|
|
transition={{duration: 0.28, ease: 'easeOut'}}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
export function GameCanvasEntityLayer({
|
|
companions,
|
|
currentScenePreset,
|
|
sceneTransitionToken,
|
|
isSceneTransitionEntering,
|
|
isSceneTransitionExiting,
|
|
transitionSweepPx,
|
|
sceneTransitionExitDurationS,
|
|
sceneTransitionEntryDurationS,
|
|
companionAnchorLeft,
|
|
companionAnchorBottom,
|
|
playerBottomOffsetPx,
|
|
sceneTransitionPhase,
|
|
inBattle,
|
|
onEntitySelect = null,
|
|
playerLeft,
|
|
playerCharacter,
|
|
playerHp,
|
|
playerMaxHp,
|
|
effectivePlayerFacing,
|
|
effectivePlayerAnimationState,
|
|
shouldShowPlayerDialogueIcon,
|
|
dialogueIndicator = null,
|
|
npcAffinityEffect = null,
|
|
sceneCombatants,
|
|
monsters,
|
|
getHostileNpcOuterLeft,
|
|
groundBottom,
|
|
stageLiftPx,
|
|
encounter,
|
|
sideAnchor,
|
|
cameraAnchorX,
|
|
monsterAnchorMeters,
|
|
playerX,
|
|
}: GameCanvasEntityLayerProps) {
|
|
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
|
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
|
const combatFeedbackSequenceRef = useRef(0);
|
|
const shouldRenderPeacefulEncounter =
|
|
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
|
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
|
() => {
|
|
if (!inBattle) return [];
|
|
|
|
return [
|
|
{key: 'player', kind: 'player', hp: playerHp},
|
|
...companions.map(companion => ({
|
|
key: `companion:${companion.npcId}`,
|
|
kind: 'companion' as const,
|
|
hp: companion.hp,
|
|
})),
|
|
...sceneCombatants.map(hostileNpc => ({
|
|
key: `hostile:${hostileNpc.id}`,
|
|
kind: 'hostile' as const,
|
|
hp: hostileNpc.hp,
|
|
})),
|
|
];
|
|
},
|
|
[companions, inBattle, playerHp, sceneCombatants],
|
|
);
|
|
const combatFeedbackByTarget = useMemo(() => {
|
|
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
|
combatFeedbackEvents.forEach(event => {
|
|
feedbackByTarget.set(event.targetKey, [
|
|
...(feedbackByTarget.get(event.targetKey) ?? []),
|
|
event,
|
|
]);
|
|
});
|
|
return feedbackByTarget;
|
|
}, [combatFeedbackEvents]);
|
|
const removeCombatFeedbackEvent = (eventId: string) => {
|
|
setCombatFeedbackEvents(events => events.filter(event => event.id !== eventId));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!inBattle) {
|
|
previousCombatSamplesRef.current = null;
|
|
setCombatFeedbackEvents([]);
|
|
return;
|
|
}
|
|
|
|
const previousSamples = previousCombatSamplesRef.current;
|
|
if (previousSamples) {
|
|
const result = buildCombatFeedbackEvents(
|
|
previousSamples,
|
|
combatHealthSamples,
|
|
combatFeedbackSequenceRef.current,
|
|
);
|
|
|
|
if (result.events.length > 0) {
|
|
setCombatFeedbackEvents(events => [...events.slice(-8), ...result.events]);
|
|
}
|
|
|
|
combatFeedbackSequenceRef.current = result.nextSequence;
|
|
}
|
|
|
|
previousCombatSamplesRef.current = new Map(
|
|
combatHealthSamples.map(sample => [sample.key, sample]),
|
|
);
|
|
}, [combatHealthSamples, inBattle]);
|
|
|
|
return (
|
|
<>
|
|
{companions.map(companion => {
|
|
const slotOffset = getCompanionSlotOffset(companion.slot);
|
|
const feedbackTargetKey = `companion:${companion.npcId}`;
|
|
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
|
const companionFacing = companion.facing ?? 'right';
|
|
return (
|
|
<motion.div
|
|
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
|
|
className="absolute"
|
|
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
|
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
|
transition={{
|
|
duration: isSceneTransitionExiting
|
|
? sceneTransitionExitDurationS
|
|
: isSceneTransitionEntering
|
|
? sceneTransitionEntryDurationS
|
|
: 0.18,
|
|
ease: 'linear',
|
|
delay: isSceneTransitionEntering
|
|
? (companion.slot === 'upper'
|
|
? SCENE_TRANSITION_UPPER_COMPANION_DELAY_S
|
|
: SCENE_TRANSITION_LOWER_COMPANION_DELAY_S)
|
|
: 0,
|
|
}}
|
|
style={{
|
|
left: companionAnchorLeft,
|
|
bottom: companionAnchorBottom,
|
|
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: `${slotOffset.left}px`,
|
|
bottom: `${slotOffset.bottom}px`,
|
|
transform: `translate(${companion.entryOffsetX ?? 0}px, ${companion.entryOffsetY ?? 0}px)`,
|
|
transition: companion.transitionMs
|
|
? `transform ${companion.transitionMs}ms linear`
|
|
: undefined,
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
|
|
ariaLabel={`查看${companion.character.name}详情`}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
|
{inBattle && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2"
|
|
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
|
>
|
|
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
|
|
</div>
|
|
)}
|
|
<CombatReactiveSpriteFrame events={feedbackEvents} facing={companionFacing}>
|
|
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
|
|
<RoleCharacterSprite
|
|
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
|
|
character={companion.character}
|
|
facing={sceneTransitionPhase === 'idle' ? companionFacing : 'right'}
|
|
/>
|
|
</div>
|
|
</CombatReactiveSpriteFrame>
|
|
</SceneEntityButton>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
|
|
<motion.div
|
|
key={`player-${currentScenePreset?.id ?? 'none'}-${sceneTransitionToken}`}
|
|
className="absolute"
|
|
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
|
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
|
transition={{
|
|
duration: isSceneTransitionExiting
|
|
? sceneTransitionExitDurationS
|
|
: isSceneTransitionEntering
|
|
? sceneTransitionEntryDurationS
|
|
: 0.18,
|
|
ease: 'linear',
|
|
}}
|
|
style={{
|
|
left: playerLeft,
|
|
bottom: companionAnchorBottom,
|
|
zIndex: getSceneEntityZIndex(playerBottomOffsetPx),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<CombatFeedbackNumbers
|
|
events={combatFeedbackByTarget.get('player') ?? []}
|
|
onDone={removeCombatFeedbackEvent}
|
|
/>
|
|
{inBattle && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2"
|
|
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
|
>
|
|
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
|
|
</div>
|
|
)}
|
|
<SceneEntityButton
|
|
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
|
|
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
|
|
className="relative block"
|
|
>
|
|
<div className="relative">
|
|
<CombatReactiveSpriteFrame
|
|
events={combatFeedbackByTarget.get('player') ?? []}
|
|
facing={effectivePlayerFacing}
|
|
>
|
|
{playerCharacter && (
|
|
<RoleCharacterSprite
|
|
state={effectivePlayerAnimationState}
|
|
character={playerCharacter}
|
|
facing={effectivePlayerFacing}
|
|
/>
|
|
)}
|
|
</CombatReactiveSpriteFrame>
|
|
</div>
|
|
{shouldShowPlayerDialogueIcon && (
|
|
<div className="absolute -top-9 right-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator?.activeSpeaker === 'player'}
|
|
flip={effectivePlayerFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</SceneEntityButton>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{sceneCombatants.map(hostileNpc => {
|
|
const npcEncounter = hostileNpc.encounter;
|
|
if (!npcEncounter) return null;
|
|
const config = monsters.find(item => item.id === hostileNpc.id);
|
|
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
|
|
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
|
|
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
|
|
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
|
: null;
|
|
const npcSceneSpriteFacing =
|
|
npcCharacter
|
|
? hostileNpc.facing
|
|
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
|
const npcCombatHpTop = getNpcCombatHpTop(
|
|
npcCharacter ? npcEncounter?.characterId : null,
|
|
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
|
);
|
|
const feedbackTargetKey = `hostile:${hostileNpc.id}`;
|
|
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
|
const hostileNpcBottomOffsetPx =
|
|
npcMonsterConfig
|
|
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
|
|
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
|
|
const opponentBottom = npcCharacter
|
|
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
|
|
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
|
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
|
const entityBottomOffsetPx = npcCharacter
|
|
? getEncounterCharacterBottomOffsetPx(
|
|
stageLiftPx,
|
|
npcEncounter,
|
|
npcCharacter,
|
|
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
|
)
|
|
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
|
|
|
|
return (
|
|
<div
|
|
key={hostileNpc.id}
|
|
className="absolute"
|
|
style={{
|
|
left: getHostileNpcOuterLeft(hostileNpc),
|
|
bottom: entityBottom,
|
|
zIndex: getSceneEntityZIndex(entityBottomOffsetPx),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
|
|
ariaLabel={`查看${hostileNpc.name}详情`}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
|
{inBattle && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2"
|
|
style={{top: `${npcCombatHpTop}px`}}
|
|
>
|
|
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
|
|
</div>
|
|
)}
|
|
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
|
{npcCharacter ? (
|
|
<RoleCharacterSprite
|
|
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
|
character={npcCharacter}
|
|
facing={npcSceneSpriteFacing}
|
|
/>
|
|
) : npcMonsterConfig ? (
|
|
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
|
<HostileNpcAnimator
|
|
hostileNpc={npcMonsterConfig}
|
|
animation={hostileNpc.animation}
|
|
flip={hostileNpc.facing === 'right'}
|
|
className="scale-[1.82] origin-bottom"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<MedievalNpcAnimator
|
|
encounter={npcEncounter}
|
|
className="origin-bottom drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
|
facing={npcSceneSpriteFacing}
|
|
scale={GENERIC_NPC_SCENE_SCALE}
|
|
/>
|
|
)}
|
|
</CombatReactiveSpriteFrame>
|
|
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
|
|
<div className="absolute -top-9 left-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator.activeSpeaker === 'npc'}
|
|
flip={npcSceneSpriteFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
|
|
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
|
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
|
) : null}
|
|
</SceneEntityButton>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{shouldRenderPeacefulEncounter &&
|
|
(() => {
|
|
if (!encounter) {
|
|
return null;
|
|
}
|
|
|
|
const isCampCompanionEncounter =
|
|
encounter.specialBehavior === 'initial_companion'
|
|
|| encounter.specialBehavior === 'camp_companion';
|
|
const peacefulAnchorX = RESOLVED_ENTITY_X_METERS;
|
|
const isPeacefulEncounterMoving =
|
|
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|
|
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
|
|
const towardPeacefulPlayer = getFacingTowardPlayer(peacefulAnchorX, playerX);
|
|
const peacefulResolvedCharacter =
|
|
encounter.kind !== 'treasure' && encounter.characterId
|
|
? getCharacterById(encounter.characterId)
|
|
: null;
|
|
const peacefulMonsterConfig = !peacefulResolvedCharacter &&
|
|
encounter.kind === 'npc' && encounter.monsterPresetId
|
|
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
|
|
: null;
|
|
const peacefulHostileBottomOffsetPx =
|
|
peacefulMonsterConfig
|
|
? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig)
|
|
: getSceneNpcVisualBottomOffsetPx(encounter);
|
|
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
|
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
|
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
|
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
|
|
|
return (
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: getMonsterWorldLeft(
|
|
sideAnchor,
|
|
peacefulAnchorX,
|
|
cameraAnchorX,
|
|
monsterAnchorMeters,
|
|
),
|
|
bottom: encounter.characterId
|
|
? getEncounterCharacterOpponentBottom(
|
|
groundBottom,
|
|
stageLiftPx,
|
|
encounter,
|
|
getCharacterById(encounter.characterId),
|
|
)
|
|
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
|
|
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
|
|
transition: isCampCompanionEncounter
|
|
? 'bottom 180ms ease'
|
|
: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
|
|
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
|
{encounter.kind === 'treasure' ? (
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
|
|
<ResolvedAssetImage
|
|
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
|
|
alt={encounter.npcName}
|
|
className="h-12 w-12 object-contain"
|
|
style={{imageRendering: 'pixelated'}}
|
|
/>
|
|
</div>
|
|
) : peacefulResolvedCharacter &&
|
|
!encounter.visual &&
|
|
!encounter.imageSrc?.trim() ? (
|
|
<RoleCharacterSprite
|
|
state={AnimationState.IDLE}
|
|
character={peacefulResolvedCharacter}
|
|
facing={peacefulNpcSpriteFacing}
|
|
/>
|
|
) : peacefulMonsterConfig ? (
|
|
<HostileNpcAnimator
|
|
hostileNpc={peacefulMonsterConfig}
|
|
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
|
flip={towardPeacefulPlayer === 'right'}
|
|
className="scale-[1.82] origin-bottom"
|
|
/>
|
|
) : (
|
|
<SceneEncounterNpcSprite
|
|
encounter={encounter}
|
|
state={AnimationState.IDLE}
|
|
facing={peacefulNpcSpriteFacing}
|
|
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
|
/>
|
|
)}
|
|
</div>
|
|
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
|
|
<div className="absolute -top-9 left-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator.activeSpeaker === 'npc'}
|
|
flip={peacefulNpcSpriteFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
|
|
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
|
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
|
) : null}
|
|
</SceneEntityButton>
|
|
</div>
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|