457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
import {motion} from 'motion/react';
|
|
|
|
import {getCharacterById} from '../../data/characterPresets';
|
|
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
|
import {RESOLVED_ENTITY_X_METERS} from '../../data/sceneEncounterPreviews';
|
|
import {
|
|
AnimationState,
|
|
type Character,
|
|
type CompanionRenderState,
|
|
type Encounter,
|
|
type SceneHostileNpc,
|
|
type ScenePresetInfo,
|
|
type WorldType,
|
|
} from '../../types';
|
|
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
|
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
|
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
|
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
|
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
|
import {
|
|
DialogueBubbleIcon,
|
|
type GameCanvasEntitySelection,
|
|
GENERIC_NPC_SCENE_SCALE,
|
|
getCharacterBottomOffsetPx,
|
|
getCharacterOpponentBottom,
|
|
getCompanionSlotOffset,
|
|
getMonsterWorldLeft,
|
|
getNpcCombatHpTop,
|
|
getSceneEntityZIndex,
|
|
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
|
|
HpBar,
|
|
mapHostileNpcAnimationToCharacterState,
|
|
MONSTER_RENDER_OFFSETS,
|
|
ROLE_CHARACTER_FRAME_CLASS,
|
|
RoleCharacterSprite,
|
|
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
|
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
|
|
SceneEncounterNpcSprite,
|
|
SceneEntityButton,
|
|
} from './GameCanvasShared';
|
|
|
|
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
|
|
|
interface GameCanvasEntityLayerProps {
|
|
companions: CompanionRenderState[];
|
|
currentScenePreset: ScenePresetInfo | null;
|
|
sceneTransitionToken: number;
|
|
isSceneTransitionEntering: boolean;
|
|
isSceneTransitionExiting: boolean;
|
|
transitionSweepPx: number;
|
|
sceneTransitionExitDurationS: number;
|
|
sceneTransitionEntryDurationS: number;
|
|
companionAnchorLeft: string;
|
|
companionAnchorBottom: string;
|
|
playerBottomOffsetPx: number;
|
|
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
|
inBattle: boolean;
|
|
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
|
|
playerLeft: string;
|
|
playerCharacter: Character | null;
|
|
playerHp: number;
|
|
playerMaxHp: number;
|
|
effectivePlayerFacing: 'left' | 'right';
|
|
effectivePlayerAnimationState: AnimationState;
|
|
shouldShowPlayerDialogueIcon: boolean;
|
|
dialogueIndicator?: {
|
|
showPlayer: boolean;
|
|
showEncounter: boolean;
|
|
activeSpeaker?: 'player' | 'npc' | null;
|
|
} | null;
|
|
npcAffinityEffect?: {
|
|
eventId: string;
|
|
npcId: string;
|
|
delta: number;
|
|
} | null;
|
|
sceneCombatants: SceneHostileNpc[];
|
|
monsters: MonsterSpriteConfig[];
|
|
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
|
groundBottom: string;
|
|
stageLiftPx: number;
|
|
encounter: Encounter | null;
|
|
sideAnchor: string;
|
|
cameraAnchorX: number;
|
|
monsterAnchorMeters: number;
|
|
playerX: number;
|
|
}
|
|
|
|
export function GameCanvasEntityLayer({
|
|
companions,
|
|
currentScenePreset,
|
|
sceneTransitionToken,
|
|
isSceneTransitionEntering,
|
|
isSceneTransitionExiting,
|
|
transitionSweepPx,
|
|
sceneTransitionExitDurationS,
|
|
sceneTransitionEntryDurationS,
|
|
companionAnchorLeft,
|
|
companionAnchorBottom,
|
|
playerBottomOffsetPx,
|
|
sceneTransitionPhase,
|
|
inBattle,
|
|
onEntitySelect = null,
|
|
playerLeft,
|
|
playerCharacter,
|
|
playerHp,
|
|
playerMaxHp,
|
|
effectivePlayerFacing,
|
|
effectivePlayerAnimationState,
|
|
shouldShowPlayerDialogueIcon,
|
|
dialogueIndicator = null,
|
|
npcAffinityEffect = null,
|
|
sceneCombatants,
|
|
monsters,
|
|
getHostileNpcOuterLeft,
|
|
groundBottom,
|
|
stageLiftPx,
|
|
encounter,
|
|
sideAnchor,
|
|
cameraAnchorX,
|
|
monsterAnchorMeters,
|
|
playerX,
|
|
}: GameCanvasEntityLayerProps) {
|
|
const shouldRenderPeacefulEncounter =
|
|
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
|
|
|
return (
|
|
<>
|
|
{companions.map(companion => {
|
|
const slotOffset = getCompanionSlotOffset(companion.slot);
|
|
return (
|
|
<motion.div
|
|
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
|
|
className="absolute"
|
|
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
|
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
|
transition={{
|
|
duration: isSceneTransitionExiting
|
|
? sceneTransitionExitDurationS
|
|
: isSceneTransitionEntering
|
|
? sceneTransitionEntryDurationS
|
|
: 0.18,
|
|
ease: 'linear',
|
|
delay: isSceneTransitionEntering
|
|
? (companion.slot === 'upper'
|
|
? SCENE_TRANSITION_UPPER_COMPANION_DELAY_S
|
|
: SCENE_TRANSITION_LOWER_COMPANION_DELAY_S)
|
|
: 0,
|
|
}}
|
|
style={{
|
|
left: companionAnchorLeft,
|
|
bottom: companionAnchorBottom,
|
|
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: `${slotOffset.left}px`,
|
|
bottom: `${slotOffset.bottom}px`,
|
|
transform: `translate(${companion.entryOffsetX ?? 0}px, ${companion.entryOffsetY ?? 0}px)`,
|
|
transition: companion.transitionMs
|
|
? `transform ${companion.transitionMs}ms linear`
|
|
: undefined,
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
|
|
ariaLabel={`查看${companion.character.name}详情`}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
{inBattle && (
|
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
|
|
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
|
|
</div>
|
|
)}
|
|
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
|
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
|
|
<RoleCharacterSprite
|
|
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
|
|
character={companion.character}
|
|
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SceneEntityButton>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
|
|
<motion.div
|
|
key={`player-${currentScenePreset?.id ?? 'none'}-${sceneTransitionToken}`}
|
|
className="absolute"
|
|
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
|
|
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
|
|
transition={{
|
|
duration: isSceneTransitionExiting
|
|
? sceneTransitionExitDurationS
|
|
: isSceneTransitionEntering
|
|
? sceneTransitionEntryDurationS
|
|
: 0.18,
|
|
ease: 'linear',
|
|
}}
|
|
style={{
|
|
left: playerLeft,
|
|
bottom: companionAnchorBottom,
|
|
zIndex: getSceneEntityZIndex(playerBottomOffsetPx),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
{inBattle && (
|
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
|
|
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
|
|
</div>
|
|
)}
|
|
<SceneEntityButton
|
|
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
|
|
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
|
|
className="relative block"
|
|
>
|
|
<div className="relative">
|
|
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
|
{playerCharacter && (
|
|
<RoleCharacterSprite
|
|
state={effectivePlayerAnimationState}
|
|
character={playerCharacter}
|
|
facing={effectivePlayerFacing}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{shouldShowPlayerDialogueIcon && (
|
|
<div className="absolute -top-9 right-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator?.activeSpeaker === 'player'}
|
|
flip={effectivePlayerFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</SceneEntityButton>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{sceneCombatants.map(hostileNpc => {
|
|
const npcEncounter = hostileNpc.encounter;
|
|
if (!npcEncounter) return null;
|
|
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;
|
|
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
|
|
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
|
: null;
|
|
const npcSceneSpriteFacing =
|
|
npcCharacter
|
|
? hostileNpc.facing
|
|
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
|
const npcCombatHpTop = getNpcCombatHpTop(
|
|
npcCharacter ? npcEncounter?.characterId : null,
|
|
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
|
);
|
|
const hostileNpcBottomOffsetPx = npcMonsterConfig
|
|
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
|
|
: 0;
|
|
const opponentBottom = npcCharacter
|
|
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
|
|
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
|
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
|
const entityBottomOffsetPx = npcCharacter
|
|
? getCharacterBottomOffsetPx(
|
|
stageLiftPx,
|
|
npcCharacter,
|
|
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
|
)
|
|
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
|
|
|
|
return (
|
|
<div
|
|
key={hostileNpc.id}
|
|
className="absolute"
|
|
style={{
|
|
left: getHostileNpcOuterLeft(hostileNpc),
|
|
bottom: entityBottom,
|
|
zIndex: getSceneEntityZIndex(entityBottomOffsetPx),
|
|
transition: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
|
|
ariaLabel={`查看${hostileNpc.name}详情`}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
{inBattle && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2"
|
|
style={{top: `${npcCombatHpTop}px`}}
|
|
>
|
|
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
|
|
</div>
|
|
)}
|
|
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
|
{npcCharacter ? (
|
|
<RoleCharacterSprite
|
|
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
|
character={npcCharacter}
|
|
facing={npcSceneSpriteFacing}
|
|
/>
|
|
) : npcMonsterConfig ? (
|
|
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
|
<HostileNpcAnimator
|
|
hostileNpc={npcMonsterConfig}
|
|
animation={hostileNpc.animation}
|
|
flip={hostileNpc.facing === 'right'}
|
|
className="scale-[1.82] origin-bottom"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<MedievalNpcAnimator
|
|
encounter={npcEncounter}
|
|
className="origin-bottom drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
|
facing={npcSceneSpriteFacing}
|
|
scale={GENERIC_NPC_SCENE_SCALE}
|
|
/>
|
|
)}
|
|
</div>
|
|
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
|
|
<div className="absolute -top-9 left-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator.activeSpeaker === 'npc'}
|
|
flip={npcSceneSpriteFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
|
|
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
|
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
|
) : null}
|
|
</SceneEntityButton>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{shouldRenderPeacefulEncounter &&
|
|
(() => {
|
|
if (!encounter) {
|
|
return null;
|
|
}
|
|
|
|
const isCampCompanionEncounter =
|
|
encounter.specialBehavior === 'initial_companion'
|
|
|| encounter.specialBehavior === 'camp_companion';
|
|
const peacefulAnchorX = RESOLVED_ENTITY_X_METERS;
|
|
const isPeacefulEncounterMoving =
|
|
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|
|
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
|
|
const towardPeacefulPlayer = getFacingTowardPlayer(peacefulAnchorX, playerX);
|
|
const peacefulResolvedCharacter =
|
|
encounter.kind !== 'treasure' && encounter.characterId
|
|
? getCharacterById(encounter.characterId)
|
|
: null;
|
|
const peacefulMonsterConfig = !peacefulResolvedCharacter &&
|
|
encounter.kind === 'npc' && encounter.monsterPresetId
|
|
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
|
|
: null;
|
|
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
|
|
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
|
|
: 0;
|
|
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
|
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
|
|
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
|
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
|
|
|
return (
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: getMonsterWorldLeft(
|
|
sideAnchor,
|
|
peacefulAnchorX,
|
|
cameraAnchorX,
|
|
monsterAnchorMeters,
|
|
),
|
|
bottom: encounter.characterId
|
|
? getCharacterOpponentBottom(
|
|
groundBottom,
|
|
stageLiftPx,
|
|
getCharacterById(encounter.characterId),
|
|
)
|
|
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
|
|
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
|
|
transition: isCampCompanionEncounter
|
|
? 'bottom 180ms ease'
|
|
: 'left 260ms linear, bottom 180ms ease',
|
|
}}
|
|
>
|
|
<SceneEntityButton
|
|
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
|
|
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
|
|
className="relative flex w-28 flex-col items-center"
|
|
>
|
|
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
|
{encounter.kind === 'treasure' ? (
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
|
|
<ResolvedAssetImage
|
|
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
|
|
alt={encounter.npcName}
|
|
className="h-12 w-12 object-contain"
|
|
style={{imageRendering: 'pixelated'}}
|
|
/>
|
|
</div>
|
|
) : peacefulResolvedCharacter &&
|
|
!encounter.visual &&
|
|
!encounter.imageSrc?.trim() ? (
|
|
<RoleCharacterSprite
|
|
state={AnimationState.IDLE}
|
|
character={peacefulResolvedCharacter}
|
|
facing={peacefulNpcSpriteFacing}
|
|
/>
|
|
) : peacefulMonsterConfig ? (
|
|
<HostileNpcAnimator
|
|
hostileNpc={peacefulMonsterConfig}
|
|
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
|
flip={towardPeacefulPlayer === 'right'}
|
|
className="scale-[1.82] origin-bottom"
|
|
/>
|
|
) : (
|
|
<SceneEncounterNpcSprite
|
|
encounter={encounter}
|
|
state={AnimationState.IDLE}
|
|
facing={peacefulNpcSpriteFacing}
|
|
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
|
/>
|
|
)}
|
|
</div>
|
|
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
|
|
<div className="absolute -top-9 left-1">
|
|
<DialogueBubbleIcon
|
|
active={dialogueIndicator.activeSpeaker === 'npc'}
|
|
flip={peacefulNpcSpriteFacing === 'left'}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
|
|
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
|
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
|
) : null}
|
|
</SceneEntityButton>
|
|
</div>
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|