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
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);
}