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 ( 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}`} > {deltaText} ); } function CombatFeedbackNumbers({ events, onDone, }: { events: CombatFeedbackEvent[]; onDone: (eventId: string) => void; }) { return ( <> {events.map(event => ( ))} ); } 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 ( {children} ); } 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([]); const previousCombatSamplesRef = useRef | null>(null); const combatFeedbackSequenceRef = useRef(0); const shouldRenderPeacefulEncounter = Boolean(encounter) && (!inBattle || sceneCombatants.length === 0); const combatHealthSamples = useMemo( () => { 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(); 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 (
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 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 (
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 ? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig) : getSceneNpcVisualBottomOffsetPx(encounter); const peacefulBottomOffsetPx = peacefulResolvedCharacter ? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, 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}
); })()} ); }