Files
Genarrative/src/components/game-canvas/GameCanvasEntityLayer.test.tsx
2026-05-22 03:14:11 +08:00

611 lines
19 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,
getCombatFloatingNumberPresentation,
} from './GameCanvasEntityLayer';
import {
CHARACTER_COMBAT_HP_TOP_PX,
ENTITY_CONTAINER_REM,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMirroredStageEntityLeft,
getMonsterWorldLeft,
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('keeps combat floating numbers readable on dark noisy battle backgrounds', () => {
const damage = getCombatFloatingNumberPresentation(false);
const healing = getCombatFloatingNumberPresentation(true);
expect(damage.toneClass).toContain('bg-rose-950/72');
expect(damage.toneClass).toContain('text-rose-50');
expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29');
expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0');
expect(healing.toneClass).toContain('bg-emerald-950/70');
expect(healing.toneClass).toContain('text-emerald-50');
expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59');
expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0');
});
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('does not apply scene visual ground offset twice for custom character enemies in battle', () => {
const hostileNpc = createHostileNpc({
encounter: createEncounter({
id: 'npc-shark-elder',
npcName: '珊瑚长老',
characterId: 'hero',
imageSrc: '/generated-custom-world-npc/shark-elder.png',
}),
});
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={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[hostileNpc]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('bottom:calc(calc(18% + 68px) + -78px)');
expect(html).not.toContain('bottom:calc(calc(18% + 68px - 78px) + -78px)');
});
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('keeps battle opponent visible when compat payload misses encounter context', () => {
const hostileNpc = createHostileNpc({
encounter: undefined,
name: '断桥匪首',
description: '刚进入战斗时的旧快照目标',
});
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={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[hostileNpc]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('查看断桥匪首详情');
expect(html).toContain('from-rose-500 to-red-400');
});
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('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
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('查看后排乙详情');
});
it('hides opposite scene actors while the player exits for a scene transition', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
]}
currentScenePreset={null}
sceneTransitionToken={1}
isSceneTransitionEntering={false}
isSceneTransitionExiting={true}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="exiting"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.RUN}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[createHostileNpc({ name: '旧场景敌人' })]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).not.toContain('查看旧场景敌人详情');
expect(html).not.toContain('查看主角色详情');
expect(html).not.toContain('查看后排甲详情');
});
it('keeps hostile combatant identity stable while attack position changes', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const attackingNpc = createHostileNpc({
id: 'npc-attacker',
xMeters: 0.1,
animation: 'attack',
combatMode: 'melee',
encounter: createEncounter({
id: 'npc-attacker',
npcName: '突进敌人',
}),
});
const renderedLeft = getMonsterWorldLeft(
sideAnchor,
attackingNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
);
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={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[attackingNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
expect(html).toContain(`left:${renderedLeft}`);
});
it('keeps enemy formation positions when battle starts before any attack dash', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const frontNpc = createHostileNpc({
id: 'npc-front',
xMeters: 3.2,
encounter: createEncounter({
id: 'npc-front',
npcName: '前排敌人',
}),
});
const backNpc = createHostileNpc({
id: 'npc-back',
xMeters: 4.28,
yOffset: 62,
encounter: createEncounter({
id: 'npc-back',
npcName: '后排敌人',
}),
});
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={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[frontNpc, backNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
const frontLeft = `left:${getMonsterWorldLeft(
sideAnchor,
frontNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
const backLeft = `left:${getMonsterWorldLeft(
sideAnchor,
backNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
expect(html).toContain(frontLeft);
expect(html).toContain(backLeft);
});
});