This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
expect(html).not.toContain('好感度变化 +3');
});
it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[
createHostileNpc({
hp: 4,
maxHp: 10,
animation: 'die',
}),
]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('from-rose-500 to-red-400');
});
it('renders scene act back-row encounters alongside the primary encounter', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer

View File

@@ -27,6 +27,7 @@ import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getBattleCompanionSlotOffset,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
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 (!inBattle) return [];
if (!shouldRenderCombatPresentation) return [];
return [
{key: 'player', kind: 'player', hp: playerHp},
@@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({
})),
];
},
[companions, inBattle, playerHp, sceneCombatants],
[companions, playerHp, sceneCombatants, shouldRenderCombatPresentation],
);
const combatFeedbackByTarget = useMemo(() => {
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
};
useEffect(() => {
if (!inBattle) {
if (!shouldRenderCombatPresentation) {
previousCombatSamplesRef.current = null;
setCombatFeedbackEvents([]);
return;
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
previousCombatSamplesRef.current = new Map(
combatHealthSamples.map(sample => [sample.key, sample]),
);
}, [combatHealthSamples, inBattle]);
}, [combatHealthSamples, shouldRenderCombatPresentation]);
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
const slotOffset = inBattle
? getBattleCompanionSlotOffset(companion.slot)
: getCompanionSlotOffset(companion.slot);
const feedbackTargetKey = `companion:${companion.npcId}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const companionFacing = companion.facing ?? 'right';
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
style={{
left: companionAnchorLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
events={combatFeedbackByTarget.get('player') ?? []}
onDone={removeCombatFeedbackEvent}
/>
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${npcCombatHpTop}px`}}

View File

@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
: {left: -34, bottom: 10};
}
export function getBattleCompanionSlotOffset(slot: CompanionRenderState['slot']) {
return slot === 'upper'
? {left: -118, bottom: 86}
: {left: -92, bottom: 26};
}
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
if (animation === 'move') return AnimationState.RUN;
if (animation === 'attack') return AnimationState.ATTACK;