128
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
128
src/components/game-canvas/GameCanvasEntityLayer.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
kind: 'npc',
|
||||
npcName: '柳无声',
|
||||
npcDescription: '桥口旧识',
|
||||
npcAvatar: '/npc-liu.png',
|
||||
context: '断桥',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
|
||||
return {
|
||||
id: 'npc-liu',
|
||||
name: '柳无声',
|
||||
action: '对峙',
|
||||
description: '桥口旧识',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
encounter: createEncounter(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEntityLayer(effectNpcId: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
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={
|
||||
effectNpcId
|
||||
? {
|
||||
eventId: 'effect-1',
|
||||
npcId: effectNpcId,
|
||||
delta: 3,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sceneCombatants={[createHostileNpc()]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GameCanvasEntityLayer', () => {
|
||||
it('renders affinity effect on the matching hostile npc', () => {
|
||||
const html = renderEntityLayer('npc-liu');
|
||||
|
||||
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
|
||||
expect(html).toContain('aria-label="好感度变化 +3"');
|
||||
});
|
||||
|
||||
it('does not render affinity effect on a different npc', () => {
|
||||
const html = renderEntityLayer('npc-other');
|
||||
|
||||
expect(html).not.toContain('npc-affinity-effect-npc-liu');
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
@@ -66,6 +67,11 @@ interface GameCanvasEntityLayerProps {
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: {
|
||||
eventId: string;
|
||||
npcId: string;
|
||||
delta: number;
|
||||
} | null;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
monsters: MonsterSpriteConfig[];
|
||||
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
||||
@@ -101,6 +107,7 @@ export function GameCanvasEntityLayer({
|
||||
effectivePlayerAnimationState,
|
||||
shouldShowPlayerDialogueIcon,
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
sceneCombatants,
|
||||
monsters,
|
||||
getHostileNpcOuterLeft,
|
||||
@@ -326,6 +333,10 @@ export function GameCanvasEntityLayer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
|
||||
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
@@ -436,6 +447,10 @@ export function GameCanvasEntityLayer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
|
||||
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ export function GameCanvasRuntime({
|
||||
activeCombatEffects = [],
|
||||
companions = [],
|
||||
dialogueIndicator = null,
|
||||
npcAffinityEffect = null,
|
||||
onEntitySelect = null,
|
||||
onSceneNameClick = null,
|
||||
sceneTransitionPhase = 'idle',
|
||||
@@ -192,6 +193,7 @@ export function GameCanvasRuntime({
|
||||
effectivePlayerAnimationState={effectivePlayerAnimationState}
|
||||
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={npcAffinityEffect}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
monsters={monsters}
|
||||
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
ScenePresetInfo,
|
||||
StoryNpcAffinityEffect,
|
||||
StoryEngineMemoryState,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
@@ -54,6 +55,7 @@ export interface GameCanvasProps {
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
||||
onSceneNameClick?: (() => void) | null;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
@@ -68,6 +70,10 @@ export const ENTITY_CONTAINER_REM = 7;
|
||||
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
|
||||
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
|
||||
export const GENERIC_NPC_SCENE_SCALE = 1.72;
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
objectPosition: 'center bottom',
|
||||
};
|
||||
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
|
||||
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
|
||||
|
||||
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
59
src/components/game-canvas/NpcAffinityEffectBadge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type { StoryNpcAffinityEffect } from '../../types';
|
||||
|
||||
interface NpcAffinityEffectBadgeProps {
|
||||
effect: StoryNpcAffinityEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天结算后的好感度浮出特效。
|
||||
* 仅负责表现层,不承担任何数值计算。
|
||||
*/
|
||||
export function NpcAffinityEffectBadge({
|
||||
effect,
|
||||
}: NpcAffinityEffectBadgeProps) {
|
||||
const isPositive = effect.delta > 0;
|
||||
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={effect.eventId}
|
||||
initial={{ opacity: 0, y: 24, scale: 0.8 }}
|
||||
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
|
||||
transition={{ duration: 1.45, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
|
||||
data-testid={`npc-affinity-effect-${effect.npcId}`}
|
||||
aria-label={`好感度变化 ${deltaText}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
|
||||
<div className="relative flex items-center gap-1 text-rose-50">
|
||||
<Heart className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="text-xs font-semibold tracking-[0.08em]">
|
||||
{deltaText}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
|
||||
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
|
||||
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
|
||||
{deltaText}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full border ${
|
||||
isPositive
|
||||
? 'border-rose-200/45 bg-rose-500/18'
|
||||
: 'border-slate-200/35 bg-slate-700/30'
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user