feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.compact {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.75);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
color: #93c5fd;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.compact .chip {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chipRemove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { InitiativeConditionId } from '../../../shared/types/initiative';
|
||||
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './InitiativeConditionChips.module.css';
|
||||
|
||||
export function InitiativeConditionChips({
|
||||
conditions,
|
||||
removable = false,
|
||||
onRemove,
|
||||
compact = false,
|
||||
}: {
|
||||
conditions: readonly InitiativeConditionId[];
|
||||
removable?: boolean;
|
||||
onRemove?: (id: InitiativeConditionId) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
if (conditions.length === 0) return null;
|
||||
return (
|
||||
<div className={[styles.row, compact ? styles.compact : ''].filter(Boolean).join(' ')}>
|
||||
{conditions.map((id) => (
|
||||
<span key={id} className={styles.chip} data-testid={`initiative-condition-${id}`}>
|
||||
<span className={styles.chipLabel}>{t(`initiative.condition.${id}`)}</span>
|
||||
{removable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
aria-label={t('initiative.removeCondition')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { InitiativeConditionId } from '../../../shared/types/initiative';
|
||||
|
||||
import { InitiativeConditionChips } from './InitiativeConditionChips';
|
||||
import styles from './TokenInitiativeDecor.module.css';
|
||||
|
||||
export {
|
||||
initiativeConditionsForNpc,
|
||||
initiativeConditionsForPlayer,
|
||||
initiativeHighlightForNpc,
|
||||
initiativeHighlightForPlayer,
|
||||
} from './initiativeSceneHelpers';
|
||||
|
||||
/** Чипы состояний под токеном (видны и на презентации). */
|
||||
export function TokenInitiativeDecor({
|
||||
conditions,
|
||||
}: {
|
||||
conditions: readonly InitiativeConditionId[];
|
||||
}) {
|
||||
if (conditions.length === 0) return null;
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<InitiativeConditionChips conditions={conditions} compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InitiativeConditionId, InitiativeState } from '../../../shared/types/initiative';
|
||||
import {
|
||||
initiativeNpcEntryId,
|
||||
initiativePlayerEntryId,
|
||||
} from '../../../shared/types/initiative';
|
||||
|
||||
export function initiativeHighlightForNpc(
|
||||
state: InitiativeState | null | undefined,
|
||||
placementId: string,
|
||||
): boolean {
|
||||
if (!state?.enabled || !state.activeId) return false;
|
||||
return state.activeId === initiativeNpcEntryId(placementId);
|
||||
}
|
||||
|
||||
export function initiativeHighlightForPlayer(
|
||||
state: InitiativeState | null | undefined,
|
||||
playerId: string,
|
||||
): boolean {
|
||||
if (!state?.enabled || !state.activeId) return false;
|
||||
return state.activeId === initiativePlayerEntryId(playerId);
|
||||
}
|
||||
|
||||
export function initiativeConditionsForNpc(
|
||||
state: InitiativeState | null | undefined,
|
||||
placementId: string,
|
||||
): InitiativeConditionId[] {
|
||||
if (!state) return [];
|
||||
return state.conditions[initiativeNpcEntryId(placementId)] ?? [];
|
||||
}
|
||||
|
||||
export function initiativeConditionsForPlayer(
|
||||
state: InitiativeState | null | undefined,
|
||||
playerId: string,
|
||||
): InitiativeConditionId[] {
|
||||
if (!state) return [];
|
||||
return state.conditions[initiativePlayerEntryId(playerId)] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { InitiativeEvent, InitiativeState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useInitiativeState(): readonly [
|
||||
InitiativeState | null,
|
||||
{ dispatch: (event: InitiativeEvent) => Promise<InitiativeState> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<InitiativeState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.initiative.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.initiative.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
const r = await api.invoke(ipcChannels.initiative.dispatch, { event });
|
||||
setState(r.state);
|
||||
return r.state;
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -30,6 +30,27 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.initiativeActive {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
|
||||
.initiativeActive::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 3px solid #f59e0b;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
|
||||
@@ -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 } : {})}
|
||||
|
||||
@@ -14,6 +14,12 @@ import { usePlayerImageUrl } from './usePlayerImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import {
|
||||
TokenInitiativeDecor,
|
||||
initiativeConditionsForPlayer,
|
||||
initiativeHighlightForPlayer,
|
||||
} from '../initiative/TokenInitiativeDecor';
|
||||
import { useInitiativeState } from '../initiative/useInitiativeState';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -31,6 +37,8 @@ function PlayerSprite({
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
initiativeActive = false,
|
||||
initiativeConditions = [],
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
nx: number;
|
||||
@@ -44,6 +52,8 @@ function PlayerSprite({
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
initiativeActive?: boolean;
|
||||
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -100,8 +110,15 @@ function PlayerSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
editable ? styles.editable : '',
|
||||
initiativeActive ? styles.initiativeActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-testid={`session-player-token-${player.id}`}
|
||||
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
@@ -155,6 +172,7 @@ function PlayerSprite({
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
<TokenInitiativeDecor conditions={initiativeConditions} />
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
@@ -218,6 +236,7 @@ export function ScenePlayerTokensOverlay({
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const [initiative] = useInitiativeState();
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
@@ -238,6 +257,8 @@ export function ScenePlayerTokensOverlay({
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
initiativeActive={initiativeHighlightForPlayer(initiative, playerId)}
|
||||
initiativeConditions={initiativeConditionsForPlayer(initiative, playerId)}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
|
||||
@@ -14,6 +14,7 @@ const SECONDARY_WINDOW_MAINS = [
|
||||
'materials/main.tsx',
|
||||
'playlists/main.tsx',
|
||||
'timer/main.tsx',
|
||||
'initiative/main.tsx',
|
||||
'sceneEditor/main.tsx',
|
||||
'sceneDescription/main.tsx',
|
||||
'control/main.tsx',
|
||||
@@ -148,6 +149,12 @@ void test('ControlApp: Timer tool opens window', () => {
|
||||
assert.ok(src.includes('SessionTimerLayer'));
|
||||
});
|
||||
|
||||
void test('ControlApp: Initiative tool opens window', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('data-testid="control-initiative-tool"'));
|
||||
assert.ok(src.includes('windows.openInitiative'));
|
||||
});
|
||||
|
||||
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||
|
||||
Reference in New Issue
Block a user