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 { return { id: 'npc-liu', kind: 'npc', npcName: '柳无声', npcDescription: '桥口旧识', npcAvatar: '/npc-liu.png', context: '断桥', ...overrides, }; } function createHostileNpc(overrides: Partial = {}): 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( '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( '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( '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( '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( '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( '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( 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( 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); }); });