1
This commit is contained in:
@@ -14,8 +14,8 @@ import {
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
MONSTER_COMBAT_HP_TOP_PX,
|
||||
@@ -387,6 +387,53 @@ describe('GameCanvasEntityLayer', () => {
|
||||
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;
|
||||
|
||||
@@ -114,6 +114,22 @@ function addCssPxOffset(value: string, offsetPx: number) {
|
||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||
}
|
||||
|
||||
function getSceneTransitionMotionConfig(
|
||||
isEntering: boolean,
|
||||
isExiting: boolean,
|
||||
transitionSweepPx: number,
|
||||
durationS: number,
|
||||
) {
|
||||
return {
|
||||
initial: isEntering ? {x: -transitionSweepPx} : false,
|
||||
animate: {x: isExiting ? transitionSweepPx : 0},
|
||||
transition: {
|
||||
duration: isExiting ? durationS : isEntering ? durationS : 0.18,
|
||||
ease: 'linear' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -451,7 +467,9 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
{sceneTransitionPhase === 'exiting'
|
||||
? null
|
||||
: sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
@@ -465,9 +483,15 @@ export function GameCanvasEntityLayer({
|
||||
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
||||
: null;
|
||||
const npcSceneSpriteFacing =
|
||||
npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
isSceneTransitionEntering
|
||||
? 'right'
|
||||
: npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
const hostileNpcAnimation =
|
||||
isSceneTransitionEntering
|
||||
? ('move' as const)
|
||||
: hostileNpc.animation;
|
||||
const npcCombatHpTop = getNpcCombatHpTop(
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
@@ -498,10 +522,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={hostileRenderKey}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
bottom: entityBottom,
|
||||
@@ -526,7 +560,11 @@ export function GameCanvasEntityLayer({
|
||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
||||
{npcCharacter ? (
|
||||
<RoleCharacterSprite
|
||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
||||
state={
|
||||
isSceneTransitionEntering
|
||||
? AnimationState.RUN
|
||||
: hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)
|
||||
}
|
||||
character={npcCharacter}
|
||||
facing={npcSceneSpriteFacing}
|
||||
/>
|
||||
@@ -534,8 +572,8 @@ export function GameCanvasEntityLayer({
|
||||
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={npcMonsterConfig}
|
||||
animation={hostileNpc.animation}
|
||||
flip={hostileNpc.facing === 'right'}
|
||||
animation={hostileNpcAnimation}
|
||||
flip={npcSceneSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
</div>
|
||||
@@ -561,11 +599,11 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{shouldRenderPeacefulEncounter &&
|
||||
{sceneTransitionPhase !== 'exiting' && shouldRenderPeacefulEncounter &&
|
||||
(() => {
|
||||
if (!encounter) {
|
||||
return null;
|
||||
@@ -594,11 +632,23 @@ export function GameCanvasEntityLayer({
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
const peacefulNpcSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: towardPeacefulPlayer;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -639,7 +689,7 @@ export function GameCanvasEntityLayer({
|
||||
!encounter.visual &&
|
||||
!encounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={peacefulResolvedCharacter}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
/>
|
||||
@@ -647,13 +697,13 @@ export function GameCanvasEntityLayer({
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={peacefulMonsterConfig}
|
||||
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
||||
flip={towardPeacefulPlayer === 'right'}
|
||||
flip={peacefulNpcSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={encounter}
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
@@ -672,11 +722,12 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inBattle &&
|
||||
sceneTransitionPhase !== 'exiting' &&
|
||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||
if (ambientOffsetPx === undefined) {
|
||||
@@ -708,6 +759,9 @@ export function GameCanvasEntityLayer({
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
playerX,
|
||||
);
|
||||
const ambientSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: ambientFacing;
|
||||
const ambientBottom = ambientEncounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
@@ -717,10 +771,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -751,22 +815,22 @@ export function GameCanvasEntityLayer({
|
||||
!ambientEncounter.visual &&
|
||||
!ambientEncounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={ambientResolvedCharacter}
|
||||
facing={ambientFacing}
|
||||
facing={ambientSpriteFacing}
|
||||
/>
|
||||
) : ambientMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={ambientMonsterConfig}
|
||||
animation="idle"
|
||||
flip={ambientFacing === 'right'}
|
||||
flip={ambientSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={ambientEncounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={ambientFacing}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={ambientSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||
/>
|
||||
)}
|
||||
@@ -777,7 +841,7 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user