This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -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('查看后排乙详情');
});
});

View File

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

View File

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