1
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getMirroredStageEntityLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
@@ -173,6 +174,59 @@ describe('GameCanvasEntityLayer', () => {
|
||||
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');
|
||||
|
||||
@@ -283,4 +337,160 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).toContain('查看后排甲详情');
|
||||
expect(html).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,8 +445,6 @@ export function GameCanvasEntityLayer({
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
npcEncounter.id ?? npcEncounter.npcName,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':');
|
||||
const config = monsters.find(item => item.id === hostileNpc.id);
|
||||
@@ -469,18 +467,25 @@ export function GameCanvasEntityLayer({
|
||||
npcMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
|
||||
// 中文注释:带 characterId 的自定义世界角色在 getEncounterCharacterOpponentBottom()
|
||||
// 里已经按场景立绘脚底锚点完成了一次落地修正。
|
||||
// 若这里再把 getSceneNpcVisualBottomOffsetPx() 叠加到战斗实体底边,
|
||||
// 就会在刚进入战斗时整队额外下沉 78px,表现成敌方瞬间偏到右下角。
|
||||
const battleEntityVisualOffsetPx = npcCharacter
|
||||
? 0
|
||||
: hostileNpcBottomOffsetPx;
|
||||
const opponentBottom = npcCharacter
|
||||
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
|
||||
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx}px)`;
|
||||
const entityBottomOffsetPx = npcCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
npcEncounter,
|
||||
npcCharacter,
|
||||
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
||||
(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx,
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getPlayerWorldLeft,
|
||||
HOSTILE_NPC_SCENE_INSET_PX,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_SPEED_PX_PER_S,
|
||||
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
|
||||
@@ -119,7 +118,6 @@ export function GameCanvasRuntime({
|
||||
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
|
||||
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
|
||||
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
|
||||
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
|
||||
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
|
||||
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
|
||||
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
|
||||
@@ -132,15 +130,16 @@ export function GameCanvasRuntime({
|
||||
: playerStageLeft;
|
||||
const monsterAnchorMeters = 3.2;
|
||||
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
|
||||
if (!scrollWorld && hostileNpc.animation !== 'attack') {
|
||||
return opponentStageLeft;
|
||||
if (hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld) {
|
||||
return monsterMeleeLeft;
|
||||
}
|
||||
|
||||
const baseLeft =
|
||||
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
||||
? monsterMeleeLeft
|
||||
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
||||
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
|
||||
return getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
hostileNpc.xMeters,
|
||||
cameraAnchorX,
|
||||
monsterAnchorMeters,
|
||||
);
|
||||
};
|
||||
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
|
||||
const base = playerActionMode === 'melee' && !scrollWorld
|
||||
|
||||
Reference in New Issue
Block a user