1
This commit is contained in:
@@ -78,6 +78,7 @@ function renderEntityLayer(effectNpcId: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
@@ -185,4 +186,52 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).not.toContain('npc-affinity-effect-npc-liu');
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
|
||||
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[
|
||||
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
|
||||
createEncounter({ id: 'npc-back-2', npcName: '后排乙' }),
|
||||
]}
|
||||
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={false}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '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('查看后排乙详情');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
||||
|
||||
interface GameCanvasEntityLayerProps {
|
||||
companions: CompanionRenderState[];
|
||||
sceneActAmbientEncounters: Encounter[];
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
sceneTransitionToken: number;
|
||||
isSceneTransitionEntering: boolean;
|
||||
@@ -93,6 +94,13 @@ interface GameCanvasEntityLayerProps {
|
||||
playerX: number;
|
||||
}
|
||||
|
||||
const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
|
||||
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;
|
||||
|
||||
function addCssPxOffset(value: string, offsetPx: number) {
|
||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -177,6 +185,7 @@ function CombatReactiveSpriteFrame({
|
||||
|
||||
export function GameCanvasEntityLayer({
|
||||
companions,
|
||||
sceneActAmbientEncounters,
|
||||
currentScenePreset,
|
||||
sceneTransitionToken,
|
||||
isSceneTransitionEntering,
|
||||
@@ -415,9 +424,16 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map(hostileNpc => {
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter;
|
||||
if (!npcEncounter) return null;
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
npcEncounter.id ?? npcEncounter.npcName,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':');
|
||||
const config = monsters.find(item => item.id === hostileNpc.id);
|
||||
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
|
||||
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
|
||||
@@ -453,7 +469,7 @@ export function GameCanvasEntityLayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hostileNpc.id}
|
||||
key={hostileRenderKey}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
@@ -628,6 +644,111 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inBattle &&
|
||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||
if (ambientOffsetPx === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ambientResolvedCharacter =
|
||||
ambientEncounter.kind !== 'treasure' && ambientEncounter.characterId
|
||||
? getCharacterById(ambientEncounter.characterId)
|
||||
: null;
|
||||
const ambientMonsterConfig =
|
||||
!ambientResolvedCharacter &&
|
||||
ambientEncounter.kind === 'npc' &&
|
||||
ambientEncounter.monsterPresetId
|
||||
? monsters.find(item => item.id === ambientEncounter.monsterPresetId) ?? null
|
||||
: null;
|
||||
const ambientHostileBottomOffsetPx = ambientMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(ambientMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(ambientEncounter);
|
||||
const ambientBottomOffsetPx = ambientResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
ambientEncounter,
|
||||
ambientResolvedCharacter,
|
||||
ambientOffsetPx,
|
||||
)
|
||||
: stageLiftPx + ambientHostileBottomOffsetPx + ambientOffsetPx;
|
||||
const ambientFacing = getFacingTowardPlayer(
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
playerX,
|
||||
);
|
||||
const ambientBottom = ambientEncounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
ambientEncounter,
|
||||
getCharacterById(ambientEncounter.characterId),
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
cameraAnchorX,
|
||||
monsterAnchorMeters,
|
||||
),
|
||||
bottom: addCssPxOffset(ambientBottom, ambientOffsetPx),
|
||||
zIndex: getSceneEntityZIndex(ambientBottomOffsetPx),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={
|
||||
ambientEncounter.kind === 'npc'
|
||||
? () => onEntitySelect?.({kind: 'npc', encounter: ambientEncounter})
|
||||
: null
|
||||
}
|
||||
ariaLabel={
|
||||
ambientEncounter.kind === 'npc'
|
||||
? `查看${ambientEncounter.npcName}详情`
|
||||
: undefined
|
||||
}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{ambientResolvedCharacter &&
|
||||
!ambientEncounter.visual &&
|
||||
!ambientEncounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
character={ambientResolvedCharacter}
|
||||
facing={ambientFacing}
|
||||
/>
|
||||
) : ambientMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={ambientMonsterConfig}
|
||||
animation="idle"
|
||||
flip={ambientFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={ambientEncounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={ambientFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 幕后排角色只是同幕可见实体,不抢占当前交互目标。 */}
|
||||
{npcAffinityEffect?.npcId ===
|
||||
(ambientEncounter.id ?? ambientEncounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
|
||||
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
||||
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
|
||||
import {AnimationState, WorldType} from '../../types';
|
||||
import {buildEncounterFromSceneNpc} from '../../data/scenePresets';
|
||||
import {
|
||||
resolveActiveSceneActBackgroundImage,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import {AnimationState, type Encounter, type SceneNpc, WorldType} from '../../types';
|
||||
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
||||
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
|
||||
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
|
||||
@@ -66,6 +70,42 @@ export function GameCanvasRuntime({
|
||||
const backgroundSrc = activeSceneActBackground
|
||||
|| currentScenePreset?.imageSrc
|
||||
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
||||
const activeSceneActEncounterNpcIds =
|
||||
currentScenePreset?.id
|
||||
? resolveActiveSceneActEncounterNpcIds({
|
||||
profile: customWorldProfile,
|
||||
sceneId: currentScenePreset.id,
|
||||
storyEngineMemory,
|
||||
})
|
||||
: [];
|
||||
const activeSceneActNpcIdSet = new Set(activeSceneActEncounterNpcIds);
|
||||
const sceneActAmbientEncounters = (currentScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => {
|
||||
if (activeSceneActNpcIdSet.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateIds = [npc.id, npc.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
const encounterIds = [encounter?.id, encounter?.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
|
||||
return (
|
||||
candidateIds.some((id) => activeSceneActNpcIdSet.has(id)) &&
|
||||
!candidateIds.some((id) => encounterIds.includes(id))
|
||||
);
|
||||
})
|
||||
.slice(0, 2)
|
||||
.map((npc: SceneNpc, index): Encounter => {
|
||||
const npcEncounter = buildEncounterFromSceneNpc(npc);
|
||||
return {
|
||||
...npcEncounter,
|
||||
xMeters: 3.2 + 1.08,
|
||||
id: npcEncounter.id ?? `${npc.id}:ambient-${index}`,
|
||||
};
|
||||
});
|
||||
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
||||
const groundBottom = '18%';
|
||||
const stageLiftPx = 68;
|
||||
@@ -181,6 +221,7 @@ export function GameCanvasRuntime({
|
||||
/>
|
||||
<GameCanvasEntityLayer
|
||||
companions={companions}
|
||||
sceneActAmbientEncounters={sceneActAmbientEncounters}
|
||||
currentScenePreset={currentScenePreset}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
isSceneTransitionEntering={isSceneTransitionEntering}
|
||||
|
||||
Reference in New Issue
Block a user