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,
  getBattleCompanionSlotOffset,
  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[];
  sceneActAmbientEncounters: Encounter[];
  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;
}

const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;

function addCssPxOffset(value: string, offsetPx: number) {
  return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
}

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,
  sceneActAmbientEncounters,
  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 hasCombatAfterimage = useMemo(
    () =>
      combatFeedbackEvents.length > 0 ||
      sceneCombatants.some(
        (hostileNpc) =>
          hostileNpc.hp < hostileNpc.maxHp ||
          hostileNpc.animation === 'attack' ||
          hostileNpc.animation === 'die',
      ),
    [combatFeedbackEvents.length, sceneCombatants],
  );
  const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage;
  const shouldRenderPeacefulEncounter =
    Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
  const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
    () => {
      if (!shouldRenderCombatPresentation) 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, playerHp, sceneCombatants, shouldRenderCombatPresentation],
  );
  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 (!shouldRenderCombatPresentation) {
      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, shouldRenderCombatPresentation]);

  return (
    <>
      {companions.map(companion => {
        const slotOffset = inBattle
          ? getBattleCompanionSlotOffset(companion.slot)
          : 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) + (inBattle ? 1 : 0),
              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} />
                  {shouldRenderCombatPresentation && (
                    <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}
          />
          {shouldRenderCombatPresentation && (
            <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, index) => {
        const npcEncounter = hostileNpc.encounter;
        if (!npcEncounter) return null;
        const hostileRenderKey = [
          hostileNpc.id,
          npcEncounter.id ?? npcEncounter.npcName,
          hostileNpc.xMeters,
          hostileNpc.yOffset ?? 0,
          index,
        ].join(':');
        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={hostileRenderKey}
            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} />
              {shouldRenderCombatPresentation && (
                <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>
          );
        })()}

      {!inBattle &&
        sceneActAmbientEncounters.map((ambientEncounter, index) => {
          const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
          if (ambientOffsetPx === undefined) {
            return null;
          }

          const ambientResolvedCharacter =
            ambientEncounter.kind !== 'treasure' && ambientEncounter.characterId
              ? getCharacterById(ambientEncounter.characterId)
              : null;
          const ambientMonsterConfig =
            !ambientResolvedCharacter &&
            ambientEncounter.kind === 'npc' &&
            ambientEncounter.monsterPresetId
              ? monsters.find(item => item.id === ambientEncounter.monsterPresetId) ?? null
              : null;
          const ambientHostileBottomOffsetPx = ambientMonsterConfig
            ? getHostileNpcSceneBottomOffsetPx(ambientMonsterConfig)
            : getSceneNpcVisualBottomOffsetPx(ambientEncounter);
          const ambientBottomOffsetPx = ambientResolvedCharacter
            ? getEncounterCharacterBottomOffsetPx(
                stageLiftPx,
                ambientEncounter,
                ambientResolvedCharacter,
                ambientOffsetPx,
              )
            : stageLiftPx + ambientHostileBottomOffsetPx + ambientOffsetPx;
          const ambientFacing = getFacingTowardPlayer(
            SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
            playerX,
          );
          const ambientBottom = ambientEncounter.characterId
            ? getEncounterCharacterOpponentBottom(
                groundBottom,
                stageLiftPx,
                ambientEncounter,
                getCharacterById(ambientEncounter.characterId),
              )
            : `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;

          return (
            <div
              key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
              className="absolute"
              style={{
                left: getMonsterWorldLeft(
                  sideAnchor,
                  SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
                  cameraAnchorX,
                  monsterAnchorMeters,
                ),
                bottom: addCssPxOffset(ambientBottom, ambientOffsetPx),
                zIndex: getSceneEntityZIndex(ambientBottomOffsetPx),
                transition: 'left 260ms linear, bottom 180ms ease',
              }}
            >
              <SceneEntityButton
                onClick={
                  ambientEncounter.kind === 'npc'
                    ? () => onEntitySelect?.({kind: 'npc', encounter: ambientEncounter})
                    : null
                }
                ariaLabel={
                  ambientEncounter.kind === 'npc'
                    ? `查看${ambientEncounter.npcName}详情`
                    : undefined
                }
                className="relative flex w-28 flex-col items-center"
              >
                <div className={ROLE_CHARACTER_FRAME_CLASS}>
                  {ambientResolvedCharacter &&
                  !ambientEncounter.visual &&
                  !ambientEncounter.imageSrc?.trim() ? (
                    <RoleCharacterSprite
                      state={AnimationState.IDLE}
                      character={ambientResolvedCharacter}
                      facing={ambientFacing}
                    />
                  ) : ambientMonsterConfig ? (
                    <HostileNpcAnimator
                      hostileNpc={ambientMonsterConfig}
                      animation="idle"
                      flip={ambientFacing === 'right'}
                      className="scale-[1.82] origin-bottom"
                    />
                  ) : (
                    <SceneEncounterNpcSprite
                      encounter={ambientEncounter}
                      state={AnimationState.IDLE}
                      facing={ambientFacing}
                      className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
                    />
                  )}
                </div>
                {/* 幕后排角色只是同幕可见实体，不抢占当前交互目标。 */}
                {npcAffinityEffect?.npcId ===
                (ambientEncounter.id ?? ambientEncounter.npcName) ? (
                  <NpcAffinityEffectBadge effect={npcAffinityEffect} />
                ) : null}
              </SceneEntityButton>
            </div>
          );
        })}
    </>
  );
}
