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
@@ -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 } : {})}
/>
);
})}