feat(players): app Players library and circular NPC tokens on scenes

Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
+33 -40
View File
@@ -55,6 +55,7 @@ import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -134,9 +135,9 @@ export function EditorApp() {
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<
ReturnType<typeof computeNpcImportConflicts>
>([]);
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
[],
);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
@@ -154,6 +155,7 @@ export function EditorApp() {
const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
@@ -546,12 +548,7 @@ export function EditorApp() {
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
setImportReport(report);
setImportReportOpen(true);
clearImportFlow();
@@ -571,12 +568,7 @@ export function EditorApp() {
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
[],
[],
);
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
@@ -724,9 +716,7 @@ export function EditorApp() {
{t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName
? `: ${state.sceneBatchImport.fileName}`
: ''}
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
</div>
<div>
{Math.round(
@@ -825,6 +815,23 @@ export function EditorApp() {
{t('top.file')}
</button>
) : null}
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -900,9 +907,7 @@ export function EditorApp() {
scene={s}
reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null}
dropPlace={
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
isDragging={draggingListSceneId === s.id}
onSelect={() => {
setSelectedGraphNodeId(null);
@@ -925,9 +930,7 @@ export function EditorApp() {
return;
}
setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place
? cur
: { targetId, place },
cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
);
}}
onDropListReorder={(draggedId, targetId, place) => {
@@ -1545,6 +1548,7 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<MaterialsManagerModal
open={materialsManagerOpen}
materials={state.project?.materials ?? []}
@@ -2370,9 +2374,7 @@ function CampaignInspector({
onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2537,10 +2539,7 @@ function SceneInspector({
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
<div className={styles.spacer8} />
</div>
))}
@@ -2556,9 +2555,7 @@ function SceneInspector({
onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop}
>
{previewDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
@@ -2648,9 +2645,7 @@ function SceneInspector({
onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2947,9 +2942,7 @@ function SceneListCard({
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>