This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -2,7 +2,7 @@ import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/aiService';
} from '../../packages/shared/src/contracts/runtime';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {

View File

@@ -11,9 +11,13 @@ import {
createInventoryItemFromCatalogEntry,
ITEM_CATALOG_API_PATH,
ITEM_CATEGORY_OPTIONS,
ITEM_OVERRIDES_API_PATH,
} from '../data/itemCatalog';
import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient';
import {
EDITOR_JSON_RESOURCE_IDS,
fetchEditorJsonResource,
saveEditorJsonResource,
} from '../editor/shared/editorApiClient';
import { fetchJson } from '../editor/shared/jsonClient';
import { SectionCard as Section } from '../editor/shared/SectionCard';
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
import { PixelIcon } from './PixelIcon';
@@ -176,7 +180,9 @@ export function ItemCatalogEditor() {
try {
const [catalogResponse, overridesResponse] = await Promise.all([
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
fetchJson<Record<string, ItemCatalogOverride>>(ITEM_OVERRIDES_API_PATH),
fetchEditorJsonResource<Record<string, ItemCatalogOverride>>(
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
),
]);
if (disposed) return;
@@ -416,7 +422,11 @@ export function ItemCatalogEditor() {
setSaveMessage(null);
try {
await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record<string, unknown>);
await saveEditorJsonResource(
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
overrideMap as Record<string, unknown>,
'保存失败',
);
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
setTimeout(() => setSaveMessage(null), 5000);
} catch (error) {

View File

@@ -16,6 +16,10 @@ import {
type ScenePreset,
} from '../data/scenePresets';
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
import {
EDITOR_JSON_RESOURCE_IDS,
saveEditorJsonResource,
} from '../editor/shared/editorApiClient';
import {
buildStateFunctionDefinitions,
type FunctionAvailabilityContext,
@@ -30,7 +34,6 @@ import {
type StateFunctionOverrideMap,
} from '../data/stateFunctions';
import {cloneValue} from '../editor/shared/cloneValue';
import {saveJsonObject} from '../editor/shared/jsonClient';
import {SectionCard} from '../editor/shared/SectionCard';
import {type ResolvedChoiceState,useCombatFlow} from '../hooks/useCombatFlow';
import {
@@ -1134,8 +1137,8 @@ export function StateFunctionEditor() {
setIsSaving(true);
setSaveMessage(null);
try {
await saveJsonObject(
'/api/state-function-overrides',
await saveEditorJsonResource(
EDITOR_JSON_RESOURCE_IDS.stateFunctionOverrides,
overrideMap as Record<string, unknown>,
'保存选项行为覆盖失败',
);

View File

@@ -0,0 +1,478 @@
import { useEffect, useState } from 'react';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
AuthRiskBlockSummary,
AuthSessionSummary,
AuthUser,
} from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type AccountModalProps = {
user: AuthUser;
isOpen: boolean;
riskBlocks: AuthRiskBlockSummary[];
sessions: AuthSessionSummary[];
auditLogs: AuthAuditLogEntry[];
loadingRiskBlocks: boolean;
loadingSessions: boolean;
loadingAuditLogs: boolean;
onClose: () => void;
onLogout: () => Promise<void>;
onRefreshRiskBlocks: () => Promise<void>;
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (sessionId: string) => Promise<void>;
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onChangePhone: (phone: string, code: string) => Promise<void>;
};
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function AccountModal({
user,
isOpen,
riskBlocks,
sessions,
auditLogs,
loadingRiskBlocks,
loadingSessions,
loadingAuditLogs,
onClose,
onLogout,
onRefreshRiskBlocks,
onLiftRiskBlock,
onRefreshSessions,
onLogoutAll,
onRefreshAuditLogs,
onRevokeSession,
changePhoneCaptchaChallenge,
onSendChangePhoneCode,
onChangePhone,
}: AccountModalProps) {
const [editingPhone, setEditingPhone] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [changePhoneError, setChangePhoneError] = useState('');
const [changePhoneHint, setChangePhoneHint] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/62 px-4 py-4 sm:items-center"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,_rgba(20,23,31,0.96),_rgba(10,12,18,0.98))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.58)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-amber-200/70">
</div>
<div className="mt-2 text-2xl font-semibold text-white">
{user.displayName}
</div>
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={onClose}
>
</button>
</div>
<div className="mt-5 grid gap-3 text-sm text-zinc-200">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{resolveLoginMethodLabel(user.loginMethod)}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.phoneNumberMasked || '未绑定'}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.wechatBound ? '已绑定' : '未绑定'}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.bindingStatus === 'pending_bind_phone'
? ' 待绑定手机号'
: ' 已激活'}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="rounded-2xl border border-amber-300/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-50"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs text-amber-100/75">
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-amber-100/85">
{block.detail}
</div>
<button
type="button"
className="mt-3 h-9 rounded-2xl border border-emerald-300/20 px-3 text-xs text-emerald-50 transition hover:border-emerald-300/45 hover:bg-emerald-400/10"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="text-xs text-emerald-200/85">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-zinc-400">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-zinc-500">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-zinc-500">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="mt-3 h-9 rounded-2xl border border-rose-400/20 px-3 text-xs text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
setEditingPhone((current) => !current);
setChangePhoneError('');
setChangePhoneHint('');
}}
>
{editingPhone ? '收起' : '修改'}
</button>
</div>
{editingPhone ? (
<div className="mt-3 grid gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4">
<label className="grid gap-2 text-sm text-zinc-200">
<span></span>
<input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
value={phone}
inputMode="numeric"
placeholder="13800000000"
onChange={(event) => setPhone(event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm text-zinc-200">
<span></span>
<div className="flex gap-3">
<input
className="h-11 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
value={code}
inputMode="numeric"
placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)}
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-11 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
setSendingCode(true);
setChangePhoneError('');
try {
const result = await onSendChangePhoneCode(phone, {
challengeId: changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '发送验证码失败,请稍后再试。',
);
setChangePhoneHint('');
} finally {
setSendingCode(false);
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{changePhoneHint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{changePhoneHint}
</div>
) : null}
<CaptchaChallengeField
challenge={changePhoneCaptchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{changePhoneError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{changePhoneError}
</div>
) : null}
<button
type="button"
disabled={changingPhone || !phone.trim() || !code.trim()}
className="h-11 rounded-2xl border border-sky-300/20 px-4 text-sm font-medium text-sky-100 transition hover:border-sky-300/45 hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void (async () => {
setChangingPhone(true);
setChangePhoneError('');
try {
await onChangePhone(phone, code);
setChangePhoneHint('手机号已更新。');
setPhone('');
setCode('');
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '更换手机号失败,请稍后再试。',
);
} finally {
setChangingPhone(false);
}
})();
}}
>
{changingPhone ? '提交中...' : '确认更换手机号'}
</button>
</div>
) : null}
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-zinc-500">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-zinc-400">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-zinc-500">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<button
type="button"
className="mt-5 h-11 w-full rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="mt-3 h-11 w-full rounded-2xl border border-rose-400/20 px-4 text-sm font-medium text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
);
}

View File

@@ -5,22 +5,69 @@ import {
getStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
changePhoneNumber,
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
type AuthStatus =
| 'checking'
| 'recovering'
| 'unauthenticated'
| 'pending_bind_phone'
| 'ready'
| 'error';
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
useEffect(() => {
let isActive = true;
@@ -57,31 +104,58 @@ export function AuthGate({ children }: AuthGateProps) {
};
const hydrate = async () => {
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
}
const token = getStoredAccessToken();
if (!token) {
await ensureAutoUser();
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
if (!isActive) {
return;
}
setUser(null);
setStatus('unauthenticated');
return;
}
try {
const nextUser = await getCurrentAuthUser();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (nextUser) {
setUser(nextUser);
setStatus('ready');
setError('');
if (!nextSession.user) {
setUser(null);
setStatus('unauthenticated');
return;
}
await ensureAutoUser();
setUser(nextSession.user);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
: 'ready',
);
setError(callbackResult?.error ?? '');
} catch {
if (!isActive) {
return;
}
await ensureAutoUser();
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
setUser(null);
setStatus('unauthenticated');
}
};
@@ -100,6 +174,91 @@ export function AuthGate({ children }: AuthGateProps) {
};
}, []);
useEffect(() => {
if (!showAccountModal || status !== 'ready') {
return;
}
let isActive = true;
setLoadingRiskBlocks(true);
setLoadingSessions(true);
setLoadingAuditLogs(true);
void getAuthRiskBlocks()
.then((nextBlocks) => {
if (!isActive) {
return;
}
setRiskBlocks(nextBlocks);
})
.catch((blockError) => {
if (!isActive) {
return;
}
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingRiskBlocks(false);
});
void getAuthSessions()
.then((nextSessions) => {
if (!isActive) {
return;
}
setSessions(nextSessions);
})
.catch((sessionError) => {
if (!isActive) {
return;
}
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingSessions(false);
});
void getAuthAuditLogs()
.then((nextLogs) => {
if (!isActive) {
return;
}
setAuditLogs(nextLogs);
})
.catch((auditError) => {
if (!isActive) {
return;
}
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingAuditLogs(false);
});
return () => {
isActive = false;
};
}, [showAccountModal, status]);
if (status === 'checking') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
@@ -116,11 +275,135 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
if (status === 'unauthenticated') {
return (
<LoginScreen
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
);
}
if (status === 'pending_bind_phone' && user) {
return (
<BindPhoneScreen
user={user}
sendingCode={sendingCode}
binding={bindingPhone}
error={error}
captchaChallenge={bindCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
setBindCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setBindCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setBindingPhone(true);
setError('');
try {
const nextUser = await bindWechatPhone(phone, code);
setBindCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (bindError) {
setError(
bindError instanceof Error
? bindError.message
: '绑定手机号失败,请稍后再试。',
);
} finally {
setBindingPhone(false);
}
}}
onLogout={async () => {
await logoutAuthUser();
setUser(null);
setStatus('unauthenticated');
}}
/>
);
}
if (status !== 'ready' || !user) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="text-base font-medium text-zinc-50"></div>
<div className="text-base font-medium text-zinc-50"></div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
@@ -142,7 +425,13 @@ export function AuthGate({ children }: AuthGateProps) {
<div className="relative">
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
<span>{user.username}</span>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
onClick={() => setShowAccountModal(true)}
>
{user.displayName}
</button>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
@@ -154,6 +443,118 @@ export function AuthGate({ children }: AuthGateProps) {
</button>
</div>
</div>
<AccountModal
user={user}
isOpen={showAccountModal}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
onClose={() => setShowAccountModal(false)}
onLogout={async () => {
await logoutAuthUser();
setShowAccountModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowAccountModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
{children}
</div>
);

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = {
user: AuthUser;
sendingCode: boolean;
binding: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
onSendCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onSubmit: (phone: string, code: string) => Promise<void>;
onLogout: () => Promise<void>;
};
export function BindPhoneScreen({
user,
sendingCode,
binding,
error,
captchaChallenge,
onSendCode,
onSubmit,
onLogout,
}: BindPhoneScreenProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
{user.displayName}
</div>
</div>
<form
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
<button
type="submit"
disabled={binding || !phone.trim() || !code.trim()}
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button>
<button
type="button"
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
onClick={() => {
void onLogout();
}}
>
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { AuthCaptchaChallenge } from '../../services/authService';
type CaptchaChallengeFieldProps = {
challenge: AuthCaptchaChallenge | null;
answer: string;
onAnswerChange: (value: string) => void;
};
export function CaptchaChallengeField({
challenge,
answer,
onAnswerChange,
}: CaptchaChallengeFieldProps) {
if (!challenge) {
return null;
}
return (
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
<img
src={challenge.imageDataUrl}
alt="图形验证码"
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
/>
<input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
value={answer}
placeholder="输入图形验证码"
onChange={(event) => onAnswerChange(event.target.value)}
/>
</div>
);
}

View File

@@ -1,39 +1,82 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = {
loading: boolean;
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
error: string;
onSubmit: (username: string, password: string) => Promise<void>;
captchaChallenge: AuthCaptchaChallenge | null;
onSendCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onSubmit: (phone: string, code: string) => Promise<void>;
onStartWechatLogin: () => Promise<void>;
};
export function LoginScreen({
loading,
sendingCode,
loggingIn,
wechatLoading,
error,
captchaChallenge,
onSendCode,
onSubmit,
onStartWechatLogin,
}: LoginScreenProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
Genarrative
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
3 24 线
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
使
</div>
</div>
</div>
@@ -42,32 +85,78 @@ export function LoginScreen({
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
void onSubmit(username, password);
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="hero_name"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
/>
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
</div>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
@@ -76,10 +165,21 @@ export function LoginScreen({
<button
type="submit"
disabled={loading}
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? '正在进入...' : '进入游戏'}
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
</form>
</div>

View File

@@ -3,6 +3,7 @@ import type {
GameState,
} from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { GameShellDialogueIndicator } from './types';
import { GameCanvas } from '../GameCanvas';
export function GameShellCanvasStage({
@@ -21,11 +22,7 @@ export function GameShellCanvasStage({
visibleGameState: GameState;
hideSelectionHero: boolean;
canvasCompanionRenderStates: CompanionRenderState[];
dialogueIndicator: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
dialogueIndicator: GameShellDialogueIndicator | null;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
sceneTransitionToken: number;
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
@@ -36,9 +33,9 @@ export function GameShellCanvasStage({
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
<div className="text-center">
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl"></div>
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">GENARRATIVE</div>
<div className="selection-hero-brand px-6 text-center">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
</div>
) : (

View File

@@ -20,22 +20,7 @@ import type { GameCanvasEntitySelection } from '../GameCanvas';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
import { GameShellStoryPanels } from './GameShellStoryPanels';
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
type AdventureStatistics = {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
};
import type { GameShellAdventureStatistics } from './types';
export function GameShellMainContent({
gameState,
@@ -106,7 +91,7 @@ export function GameShellMainContent({
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: AdventureStatistics;
adventureStatistics: GameShellAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
resetForSaveAndExit: () => void;

View File

@@ -1,20 +1,13 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {getLiveGamePlayTimeMs} from '../../data/runtimeStats';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import type {StoryOption} from '../../types';
import {UI_CHROME} from '../../uiAssets';
import {GameShellCanvasStage} from './GameShellCanvasStage';
import {GameShellMainContent} from './GameShellMainContent';
import {GameShellOverlays} from './GameShellOverlays';
import {type GameShellProps} from './types';
import {useGameShellViewModel} from './useGameShellViewModel';
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel';
import { UI_CHROME } from '../../uiAssets';
import { GameShellCanvasStage } from './GameShellCanvasStage';
import { GameShellMainContent } from './GameShellMainContent';
import { GameShellOverlays } from './GameShellOverlays';
import type { GameShellProps } from './types';
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const {
gameState,
currentStory,
isLoading,
aiError,
bottomTab,
@@ -26,7 +19,6 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleChoice,
handleMapTravelToScene,
npcUi,
characterChatUi,
@@ -46,18 +38,10 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
} = entry;
const {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
const openingCampSceneId = useMemo(
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
[gameState.worldType],
);
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
const {
selectionStage,
setSelectionStage,
@@ -77,120 +61,24 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
shouldMountMapModal,
shouldMountCharacterChatModal,
shouldMountNpcModals,
} = useGameShellViewModel({
gameState,
isMapOpen,
characterChatModalOpen: Boolean(characterChatUi.modal),
hasNpcModalOpen,
});
const {
visibleGameState,
visibleCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
} = useSceneTransitionModel({
gameState,
currentStory,
openingCampSceneId,
isCharacterSelectionStage,
shouldHideStoryOptions,
hideSelectionHero,
dialogueIndicator,
characterChatSummaries,
canvasCompanionRenderStates,
adventureStatistics,
handleSceneTransitionChoice,
} = useGameShellRuntimeViewModel({
session,
story,
companions,
});
const isCharacterSelectionStage =
gameState.currentScene === 'Selection' &&
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
selectionStage !== 'start';
const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
return null;
}
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
} as const;
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
const characterChatSummaries = useMemo(
() =>
Object.fromEntries(
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
),
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const adventureStatistics = useMemo(
() => ({
playTimeMs: livePlayTimeMs,
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
questsAccepted: gameState.runtimeStats.questsAccepted,
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
itemsUsed: gameState.runtimeStats.itemsUsed,
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency,
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
inventoryStackCount: visibleGameState.playerInventory.length,
activeCompanionCount: visibleGameState.companions.length,
rosterCompanionCount: visibleGameState.roster.length,
}),
[
gameState.runtimeStats.itemsUsed,
gameState.runtimeStats.hostileNpcsDefeated,
gameState.runtimeStats.questsAccepted,
gameState.runtimeStats.scenesTraveled,
livePlayTimeMs,
visibleGameState.companions.length,
visibleGameState.currentScenePreset?.name,
visibleGameState.playerCurrency,
visibleGameState.playerInventory,
visibleGameState.quests,
visibleGameState.roster.length,
],
);
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
setClockNow(Date.now());
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter]);
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
if (transitionMode) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
}, [beginSceneTransition, handleChoice]);
return (
<div

View File

@@ -13,6 +13,7 @@ import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PanelLoadingFallback } from './GameShellLoaders';
import type { GameShellAdventureStatistics } from './types';
const AdventurePanel = lazy(async () => {
const module = await import('../AdventurePanel');
@@ -38,22 +39,6 @@ const InventoryPanel = lazy(async () => {
};
});
type AdventureStatistics = {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
};
export function GameShellStoryPanels({
visibleGameState,
visibleCurrentStory,
@@ -102,7 +87,7 @@ export function GameShellStoryPanels({
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: AdventureStatistics;
adventureStatistics: GameShellAdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;

View File

@@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import type {
CustomWorldGenerationProgress,
} from '../../../packages/shared/src/contracts/runtime';
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
@@ -11,7 +15,6 @@ import {
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
} from '../../services/aiService';
import {
@@ -365,7 +368,9 @@ export function PreGameSelectionFlow({
settingText:
generatedCustomWorldProfile.settingText.trim() ||
customWorldSettingPreview,
creatorIntent: generatedCustomWorldProfile.creatorIntent,
creatorIntent:
(generatedCustomWorldProfile.creatorIntent as JsonObject | null) ??
null,
generationMode:
options.generationMode ??
generatedCustomWorldProfile.generationMode ??
@@ -453,7 +458,7 @@ export function PreGameSelectionFlow({
const profile = await generateCustomWorldProfile(
{
settingText,
creatorIntent: customWorldCreatorIntent,
creatorIntent: customWorldCreatorIntent as unknown as JsonObject,
generationMode: customWorldGenerationMode,
},
{

View File

@@ -63,6 +63,28 @@ export interface GameShellAudioProps {
onMusicVolumeChange: (value: number) => void;
}
export interface GameShellDialogueIndicator {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
}
export interface GameShellAdventureStatistics {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
}
export interface GameShellProps {
session: GameShellSessionProps;
story: GameShellStoryProps;

View File

@@ -0,0 +1,289 @@
import { describe, expect, it } from 'vitest';
import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types';
import { AnimationState, WorldType } from '../../types';
import {
buildAdventureStatistics,
buildCanvasCompanionRenderStates,
buildCharacterChatSummaries,
buildGameShellDialogueIndicator,
} from './useGameShellRuntimeViewModel';
function createBaseGameState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 3,
questsAccepted: 2,
itemsUsed: 4,
scenesTraveled: 5,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '测试场景',
imageSrc: '/scene.png',
treasureHints: [],
npcs: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 18,
playerInventory: [
{
id: 'item-1',
name: '药草',
description: '恢复道具',
quantity: 2,
category: 'consumable',
rarity: 'common',
tags: [],
value: 1,
},
{
id: 'item-2',
name: '布料',
description: '材料',
quantity: 3,
category: 'material',
rarity: 'common',
tags: [],
value: 1,
},
],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [
{
id: 'quest-1',
issuerNpcId: 'npc-1',
issuerNpcName: '老周',
sceneId: 'scene-1',
title: '寻回包裹',
description: '找回丢失的包裹',
summary: '一项测试任务',
objective: {
kind: 'deliver_item',
targetItemId: 'item-1',
requiredCount: 1,
},
progress: 1,
status: 'completed',
reward: {
affinityBonus: 2,
currency: 10,
items: [],
},
rewardText: '测试奖励',
},
{
id: 'quest-2',
issuerNpcId: 'npc-2',
issuerNpcName: '阿青',
sceneId: 'scene-1',
title: '护送商队',
description: '保护商队通行',
summary: '另一项测试任务',
objective: {
kind: 'reach_scene',
targetSceneId: 'scene-2',
requiredCount: 1,
},
progress: 1,
status: 'turned_in',
reward: {
affinityBonus: 3,
currency: 20,
items: [],
},
rewardText: '测试奖励',
},
],
roster: [
{
npcId: 'npc-roster',
characterId: 'char-roster',
joinedAtAffinity: 10,
hp: 90,
maxHp: 90,
mana: 12,
maxMana: 12,
skillCooldowns: {},
},
],
companions: [
{
npcId: 'npc-active',
characterId: 'char-active',
joinedAtAffinity: 18,
hp: 100,
maxHp: 100,
mana: 16,
maxMana: 16,
skillCooldowns: {},
},
{
npcId: 'npc-encounter',
characterId: 'char-encounter',
joinedAtAffinity: 22,
hp: 88,
maxHp: 88,
mana: 14,
maxMana: 14,
skillCooldowns: {},
},
],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('useGameShellRuntimeViewModel helpers', () => {
it('builds a dialogue indicator only for active npc dialogue playback', () => {
const state = {
...createBaseGameState(),
currentEncounter: {
id: 'npc-encounter',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路人',
npcAvatar: '/npc.png',
context: '山道相遇',
},
};
const story = {
text: '继续对话',
displayMode: 'dialogue' as const,
dialogue: [
{
speaker: 'npc' as const,
text: '先别急着出手。',
},
{
speaker: 'player' as const,
text: '那你想说什么?',
},
],
options: [],
} satisfies StoryMoment;
expect(
buildGameShellDialogueIndicator({
isLoading: true,
visibleGameState: state,
visibleCurrentStory: story,
}),
).toEqual({
showPlayer: true,
showEncounter: true,
activeSpeaker: 'player',
});
expect(
buildGameShellDialogueIndicator({
isLoading: false,
visibleGameState: state,
visibleCurrentStory: story,
}),
).toBeNull();
});
it('derives compact chat summaries and hides the active encounter companion from canvas renders', () => {
const chatSummaries = buildCharacterChatSummaries({
'char-active': {
history: [],
summary: '已经建立起稳定默契。',
updatedAt: null,
},
'char-roster': {
history: [],
summary: '仍在营地观望局势。',
updatedAt: null,
},
} satisfies Record<string, CharacterChatRecord>);
expect(chatSummaries).toEqual({
'char-active': '已经建立起稳定默契。',
'char-roster': '仍在营地观望局势。',
});
const visibleCompanionRenderStates = [
{ npcId: 'npc-active' },
{ npcId: 'npc-encounter' },
] as CompanionRenderState[];
const visibleGameState = {
...createBaseGameState(),
currentEncounter: {
id: 'npc-encounter',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路人',
npcAvatar: '/npc.png',
context: '山道相遇',
},
};
expect(
buildCanvasCompanionRenderStates({
visibleCompanionRenderStates,
visibleGameState,
}),
).toEqual([{ npcId: 'npc-active' }]);
});
it('aggregates adventure statistics from runtime and visible state slices', () => {
const gameState = createBaseGameState();
const statistics = buildAdventureStatistics({
gameState,
visibleGameState: gameState,
livePlayTimeMs: 3210,
});
expect(statistics).toEqual({
playTimeMs: 3210,
hostileNpcsDefeated: 3,
questsAccepted: 2,
questsCompleted: 2,
questsTurnedIn: 1,
itemsUsed: 4,
scenesTraveled: 5,
currentSceneName: '断桥旧哨',
playerCurrency: 18,
inventoryItemCount: 5,
inventoryStackCount: 2,
activeCompanionCount: 2,
rosterCompanionCount: 1,
});
});
});

View File

@@ -0,0 +1,235 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import type {
CharacterChatRecord,
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type {
GameShellAdventureStatistics,
GameShellDialogueIndicator,
GameShellProps,
} from './types';
import { useGameShellViewModel } from './useGameShellViewModel';
import {
SCENE_TRANSITION_FUNCTION_MODES,
useSceneTransitionModel,
} from './useSceneTransitionModel';
export function buildGameShellDialogueIndicator(params: {
isLoading: boolean;
visibleGameState: GameState;
visibleCurrentStory: StoryMoment | null;
}): GameShellDialogueIndicator | null {
const { isLoading, visibleGameState, visibleCurrentStory } = params;
if (
!isLoading ||
visibleCurrentStory?.displayMode !== 'dialogue' ||
visibleGameState.currentEncounter?.kind !== 'npc'
) {
return null;
}
const lastSpeaker =
visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]
?.speaker ?? null;
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
};
}
export function buildCharacterChatSummaries(
characterChats: Record<string, CharacterChatRecord> | undefined,
) {
return Object.fromEntries(
Object.entries(characterChats ?? {}).map(([characterId, record]) => [
characterId,
record.summary,
]),
);
}
export function buildCanvasCompanionRenderStates(params: {
visibleCompanionRenderStates: CompanionRenderState[];
visibleGameState: GameState;
}) {
const activeEncounterNpcId =
params.visibleGameState.currentEncounter?.kind === 'npc'
? params.visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) {
return params.visibleCompanionRenderStates;
}
return params.visibleCompanionRenderStates.filter(
(companion) => companion.npcId !== activeEncounterNpcId,
);
}
export function buildAdventureStatistics(params: {
gameState: GameState;
visibleGameState: GameState;
livePlayTimeMs: number;
}): GameShellAdventureStatistics {
const { gameState, visibleGameState, livePlayTimeMs } = params;
return {
playTimeMs: livePlayTimeMs,
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
questsAccepted: gameState.runtimeStats.questsAccepted,
questsCompleted: visibleGameState.quests.filter(
(quest) => quest.status === 'completed' || quest.status === 'turned_in',
).length,
questsTurnedIn: visibleGameState.quests.filter(
(quest) => quest.status === 'turned_in',
).length,
itemsUsed: gameState.runtimeStats.itemsUsed,
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency,
inventoryItemCount: visibleGameState.playerInventory.reduce(
(sum, item) => sum + item.quantity,
0,
),
inventoryStackCount: visibleGameState.playerInventory.length,
activeCompanionCount: visibleGameState.companions.length,
rosterCompanionCount: visibleGameState.roster.length,
};
}
export function useGameShellRuntimeViewModel(params: Pick<
GameShellProps,
'session' | 'story' | 'companions'
>) {
const { session, story, companions } = params;
const {
gameState,
currentStory,
isLoading,
isMapOpen,
} = session;
const { npcUi, characterChatUi, handleChoice } = story;
const { buildCompanionRenderStates } = companions;
const [clockNow, setClockNow] = useState(() => Date.now());
const openingCampSceneId = useMemo(
() =>
gameState.worldType
? getWorldCampScenePreset(gameState.worldType)?.id ?? null
: null,
[gameState.worldType],
);
const hasNpcModalOpen = Boolean(
npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal,
);
const shellViewModel = useGameShellViewModel({
gameState,
isMapOpen,
characterChatModalOpen: Boolean(characterChatUi.modal),
hasNpcModalOpen,
});
const sceneTransitionModel = useSceneTransitionModel({
gameState,
currentStory,
openingCampSceneId,
});
const {
visibleGameState,
visibleCurrentStory,
sceneTransitionPhase,
beginSceneTransition,
} = sceneTransitionModel;
const isCharacterSelectionStage =
gameState.currentScene === 'Selection' &&
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
shellViewModel.selectionStage !== 'start';
const dialogueIndicator = useMemo(
() =>
buildGameShellDialogueIndicator({
isLoading,
visibleGameState,
visibleCurrentStory,
}),
[isLoading, visibleCurrentStory, visibleGameState],
);
const characterChatSummaries = useMemo(
() => buildCharacterChatSummaries(gameState.characterChats),
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(
() =>
buildCanvasCompanionRenderStates({
visibleCompanionRenderStates,
visibleGameState,
}),
[visibleCompanionRenderStates, visibleGameState],
);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const adventureStatistics = useMemo(
() =>
buildAdventureStatistics({
gameState,
visibleGameState,
livePlayTimeMs,
}),
[gameState, livePlayTimeMs, visibleGameState],
);
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
setClockNow(Date.now());
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter]);
const handleSceneTransitionChoice = useCallback(
(option: StoryOption) => {
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
if (transitionMode) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
},
[beginSceneTransition, handleChoice],
);
return {
...shellViewModel,
...sceneTransitionModel,
isCharacterSelectionStage,
shouldHideStoryOptions,
hideSelectionHero,
dialogueIndicator,
characterChatSummaries,
canvasCompanionRenderStates,
adventureStatistics,
handleSceneTransitionChoice,
};
}

View File

@@ -1,4 +1,8 @@
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
import {
buildEditorJsonApiPath,
EDITOR_JSON_RESOURCE_IDS,
} from '../editor/shared/editorApiClient';
import { saveJsonObject } from '../editor/shared/jsonClient';
import {
buildNpcVisualSavePayload,
@@ -6,18 +10,27 @@ import {
} from './npcVisualEditorModel';
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
export const NPC_VISUAL_OVERRIDES_API_PATH = '/api/npc-visual-overrides';
export const NPC_LAYOUT_CONFIG_API_PATH = '/api/npc-layout-config';
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
);
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
);
type SaveJsonObjectFn = typeof saveJsonObject;
type SaveEditorJsonFn = typeof saveJsonObject;
export async function persistNpcVisualOverrides(params: {
overrideMap: Record<string, MedievalNpcVisualOverride>;
npcId: string;
editorState: EditableNpcVisualState;
saveJson?: SaveJsonObjectFn;
saveJson?: SaveEditorJsonFn;
}) {
const { overrideMap, npcId, editorState, saveJson = saveJsonObject } = params;
const {
overrideMap,
npcId,
editorState,
saveJson = saveJsonObject,
} = params;
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
await saveJson(
@@ -34,7 +47,7 @@ export async function persistNpcVisualOverrides(params: {
export async function persistNpcLayoutConfig(params: {
layoutDraft: NpcLayoutConfig;
saveJson?: SaveJsonObjectFn;
saveJson?: SaveEditorJsonFn;
}) {
const { layoutDraft, saveJson = saveJsonObject } = params;
const nextLayout = cloneNpcLayoutConfig(layoutDraft);

View File

@@ -13,6 +13,7 @@ import { validateCharacterOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { cloneValue } from '../../editor/shared/cloneValue';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
@@ -83,7 +84,7 @@ export function CharacterPresetPanel() {
)
: null;
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/character-overrides',
resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateCharacterOverrides(

View File

@@ -7,6 +7,7 @@ import {
type MonsterPresetOverride,
} from '../../data/hostileNpcPresets';
import monsterOverridesJson from '../../data/monsterOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
@@ -43,7 +44,7 @@ export function MonsterPresetPanel() {
const [previewAnimation, setPreviewAnimation] =
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/monster-overrides',
resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',

View File

@@ -7,6 +7,7 @@ import {
import { validateSceneNpcOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import {
getScenePresetsByWorld,
type SceneNpcPresetOverride,
@@ -116,7 +117,7 @@ export function SceneNpcPresetPanel() {
(effectiveNpc?.initialAffinity ?? 0) < 0,
);
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/scene-npc-overrides',
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneNpcOverrides(

View File

@@ -5,6 +5,7 @@ import { validateSceneOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
import sceneOverridesJson from '../../data/sceneOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import {
getSceneHostileNpcPresetIds,
getSceneHostileNpcs,
@@ -46,7 +47,7 @@ export function ScenePresetPanel() {
);
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/scene-overrides',
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),

View File

@@ -1,20 +1,24 @@
import {
fetchJson,
parseApiErrorMessage,
} from '../../editor/shared/jsonClient';
ASSET_API_PATHS,
postApiJson,
} from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
'/api/character-visual/generate';
ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
'/api/character-visual/publish';
export const CHARACTER_VISUAL_JOB_API_PATH = '/api/character-visual/jobs';
export const CHARACTER_ANIMATION_GENERATE_API_PATH = '/api/animation/generate';
export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish';
export const CHARACTER_ANIMATION_JOB_API_PATH = '/api/animation/jobs';
ASSET_API_PATHS.characterVisualPublish;
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 =
ASSET_API_PATHS.characterAnimationPublish;
export const CHARACTER_ANIMATION_JOB_API_PATH =
ASSET_API_PATHS.characterAnimationJobs;
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
'/api/animation/import-video';
ASSET_API_PATHS.characterAnimationImportVideo;
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
'/api/animation/templates';
ASSET_API_PATHS.characterAnimationTemplates;
export type CharacterVisualSourceMode =
| 'text-to-image'
@@ -119,26 +123,13 @@ export type CharacterAssetJobStatus = {
export async function generateCharacterVisualCandidates(
payload: CharacterVisualGenerationPayload,
) {
const response = await fetch(CHARACTER_VISUAL_GENERATE_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '生成角色主形象候选失败'),
);
}
return JSON.parse(responseText) as {
return postApiJson<{
ok: true;
taskId: string;
model: string;
prompt: string;
drafts: CharacterVisualDraft[];
};
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
}
export async function fetchCharacterVisualJobStatus(taskId: string) {
@@ -151,41 +142,19 @@ export async function fetchCharacterVisualJobStatus(taskId: string) {
export async function publishCharacterVisualAsset(
payload: CharacterVisualPublishPayload,
) {
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '发布角色主形象失败'));
}
return JSON.parse(responseText) as {
return postApiJson<{
ok: true;
assetId: string;
portraitPath: string;
overrideMap: Record<string, unknown>;
saveMessage: string;
};
}>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败');
}
export async function generateCharacterAnimationDraft(
payload: CharacterAnimationGenerationPayload,
) {
const response = await fetch(CHARACTER_ANIMATION_GENERATE_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '生成角色动作草稿失败'));
}
return JSON.parse(responseText) as
return postApiJson<
| {
ok: true;
taskId: string;
@@ -201,7 +170,8 @@ export async function generateCharacterAnimationDraft(
model: string;
prompt: string;
previewVideoPath: string;
};
}
>(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败');
}
export async function fetchCharacterAnimationJobStatus(taskId: string) {
@@ -224,23 +194,12 @@ export async function importCharacterAnimationVideo(payload: {
videoSource: string;
sourceLabel?: string;
}) {
const response = await fetch(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '导入动作视频失败'));
}
return JSON.parse(responseText) as {
return postApiJson<{
ok: true;
importedVideoPath: string;
draftId: string;
saveMessage: string;
};
}>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败');
}
export async function publishCharacterAnimationAssets(payload: {
@@ -249,22 +208,11 @@ export async function publishCharacterAnimationAssets(payload: {
animations: Record<string, CharacterAnimationDraftPayload>;
updateCharacterOverride?: boolean;
}) {
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '发布角色基础动作失败'));
}
return JSON.parse(responseText) as {
return postApiJson<{
ok: true;
animationSetId: string;
overrideMap: Record<string, unknown>;
animationMap: Record<string, unknown>;
saveMessage: string;
};
}>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败');
}