feat(control): grid snap, NPC context actions, and overlay dim fix

Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-06 13:48:59 +08:00
parent e53d1ea934
commit 4456eb0277
24 changed files with 563 additions and 41 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
* Re-enable after QA.
*/
export const USERS_BRANCH_FEATURES_ENABLED = false;
export const USERS_BRANCH_FEATURES_ENABLED = true;
+2
View File
@@ -14,5 +14,7 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /tokenGridSnap:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
});
+16 -1
View File
@@ -197,6 +197,11 @@ export const ipcChannels = {
dispatch: 'sceneTokensSession.dispatch',
stateChanged: 'sceneTokensSession.stateChanged',
},
tokenGridSnap: {
getState: 'tokenGridSnap.getState',
setEnabled: 'tokenGridSnap.setEnabled',
stateChanged: 'tokenGridSnap.stateChanged',
},
players: {
list: 'players.list',
upsert: 'players.upsert',
@@ -293,6 +298,7 @@ export type IpcEventMap = {
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
@@ -673,7 +679,7 @@ export type IpcInvokeMap = {
res: { ok: true };
};
[ipcChannels.windows.openNpcs]: {
req: Record<string, never>;
req: { npcId?: NpcId | null };
res: { ok: true };
};
[ipcChannels.windows.closeNpcs]: {
@@ -768,6 +774,14 @@ export type IpcInvokeMap = {
req: { event: SceneTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.tokenGridSnap.getState]: {
req: Record<string, never>;
res: { enabled: boolean };
};
[ipcChannels.tokenGridSnap.setEnabled]: {
req: { enabled: boolean };
res: { enabled: boolean };
};
[ipcChannels.players.list]: {
req: Record<string, never>;
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
@@ -873,6 +887,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
+1
View File
@@ -9,6 +9,7 @@ export * from './npcDisposition';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
export * from './sceneGridSnap';
export * from './sceneTraps';
export * from './sceneView';
export * from './videoPlayback';
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { snapNormToGridCell } from './sceneGridSnap';
void test('snapNormToGridCell: disabled / invalid size = no-op', () => {
assert.deepEqual(
snapNormToGridCell(0.33, 0.77, { enabled: false, type: 'square', sizeN: 0.1 }, 1000, 800),
{ nx: 0.33, ny: 0.77 },
);
assert.deepEqual(
snapNormToGridCell(0.33, 0.77, { enabled: true, type: 'square', sizeN: 0.1 }, 0, 800),
{ nx: 0.33, ny: 0.77 },
);
});
void test('snapNormToGridCell: square snaps to cell center', () => {
// minDim=1000, sizeN=0.1 → cell=100. Center of (0,0) at (50,50) → (0.05, 0.05) in 1000×1000.
const at = snapNormToGridCell(0.04, 0.06, { enabled: true, type: 'square', sizeN: 0.1 }, 1000, 1000);
assert.ok(Math.abs(at.nx - 0.05) < 1e-9);
assert.ok(Math.abs(at.ny - 0.05) < 1e-9);
const next = snapNormToGridCell(0.14, 0.16, { enabled: true, type: 'square', sizeN: 0.1 }, 1000, 1000);
assert.ok(Math.abs(next.nx - 0.15) < 1e-9);
assert.ok(Math.abs(next.ny - 0.15) < 1e-9);
});
void test('snapNormToGridCell: hex snaps to flat-top center', () => {
const cell = 100;
const horiz = cell * 0.75;
const vert = (Math.sqrt(3) / 2) * cell;
// Center of col=1,row=0: (75, vert/2) because odd col offset.
const cx = 1 * horiz;
const cy = 0 * vert + vert / 2;
const near = snapNormToGridCell(
(cx + 3) / 1000,
(cy - 4) / 1000,
{ enabled: true, type: 'hex', sizeN: 0.1 },
1000,
1000,
);
assert.ok(Math.abs(near.nx - cx / 1000) < 1e-9);
assert.ok(Math.abs(near.ny - cy / 1000) < 1e-9);
});
+88
View File
@@ -0,0 +1,88 @@
/** Привязка нормализованных координат токена к центру ячейки сетки сцены. */
import type { SceneGrid } from './sceneGrid';
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
function cellPx(sizeN: number, contentW: number, contentH: number): number {
const minDim = Math.min(contentW, contentH);
return Math.max(4, sizeN * minDim);
}
function snapSquare(
nx: number,
ny: number,
contentW: number,
contentH: number,
sizeN: number,
): { nx: number; ny: number } {
const cell = cellPx(sizeN, contentW, contentH);
const px = nx * contentW;
const py = ny * contentH;
const col = Math.round(px / cell - 0.5);
const row = Math.round(py / cell - 0.5);
return {
nx: clamp01(((col + 0.5) * cell) / contentW),
ny: clamp01(((row + 0.5) * cell) / contentH),
};
}
/**
* Flat-top hex, как в `SceneGridOverlay.drawHexGrid`:
* центры `(col * horiz, row * vert + (col % 2 === 0 ? 0 : vert/2))`.
*/
function snapHex(
nx: number,
ny: number,
contentW: number,
contentH: number,
sizeN: number,
): { nx: number; ny: number } {
const cell = cellPx(sizeN, contentW, contentH);
const hexW = cell;
const vert = (Math.sqrt(3) / 2) * hexW;
const horiz = hexW * 0.75;
const px = nx * contentW;
const py = ny * contentH;
const col0 = Math.round(px / horiz);
const row0 = Math.round(py / vert);
let bestNx = nx;
let bestNy = ny;
let bestD = Number.POSITIVE_INFINITY;
for (let col = col0 - 2; col <= col0 + 2; col++) {
for (let row = row0 - 2; row <= row0 + 2; row++) {
const cx = col * horiz;
const cy = row * vert + (col % 2 === 0 ? 0 : vert / 2);
const d = (cx - px) * (cx - px) + (cy - py) * (cy - py);
if (d < bestD) {
bestD = d;
bestNx = cx / contentW;
bestNy = cy / contentH;
}
}
}
return { nx: clamp01(bestNx), ny: clamp01(bestNy) };
}
/**
* Snap центра токена к ближайшему центру ячейки.
* Если сетка выключена / нет размеров — координаты без изменений.
*/
export function snapNormToGridCell(
nx: number,
ny: number,
grid: Pick<SceneGrid, 'enabled' | 'type' | 'sizeN'> | null | undefined,
contentW: number,
contentH: number,
): { nx: number; ny: number } {
if (!grid?.enabled) return { nx, ny };
if (!(contentW > 0) || !(contentH > 0)) return { nx, ny };
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return { nx, ny };
if (grid.type === 'hex') {
return snapHex(nx, ny, contentW, contentH, grid.sizeN);
}
return snapSquare(nx, ny, contentW, contentH, grid.sizeN);
}