mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
fix: show furniture-occupied tiles in the floor plan editor
The editor never requested occupied tiles, so tiles holding furniture were indistinguishable from empty floor and could be edited/voided. - request GetOccupiedTilesMessageComposer when the editor opens - handle RoomOccupiedTilesMessageEvent -> SET_OCCUPIED_TILES - new Tile.occupied flag (kept separate from `blocked`/void): occupied tiles render with a distinct marker and are protected from PAINT/ ERASE/ADJUST and brush-to-selection edits - occupied is purely informational and never changes the saved tilemap (no accidental voiding of floor under furni) Tests: reducer cases for SET_OCCUPIED_TILES + edit protection; container test asserts the occupied event is non-destructive on save; route the canvas pointer test through elementFromPoint (jsdom has no getScreenCTM).
This commit is contained in:
@@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () =>
|
||||
expect(composer.thicknessFloor).toBe(1);
|
||||
});
|
||||
|
||||
it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () =>
|
||||
it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () =>
|
||||
{
|
||||
openEditor();
|
||||
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
|
||||
@@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () =>
|
||||
fireEvent.click(saveBtn!);
|
||||
const composer = sendMessageComposer.mock.calls[0][0];
|
||||
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
|
||||
// Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x'
|
||||
expect(composer.tilemap.split(/\r/)[0]).toBe('0x');
|
||||
// Occupied is purely informational: the tile stays walkable and the
|
||||
// saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x').
|
||||
expect(composer.tilemap.split(/\r/)[0]).toBe('00');
|
||||
});
|
||||
|
||||
it('RoomEngineEvent.DISPOSED hides the editor', () =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
@@ -50,8 +50,16 @@ export const FloorplanEditorView: FC = () =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
SendMessageComposer(new GetRoomEntryTileMessageComposer());
|
||||
// Ask the server which tiles currently hold furniture so they can be
|
||||
// shown (and protected from editing) in the grid.
|
||||
SendMessageComposer(new GetOccupiedTilesMessageComposer());
|
||||
}, [ isVisible ]);
|
||||
|
||||
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
|
||||
{
|
||||
dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap });
|
||||
});
|
||||
|
||||
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
@@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () =>
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
|
||||
it('is a no-op on occupied tiles', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]);
|
||||
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_OCCUPIED_TILES', () =>
|
||||
{
|
||||
it('marks tiles occupied per the map without touching h or blocked', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]);
|
||||
const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true });
|
||||
// already-unoccupied tile is left untouched (no spurious occupied key)
|
||||
expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('does not block editing of non-occupied tiles', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]);
|
||||
const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] });
|
||||
// col 0 (not occupied) can still be painted; col 1 (occupied) cannot
|
||||
const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' });
|
||||
expect(painted.tiles[0][0].h).toBe(5);
|
||||
const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' });
|
||||
expect(blocked).toBe(occupied);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — SET_DOOR', () =>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
{
|
||||
const row = clamp64(action.row);
|
||||
const col = clamp64(action.col);
|
||||
if(state.tiles[row]?.[col]?.occupied) return state;
|
||||
const tiles = ensureRect(state.tiles, row + 1, col + 1);
|
||||
const target = { h: clampHeight(action.h), blocked: false };
|
||||
const next = setTile(tiles, row, col, target);
|
||||
@@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
const col = action.col | 0;
|
||||
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
|
||||
const current = state.tiles[row][col];
|
||||
if(current.occupied) return state;
|
||||
const target = { h: current.h, blocked: true };
|
||||
const next = setTile(state.tiles, row, col, target);
|
||||
if(next === state.tiles) return state;
|
||||
@@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
const col = action.col | 0;
|
||||
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
|
||||
const current = state.tiles[row][col];
|
||||
if(current.blocked) return state;
|
||||
if(current.blocked || current.occupied) return state;
|
||||
const newH = clampHeight(current.h + action.delta);
|
||||
if(newH === current.h) return state;
|
||||
const next = setTile(state.tiles, row, col, { h: newH, blocked: false });
|
||||
@@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
if(value === state.wallHeight) return state;
|
||||
return { ...state, wallHeight: value };
|
||||
}
|
||||
case 'SET_OCCUPIED_TILES':
|
||||
{
|
||||
// Mark tiles that currently hold furniture (server-reported). Leaves
|
||||
// height + blocked untouched so it never alters the saved tilemap.
|
||||
const map = action.map ?? [];
|
||||
let changed = false;
|
||||
const tiles = state.tiles.map((r, ri) => r.map((tile, ci) =>
|
||||
{
|
||||
const occ = !!map[ri]?.[ci];
|
||||
if((tile.occupied ?? false) === occ) return tile;
|
||||
changed = true;
|
||||
return { ...tile, occupied: occ };
|
||||
}));
|
||||
if(!changed) return state;
|
||||
return { ...state, tiles };
|
||||
}
|
||||
case 'BRUSH_SET':
|
||||
{
|
||||
const h = action.h ?? state.brush.h;
|
||||
@@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
const col = parseInt(cStr, 10);
|
||||
const current = tiles[row]?.[col];
|
||||
if(!current) continue;
|
||||
if(current.occupied) continue;
|
||||
|
||||
switch(state.brush.action)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export type Tile = { h: number; blocked: boolean };
|
||||
// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that
|
||||
// currently has furniture on it (reported by the server); kept separate so it
|
||||
// stays visible and is NOT voided on save — it just can't be edited.
|
||||
export type Tile = { h: number; blocked: boolean; occupied?: boolean };
|
||||
|
||||
export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
export type ThicknessLevel = 0 | 1 | 2 | 3;
|
||||
@@ -39,6 +42,7 @@ export type FloorplanAction =
|
||||
| { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource }
|
||||
| { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource }
|
||||
| { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource }
|
||||
| { type: 'SET_OCCUPIED_TILES'; map: boolean[][] }
|
||||
| { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode }
|
||||
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
|
||||
| { type: 'SELECT_ALL' }
|
||||
|
||||
@@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () =>
|
||||
const dispatch = vi.fn();
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
|
||||
const svg = container.querySelector('svg') as SVGSVGElement;
|
||||
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
|
||||
// usePointerToTile resolves the tile via document.elementFromPoint first
|
||||
// (the tile polygons carry data-row/data-col). jsdom returns null and has
|
||||
// no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon.
|
||||
const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element;
|
||||
// jsdom's document has no elementFromPoint at all — define it for this test.
|
||||
const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly;
|
||||
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
|
||||
expect(dispatch).toHaveBeenCalled();
|
||||
const call = dispatch.mock.calls[0][0];
|
||||
expect(call.type).toBe('PAINT_TILE');
|
||||
(document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp;
|
||||
});
|
||||
|
||||
it('zoom in/out buttons adjust the viewBox', () =>
|
||||
|
||||
@@ -104,6 +104,17 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
{ tile.occupied && (
|
||||
<polygon
|
||||
data-testid="occupied-marker"
|
||||
points={ points }
|
||||
fill="rgba(249, 115, 22, 0.40)"
|
||||
stroke="#f97316"
|
||||
strokeWidth={ 1 }
|
||||
strokeDasharray="2 2"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
) }
|
||||
{ selected && (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
|
||||
Reference in New Issue
Block a user