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:
Ivan Fontosh
2026-07-30 17:11:01 +08:00
parent 101f595bac
commit cc50e64e21
38 changed files with 1803 additions and 59 deletions
+83 -8
View File
@@ -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>
);
}