feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -595,3 +595,43 @@
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
@@ -8,19 +9,27 @@ import {
|
||||
isNodeInSideStoryline,
|
||||
listSideStoryStarts,
|
||||
} from '../../shared/graph/sceneGraphLineage';
|
||||
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import type { GraphNodeId, NpcDisposition, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
NPC_TOKEN_SESSION_SCALE_MAX,
|
||||
NPC_TOKEN_SESSION_SCALE_MIN,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
|
||||
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { SceneNpcTokensOverlay } from '../shared/playerToken/SceneNpcTokensOverlay';
|
||||
import {
|
||||
resolveNpcTokenDisposition,
|
||||
SceneNpcTokensOverlay,
|
||||
} from '../shared/playerToken/SceneNpcTokensOverlay';
|
||||
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
|
||||
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
|
||||
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
|
||||
@@ -59,6 +68,12 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||||
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
||||
const LIGHTNING_EFFECT_MS = 840;
|
||||
|
||||
function dispositionMakeKey(d: NpcDisposition): string {
|
||||
if (d === 'hostile') return 'npcs.makeHostile';
|
||||
if (d === 'friendly') return 'npcs.makeFriendly';
|
||||
return 'npcs.makeNeutral';
|
||||
}
|
||||
|
||||
function clampAudioGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
@@ -127,6 +142,13 @@ export function ControlApp() {
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
||||
const { players: appPlayers } = useAppPlayers();
|
||||
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
@@ -1704,6 +1726,27 @@ export function ControlApp() {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
|
||||
<Button
|
||||
data-testid="toggle-session-players"
|
||||
onClick={() => {
|
||||
if (scenePlayerTokensSession.visible) {
|
||||
scenePlayerTokensApi.dispatch({ kind: 'setVisible', visible: false });
|
||||
return;
|
||||
}
|
||||
const grid = currentScene?.grid;
|
||||
const sizeN =
|
||||
grid?.enabled && Number.isFinite(grid.sizeN)
|
||||
? grid.sizeN
|
||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
|
||||
}}
|
||||
>
|
||||
{scenePlayerTokensSession.visible
|
||||
? t('control.hidePlayers')
|
||||
: t('control.showPlayers')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||
{t('control.stopPresentation')}
|
||||
</Button>
|
||||
@@ -1914,11 +1957,33 @@ export function ControlApp() {
|
||||
library={session?.project?.npcs ?? []}
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={previewContentRect}
|
||||
grid={currentScene?.grid}
|
||||
grid={currentScene?.grid ?? null}
|
||||
editable
|
||||
onMove={(placementId, nx, ny) => {
|
||||
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
|
||||
}}
|
||||
onContextMenu={(e, placement) => {
|
||||
setNpcSessionCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
placementId: String(placement.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{previewContentRect ? (
|
||||
<ScenePlayerTokensOverlay
|
||||
library={appPlayers}
|
||||
session={scenePlayerTokensSession}
|
||||
displayScale={
|
||||
sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE
|
||||
}
|
||||
viewport={previewContentRect}
|
||||
grid={currentScene?.grid ?? null}
|
||||
editable
|
||||
onMove={(playerId, nx, ny) => {
|
||||
scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{previewContentRect ? (
|
||||
@@ -2315,6 +2380,80 @@ export function ControlApp() {
|
||||
</Surface>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{npcSessionCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setNpcSessionCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: npcSessionCtxMenu.x, top: npcSessionCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
{(() => {
|
||||
const placement = (currentScene?.npcTokens ?? []).find(
|
||||
(item) => String(item.id) === npcSessionCtxMenu.placementId,
|
||||
);
|
||||
const npc = placement
|
||||
? (session?.project?.npcs ?? []).find((item) => item.id === placement.npcId)
|
||||
: undefined;
|
||||
if (!placement || !npc) return null;
|
||||
const override =
|
||||
sceneNpcTokensSession.byPlacementId[npcSessionCtxMenu.placementId];
|
||||
const current = resolveNpcTokenDisposition(
|
||||
placement,
|
||||
npc,
|
||||
override?.disposition,
|
||||
);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
return (
|
||||
<>
|
||||
{otherNpcDispositions(current).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'setDisposition',
|
||||
placementId: npcSessionCtxMenu.placementId,
|
||||
disposition: d,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{t(dispositionMakeKey(d))}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'setInactive',
|
||||
placementId: npcSessionCtxMenu.placementId,
|
||||
inactive: !inactive,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,68 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.splitRun {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitRun:disabled,
|
||||
.splitRun[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splitRunMain,
|
||||
.splitRunChevron {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitRunMain {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.splitRunMain:hover:not(:disabled),
|
||||
.splitRunChevron:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||
}
|
||||
|
||||
.splitRunMain:active:not(:disabled),
|
||||
.splitRunChevron:active:not(:disabled),
|
||||
.splitRunChevronOpen {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||
}
|
||||
|
||||
.splitRunDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.splitRunChevron {
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.splitRunChevronSvg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||
import { PlayersManagerModal } from './PlayersModals';
|
||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
@@ -156,6 +157,10 @@ export function EditorApp() {
|
||||
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
|
||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||
const onProjectNotice = useCallback(
|
||||
(code: ProjectNoticeCode) => {
|
||||
@@ -353,13 +358,17 @@ export function EditorApp() {
|
||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||
|
||||
const launchFromGraphNode = useCallback(
|
||||
(graphNodeId: GraphNodeId) => {
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
|
||||
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
|
||||
});
|
||||
} catch {
|
||||
setLaunching(false);
|
||||
}
|
||||
@@ -463,6 +472,23 @@ export function EditorApp() {
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
|
||||
else setRunMenuPos(null);
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.closest('[data-runmenu-root="1"]')) return;
|
||||
setRunMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [runMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let off: (() => void) | null = null;
|
||||
void (async () => {
|
||||
@@ -842,16 +868,55 @@ export function EditorApp() {
|
||||
<div className={styles.headerActions}>
|
||||
{state.project ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
<div
|
||||
ref={runSplitRef}
|
||||
className={styles.splitRun}
|
||||
data-runmenu-root="1"
|
||||
aria-disabled={runDisabled || launching ? true : undefined}
|
||||
>
|
||||
{t('top.run')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.splitRunMain}
|
||||
disabled={runDisabled || launching}
|
||||
data-testid="run-main-btn"
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
</button>
|
||||
<span className={styles.splitRunDivider} aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.splitRunChevron,
|
||||
runMenuOpen ? styles.splitRunChevronOpen : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={runDisabled || launching}
|
||||
aria-label={t('top.runMenuAria')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={runMenuOpen}
|
||||
data-testid="run-menu-btn"
|
||||
onClick={() => {
|
||||
if (runDisabled || launching) return;
|
||||
setRunMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
|
||||
<path
|
||||
d="M2.5 4.25 L6 7.75 L9.5 4.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{runDisabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1549,6 +1614,38 @@ export function EditorApp() {
|
||||
/>
|
||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
||||
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
|
||||
<LaunchPlayersModal
|
||||
open={launchPlayersOpen}
|
||||
onClose={() => setLaunchPlayersOpen(false)}
|
||||
onConfirm={(playerIds) => {
|
||||
if (!graphStartGraphNodeId) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
||||
}}
|
||||
/>
|
||||
{runMenuOpen && runMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
data-runmenu-root="1"
|
||||
className={styles.fileMenu}
|
||||
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fileMenuItem}
|
||||
role="menuitem"
|
||||
data-testid="run-with-players-item"
|
||||
onClick={() => {
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.runWithPlayers')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<MaterialsManagerModal
|
||||
open={materialsManagerOpen}
|
||||
materials={state.project?.materials ?? []}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.dialog {
|
||||
width: min(440px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 10px 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import launchStyles from './LaunchPlayersModal.module.css';
|
||||
|
||||
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
|
||||
export function LaunchPlayersModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (playerIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
|
||||
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedPlayers(new Set());
|
||||
setSelectedTeams(new Set());
|
||||
}, [open]);
|
||||
|
||||
const resolvedIds = useMemo(
|
||||
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
|
||||
[players, selectedPlayers, selectedTeams],
|
||||
);
|
||||
|
||||
const standalonePlayers = useMemo(
|
||||
() =>
|
||||
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
|
||||
[players, selectedTeams],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const togglePlayer = (id: PlayerId) => {
|
||||
setSelectedPlayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = String(id);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTeam = (id: PlayerTeamId) => {
|
||||
const key = String(id);
|
||||
setSelectedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
|
||||
setSelectedPlayers((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const p of players) {
|
||||
if (String(p.teamId) !== key) continue;
|
||||
if (next.delete(String(p.id))) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="launch-players-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
|
||||
{teams.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{teams.map((team: AppPlayerTeam) => {
|
||||
const count = players.filter((p) => p.teamId === team.id).length;
|
||||
return (
|
||||
<label key={team.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeams.has(String(team.id))}
|
||||
onChange={() => toggleTeam(team.id)}
|
||||
data-testid={`launch-team-${team.id}`}
|
||||
/>
|
||||
<span className={launchStyles.teamDot} style={{ background: team.color }} />
|
||||
<span>
|
||||
{team.name} ({count})
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{standalonePlayers.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{standalonePlayers.map((player: AppPlayer) => (
|
||||
<label key={player.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayers.has(String(player.id))}
|
||||
onChange={() => togglePlayer(player.id)}
|
||||
data-testid={`launch-player-${player.id}`}
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : players.length === 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={resolvedIds.length === 0}
|
||||
data-testid="launch-players-confirm"
|
||||
onClick={() => onConfirm(resolvedIds)}
|
||||
>
|
||||
{t('top.runWithPlayersConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
'top.runMenuAria': 'Дополнительные варианты запуска',
|
||||
'top.runWithPlayers': 'Запустить с игроками',
|
||||
'top.runWithPlayersTitle': 'Запуск с игроками',
|
||||
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
|
||||
'top.runWithPlayersTeams': 'Команды',
|
||||
'top.runWithPlayersPlayers': 'Игроки',
|
||||
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
|
||||
'top.runWithPlayersConfirm': 'Запустить',
|
||||
'top.launching': 'Запуск…',
|
||||
'top.afterLicense': 'Доступно после активации лицензии',
|
||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||
@@ -207,7 +215,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
@@ -465,6 +473,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Выберите аватар.',
|
||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'ТИП',
|
||||
'npcs.disposition.hostile': 'Враждебный',
|
||||
'npcs.disposition.neutral': 'Нейтральный',
|
||||
'npcs.disposition.friendly': 'Дружественный',
|
||||
'npcs.makeHostile': 'Сделать враждебным',
|
||||
'npcs.makeNeutral': 'Сделать нейтральным',
|
||||
'npcs.makeFriendly': 'Сделать дружественным',
|
||||
'npcs.inactive': 'Неактивен',
|
||||
'npcs.makeActive': 'Сделать активным',
|
||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
@@ -603,8 +620,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Пройдено',
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.npcTokenScale': 'Размер НПС',
|
||||
'control.stopPresentation': 'Выключить демонстрацию',
|
||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||
'control.stopPresentation': 'Выключить',
|
||||
'control.showPlayers': 'Показать игроков',
|
||||
'control.hidePlayers': 'Скрыть игроков',
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
@@ -710,6 +729,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
'top.runMenuAria': 'More launch options',
|
||||
'top.runWithPlayers': 'Run with players',
|
||||
'top.runWithPlayersTitle': 'Launch with players',
|
||||
'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.',
|
||||
'top.runWithPlayersTeams': 'Teams',
|
||||
'top.runWithPlayersPlayers': 'Players',
|
||||
'top.runWithPlayersEmpty': 'No players in the library.',
|
||||
'top.runWithPlayersConfirm': 'Launch',
|
||||
'top.launching': 'Starting…',
|
||||
'top.afterLicense': 'Available after license activation',
|
||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
||||
@@ -803,7 +830,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
@@ -1063,6 +1090,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Choose an avatar.',
|
||||
'npcs.chooseAvatar': 'Choose avatar',
|
||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'TYPE',
|
||||
'npcs.disposition.hostile': 'Hostile',
|
||||
'npcs.disposition.neutral': 'Neutral',
|
||||
'npcs.disposition.friendly': 'Friendly',
|
||||
'npcs.makeHostile': 'Make Hostile',
|
||||
'npcs.makeNeutral': 'Make Neutral',
|
||||
'npcs.makeFriendly': 'Make Friendly',
|
||||
'npcs.inactive': 'Inactive',
|
||||
'npcs.makeActive': 'Make Active',
|
||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
'npcs.descriptionPlaceholder': 'Character description…',
|
||||
@@ -1201,8 +1237,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Visited',
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'control.npcTokenScale': 'NPC size',
|
||||
'control.stopPresentation': 'Stop presentation',
|
||||
'control.npcTokenScale': 'Play token size',
|
||||
'control.stopPresentation': 'Turn off',
|
||||
'control.showPlayers': 'Show players',
|
||||
'control.hidePlayers': 'Hide players',
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
@@ -739,7 +740,7 @@ export function NpcsEditorApp() {
|
||||
<PlayerTokenView
|
||||
name={selected.name}
|
||||
imageUrl={selectedUrl}
|
||||
ringColor={selected.ringColor}
|
||||
ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
|
||||
imageOffset={selected.imageOffset}
|
||||
imageScale={selected.imageScale}
|
||||
sizePx={140}
|
||||
@@ -782,17 +783,21 @@ export function NpcsEditorApp() {
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<div className={styles.fieldLabel}>{t('npcs.ringColor')}</div>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
value={selected.ringColor}
|
||||
onChange={(e) => {
|
||||
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
|
||||
<Select
|
||||
value={selected.disposition ?? 'neutral'}
|
||||
ariaLabel={t('npcs.disposition')}
|
||||
onChange={(next) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
ringColor: e.currentTarget.value,
|
||||
disposition: next as 'hostile' | 'neutral' | 'friendly',
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ value: 'hostile', label: t('npcs.disposition.hostile') },
|
||||
{ value: 'neutral', label: t('npcs.disposition.neutral') },
|
||||
{ value: 'friendly', label: t('npcs.disposition.friendly') },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -465,3 +465,49 @@
|
||||
.toolbar button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem,
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
color: var(--color-danger, #e57373);
|
||||
}
|
||||
|
||||
.ctxItem:hover,
|
||||
.ctxItemDanger:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type {
|
||||
NpcDisposition,
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
@@ -17,6 +18,11 @@ import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
otherNpcDispositions,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import {
|
||||
asSceneTokenId,
|
||||
asTokenId,
|
||||
@@ -39,6 +45,7 @@ import {
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
@@ -53,6 +60,11 @@ import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import { TokenEditModal } from './TokenEditModal';
|
||||
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
|
||||
|
||||
function dispositionMakeKey(d: NpcDisposition): string {
|
||||
if (d === 'hostile') return 'npcs.makeHostile';
|
||||
if (d === 'friendly') return 'npcs.makeFriendly';
|
||||
return 'npcs.makeNeutral';
|
||||
}
|
||||
function isTypingTarget(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
@@ -127,7 +139,7 @@ function SceneNpcMarker({
|
||||
sizePx,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onContextMenu,
|
||||
onMovePointerDown,
|
||||
onResizePointerDown,
|
||||
}: {
|
||||
@@ -138,11 +150,12 @@ function SceneNpcMarker({
|
||||
sizePx: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
onMovePointerDown: (e: React.PointerEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
|
||||
return (
|
||||
<div
|
||||
data-testid={`scene-npc-token-${placement.id}`}
|
||||
@@ -151,7 +164,7 @@ function SceneNpcMarker({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
onContextMenu(e);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -162,7 +175,7 @@ function SceneNpcMarker({
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
@@ -174,6 +187,7 @@ function SceneNpcMarker({
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(npc.disposition);
|
||||
return (
|
||||
<div
|
||||
className={styles.npcTile}
|
||||
@@ -188,7 +202,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={70}
|
||||
@@ -199,6 +213,7 @@ function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
|
||||
export function SceneEditorApp() {
|
||||
const api = getDndApi();
|
||||
const { t } = useEditorI18n();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
@@ -211,6 +226,11 @@ export function SceneEditorApp() {
|
||||
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
|
||||
>(null);
|
||||
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
|
||||
const [npcCtxMenu, setNpcCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [selected, setSelected] = useState<Selection>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
@@ -439,12 +459,14 @@ export function SceneEditorApp() {
|
||||
localGrid.enabled && Number.isFinite(localGrid.sizeN)
|
||||
? clampSceneNpcTokenSizeN(localGrid.sizeN)
|
||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
const npc = project?.npcs.find((item) => item.id === npcId);
|
||||
const placement: SceneNpcToken = {
|
||||
id: asSceneNpcTokenId(randomId('snpc')),
|
||||
npcId,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: gridCellSize,
|
||||
disposition: normalizeNpcDisposition(npc?.disposition),
|
||||
};
|
||||
setSelected({ kind: 'npcToken', id: placement.id });
|
||||
persistNpcTokens([...npcTokensRef.current, placement]);
|
||||
@@ -871,9 +893,9 @@ export function SceneEditorApp() {
|
||||
sizePx={sizePx}
|
||||
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
|
||||
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
|
||||
onDelete={() => {
|
||||
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== tok.id));
|
||||
setSelected((current) => (current?.id === tok.id ? null : current));
|
||||
onContextMenu={(e) => {
|
||||
setSelected({ kind: 'npcToken', id: tok.id });
|
||||
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
@@ -1018,6 +1040,59 @@ export function SceneEditorApp() {
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{npcCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setNpcCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = npcCtxMenu.placementId;
|
||||
setNpcCtxMenu(null);
|
||||
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
|
||||
setSelected((current) => (current?.id === id ? null : current));
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
|
||||
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
|
||||
if (!tok || !npc) return null;
|
||||
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
|
||||
return otherNpcDispositions(current).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateNpcToken(tok.id, { disposition: d });
|
||||
setNpcCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{t(dispositionMakeKey(d))}
|
||||
</button>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
|
||||
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
|
||||
import { useAppPlayers } from './playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
@@ -28,6 +31,7 @@ import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
export type PresentationViewProps = {
|
||||
session: SessionState | null;
|
||||
@@ -50,8 +54,10 @@ export function PresentationView({
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const appTokens = useAppTokens();
|
||||
const { players: appPlayers } = useAppPlayers();
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
@@ -189,6 +195,15 @@ export function PresentationView({
|
||||
grid={scene.grid}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<ScenePlayerTokensOverlay
|
||||
library={appPlayers}
|
||||
session={scenePlayerTokensSession}
|
||||
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
|
||||
@@ -26,6 +26,8 @@ export type PlayerTokenViewProps = {
|
||||
className?: string;
|
||||
/** Без подписи имени (компактный маркер). */
|
||||
hideName?: boolean;
|
||||
/** Session «Неактивен»: серый фильтр аватара. */
|
||||
inactive?: boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
@@ -41,6 +43,7 @@ export function PlayerTokenView({
|
||||
onImageScaleChange,
|
||||
className,
|
||||
hideName = false,
|
||||
inactive = false,
|
||||
'data-testid': testId,
|
||||
}: PlayerTokenViewProps) {
|
||||
const offset = clampPlayerImageOffset(imageOffset);
|
||||
@@ -128,7 +131,7 @@ export function PlayerTokenView({
|
||||
style={{
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
borderColor: ringColor,
|
||||
borderColor: inactive ? '#9ca3af' : ringColor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -143,6 +146,7 @@ export function PlayerTokenView({
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
||||
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
NpcDisposition,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
} from '../../../shared/types/npcDisposition';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export function resolveNpcTokenDisposition(
|
||||
placement: SceneNpcToken,
|
||||
npc: ProjectNpc,
|
||||
sessionDisposition?: NpcDisposition,
|
||||
): NpcDisposition {
|
||||
if (sessionDisposition) return normalizeNpcDisposition(sessionDisposition);
|
||||
return normalizeNpcDisposition(placement.disposition ?? npc.disposition);
|
||||
}
|
||||
|
||||
function NpcSprite({
|
||||
placement,
|
||||
npc,
|
||||
@@ -24,7 +39,10 @@ function NpcSprite({
|
||||
editable,
|
||||
displayScale,
|
||||
gridFit,
|
||||
disposition,
|
||||
inactive,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
@@ -34,7 +52,10 @@ function NpcSprite({
|
||||
editable: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
disposition: NpcDisposition;
|
||||
inactive: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -47,6 +68,7 @@ function NpcSprite({
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||
@@ -64,7 +86,7 @@ function NpcSprite({
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
onMove?.(String(placement.id), drag.lastNx, drag.lastNy);
|
||||
flush(drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
@@ -78,6 +100,15 @@ function NpcSprite({
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
? (e) => {
|
||||
@@ -107,6 +138,7 @@ function NpcSprite({
|
||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -116,10 +148,11 @@ function NpcSprite({
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
ringColor={npcDispositionRingColor(disposition, inactive)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
inactive={inactive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -133,6 +166,7 @@ export function SceneNpcTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -142,6 +176,7 @@ export function SceneNpcTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
}) {
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
@@ -153,6 +188,8 @@ export function SceneNpcTokensOverlay({
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
const override = session?.byPlacementId[String(placement.id)];
|
||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
return (
|
||||
<NpcSprite
|
||||
key={placement.id}
|
||||
@@ -164,7 +201,10 @@ export function SceneNpcTokensOverlay({
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AppPlayer,
|
||||
SceneGrid,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { usePlayerImageUrl } from './usePlayerImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function PlayerSprite({
|
||||
player,
|
||||
nx,
|
||||
ny,
|
||||
sizeN,
|
||||
viewport,
|
||||
editable,
|
||||
displayScale,
|
||||
gridFit,
|
||||
onMove,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
nx: number;
|
||||
ny: number;
|
||||
sizeN: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
|
||||
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
|
||||
};
|
||||
};
|
||||
|
||||
const end = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
flush(drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`session-player-token-${player.id}`}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = point(e);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startNx: pos.nx,
|
||||
startNy: pos.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
lastNx: pos.nx,
|
||||
lastNy: pos.ny,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
? (e) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
const p = point(e);
|
||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenePlayerTokensOverlay({
|
||||
library,
|
||||
session,
|
||||
displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
viewport,
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
}: {
|
||||
library: readonly AppPlayer[];
|
||||
session: ScenePlayerTokensSessionState | null;
|
||||
/** Общий масштаб с НПС-токенами. */
|
||||
displayScale?: number;
|
||||
viewport: Viewport | null;
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
}) {
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{session.selectedPlayerIds.map((playerId) => {
|
||||
const player = byId.get(playerId);
|
||||
const placement = session.byPlayerId[playerId];
|
||||
if (!player || !placement) return null;
|
||||
return (
|
||||
<PlayerSprite
|
||||
key={playerId}
|
||||
player={player}
|
||||
nx={placement.nx}
|
||||
ny={placement.ny}
|
||||
sizeN={placement.sizeN}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** Во время drag шлёт onMove не чаще одного раза за кадр (для live-синхронизации презентации). */
|
||||
export function useLiveDragBroadcast(
|
||||
onMove: ((id: string, nx: number, ny: number) => void) | undefined,
|
||||
id: string,
|
||||
): {
|
||||
schedule: (nx: number, ny: number) => void;
|
||||
flush: (nx: number, ny: number) => void;
|
||||
} {
|
||||
const rafRef = useRef(0);
|
||||
const pendingRef = useRef<{ nx: number; ny: number } | null>(null);
|
||||
const onMoveRef = useRef(onMove);
|
||||
onMoveRef.current = onMove;
|
||||
const idRef = useRef(id);
|
||||
idRef.current = id;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const schedule = useCallback((nx: number, ny: number) => {
|
||||
if (!onMoveRef.current) return;
|
||||
pendingRef.current = { nx, ny };
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = 0;
|
||||
const pending = pendingRef.current;
|
||||
const cb = onMoveRef.current;
|
||||
if (!pending || !cb) return;
|
||||
cb(idRef.current, pending.nx, pending.ny);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flush = useCallback((nx: number, ny: number) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
}
|
||||
pendingRef.current = null;
|
||||
onMoveRef.current?.(idRef.current, nx, ny);
|
||||
}, []);
|
||||
|
||||
return { schedule, flush };
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionPlacement,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
|
||||
@@ -18,6 +20,10 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
}
|
||||
|
||||
function applyEvent(
|
||||
prev: SceneNpcTokensSessionState,
|
||||
event: SceneNpcTokensSessionEvent,
|
||||
@@ -34,11 +40,69 @@ function applyEvent(
|
||||
if (prev.scale === scale) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, scale };
|
||||
}
|
||||
if (event.kind === 'setDisposition') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (cur?.disposition === disposition) return prev;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
disposition,
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setInactive') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (Boolean(cur?.inactive) === inactive) return prev;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
// move
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[event.placementId]: { nx: event.nx, ny: event.ny },
|
||||
[placementId]: {
|
||||
nx: event.nx,
|
||||
ny: event.ny,
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
ScenePlayerTokensSessionEvent,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function applyEvent(
|
||||
prev: ScenePlayerTokensSessionState,
|
||||
event: ScenePlayerTokensSessionEvent,
|
||||
): ScenePlayerTokensSessionState {
|
||||
if (event.kind === 'clear') {
|
||||
return { revision: prev.revision + 1, selectedPlayerIds: [], visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'clearPlacements') {
|
||||
return { ...prev, revision: prev.revision + 1, visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'setSelection') {
|
||||
const seen = new Set<string>();
|
||||
const selectedPlayerIds: string[] = [];
|
||||
for (const id of event.playerIds) {
|
||||
const s = String(id ?? '').trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
selectedPlayerIds.push(s);
|
||||
}
|
||||
return { revision: prev.revision + 1, selectedPlayerIds, visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'setVisible') {
|
||||
if (event.visible && prev.selectedPlayerIds.length === 0) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, visible: Boolean(event.visible) };
|
||||
}
|
||||
if (event.kind === 'show') {
|
||||
if (prev.selectedPlayerIds.length === 0) return prev;
|
||||
// placements приходят с main; локально только включаем visible после seed через broadcast
|
||||
return { ...prev, revision: prev.revision + 1, visible: true };
|
||||
}
|
||||
if (event.kind === 'seedBottom') {
|
||||
return prev;
|
||||
}
|
||||
if (event.kind === 'move') {
|
||||
const prevPlacement = prev.byPlayerId[event.playerId];
|
||||
return {
|
||||
...prev,
|
||||
revision: prev.revision + 1,
|
||||
byPlayerId: {
|
||||
...prev.byPlayerId,
|
||||
[event.playerId]: {
|
||||
nx: event.nx,
|
||||
ny: event.ny,
|
||||
sizeN: prevPlacement?.sizeN ?? 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
export function useScenePlayerTokensSession(): [
|
||||
ScenePlayerTokensSessionState,
|
||||
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<ScenePlayerTokensSessionState>({
|
||||
revision: 0,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
|
||||
if (res.state.revision < localRevRef.current) return;
|
||||
localRevRef.current = res.state.revision;
|
||||
setState(res.state);
|
||||
});
|
||||
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: ScenePlayerTokensSessionEvent) => {
|
||||
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, { dispatch }];
|
||||
}
|
||||
@@ -112,10 +112,21 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
|
||||
assert.ok(control.includes('SceneNpcTokensOverlay'));
|
||||
assert.ok(presentation.includes('SceneNpcTokensOverlay'));
|
||||
assert.ok(control.includes('sceneNpcTokensSession'));
|
||||
assert.ok(control.includes('ScenePlayerTokensOverlay'));
|
||||
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
|
||||
assert.ok(control.includes('toggle-session-players'));
|
||||
});
|
||||
|
||||
void test('EditorApp: split Run button and launch-with-players modal', () => {
|
||||
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
|
||||
assert.ok(editor.includes('splitRun'));
|
||||
assert.ok(editor.includes('run-menu-btn'));
|
||||
assert.ok(editor.includes('LaunchPlayersModal'));
|
||||
assert.ok(editor.includes('top.runWithPlayers'));
|
||||
});
|
||||
|
||||
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('PlayerTokenView'));
|
||||
assert.ok(src.includes('ringColor') || src.includes('updateNpcFields'));
|
||||
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user