统一 Rust 与 TypeScript 格式化门禁

纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
This commit is contained in:
2026-09-01 16:26:20 +08:00
parent ae79d50b14
commit 071faa482c
1051 changed files with 28412 additions and 19834 deletions
+15 -15
View File
@@ -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 () => {
+22 -12
View File
@@ -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 (
+4 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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',
})}
/>,
);
+6 -3
View File
@@ -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)
+39 -14
View File
@@ -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>
+2 -1
View File
@@ -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}`;
}
+3 -3
View File
@@ -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');
+197 -122
View File
@@ -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"
+3 -1
View File
@@ -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
+9 -3
View File
@@ -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>
);
+5 -1
View File
@@ -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>
+16 -10
View File
@@ -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]);
+3 -1
View File
@@ -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
View File
@@ -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>
+147 -30
View File
@@ -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>
);
+3 -1
View File
@@ -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');
});
-1
View File
@@ -559,7 +559,6 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
使用效果:{tradeDetailEffectText}
</PlatformStatusMessage>
)}
</div>
<PlatformDarkModalFooter
+6 -1
View File
@@ -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}
+7 -4
View File
@@ -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) =>
+81 -36
View File
@@ -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