1
This commit is contained in:
@@ -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
|
||||
|
||||
62
src/components/game-canvas/combatFeedback.test.ts
Normal file
62
src/components/game-canvas/combatFeedback.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCombatFeedbackEvents,
|
||||
type CombatFeedbackHealthSample,
|
||||
} from './combatFeedback';
|
||||
|
||||
function toSample(key: string, hp: number): CombatFeedbackHealthSample {
|
||||
return {
|
||||
key,
|
||||
kind: key.startsWith('hostile') ? 'hostile' : 'player',
|
||||
hp,
|
||||
};
|
||||
}
|
||||
|
||||
describe('combatFeedback', () => {
|
||||
it('creates red damage and green healing deltas from committed hp changes', () => {
|
||||
const previous = new Map([
|
||||
['player', toSample('player', 20)],
|
||||
['hostile:npc-liu', toSample('hostile:npc-liu', 8)],
|
||||
]);
|
||||
|
||||
const result = buildCombatFeedbackEvents(
|
||||
previous,
|
||||
[
|
||||
toSample('player', 11),
|
||||
toSample('hostile:npc-liu', 9),
|
||||
],
|
||||
4,
|
||||
);
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
id: 'player:5',
|
||||
targetKey: 'player',
|
||||
kind: 'player',
|
||||
delta: -9,
|
||||
},
|
||||
{
|
||||
id: 'hostile:npc-liu:6',
|
||||
targetKey: 'hostile:npc-liu',
|
||||
kind: 'hostile',
|
||||
delta: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.nextSequence).toBe(6);
|
||||
});
|
||||
|
||||
it('ignores first render samples and unchanged hp', () => {
|
||||
const result = buildCombatFeedbackEvents(
|
||||
new Map([['player', toSample('player', 20)]]),
|
||||
[
|
||||
toSample('player', 20),
|
||||
toSample('companion:npc-chen', 6),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.nextSequence).toBe(0);
|
||||
});
|
||||
});
|
||||
44
src/components/game-canvas/combatFeedback.ts
Normal file
44
src/components/game-canvas/combatFeedback.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type CombatFeedbackTargetKind = 'player' | 'companion' | 'hostile';
|
||||
|
||||
export interface CombatFeedbackHealthSample {
|
||||
key: string;
|
||||
kind: CombatFeedbackTargetKind;
|
||||
hp: number;
|
||||
}
|
||||
|
||||
export interface CombatFeedbackEvent {
|
||||
id: string;
|
||||
targetKey: string;
|
||||
kind: CombatFeedbackTargetKind;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export function buildCombatFeedbackEvents(
|
||||
previousSamples: Map<string, CombatFeedbackHealthSample>,
|
||||
currentSamples: CombatFeedbackHealthSample[],
|
||||
sequence: number,
|
||||
) {
|
||||
let nextSequence = sequence;
|
||||
const events: CombatFeedbackEvent[] = [];
|
||||
|
||||
currentSamples.forEach(sample => {
|
||||
const previous = previousSamples.get(sample.key);
|
||||
if (!previous) return;
|
||||
|
||||
const delta = sample.hp - previous.hp;
|
||||
if (delta === 0) return;
|
||||
|
||||
nextSequence += 1;
|
||||
events.push({
|
||||
id: `${sample.key}:${nextSequence}`,
|
||||
targetKey: sample.key,
|
||||
kind: sample.kind,
|
||||
delta,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
nextSequence,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user