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