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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user