mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
feat(floorplan-editor): React rewrite + live in-room preview + UX polish
Complete modernization of the floor-plan editor. Three layered
changes shipped together since they share state shapes and the
test infrastructure stubs.
1) React rewrite (state + hooks + views + tests)
Drops the FloorplanEditorContext singleton + legacy view
components and replaces them with a pure-React reducer
architecture:
- state/ — typed FloorplanState + FloorplanAction union,
pure reducer covering PAINT_TILE / ERASE_TILE /
ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS /
SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL /
CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING /
APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged
('local' | 'remote') so the editor can distinguish user
edits from server pushes. Co-located encoding helpers
(parseTilemap / serializeTilemap) and area-counter
selectors.
- hooks/ — useFloorplanReducer (wraps useReducer with a
history stack + loadFromServer + undo/redo), useTool
(pointer events -> dispatch), usePointerToTile (screen
-> tile projection that respects the viewBox origin so
pan/zoom stays accurate).
- views/ — FloorplanCanvasSVG, FloorplanHeightPicker,
FloorplanToolbar, FloorplanOptionsPanel,
FloorplanImportExport, FloorplanTile,
FloorplanPreviewSVG (alternative iso preview kept as a
fallback view, not wired into the main layout).
- Co-located Vitest suites for every module above (encoding,
reducer, selectors, hooks, views, integration). 100+ new
test cases.
2) Live in-room preview (NEW capability)
useFloorplanLiveSync drives client-side preview of the edit
directly into the active room — every tile / door / wall
height / thickness change is applied through
GetRoomMessageHandler().applyFloorModelLocally (new public
method on the renderer, see paired renderer PR) with
zero server traffic during editing. The wire
UpdateFloorPropertiesMessageComposer is only sent when the
user explicitly clicks Save. Thickness slider additionally
calls RoomEngine.updateRoomInstancePlaneThickness for
zero-latency wall/floor-depth feedback while dragging.
Toggle 'Live preview ON / OFF' in the bottom strip (default
ON) lets the user opt out if they want to keep changes
contained to the editor's own preview until Save.
Revert button re-applies the original snapshot locally so
the room snaps back to where it was when the editor opened.
3) UX polish
- Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a
100-step history stack inside useFloorplanReducer. Local
mutating actions push history; brush/selection UI bumps
and remote dispatches bypass it; loadFromServer wipes the
stack.
- Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label.
Shift+drag or middle-mouse drag pans the canvas.
- Auto-fit on first paint: computes the screen-space
bounding box of the painted (non-blocked) tiles, picks the
zoom that just contains them with a 5 % margin, pans so
the room sits in the viewport centre. Default view is now
'room fills the canvas' instead of 'room is a dot at the
top-centre of a huge empty canvas'. Clicking the % label
re-runs the fit; crosshair button keeps zoom and recentres
the pan only.
- Door direction control: arrows + door icon triplet
(8-way rotate by single click on prev/next, full cycle
forward on the icon itself). Wall and floor thickness
collapse from two 4-button rows into two compact
segmented selectors (active state in emerald). Saves
significant horizontal space.
- Habbo floor pattern tile (~186 B PNG, vendored from
habbofurni.com/images/furni_floor.png) tiled as the
canvas background with image-rendering: pixelated so the
texture stays crisp at every zoom level. Replaces the
solid black background.
Test infrastructure
nitro-renderer.mock grows constructors / proxies / functions
for everything the new floor-editor tests transitively
import (floor composers + events, RoomEngineEvent,
ILinkEventTracker, convertNumbersForSaving /
convertSettingToNumber, GetRoomMessageHandler,
GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a
sufficiently real .updatePreviewModel / dispose surface,
and a TextureUtils.createRenderTexture that returns an
object with a no-op .destroy). test-setup adds a no-op
ResizeObserver polyfill (jsdom doesn't ship one and the
optional FloorplanRoomPreview observes its container) and
a draggable-windows-container portal root for tests that
mount NitroCardView.
Files: 44 changed (mostly new). yarn typecheck 0 errors,
yarn test 341/341 green.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
|
||||
import {
|
||||
buildLivePreviewPayload,
|
||||
LivePreviewPayload,
|
||||
livePreviewPayloadsEqual,
|
||||
useFloorplanLiveSync
|
||||
} from './useFloorplanLiveSync';
|
||||
|
||||
// Spy on the renderer hook seam — we hand back a manager that
|
||||
// records calls to applyFloorModelLocally so we can assert what
|
||||
// the hook pushed to the room.
|
||||
const applyMock = vi.fn<(model: string, wallHeight: number, scale: boolean) => boolean>();
|
||||
const thicknessMock = vi.fn<(roomId: number, wall: number, floor: number) => boolean>();
|
||||
|
||||
vi.mock('@nitrots/nitro-renderer', async (importOriginal) =>
|
||||
{
|
||||
const actual = await importOriginal<typeof import('@nitrots/nitro-renderer')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
GetRoomMessageHandler: () => ({
|
||||
applyFloorModelLocally: (model: string, wallHeight: number, scale: boolean) =>
|
||||
applyMock(model, wallHeight, scale)
|
||||
}),
|
||||
GetRoomEngine: () => ({
|
||||
updateRoomInstancePlaneThickness: (roomId: number, wall: number, floor: number) =>
|
||||
thicknessMock(roomId, wall, floor)
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Pin the active room id so the engine.updateRoomInstancePlaneThickness
|
||||
// branch is exercised. -1 is the "no active room" sentinel and skips
|
||||
// the thickness call.
|
||||
vi.mock('../../session/useSessionSnapshots', () => ({
|
||||
useActiveRoomSessionSnapshot: () => ({ roomId: 42 })
|
||||
}));
|
||||
|
||||
const baseState: FloorplanState = {
|
||||
tiles: [
|
||||
[ { h: 0, blocked: false }, { h: 0, blocked: false } ],
|
||||
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
|
||||
],
|
||||
door: { x: 1, y: 1, dir: 2 },
|
||||
thickness: { wall: 1, floor: 1 },
|
||||
wallHeight: 1,
|
||||
brush: { h: 0, action: 'SET' },
|
||||
selection: new Set(),
|
||||
squareSelect: false,
|
||||
lease: { holder: null, me: false, expiresAt: null },
|
||||
seq: 0
|
||||
};
|
||||
|
||||
const samePayload = (s: FloorplanState): LivePreviewPayload => buildLivePreviewPayload(s);
|
||||
|
||||
describe('useFloorplanLiveSync', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
applyMock.mockReset().mockReturnValue(true);
|
||||
thicknessMock.mockReset().mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not call the renderer before a baseline is set', () =>
|
||||
{
|
||||
renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
|
||||
|
||||
expect(applyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call the renderer when the state equals the baseline', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.setBaseline(samePayload(baseState));
|
||||
});
|
||||
|
||||
expect(applyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the new floor model locally on every diverging state change', () =>
|
||||
{
|
||||
const { result, rerender } = renderHook(
|
||||
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
|
||||
{ initialProps: { state: baseState } }
|
||||
);
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.setBaseline(samePayload(baseState));
|
||||
});
|
||||
|
||||
const next: FloorplanState = {
|
||||
...baseState,
|
||||
tiles: [
|
||||
[ { h: 1, blocked: false }, { h: 0, blocked: false } ],
|
||||
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
|
||||
]
|
||||
};
|
||||
|
||||
rerender({ state: next });
|
||||
|
||||
expect(applyMock).toHaveBeenCalledTimes(1);
|
||||
// The renderer parser takes server-space wallHeight (editor - 1).
|
||||
expect(applyMock.mock.calls[0][1]).toBe(0);
|
||||
expect(applyMock.mock.calls[0][2]).toBe(true);
|
||||
// Same brush/selection change should NOT push another paint.
|
||||
rerender({ state: { ...next, selection: new Set([ '0,0' ]) } });
|
||||
expect(applyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mirrors the thickness slider into the renderer engine', () =>
|
||||
{
|
||||
const { result, rerender } = renderHook(
|
||||
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
|
||||
{ initialProps: { state: baseState } }
|
||||
);
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.setBaseline(samePayload(baseState));
|
||||
});
|
||||
|
||||
rerender({ state: { ...baseState, thickness: { wall: 3, floor: 2 } } });
|
||||
|
||||
// Thickness 3 -> 2.0, thickness 2 -> 1.0
|
||||
const [ roomId, wall, floor ] = thicknessMock.mock.calls[thicknessMock.mock.calls.length - 1];
|
||||
expect(roomId).toBe(42);
|
||||
expect(wall).toBe(2);
|
||||
expect(floor).toBe(1);
|
||||
});
|
||||
|
||||
it('does not call the renderer when disabled', () =>
|
||||
{
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled, state }: { enabled: boolean; state: FloorplanState }) =>
|
||||
useFloorplanLiveSync({ enabled, state }),
|
||||
{ initialProps: { enabled: false, state: baseState } }
|
||||
);
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.setBaseline(samePayload(baseState));
|
||||
});
|
||||
|
||||
rerender({ enabled: false, state: { ...baseState, wallHeight: 5 } });
|
||||
|
||||
expect(applyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revert re-applies the baseline to the renderer', () =>
|
||||
{
|
||||
const { result, rerender } = renderHook(
|
||||
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
|
||||
{ initialProps: { state: baseState } }
|
||||
);
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.setBaseline(samePayload(baseState));
|
||||
});
|
||||
|
||||
rerender({ state: { ...baseState, wallHeight: 7 } });
|
||||
|
||||
// One push from the wallHeight bump.
|
||||
expect(applyMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.revert();
|
||||
});
|
||||
|
||||
expect(applyMock).toHaveBeenCalledTimes(2);
|
||||
// Revert applies the baseline payload, which has wallHeight=1
|
||||
// → server-space 0.
|
||||
expect(applyMock.mock.calls[1][1]).toBe(0);
|
||||
});
|
||||
|
||||
it('revert is a no-op when no baseline has been recorded', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
|
||||
|
||||
act(() =>
|
||||
{
|
||||
result.current.revert();
|
||||
});
|
||||
|
||||
expect(applyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('livePreviewPayloadsEqual', () =>
|
||||
{
|
||||
const p: LivePreviewPayload = {
|
||||
tilemap: 'xx\rxx',
|
||||
doorX: 0,
|
||||
doorY: 0,
|
||||
doorDir: 2,
|
||||
thicknessWall: 1,
|
||||
thicknessFloor: 1,
|
||||
wallHeight: 3
|
||||
};
|
||||
|
||||
it('returns true for identical payloads', () =>
|
||||
{
|
||||
expect(livePreviewPayloadsEqual(p, { ...p })).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a tilemap diff', () =>
|
||||
{
|
||||
expect(livePreviewPayloadsEqual(p, { ...p, tilemap: '00\r00' })).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a wallHeight diff', () =>
|
||||
{
|
||||
expect(livePreviewPayloadsEqual(p, { ...p, wallHeight: 4 })).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a door diff', () =>
|
||||
{
|
||||
expect(livePreviewPayloadsEqual(p, { ...p, doorDir: 4 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { GetRoomEngine, GetRoomMessageHandler } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { serializeTilemap } from '../../../components/floorplan-editor/state/encoding';
|
||||
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
|
||||
import { useActiveRoomSessionSnapshot } from '../../session/useSessionSnapshots';
|
||||
|
||||
/**
|
||||
* Client-side live preview for the floor-plan editor.
|
||||
*
|
||||
* Every tile / door / thickness / wallHeight change in the editor
|
||||
* is applied IMMEDIATELY to the 3D room behind the editor card
|
||||
* via the renderer's local `RoomMessageHandler.applyFloorModelLocally`
|
||||
* (added in the renderer's `feat/floorplan-live-preview` branch).
|
||||
* Nothing is sent to the server until the user explicitly clicks
|
||||
* Save — at that point `FloorplanEditorView` fires the
|
||||
* `UpdateFloorPropertiesMessageComposer` directly.
|
||||
*
|
||||
* Closing the editor without saving leaves the live preview
|
||||
* in place visually. To restore the pre-edit room, call `revert`
|
||||
* — it re-applies the baseline payload locally. The next
|
||||
* `FloorHeightMapEvent` from the server (e.g. on room re-enter)
|
||||
* also wins and overwrites whatever preview is in place.
|
||||
*
|
||||
* Thickness changes additionally call
|
||||
* `RoomEngine.updateRoomInstancePlaneThickness` for zero-latency
|
||||
* wall/floor depth feedback (the full geometry rebuild that
|
||||
* `applyFloorModelLocally` performs already reflects the new
|
||||
* thickness in its plane data, but the dedicated thickness
|
||||
* setter is cheaper and updates instantly as a slider is dragged).
|
||||
*/
|
||||
|
||||
export type LivePreviewPayload = {
|
||||
/** Newline-or-CR-separated tilemap (the renderer parser accepts \r). */
|
||||
tilemap: string;
|
||||
doorX: number;
|
||||
doorY: number;
|
||||
doorDir: number;
|
||||
/** Editor-space (0..3). */
|
||||
thicknessWall: number;
|
||||
thicknessFloor: number;
|
||||
/** Editor-space (1..N). Server space is `wallHeight - 1`. */
|
||||
wallHeight: number;
|
||||
};
|
||||
|
||||
export type UseFloorplanLiveSyncOptions = {
|
||||
enabled: boolean;
|
||||
state: FloorplanState;
|
||||
};
|
||||
|
||||
export type UseFloorplanLiveSyncApi = {
|
||||
/**
|
||||
* Mark a payload as "currently shown in the room" so subsequent
|
||||
* state diffs are computed against it. Editors call this on
|
||||
* every server-driven snapshot push (FloorHeightMapEvent,
|
||||
* RoomVisualizationSettingsEvent, …).
|
||||
*/
|
||||
setBaseline: (payload: LivePreviewPayload) => void;
|
||||
/**
|
||||
* Restore the in-room preview to the recorded baseline.
|
||||
* Use when the user closes the editor without saving.
|
||||
*/
|
||||
revert: () => void;
|
||||
};
|
||||
|
||||
const THICKNESS_RENDERER_VALUE: Record<number, number> = {
|
||||
0: 0.25,
|
||||
1: 0.5,
|
||||
2: 1,
|
||||
3: 2
|
||||
};
|
||||
|
||||
export const buildLivePreviewPayload = (state: FloorplanState): LivePreviewPayload => ({
|
||||
tilemap: serializeTilemap(state.tiles),
|
||||
doorX: state.door.x,
|
||||
doorY: state.door.y,
|
||||
doorDir: state.door.dir,
|
||||
thicknessWall: state.thickness.wall,
|
||||
thicknessFloor: state.thickness.floor,
|
||||
wallHeight: state.wallHeight
|
||||
});
|
||||
|
||||
export const livePreviewPayloadsEqual = (a: LivePreviewPayload, b: LivePreviewPayload): boolean =>
|
||||
a.tilemap === b.tilemap
|
||||
&& a.doorX === b.doorX
|
||||
&& a.doorY === b.doorY
|
||||
&& a.doorDir === b.doorDir
|
||||
&& a.thicknessWall === b.thicknessWall
|
||||
&& a.thicknessFloor === b.thicknessFloor
|
||||
&& a.wallHeight === b.wallHeight;
|
||||
|
||||
const applyToRenderer = (payload: LivePreviewPayload, roomId: number): boolean =>
|
||||
{
|
||||
const handler = GetRoomMessageHandler();
|
||||
|
||||
if(!handler || typeof handler.applyFloorModelLocally !== 'function') return false;
|
||||
|
||||
const ok = handler.applyFloorModelLocally(payload.tilemap, Math.max(0, (payload.wallHeight | 0) - 1), true);
|
||||
|
||||
if(!ok) return false;
|
||||
|
||||
if(roomId >= 0)
|
||||
{
|
||||
const engine = GetRoomEngine();
|
||||
const wall = THICKNESS_RENDERER_VALUE[payload.thicknessWall];
|
||||
const floor = THICKNESS_RENDERER_VALUE[payload.thicknessFloor];
|
||||
|
||||
if(engine && typeof engine.updateRoomInstancePlaneThickness === 'function' && wall !== undefined && floor !== undefined)
|
||||
{
|
||||
engine.updateRoomInstancePlaneThickness(roomId, wall, floor);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloorplanLiveSyncApi =>
|
||||
{
|
||||
const { enabled, state } = opts;
|
||||
const session = useActiveRoomSessionSnapshot();
|
||||
const roomId = session?.roomId ?? -1;
|
||||
|
||||
const baselineRef = useRef<LivePreviewPayload | null>(null);
|
||||
const lastAppliedRef = useRef<LivePreviewPayload | null>(null);
|
||||
|
||||
// Destructure first so the memo deps stay precise without
|
||||
// triggering exhaustive-deps on `state` as a whole.
|
||||
const { tiles, door, thickness, wallHeight } = state;
|
||||
const currentPayload = useMemo<LivePreviewPayload>(() => ({
|
||||
tilemap: serializeTilemap(tiles),
|
||||
doorX: door.x,
|
||||
doorY: door.y,
|
||||
doorDir: door.dir,
|
||||
thicknessWall: thickness.wall,
|
||||
thicknessFloor: thickness.floor,
|
||||
wallHeight
|
||||
}), [ tiles, door, thickness, wallHeight ]);
|
||||
|
||||
const setBaseline = useCallback((payload: LivePreviewPayload) =>
|
||||
{
|
||||
baselineRef.current = payload;
|
||||
lastAppliedRef.current = payload;
|
||||
}, []);
|
||||
|
||||
const revert = useCallback(() =>
|
||||
{
|
||||
const baseline = baselineRef.current;
|
||||
|
||||
if(!baseline) return;
|
||||
|
||||
if(applyToRenderer(baseline, roomId)) lastAppliedRef.current = baseline;
|
||||
}, [ roomId ]);
|
||||
|
||||
// Apply the current payload to the renderer whenever it
|
||||
// diverges from what's already in the room. Synchronous + no
|
||||
// debounce — the renderer pipeline is fast enough that every
|
||||
// brush stroke can land a paint.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!enabled) return;
|
||||
|
||||
const previous = lastAppliedRef.current;
|
||||
|
||||
if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return;
|
||||
if(!previous && !baselineRef.current) return;
|
||||
|
||||
if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload;
|
||||
}, [ enabled, currentPayload, roomId ]);
|
||||
|
||||
return { setBaseline, revert };
|
||||
};
|
||||
Reference in New Issue
Block a user