This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

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

View File

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