This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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