238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
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';
|
|
import {
|
|
CHARACTER_COMBAT_HP_TOP_PX,
|
|
ENTITY_CONTAINER_REM,
|
|
getEncounterCharacterBottomOffsetPx,
|
|
getEncounterCharacterOpponentBottom,
|
|
getHostileNpcSceneBottomOffsetPx,
|
|
getMirroredStageEntityLeft,
|
|
getNpcCombatHpTop,
|
|
getSceneNpcVisualBottomOffsetPx,
|
|
MONSTER_COMBAT_HP_TOP_PX,
|
|
} from './GameCanvasShared';
|
|
|
|
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={[]}
|
|
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={
|
|
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('uses mirrored stage anchors for player and opponent containers', () => {
|
|
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
|
|
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
|
|
});
|
|
|
|
it('lowers large monster sprites to the shared scene ground line', () => {
|
|
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 62})).toBe(-78);
|
|
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 46})).toBe(-68);
|
|
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 37})).toBe(-52);
|
|
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
|
|
});
|
|
|
|
it('uses scene npc visual anchors instead of template character foot offsets', () => {
|
|
const sceneNpcEncounter = createEncounter({
|
|
characterId: 'hero',
|
|
monsterPresetId: 'monster-20',
|
|
imageSrc: '/generated-custom-world-npc/shark.png',
|
|
});
|
|
const character = createCharacter();
|
|
|
|
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
|
|
.toBe('calc(18% + 68px - 78px)');
|
|
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
|
|
.toBe(-10);
|
|
});
|
|
|
|
it('lowers scene npc custom visuals even without character ids', () => {
|
|
const sceneNpcEncounter = createEncounter({
|
|
visual: {
|
|
race: 'elf',
|
|
bodyColor: 'blue',
|
|
headIndex: 0,
|
|
hairColorIndex: 1,
|
|
hairStyleFrame: 2,
|
|
facialHairEnabled: false,
|
|
facialHairColorIndex: 0,
|
|
facialHairStyleFrame: 0,
|
|
},
|
|
});
|
|
|
|
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
|
|
});
|
|
|
|
it('keeps combat hp bars above character and monster silhouettes', () => {
|
|
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
|
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
|
|
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
|
const html = renderToStaticMarkup(
|
|
<GameCanvasEntityLayer
|
|
companions={[]}
|
|
sceneActAmbientEncounters={[
|
|
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
|
|
createEncounter({ id: 'npc-back-2', npcName: '后排乙' }),
|
|
]}
|
|
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={[]}
|
|
monsters={[]}
|
|
getHostileNpcOuterLeft={() => '70%'}
|
|
groundBottom="18%"
|
|
stageLiftPx={68}
|
|
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
|
|
sideAnchor="15%"
|
|
cameraAnchorX={0}
|
|
monsterAnchorMeters={3.2}
|
|
playerX={0}
|
|
/>,
|
|
);
|
|
|
|
expect(html).toContain('查看主角色详情');
|
|
expect(html).toContain('查看后排甲详情');
|
|
expect(html).toContain('查看后排乙详情');
|
|
});
|
|
});
|