统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
This commit is contained in:
+15
-15
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -66,14 +72,12 @@ vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleCustomWorldSelect(
|
||||
{
|
||||
id: 'profile-1',
|
||||
name: '潮雾列岛',
|
||||
} as Parameters<
|
||||
PlatformEntryFlowShellProps['handleCustomWorldSelect']
|
||||
>[0],
|
||||
)
|
||||
handleCustomWorldSelect({
|
||||
id: 'profile-1',
|
||||
name: '潮雾列岛',
|
||||
} as Parameters<
|
||||
PlatformEntryFlowShellProps['handleCustomWorldSelect']
|
||||
>[0])
|
||||
}
|
||||
>
|
||||
进入 RPG
|
||||
@@ -223,13 +227,9 @@ describe('App title sync', () => {
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||
'disabled',
|
||||
);
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe('disabled');
|
||||
await screen.findByText('enabled');
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||
'enabled',
|
||||
);
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe('enabled');
|
||||
});
|
||||
|
||||
test('原生壳直达二级页面时补齐 H5 返回锚点', async () => {
|
||||
|
||||
@@ -42,17 +42,25 @@ export default function Match3DPlaygroundApp() {
|
||||
setRun(nextRun);
|
||||
}, []);
|
||||
|
||||
const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => {
|
||||
const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId;
|
||||
const result = await runtimeSessionRef.current.adapter.clickItem(runId, payload);
|
||||
setRun(result.run);
|
||||
return result;
|
||||
}, []);
|
||||
const handleClickItem = useCallback(
|
||||
async (payload: Match3DClickItemRequest) => {
|
||||
const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId;
|
||||
const result = await runtimeSessionRef.current.adapter.clickItem(
|
||||
runId,
|
||||
payload,
|
||||
);
|
||||
setRun(result.run);
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
void runtimeSessionRef.current.adapter.restartRun(run.runId).then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
void runtimeSessionRef.current.adapter
|
||||
.restartRun(run.runId)
|
||||
.then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
}, [run.runId]);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
@@ -60,9 +68,11 @@ export default function Match3DPlaygroundApp() {
|
||||
}, []);
|
||||
|
||||
const handleTimeExpired = useCallback(() => {
|
||||
void runtimeSessionRef.current.adapter.finishTimeUp(run.runId).then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
void runtimeSessionRef.current.adapter
|
||||
.finishTimeUp(run.runId)
|
||||
.then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
}, [run.runId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,10 @@ export function RpgRuntimeApp({
|
||||
const handledIntentTokenRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialIntent || handledIntentTokenRef.current === initialIntent.token) {
|
||||
if (
|
||||
!initialIntent ||
|
||||
handledIntentTokenRef.current === initialIntent.token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,9 @@ describe('CharacterAnimator portrait fallbacks', () => {
|
||||
render(
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={buildCharacter({generatedVisualAssetId: 'assetobj-role-main'})}
|
||||
character={buildCharacter({
|
||||
generatedVisualAssetId: 'assetobj-role-main',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
}) => {
|
||||
const explicitConfig = character.animationMap?.[state];
|
||||
const hasGeneratedPortraitOnly =
|
||||
Boolean(character.generatedVisualAssetId && character.portrait?.trim())
|
||||
&& !explicitConfig;
|
||||
Boolean(character.generatedVisualAssetId && character.portrait?.trim()) &&
|
||||
!explicitConfig;
|
||||
const usePortraitIdleFallback =
|
||||
!explicitConfig && state === AnimationState.IDLE;
|
||||
const usePortraitDeathFallback =
|
||||
@@ -121,7 +121,10 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
character.animationMap?.[AnimationState.IDLE] ??
|
||||
DEFAULT_ANIMATIONS[AnimationState.IDLE];
|
||||
const fallbackToPortrait =
|
||||
hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
|
||||
hasGeneratedPortraitOnly ||
|
||||
usePortraitIdleFallback ||
|
||||
usePortraitDeathFallback ||
|
||||
hasRenderError;
|
||||
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
|
||||
const startFrame =
|
||||
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
|
||||
|
||||
@@ -32,7 +32,8 @@ export function CharacterChatModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!modal || !scrollContainerRef.current) return;
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
scrollContainerRef.current.scrollTop =
|
||||
scrollContainerRef.current.scrollHeight;
|
||||
}, [modal]);
|
||||
|
||||
return (
|
||||
@@ -52,12 +53,16 @@ export function CharacterChatModal({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">角色聊天</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{modal.target.character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
|
||||
角色聊天
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{modal.target.character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{modal.target.character.title} / {modal.target.roleLabel}
|
||||
</div>
|
||||
@@ -71,8 +76,13 @@ export function CharacterChatModal({
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
|
||||
<div className="space-y-4 sm:max-h-full sm:overflow-y-auto sm:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-2 text-xs font-bold text-white">角色状态</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-2 text-xs font-bold text-white">
|
||||
角色状态
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
@@ -102,8 +112,13 @@ export function CharacterChatModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-2 text-xs font-bold text-white">聊天总结</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-2 text-xs font-bold text-white">
|
||||
聊天总结
|
||||
</div>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="dark"
|
||||
@@ -136,9 +151,14 @@ export function CharacterChatModal({
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
|
||||
{message.speaker === 'player' ? '你' : modal.target.character.name}
|
||||
{message.speaker === 'player'
|
||||
? '你'
|
||||
: modal.target.character.name}
|
||||
</div>
|
||||
{message.text || (modal.isSending && message.speaker === 'character' ? '正在回复...' : '...')}
|
||||
{message.text ||
|
||||
(modal.isSending && message.speaker === 'character'
|
||||
? '正在回复...'
|
||||
: '...')}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -198,7 +218,7 @@ export function CharacterChatModal({
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={event => {
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSendDraft();
|
||||
}}
|
||||
@@ -206,7 +226,7 @@ export function CharacterChatModal({
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
value={modal.draft}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
placeholder={`对${modal.target.character.name}说点什么...`}
|
||||
disabled={modal.isSending}
|
||||
rows={4}
|
||||
@@ -221,9 +241,14 @@ export function CharacterChatModal({
|
||||
type="submit"
|
||||
disabled={modal.isSending || !modal.draft.trim()}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
modal.isSending || !modal.draft.trim() ? 'text-zinc-600' : 'text-white'
|
||||
modal.isSending || !modal.draft.trim()
|
||||
? 'text-zinc-600'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{modal.isSending ? '对话生成中...' : '发送'}
|
||||
</button>
|
||||
|
||||
@@ -52,7 +52,8 @@ export function buildCharacterSkillRenderId(
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
const fallbackSeed = skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
|
||||
const fallbackSeed =
|
||||
skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
|
||||
return `skill-${fallbackSeed}-${index}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ test('营地编组战斗中提示复用暗色 PlatformStatusMessage chrome', ()
|
||||
const activeEmptyState = screen.getByText('当前没有已出战的同行者。');
|
||||
const reserveEmptyState = screen.getByText('当前还没有后备同行者。');
|
||||
const activeCountBadge = screen.getByText(/^出战 0\//);
|
||||
const campFooter = screen.getByText('营地气氛').closest(
|
||||
'.platform-dark-modal-footer',
|
||||
);
|
||||
const campFooter = screen
|
||||
.getByText('营地气氛')
|
||||
.closest('.platform-dark-modal-footer');
|
||||
|
||||
expect(currentSection?.className).toContain('bg-black/25');
|
||||
expect(reserveSection?.className).toContain('bg-black/25');
|
||||
|
||||
@@ -54,26 +54,36 @@ function buildCampMoments(
|
||||
|
||||
const moments: string[] = [];
|
||||
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
|
||||
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
|
||||
moments.push(
|
||||
`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (activeCompanions.length >= 2) {
|
||||
const firstCompanion = activeCompanions[0];
|
||||
const secondCompanion = activeCompanions[1];
|
||||
if (firstCompanion && secondCompanion) {
|
||||
moments.push(`${firstCompanion.character.name}和${secondCompanion.character.name}正低声商量下一段路怎么走。`);
|
||||
moments.push(
|
||||
`${firstCompanion.character.name}和${secondCompanion.character.name}正低声商量下一段路怎么走。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
|
||||
const trustedCompanion = activeCompanions.find(
|
||||
(item) => item.companion.joinedAtAffinity >= 70,
|
||||
);
|
||||
if (trustedCompanion) {
|
||||
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
|
||||
moments.push(
|
||||
`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (reserveCompanions.length > 0) {
|
||||
const reserveCompanion = reserveCompanions[0];
|
||||
if (reserveCompanion) {
|
||||
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
|
||||
moments.push(
|
||||
`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,30 +104,39 @@ export function CompanionCampModal({
|
||||
onBenchCompanion,
|
||||
onActivateCompanion,
|
||||
}: CompanionCampModalProps) {
|
||||
const [selectedSwapNpcId, setSelectedSwapNpcId] = useState<string | null>(null);
|
||||
const [selectedSwapNpcId, setSelectedSwapNpcId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const activeCompanionCards = useMemo<CompanionCardData[]>(
|
||||
() => companions
|
||||
.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
() =>
|
||||
companions
|
||||
.map((companion) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
[companions],
|
||||
);
|
||||
|
||||
const reserveCompanionCards = useMemo<CompanionCardData[]>(
|
||||
() => roster
|
||||
.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
() =>
|
||||
roster
|
||||
.map((companion) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
return character ? { companion, character } : null;
|
||||
})
|
||||
.filter(Boolean) as CompanionCardData[],
|
||||
[roster],
|
||||
);
|
||||
|
||||
const campMoments = useMemo(
|
||||
() => buildCampMoments(playerCharacter, activeCompanionCards, reserveCompanionCards),
|
||||
() =>
|
||||
buildCampMoments(
|
||||
playerCharacter,
|
||||
activeCompanionCards,
|
||||
reserveCompanionCards,
|
||||
),
|
||||
[activeCompanionCards, playerCharacter, reserveCompanionCards],
|
||||
);
|
||||
|
||||
@@ -147,13 +166,15 @@ export function CompanionCampModal({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-sm font-semibold text-white">营地编组</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
{playerCharacter
|
||||
? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}`
|
||||
: '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
|
||||
@@ -174,7 +195,10 @@ export function CompanionCampModal({
|
||||
可直接把同行者转入后备,或先选定替换位,再让后备成员归队。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
<StatusPill
|
||||
label="出战"
|
||||
value={`${companions.length}/${MAX_COMPANIONS}`}
|
||||
/>
|
||||
</div>
|
||||
{inBattle && (
|
||||
<PlatformStatusMessage
|
||||
@@ -188,63 +212,83 @@ export function CompanionCampModal({
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
|
||||
const selectedForSwap = selectedSwapNpcId === companion.npcId;
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
data-testid={`active-companion-card-${companion.npcId}`}
|
||||
surface={selectedForSwap ? 'darkSky' : 'dark'}
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{ style: { imageRendering: 'pixelated' } }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
{activeCompanionCards.length > 0 ? (
|
||||
activeCompanionCards.map(({ companion, character }) => {
|
||||
const selectedForSwap =
|
||||
selectedSwapNpcId === companion.npcId;
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
data-testid={`active-companion-card-${companion.npcId}`}
|
||||
surface={selectedForSwap ? 'darkSky' : 'dark'}
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{
|
||||
style: { imageRendering: 'pixelated' },
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
{character.title}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill
|
||||
label="生命"
|
||||
value={`${companion.hp}/${companion.maxHp}`}
|
||||
/>
|
||||
<StatusPill
|
||||
label="灵力"
|
||||
value={`${companion.mana}/${companion.maxMana}`}
|
||||
/>
|
||||
<StatusPill
|
||||
label="好感"
|
||||
value={`${companion.joinedAtAffinity}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<PlatformDarkOptionCard
|
||||
disabled={inBattle}
|
||||
onClick={() => setSelectedSwapNpcId(companion.npcId)}
|
||||
selected={selectedForSwap}
|
||||
tone="sky"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
设为替换位
|
||||
</PlatformDarkOptionCard>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
disabled={inBattle}
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
>
|
||||
转入后备
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}) : (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<PlatformDarkOptionCard
|
||||
disabled={inBattle}
|
||||
onClick={() =>
|
||||
setSelectedSwapNpcId(companion.npcId)
|
||||
}
|
||||
selected={selectedForSwap}
|
||||
tone="sky"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
设为替换位
|
||||
</PlatformDarkOptionCard>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
disabled={inBattle}
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
>
|
||||
转入后备
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="inline"
|
||||
@@ -270,56 +314,87 @@ export function CompanionCampModal({
|
||||
后备同行者会在营地待命,随时可以重新召回。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
|
||||
<StatusPill
|
||||
label="后备"
|
||||
value={`${reserveCompanionCards.length}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
|
||||
const needsSwap = companions.length >= MAX_COMPANIONS;
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
data-testid={`reserve-companion-card-${companion.npcId}`}
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{ style: { imageRendering: 'pixelated' } }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
{reserveCompanionCards.length > 0 ? (
|
||||
reserveCompanionCards.map(({ companion, character }) => {
|
||||
const needsSwap = companions.length >= MAX_COMPANIONS;
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={companion.npcId}
|
||||
data-testid={`reserve-companion-card-${companion.npcId}`}
|
||||
surface="dark"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformMediaFrame
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
fallbackLabel={character.name}
|
||||
aspect="square"
|
||||
surface="editorDark"
|
||||
className="h-16 w-16 shrink-0 rounded-xl"
|
||||
imageClassName="h-full w-full scale-125 object-contain"
|
||||
imageProps={{
|
||||
style: { imageRendering: 'pixelated' },
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
{character.title}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill
|
||||
label="生命"
|
||||
value={`${companion.hp}/${companion.maxHp}`}
|
||||
/>
|
||||
<StatusPill
|
||||
label="灵力"
|
||||
value={`${companion.mana}/${companion.maxMana}`}
|
||||
/>
|
||||
<StatusPill
|
||||
label="好感"
|
||||
value={`${companion.joinedAtAffinity}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={inBattle || (needsSwap && !selectedSwapNpcId) ? 'ghost' : 'success'}
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
|
||||
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
|
||||
className="mt-3"
|
||||
>
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}) : (
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
tone={
|
||||
inBattle || (needsSwap && !selectedSwapNpcId)
|
||||
? 'ghost'
|
||||
: 'success'
|
||||
}
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={
|
||||
inBattle || (needsSwap && !selectedSwapNpcId)
|
||||
}
|
||||
onClick={() =>
|
||||
onActivateCompanion(
|
||||
companion.npcId,
|
||||
needsSwap ? selectedSwapNpcId : null,
|
||||
)
|
||||
}
|
||||
className="mt-3"
|
||||
>
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PlatformEmptyState
|
||||
surface="editorDark"
|
||||
size="inline"
|
||||
|
||||
@@ -33,7 +33,9 @@ export function CustomWorldCoverArtwork({
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className={`custom-world-cover-artwork relative overflow-hidden ${className}`}>
|
||||
<div
|
||||
className={`custom-world-cover-artwork relative overflow-hidden ${className}`}
|
||||
>
|
||||
{imageSrc || fallbackImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
|
||||
@@ -2358,9 +2358,7 @@ test('作品封面上传在原生壳内优先走 HostBridge 图片导入', async
|
||||
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('img', { name: '上传封面裁剪预览' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('img', { name: '上传封面裁剪预览' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('作品封面取消原生导入时不触发浏览器文件输入', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import {lazy, Suspense} from 'react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type {GameCanvasProps} from './game-canvas/GameCanvasShared';
|
||||
import type { GameCanvasProps } from './game-canvas/GameCanvasShared';
|
||||
|
||||
export type {
|
||||
GameCanvasEntitySelection,
|
||||
@@ -37,7 +37,13 @@ function GameCanvasLoadingFallback({
|
||||
|
||||
export function GameCanvas(props: GameCanvasProps) {
|
||||
return (
|
||||
<Suspense fallback={<GameCanvasLoadingFallback sceneName={props.currentScenePreset?.name ?? null} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<GameCanvasLoadingFallback
|
||||
sceneName={props.currentScenePreset?.name ?? null}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GameCanvasRuntime {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -80,7 +80,11 @@ export function GenerationHeaderBackButton({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={disabled && disabledOpacity != null ? { opacity: disabledOpacity } : undefined}
|
||||
style={
|
||||
disabled && disabledOpacity != null
|
||||
? { opacity: disabledOpacity }
|
||||
: undefined
|
||||
}
|
||||
icon={<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />}
|
||||
>
|
||||
<span className="break-keep">{label}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface HostileNpcAnimationConfig {
|
||||
start: number;
|
||||
@@ -40,7 +40,10 @@ export const HostileNpcAnimator: React.FC<HostileNpcAnimatorProps> = ({
|
||||
(animation === 'die' ? hostileNpc.animations.attack : undefined) ??
|
||||
(animation === 'move' ? hostileNpc.animations.attack : undefined) ??
|
||||
hostileNpc.animations.idle;
|
||||
const columns = Math.max(1, Math.floor(hostileNpc.sheetWidth / hostileNpc.frameWidth));
|
||||
const columns = Math.max(
|
||||
1,
|
||||
Math.floor(hostileNpc.sheetWidth / hostileNpc.frameWidth),
|
||||
);
|
||||
const shouldLoop = animation !== 'die' || !hostileNpc.animations.die;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,14 +53,17 @@ export const HostileNpcAnimator: React.FC<HostileNpcAnimatorProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setFrameOffset(prev => {
|
||||
if (!shouldLoop) {
|
||||
return Math.min(prev + 1, anim.frames - 1);
|
||||
}
|
||||
return (prev + 1) % anim.frames;
|
||||
});
|
||||
}, 1000 / (anim.fps ?? 12));
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
setFrameOffset((prev) => {
|
||||
if (!shouldLoop) {
|
||||
return Math.min(prev + 1, anim.frames - 1);
|
||||
}
|
||||
return (prev + 1) % anim.frames;
|
||||
});
|
||||
},
|
||||
1000 / (anim.fps ?? 12),
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [anim, shouldLoop]);
|
||||
|
||||
@@ -96,7 +96,9 @@ test('背包工坊材料需求状态复用暗色平台胶囊标签', () => {
|
||||
expect(forgePanel?.className).toContain('bg-black/25');
|
||||
expect(recipePanel?.className).toContain('border-white/10');
|
||||
expect(recipePanel?.className).toContain('bg-black/25');
|
||||
expect(forgeButton.className).toContain('platform-action-button--editor-dark');
|
||||
expect(forgeButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(forgeButton.className).toContain('rounded-lg');
|
||||
expect(forgeButton.className).toContain('bg-emerald-400');
|
||||
expect(forgeButton.className).toContain('disabled:bg-black/20');
|
||||
|
||||
+123
-41
@@ -34,7 +34,9 @@ function getMapDestinationStackHeight(count: number) {
|
||||
|
||||
function getMapDestinationCenterPercent(index: number, count: number) {
|
||||
const totalHeight = getMapDestinationStackHeight(count);
|
||||
const centerY = index * (MAP_NODE_MIN_HEIGHT_PX + MAP_NODE_GAP_PX) + MAP_NODE_MIN_HEIGHT_PX / 2;
|
||||
const centerY =
|
||||
index * (MAP_NODE_MIN_HEIGHT_PX + MAP_NODE_GAP_PX) +
|
||||
MAP_NODE_MIN_HEIGHT_PX / 2;
|
||||
return (centerY / totalHeight) * 100;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,9 @@ function MudMapRoom({
|
||||
>
|
||||
{label}
|
||||
</PlatformPillBadge>
|
||||
<div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
|
||||
<div
|
||||
className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}
|
||||
>
|
||||
{scene.name}
|
||||
</div>
|
||||
{!compact && description ? (
|
||||
@@ -95,7 +99,11 @@ function MudMapRoom({
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="block h-full w-full text-left"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
@@ -130,7 +138,9 @@ function buildFallbackConnectionEntries(
|
||||
const forwardSceneId = currentScenePreset?.forwardSceneId;
|
||||
const forwardScene =
|
||||
connectedScenes.find((scene) => scene.id === forwardSceneId) ?? null;
|
||||
const branchScenes = connectedScenes.filter((scene) => scene.id !== forwardSceneId);
|
||||
const branchScenes = connectedScenes.filter(
|
||||
(scene) => scene.id !== forwardSceneId,
|
||||
);
|
||||
|
||||
const fallbackEntries: Array<MapConnectionEntry | null> = [
|
||||
forwardScene
|
||||
@@ -144,7 +154,12 @@ function buildFallbackConnectionEntries(
|
||||
(scene, index) =>
|
||||
({
|
||||
scene,
|
||||
label: index === 0 ? '支路左侧' : index === 1 ? '支路右侧' : `支路${index + 1}`,
|
||||
label:
|
||||
index === 0
|
||||
? '支路左侧'
|
||||
: index === 1
|
||||
? '支路右侧'
|
||||
: `支路${index + 1}`,
|
||||
summary: '可转向另一片区域',
|
||||
}) satisfies MapConnectionEntry,
|
||||
),
|
||||
@@ -162,7 +177,9 @@ export function MapModal({
|
||||
isTraveling = false,
|
||||
canTravel = true,
|
||||
}: MapModalProps) {
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
resolvedUrl: resolvedBackdropImageSrc,
|
||||
shouldResolve: shouldResolveBackdropImage,
|
||||
@@ -177,8 +194,8 @@ export function MapModal({
|
||||
);
|
||||
const connectionEntries = useMemo(() => {
|
||||
if (currentScenePreset?.connections?.length) {
|
||||
const entries: Array<MapConnectionEntry | null> = currentScenePreset.connections
|
||||
.map((connection) => {
|
||||
const entries: Array<MapConnectionEntry | null> =
|
||||
currentScenePreset.connections.map((connection) => {
|
||||
const scene = connectedScenes.find(
|
||||
(item) => item.id === connection.sceneId,
|
||||
);
|
||||
@@ -201,10 +218,12 @@ export function MapModal({
|
||||
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
|
||||
}, [connectedScenes, currentScenePreset]);
|
||||
const sceneBackdropStyle = buildSceneBackdropStyle(
|
||||
resolvedBackdropImageSrc
|
||||
|| (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
|
||||
resolvedBackdropImageSrc ||
|
||||
(!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
|
||||
);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(
|
||||
connectionEntries.length,
|
||||
);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -214,7 +233,11 @@ export function MapModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScene) return;
|
||||
if (!connectionEntries.some((scene) => scene.scene.id === pendingScene.scene.id)) {
|
||||
if (
|
||||
!connectionEntries.some(
|
||||
(scene) => scene.scene.id === pendingScene.scene.id,
|
||||
)
|
||||
) {
|
||||
setPendingScene(null);
|
||||
}
|
||||
}, [connectionEntries, pendingScene]);
|
||||
@@ -247,7 +270,7 @@ export function MapModal({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="map-modal-backdrop pointer-events-none absolute inset-0"
|
||||
@@ -270,12 +293,18 @@ export function MapModal({
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="text-emerald-200/75">当前位置</div>
|
||||
<div className="mt-1 text-sm text-white">{currentScenePreset.name}</div>
|
||||
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
|
||||
<div className="mt-1 text-sm text-white">
|
||||
{currentScenePreset.name}
|
||||
</div>
|
||||
<div className="mt-2 text-zinc-300">
|
||||
{currentScenePreset.description}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5 text-zinc-300">
|
||||
{connectionEntries.map((entry) => (
|
||||
<div key={entry.scene.id}>{`- ${entry.label}:${entry.scene.name}`}</div>
|
||||
<div
|
||||
key={entry.scene.id}
|
||||
>{`- ${entry.label}:${entry.scene.name}`}</div>
|
||||
))}
|
||||
{connectionEntries.length === 0 && <div>- 暂无</div>}
|
||||
</div>
|
||||
@@ -284,12 +313,25 @@ export function MapModal({
|
||||
<div className="min-h-0 p-1 font-mono md:overflow-y-auto">
|
||||
<div className="md:hidden">
|
||||
<div className="grid grid-cols-[minmax(0,0.9fr)_2rem_minmax(0,1.1fr)] items-start gap-3">
|
||||
<div className="w-full max-w-[7.5rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
<div
|
||||
className="w-full max-w-[7.5rem] justify-self-start"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
<MudMapRoom
|
||||
scene={currentScenePreset}
|
||||
label="当前位置"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-${entry.scene.id}`}
|
||||
@@ -304,8 +346,11 @@ export function MapModal({
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.map(entry => (
|
||||
<div
|
||||
className="space-y-3"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
{connectionEntries.map((entry) => (
|
||||
<MudMapRoom
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
@@ -323,12 +368,25 @@ export function MapModal({
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-[minmax(0,12rem)_4rem_minmax(0,1fr)] items-start gap-4">
|
||||
<div className="w-full max-w-[9rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
<div
|
||||
className="w-full max-w-[9rem] justify-self-start"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
<MudMapRoom
|
||||
scene={currentScenePreset}
|
||||
label="当前位置"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-desktop-${entry.scene.id}`}
|
||||
@@ -343,8 +401,11 @@ export function MapModal({
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{connectionEntries.map(entry => (
|
||||
<div
|
||||
className="space-y-3"
|
||||
style={{ minHeight: `${destinationStackHeightPx}px` }}
|
||||
>
|
||||
{connectionEntries.map((entry) => (
|
||||
<MudMapRoom
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
@@ -369,7 +430,7 @@ export function MapModal({
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="map-modal-overlay fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
|
||||
onClick={event => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setPendingScene(null);
|
||||
}}
|
||||
@@ -381,12 +442,16 @@ export function MapModal({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">
|
||||
场景切换
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{pendingScene.scene.name}
|
||||
</div>
|
||||
</div>
|
||||
<PixelCloseButton
|
||||
onClick={() => setPendingScene(null)}
|
||||
@@ -402,8 +467,12 @@ export function MapModal({
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.18em] text-amber-200/75">目标场景</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-amber-200/75">
|
||||
目标场景
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">
|
||||
{pendingScene.scene.name}
|
||||
</div>
|
||||
<PlatformPillBadge
|
||||
tone="darkAmber"
|
||||
size="xxs"
|
||||
@@ -411,7 +480,9 @@ export function MapModal({
|
||||
>
|
||||
{pendingScene.label}
|
||||
</PlatformPillBadge>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
|
||||
{pendingScene.scene.description}
|
||||
</div>
|
||||
{pendingScene.summary ? (
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
连接说明:{pendingScene.summary}
|
||||
@@ -427,8 +498,12 @@ export function MapModal({
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">当前</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
当前
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{currentScenePreset.name}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
@@ -437,11 +512,14 @@ export function MapModal({
|
||||
radius="xs"
|
||||
padding="md"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">前往</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
前往
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{pendingScene.scene.name}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<PlatformDarkModalFooter data-testid="map-travel-footer">
|
||||
<PlatformActionButton
|
||||
@@ -459,7 +537,11 @@ export function MapModal({
|
||||
disabled={isTraveling || !canTravel}
|
||||
onClick={confirmTravel}
|
||||
>
|
||||
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
|
||||
{isTraveling
|
||||
? '切换中...'
|
||||
: canTravel
|
||||
? '确认前往'
|
||||
: '当前不可切换'}
|
||||
</PlatformActionButton>
|
||||
</PlatformDarkModalFooter>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AtlasTileSpec, buildMedievalNpcVisual, MedievalNpcVisualSpec } from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
AtlasTileSpec,
|
||||
buildMedievalNpcVisual,
|
||||
MedievalNpcVisualSpec,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import { Encounter } from '../types';
|
||||
import {
|
||||
DEFAULT_NPC_LAYOUT_CONFIG,
|
||||
@@ -12,17 +16,28 @@ const TILE_SIZE = 32;
|
||||
const HAND_TILE_SIZE = 16;
|
||||
const IDLE_FRAME_MS = 140;
|
||||
|
||||
function mergeLayoutConfig(layoutConfig?: Partial<NpcLayoutConfig>): NpcLayoutConfig {
|
||||
function mergeLayoutConfig(
|
||||
layoutConfig?: Partial<NpcLayoutConfig>,
|
||||
): NpcLayoutConfig {
|
||||
if (!layoutConfig) return DEFAULT_NPC_LAYOUT_CONFIG;
|
||||
|
||||
return {
|
||||
body: { ...DEFAULT_NPC_LAYOUT_CONFIG.body, ...layoutConfig.body },
|
||||
head: { ...DEFAULT_NPC_LAYOUT_CONFIG.head, ...layoutConfig.head },
|
||||
facialHair: { ...DEFAULT_NPC_LAYOUT_CONFIG.facialHair, ...layoutConfig.facialHair },
|
||||
facialHair: {
|
||||
...DEFAULT_NPC_LAYOUT_CONFIG.facialHair,
|
||||
...layoutConfig.facialHair,
|
||||
},
|
||||
hair: { ...DEFAULT_NPC_LAYOUT_CONFIG.hair, ...layoutConfig.hair },
|
||||
headgear: { ...DEFAULT_NPC_LAYOUT_CONFIG.headgear, ...layoutConfig.headgear },
|
||||
headgear: {
|
||||
...DEFAULT_NPC_LAYOUT_CONFIG.headgear,
|
||||
...layoutConfig.headgear,
|
||||
},
|
||||
hand: { ...DEFAULT_NPC_LAYOUT_CONFIG.hand, ...layoutConfig.hand },
|
||||
mainHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.mainHand, ...layoutConfig.mainHand },
|
||||
mainHand: {
|
||||
...DEFAULT_NPC_LAYOUT_CONFIG.mainHand,
|
||||
...layoutConfig.mainHand,
|
||||
},
|
||||
offHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.offHand, ...layoutConfig.offHand },
|
||||
};
|
||||
}
|
||||
@@ -107,20 +122,28 @@ export function MedievalNpcAnimator({
|
||||
encounter?: Encounter;
|
||||
visualSpec?: MedievalNpcVisualSpec;
|
||||
layoutConfig?: Partial<NpcLayoutConfig>;
|
||||
onPartPointerDown?: (part: NpcLayoutPart, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onPartPointerDown?: (
|
||||
part: NpcLayoutPart,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
selectedPart?: NpcLayoutPart | null;
|
||||
className?: string;
|
||||
scale?: number;
|
||||
facing?: 'left' | 'right';
|
||||
}) {
|
||||
const [frameCursor, setFrameCursor] = useState(0);
|
||||
const visual = visualSpec ?? buildMedievalNpcVisual(encounter ?? {
|
||||
npcName: '预览角色',
|
||||
npcDescription: '用于预览的角色外形。',
|
||||
npcAvatar: '预',
|
||||
context: '预览',
|
||||
});
|
||||
const bodyFrame = visual.bodyFrames[frameCursor % visual.bodyFrames.length] ?? 0;
|
||||
const visual =
|
||||
visualSpec ??
|
||||
buildMedievalNpcVisual(
|
||||
encounter ?? {
|
||||
npcName: '预览角色',
|
||||
npcDescription: '用于预览的角色外形。',
|
||||
npcAvatar: '预',
|
||||
context: '预览',
|
||||
},
|
||||
);
|
||||
const bodyFrame =
|
||||
visual.bodyFrames[frameCursor % visual.bodyFrames.length] ?? 0;
|
||||
const headFrame = visual.headFrame;
|
||||
const hairFrame = visual.hairFrame;
|
||||
const handFrame = visual.handFrame;
|
||||
@@ -137,13 +160,14 @@ export function MedievalNpcAnimator({
|
||||
const getPartHandlers = (part: NpcLayoutPart) =>
|
||||
onPartPointerDown
|
||||
? {
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => onPartPointerDown(part, event),
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) =>
|
||||
onPartPointerDown(part, event),
|
||||
}
|
||||
: {};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameCursor(prev => (prev + 1) % 4);
|
||||
setFrameCursor((prev) => (prev + 1) % 4);
|
||||
}, IDLE_FRAME_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
@@ -168,46 +192,139 @@ export function MedievalNpcAnimator({
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: '50%', bottom: 0, width: `${TILE_SIZE}px`, height: `${TILE_SIZE}px`, transform: 'translateX(-50%)' }}>
|
||||
<div className={getPartClassName('body')} style={{ position: 'absolute', left: `${layout.body.x}px`, top: `${layout.body.y}px` }} {...getPartHandlers('body')}>
|
||||
<LayerSprite src={visual.bodySrc} frameIndex={bodyFrame} zIndex={1} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 0,
|
||||
width: `${TILE_SIZE}px`,
|
||||
height: `${TILE_SIZE}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={getPartClassName('body')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.body.x}px`,
|
||||
top: `${layout.body.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('body')}
|
||||
>
|
||||
<LayerSprite
|
||||
src={visual.bodySrc}
|
||||
frameIndex={bodyFrame}
|
||||
zIndex={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getPartClassName('hand')}
|
||||
style={{ position: 'absolute', left: `${layout.hand.x}px`, top: `${layout.hand.y}px`, width: `${HAND_TILE_SIZE}px`, height: `${HAND_TILE_SIZE}px`, zIndex: 5 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.hand.x}px`,
|
||||
top: `${layout.hand.y}px`,
|
||||
width: `${HAND_TILE_SIZE}px`,
|
||||
height: `${HAND_TILE_SIZE}px`,
|
||||
zIndex: 5,
|
||||
}}
|
||||
{...getPartHandlers('hand')}
|
||||
>
|
||||
{visual.mainHand && (
|
||||
<div className={getPartClassName('mainHand')} style={{ position: 'absolute', left: `${layout.mainHand.x}px`, top: `${layout.mainHand.y}px` }} {...getPartHandlers('mainHand')}>
|
||||
<div
|
||||
className={getPartClassName('mainHand')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.mainHand.x}px`,
|
||||
top: `${layout.mainHand.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('mainHand')}
|
||||
>
|
||||
<AtlasSprite spec={visual.mainHand} zIndex={11} />
|
||||
</div>
|
||||
)}
|
||||
<LayerSprite src={visual.handSrc} frameIndex={handFrame} tileSize={HAND_TILE_SIZE} zIndex={12} />
|
||||
<LayerSprite
|
||||
src={visual.handSrc}
|
||||
frameIndex={handFrame}
|
||||
tileSize={HAND_TILE_SIZE}
|
||||
zIndex={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={getPartClassName('head')} style={{ position: 'absolute', left: `${layout.head.x}px`, top: `${layout.head.y}px` }} {...getPartHandlers('head')}>
|
||||
<LayerSprite src={visual.headSrc} frameIndex={headFrame} zIndex={6} />
|
||||
<div
|
||||
className={getPartClassName('head')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.head.x}px`,
|
||||
top: `${layout.head.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('head')}
|
||||
>
|
||||
<LayerSprite
|
||||
src={visual.headSrc}
|
||||
frameIndex={headFrame}
|
||||
zIndex={6}
|
||||
/>
|
||||
</div>
|
||||
{visual.facialHairSrc && (
|
||||
<div className={getPartClassName('facialHair')} style={{ position: 'absolute', left: `${layout.facialHair.x}px`, top: `${layout.facialHair.y}px` }} {...getPartHandlers('facialHair')}>
|
||||
<LayerSprite src={visual.facialHairSrc} frameIndex={facialFrame} zIndex={7} />
|
||||
<div
|
||||
className={getPartClassName('facialHair')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.facialHair.x}px`,
|
||||
top: `${layout.facialHair.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('facialHair')}
|
||||
>
|
||||
<LayerSprite
|
||||
src={visual.facialHairSrc}
|
||||
frameIndex={facialFrame}
|
||||
zIndex={7}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={getPartClassName('hair')} style={{ position: 'absolute', left: `${layout.hair.x}px`, top: `${layout.hair.y}px` }} {...getPartHandlers('hair')}>
|
||||
<LayerSprite src={visual.hairSrc} frameIndex={hairFrame} zIndex={8} />
|
||||
<div
|
||||
className={getPartClassName('hair')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.hair.x}px`,
|
||||
top: `${layout.hair.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('hair')}
|
||||
>
|
||||
<LayerSprite
|
||||
src={visual.hairSrc}
|
||||
frameIndex={hairFrame}
|
||||
zIndex={8}
|
||||
/>
|
||||
</div>
|
||||
{visual.headgear && (
|
||||
<div className={getPartClassName('headgear')} style={{ position: 'absolute', left: `${layout.headgear.x}px`, top: `${layout.headgear.y}px` }} {...getPartHandlers('headgear')}>
|
||||
<div
|
||||
className={getPartClassName('headgear')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.headgear.x}px`,
|
||||
top: `${layout.headgear.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('headgear')}
|
||||
>
|
||||
<AtlasSprite spec={visual.headgear} zIndex={9} />
|
||||
</div>
|
||||
)}
|
||||
{visual.offHand && (
|
||||
<div className={getPartClassName('offHand')} style={{ position: 'absolute', left: `${layout.offHand.x}px`, top: `${layout.offHand.y}px` }} {...getPartHandlers('offHand')}>
|
||||
<div
|
||||
className={getPartClassName('offHand')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${layout.offHand.x}px`,
|
||||
top: `${layout.offHand.y}px`,
|
||||
}}
|
||||
{...getPartHandlers('offHand')}
|
||||
>
|
||||
<AtlasSprite spec={visual.offHand} zIndex={10} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -317,6 +317,8 @@ test('NPC 弹窗标准 dark footer CTA 复用 PlatformActionButton', async () =>
|
||||
expect(tradeDetailFooter.className).toContain('platform-dark-modal-footer');
|
||||
expect(tradeDetailFooter.className).toContain('px-5');
|
||||
expect(tradeDetailFooter.className).toContain('py-4');
|
||||
expect(closeButton.className).toContain('platform-action-button--editor-dark');
|
||||
expect(closeButton.className).toContain(
|
||||
'platform-action-button--editor-dark',
|
||||
);
|
||||
expect(closeButton.className).toContain('rounded-2xl');
|
||||
});
|
||||
|
||||
@@ -559,7 +559,6 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
使用效果:{tradeDetailEffectText}
|
||||
</PlatformStatusMessage>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<PlatformDarkModalFooter
|
||||
|
||||
@@ -7,7 +7,12 @@ interface PixelIconProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function PixelIcon({ src, alt = '', className = '', style }: PixelIconProps) {
|
||||
export function PixelIcon({
|
||||
src,
|
||||
alt = '',
|
||||
className = '',
|
||||
style,
|
||||
}: PixelIconProps) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -22,10 +22,13 @@ export function ResolvedAssetImage({
|
||||
onError,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||||
src,
|
||||
{
|
||||
objectKey,
|
||||
refreshKey,
|
||||
},
|
||||
);
|
||||
const normalizedSource = src?.trim() ?? '';
|
||||
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
|
||||
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
|
||||
|
||||
@@ -88,7 +88,7 @@ export function CharacterDraftModal(props: {
|
||||
isOpen={isOpen}
|
||||
title="角色自定义"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
@@ -107,7 +107,7 @@ export function CharacterDraftModal(props: {
|
||||
确认进入
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PlatformSubpanel
|
||||
@@ -229,7 +229,7 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
@@ -250,12 +250,14 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||||
{isGenerating ? '生成中...' : '开始生成'}
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{hasCreatorIntentProps(props) ? (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">生成模式</div>
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
生成模式
|
||||
</div>
|
||||
<PlatformSelectField
|
||||
value={props.generationMode}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
||||
import {
|
||||
getCharacterAnimationDurationMs,
|
||||
getSkillCasterAnimation,
|
||||
getSkillDelivery,
|
||||
} from '../data/characterCombat';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
createNpcBattleMonster,
|
||||
} from '../data/npcInteractions';
|
||||
import { getScenePreset } from '../data/scenePresets';
|
||||
import { buildSkillEffects } from '../hooks/useCombatFlow';
|
||||
import {
|
||||
@@ -32,13 +39,22 @@ export interface SkillEffectPreviewProps {
|
||||
|
||||
const PLAYER_X = 0;
|
||||
|
||||
function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
|
||||
function getSkillReleaseDelayMs(
|
||||
character: Character,
|
||||
skill: CharacterSkillDefinition,
|
||||
) {
|
||||
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
|
||||
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
|
||||
const animationDuration = getCharacterAnimationDurationMs(
|
||||
character,
|
||||
getSkillCasterAnimation(skill),
|
||||
);
|
||||
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
|
||||
}
|
||||
|
||||
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
|
||||
function buildPreviewTargetMonster(
|
||||
worldType: WorldType,
|
||||
targetMonsterId?: string | null,
|
||||
) {
|
||||
const previewMonster = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
targetMonsterId ? [targetMonsterId] : [],
|
||||
@@ -76,7 +92,13 @@ export function SkillEffectPreview({
|
||||
}: SkillEffectPreviewProps) {
|
||||
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
|
||||
const fallbackTargetCharacter = useMemo(
|
||||
() => targetCharacter ?? ROLE_TEMPLATE_CHARACTERS.find(candidate => candidate.id !== character.id) ?? ROLE_TEMPLATE_CHARACTERS[0] ?? character,
|
||||
() =>
|
||||
targetCharacter ??
|
||||
ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(candidate) => candidate.id !== character.id,
|
||||
) ??
|
||||
ROLE_TEMPLATE_CHARACTERS[0] ??
|
||||
character,
|
||||
[character, targetCharacter],
|
||||
);
|
||||
|
||||
@@ -100,9 +122,13 @@ export function SkillEffectPreview({
|
||||
}, [mode, npcEncounter, targetMonsterId, worldType]);
|
||||
|
||||
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
|
||||
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
|
||||
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
|
||||
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
|
||||
const [playerActionMode, setPlayerActionMode] =
|
||||
useState<CombatActionMode>('idle');
|
||||
const [sceneHostileNpcs, setSceneMonsters] =
|
||||
useState<SceneHostileNpc[]>(initialMonsters);
|
||||
const [activeCombatEffects, setActiveCombatEffects] = useState<
|
||||
CombatVisualEffect[]
|
||||
>([]);
|
||||
const [restartTick, setRestartTick] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
@@ -127,19 +153,23 @@ export function SkillEffectPreview({
|
||||
if (mode === 'player') {
|
||||
setPlayerAnimation(casterAnimation);
|
||||
setPlayerActionMode(delivery);
|
||||
setSceneMonsters(initialMonsters.map(monster => ({
|
||||
...monster,
|
||||
action: `${monster.name}正面承受${skill.name}的预览`,
|
||||
})));
|
||||
setSceneMonsters(
|
||||
initialMonsters.map((monster) => ({
|
||||
...monster,
|
||||
action: `${monster.name}正面承受${skill.name}的预览`,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setPlayerAnimation(AnimationState.IDLE);
|
||||
setPlayerActionMode('idle');
|
||||
setSceneMonsters(initialMonsters.map(monster => ({
|
||||
...resetNpcPreviewMonster(monster),
|
||||
action: `${monster.name}施展${skill.name}`,
|
||||
characterAnimation: casterAnimation,
|
||||
combatMode: delivery,
|
||||
})));
|
||||
setSceneMonsters(
|
||||
initialMonsters.map((monster) => ({
|
||||
...resetNpcPreviewMonster(monster),
|
||||
action: `${monster.name}施展${skill.name}`,
|
||||
characterAnimation: casterAnimation,
|
||||
combatMode: delivery,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
@@ -169,17 +199,20 @@ export function SkillEffectPreview({
|
||||
impactDurationMs: 0,
|
||||
};
|
||||
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(character, skill)
|
||||
: getCharacterAnimationDurationMs(character, casterAnimation);
|
||||
const releaseDelay =
|
||||
(skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(character, skill)
|
||||
: getCharacterAnimationDurationMs(character, casterAnimation);
|
||||
|
||||
let delay = releaseDelay;
|
||||
|
||||
const schedule = (taskDelay: number, task: () => void) => {
|
||||
timers.push(window.setTimeout(() => {
|
||||
if (!active) return;
|
||||
task();
|
||||
}, taskDelay));
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
if (!active) return;
|
||||
task();
|
||||
}, taskDelay),
|
||||
);
|
||||
};
|
||||
|
||||
if (phases.cast.length > 0) {
|
||||
@@ -196,10 +229,12 @@ export function SkillEffectPreview({
|
||||
schedule(delay, () => {
|
||||
setActiveCombatEffects(phases.impact);
|
||||
if (mode === 'player') {
|
||||
setSceneMonsters(current => current.map(monster => ({
|
||||
...monster,
|
||||
action: `${monster.name}被${skill.name}命中`,
|
||||
})));
|
||||
setSceneMonsters((current) =>
|
||||
current.map((monster) => ({
|
||||
...monster,
|
||||
action: `${monster.name}被${skill.name}命中`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
delay += phases.impactDurationMs;
|
||||
@@ -209,13 +244,15 @@ export function SkillEffectPreview({
|
||||
setActiveCombatEffects([]);
|
||||
setPlayerAnimation(AnimationState.IDLE);
|
||||
setPlayerActionMode('idle');
|
||||
setSceneMonsters(initialMonsters.map(monster => resetNpcPreviewMonster(monster)));
|
||||
setSceneMonsters(
|
||||
initialMonsters.map((monster) => resetNpcPreviewMonster(monster)),
|
||||
);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
timers.forEach(timerId => window.clearTimeout(timerId));
|
||||
timers.forEach((timerId) => window.clearTimeout(timerId));
|
||||
};
|
||||
}, [character, initialMonsters, mode, restartTick, scenePreset, skill]);
|
||||
|
||||
@@ -223,13 +260,17 @@ export function SkillEffectPreview({
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{skill?.name ?? '未选择技能'}</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{skill?.name ?? '未选择技能'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
|
||||
{mode === 'player'
|
||||
? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}`
|
||||
: `受击对象:${fallbackTargetCharacter.name}`}
|
||||
</div>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
onClick={() => setRestartTick(value => value + 1)}
|
||||
onClick={() => setRestartTick((value) => value + 1)}
|
||||
disabled={!skill || isPlaying}
|
||||
surface="editorDark"
|
||||
tone="ghost"
|
||||
@@ -246,7 +287,11 @@ export function SkillEffectPreview({
|
||||
<GameCanvas
|
||||
scrollWorld={false}
|
||||
animationState={playerAnimation}
|
||||
playerCharacter={fallbackTargetCharacter && mode === 'npc' ? fallbackTargetCharacter : character}
|
||||
playerCharacter={
|
||||
fallbackTargetCharacter && mode === 'npc'
|
||||
? fallbackTargetCharacter
|
||||
: character
|
||||
}
|
||||
encounter={null}
|
||||
currentScenePreset={scenePreset}
|
||||
worldType={worldType}
|
||||
|
||||
@@ -14,7 +14,8 @@ export const CHARACTER_WORKFLOW_CACHE_API_PATH =
|
||||
ASSET_API_PATHS.characterWorkflowCache;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualJobs;
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationGenerate;
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH =
|
||||
@@ -211,9 +212,7 @@ export async function fetchCharacterWorkflowCache(
|
||||
cache: CharacterAssetWorkflowCache | null;
|
||||
}>(
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}${
|
||||
cacheScopeId
|
||||
? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}`
|
||||
: ''
|
||||
cacheScopeId ? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}` : ''
|
||||
}`,
|
||||
'读取角色形象生成缓存失败',
|
||||
);
|
||||
@@ -226,11 +225,7 @@ export async function saveCharacterWorkflowCache(
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache;
|
||||
saveMessage: string;
|
||||
}>(
|
||||
CHARACTER_WORKFLOW_CACHE_API_PATH,
|
||||
payload,
|
||||
'保存角色形象生成缓存失败',
|
||||
);
|
||||
}>(CHARACTER_WORKFLOW_CACHE_API_PATH, payload, '保存角色形象生成缓存失败');
|
||||
}
|
||||
|
||||
export async function resolveCharacterRoleAssetWorkflow(payload: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user