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:
@@ -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;
|
||||
|
||||
@@ -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\[\]/);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user