This commit is contained in:
2026-04-26 20:50:58 +08:00
parent a3a9bfa194
commit 67161bd6d1
142 changed files with 3349 additions and 10674 deletions

View File

@@ -1,4 +1,5 @@
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';
@@ -16,20 +17,24 @@ import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {ResolvedAssetImage} from '../ResolvedAssetImage';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
buildCombatFeedbackEvents,
type CombatFeedbackEvent,
type CombatFeedbackHealthSample,
} from './combatFeedback';
import {
CHARACTER_COMBAT_HP_TOP_PX,
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
CHARACTER_COMBAT_HP_TOP_PX,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
getSceneEntityZIndex,
getSceneNpcVisualBottomOffsetPx,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -40,6 +45,7 @@ import {
SceneEncounterNpcSprite,
SceneEntityButton,
} from './GameCanvasShared';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
@@ -87,6 +93,88 @@ interface GameCanvasEntityLayerProps {
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,
@@ -122,13 +210,79 @@ export function GameCanvasEntityLayer({
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}`}
@@ -172,6 +326,7 @@ export function GameCanvasEntityLayer({
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"
@@ -180,15 +335,15 @@ export function GameCanvasEntityLayer({
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<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' ? (companion.facing ?? 'right') : 'right'}
facing={sceneTransitionPhase === 'idle' ? companionFacing : 'right'}
/>
</div>
</div>
</CombatReactiveSpriteFrame>
</SceneEntityButton>
</div>
</div>
@@ -217,6 +372,10 @@ export function GameCanvasEntityLayer({
}}
>
<div className="relative">
<CombatFeedbackNumbers
events={combatFeedbackByTarget.get('player') ?? []}
onDone={removeCombatFeedbackEvent}
/>
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
@@ -231,7 +390,10 @@ export function GameCanvasEntityLayer({
className="relative block"
>
<div className="relative">
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<CombatReactiveSpriteFrame
events={combatFeedbackByTarget.get('player') ?? []}
facing={effectivePlayerFacing}
>
{playerCharacter && (
<RoleCharacterSprite
state={effectivePlayerAnimationState}
@@ -239,7 +401,7 @@ export function GameCanvasEntityLayer({
facing={effectivePlayerFacing}
/>
)}
</div>
</CombatReactiveSpriteFrame>
</div>
{shouldShowPlayerDialogueIcon && (
<div className="absolute -top-9 right-1">
@@ -270,6 +432,8 @@ export function GameCanvasEntityLayer({
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const feedbackTargetKey = `hostile:${hostileNpc.id}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const hostileNpcBottomOffsetPx =
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
@@ -303,6 +467,7 @@ export function GameCanvasEntityLayer({
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"
@@ -311,7 +476,7 @@ export function GameCanvasEntityLayer({
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
{npcCharacter ? (
<RoleCharacterSprite
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
@@ -335,7 +500,7 @@ export function GameCanvasEntityLayer({
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
</CombatReactiveSpriteFrame>
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon