feat(control): трекер инициативы в отдельном окне

Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 10:09:51 +08:00
parent db1168a46c
commit 86d6d157f0
28 changed files with 1783 additions and 4 deletions
@@ -17,6 +17,12 @@ import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import {
TokenInitiativeDecor,
initiativeConditionsForNpc,
initiativeHighlightForNpc,
} from '../initiative/TokenInitiativeDecor';
import { useInitiativeState } from '../initiative/useInitiativeState';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
@@ -48,6 +54,8 @@ function NpcSprite({
resizeTitle,
onContextMenu,
snapNorm,
initiativeActive = false,
initiativeConditions = [],
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
@@ -66,6 +74,8 @@ function NpcSprite({
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
initiativeActive?: boolean;
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
@@ -123,8 +133,15 @@ function NpcSprite({
return (
<div
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
className={[
styles.token,
canDrag ? styles.editable : '',
initiativeActive ? styles.initiativeActive : '',
]
.filter(Boolean)
.join(' ')}
data-testid={`session-npc-token-${placement.id}`}
data-initiative-active={initiativeActive ? '1' : '0'}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
@@ -189,6 +206,7 @@ function NpcSprite({
sizePx={sizePx}
inactive={inactive}
/>
<TokenInitiativeDecor conditions={initiativeConditions} />
{canResize ? (
<div
className={styles.resizeHandle}
@@ -258,6 +276,7 @@ export function SceneNpcTokensOverlay({
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
dragEnabledById?: Record<string, boolean> | null;
}) {
const [initiative] = useInitiativeState();
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
@@ -288,6 +307,8 @@ export function SceneNpcTokensOverlay({
gridFit={gridFit}
disposition={disposition}
inactive={inactive}
initiativeActive={initiativeHighlightForNpc(initiative, key)}
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}