import {motion} from 'motion/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 {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge'; import { DialogueBubbleIcon, type GameCanvasEntitySelection, GENERIC_NPC_SCENE_SCALE, getCharacterBottomOffsetPx, getCharacterOpponentBottom, getCompanionSlotOffset, getMonsterWorldLeft, getNpcCombatHpTop, getSceneEntityZIndex, HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX, 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'; 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; } 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 shouldRenderPeacefulEncounter = Boolean(encounter) && (!inBattle || sceneCombatants.length === 0); return ( <> {companions.map(companion => { const slotOffset = getCompanionSlotOffset(companion.slot); return (
onEntitySelect?.({kind: 'companion', companion})} ariaLabel={`查看${companion.character.name}详情`} className="relative flex w-28 flex-col items-center" > {inBattle && (
)}
); })}
{inBattle && (
)} onEntitySelect?.({kind: 'player'}) : null} ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined} className="relative block" >
{playerCharacter && ( )}
{shouldShowPlayerDialogueIcon && (
)}
{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 hostileNpcBottomOffsetPx = npcMonsterConfig ? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX : 0; const opponentBottom = npcCharacter ? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter) : `calc(${groundBottom} + ${stageLiftPx}px)`; const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`; const entityBottomOffsetPx = npcCharacter ? getCharacterBottomOffsetPx( stageLiftPx, npcCharacter, (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx, ) : stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx; return (
onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})} ariaLabel={`查看${hostileNpc.name}详情`} className="relative flex w-28 flex-col items-center" > {inBattle && (
)}
{npcCharacter ? ( ) : npcMonsterConfig ? (
) : ( )}
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
)} {/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */} {npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? ( ) : null}
); })} {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 ? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX : 0; const peacefulBottomOffsetPx = peacefulResolvedCharacter ? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter) : stageLiftPx + peacefulHostileBottomOffsetPx; const peacefulNpcSpriteFacing = towardPeacefulPlayer; return (
onEntitySelect?.({kind: 'npc', encounter}) : null} ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined} className="relative flex w-28 flex-col items-center" >
{encounter.kind === 'treasure' ? (
) : peacefulResolvedCharacter && !encounter.visual && !encounter.imageSrc?.trim() ? ( ) : peacefulMonsterConfig ? ( ) : ( )}
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
)} {/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */} {npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? ( ) : null}
); })()} ); }