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'; import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { useMessageEvent, useNitroEvent } from '../../hooks'; import { useFloorplanLiveSync } from '../../hooks/rooms/widgets/useFloorplanLiveSync'; import { MAX_WALL_HEIGHT, MIN_WALL_HEIGHT } from './state/constants'; import { EntryDir, ThicknessLevel } from './state/types'; import { areaCount } from './state/selectors'; import { serializeTilemap } from './state/encoding'; import { useFloorplanReducer } from './hooks/useFloorplanReducer'; import { FloorplanCanvasSVG } from './views/FloorplanCanvasSVG'; import { FloorplanHeightPicker } from './views/FloorplanHeightPicker'; import { FloorplanToolbar } from './views/FloorplanToolbar'; import { FloorplanOptionsPanel } from './views/FloorplanOptionsPanel'; import { FloorplanImportExport } from './views/FloorplanImportExport'; const clampThickness = (v: number): ThicknessLevel => { if(v <= 0) return 0; if(v >= 3) return 3; return (v | 0) as ThicknessLevel; }; export const FloorplanEditorView: FC = () => { const [ isVisible, setIsVisible ] = useState(false); const [ importExportVisible, setImportExportVisible ] = useState(false); const [ liveSync, setLiveSync ] = useState(true); const [ panMode, setPanMode ] = useState(false); const [ autoPickup, setAutoPickup ] = useState(false); const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer(); const originalRef = useRef<{ tilemap: string; entryPoint: [number, number]; entryPointDir: number; thicknessWall: ThicknessLevel; thicknessFloor: ThicknessLevel; wallHeight: number; } | null>(null); const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]); const { setBaseline, mergeBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state }); useNitroEvent(RoomEngineEvent.DISPOSED, () => setIsVisible(false)); useEffect(() => { 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, event => { dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap }); }); useMessageEvent(RoomEntryTileMessageEvent, event => { const parser = event.getParser(); originalRef.current = { tilemap: originalRef.current?.tilemap ?? '', entryPoint: [ parser.x, parser.y ], entryPointDir: parser.direction, thicknessWall: originalRef.current?.thicknessWall ?? 1, thicknessFloor: originalRef.current?.thicknessFloor ?? 1, wallHeight: originalRef.current?.wallHeight ?? -1 }; dispatch({ type: 'SET_DOOR', x: parser.x, y: parser.y, source: 'remote' }); dispatch({ type: 'SET_DOOR_DIR', dir: ((parser.direction | 0) & 7) as EntryDir, source: 'remote' }); mergeBaseline({ doorX: parser.x, doorY: parser.y, doorDir: (parser.direction | 0) & 7 }); }); useMessageEvent(FloorHeightMapEvent, event => { const parser = event.getParser(); originalRef.current = { tilemap: parser.model, entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ], entryPointDir: originalRef.current?.entryPointDir ?? 2, thicknessWall: originalRef.current?.thicknessWall ?? 1, thicknessFloor: originalRef.current?.thicknessFloor ?? 1, wallHeight: parser.wallHeight + 1 }; loadFromServer({ tilemap: parser.model, entryPoint: originalRef.current.entryPoint, entryPointDir: originalRef.current.entryPointDir, thicknessWall: originalRef.current.thicknessWall, thicknessFloor: originalRef.current.thicknessFloor, wallHeight: parser.wallHeight + 1 }); setBaseline({ tilemap: parser.model, doorX: originalRef.current.entryPoint[0], doorY: originalRef.current.entryPoint[1], doorDir: originalRef.current.entryPointDir, thicknessWall: originalRef.current.thicknessWall, thicknessFloor: originalRef.current.thicknessFloor, wallHeight: parser.wallHeight + 1 }); }); useMessageEvent(RoomVisualizationSettingsEvent, event => { const parser = event.getParser(); const wall = clampThickness(convertSettingToNumber(parser.thicknessWall)); const floor = clampThickness(convertSettingToNumber(parser.thicknessFloor)); originalRef.current = { tilemap: originalRef.current?.tilemap ?? '', entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ], entryPointDir: originalRef.current?.entryPointDir ?? 2, thicknessWall: wall, thicknessFloor: floor, wallHeight: originalRef.current?.wallHeight ?? -1 }; dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' }); mergeBaseline({ thicknessWall: wall, thicknessFloor: floor }); }); useEffect(() => { if(!isVisible) return; const handler = (e: KeyboardEvent) => { if(!(e.ctrlKey || e.metaKey)) return; const target = e.target as HTMLElement | null; const tag = target?.tagName; if(tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return; const key = e.key.toLowerCase(); if(key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); redo(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [ isVisible, undo, redo ]); useEffect(() => { const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { const parts = url.split('/'); if(parts.length < 2) return; switch(parts[1]) { case 'show': setIsVisible(true); return; case 'hide': setIsVisible(false); return; case 'toggle': setIsVisible(v => !v); return; } }, eventUrlPrefix: 'floor-editor/' }; AddLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker); }, []); const onWallHeightChange = (value: number) => { if(isNaN(value) || value <= 0) value = MIN_WALL_HEIGHT; if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT; dispatch({ type: 'SET_WALL_HEIGHT', value, source: 'local' }); }; const saveFloorChanges = () => { SendMessageComposer(new UpdateFloorPropertiesMessageComposer( serializeTilemap(state.tiles), state.door.x, state.door.y, state.door.dir, convertNumbersForSaving(state.thickness.wall), convertNumbersForSaving(state.thickness.floor), state.wallHeight - 1, autoPickup )); }; const revertChanges = () => { const o = originalRef.current; if(!o) return; loadFromServer(o); if(liveSync) revertLivePreview(); }; return ( <> { isVisible && ( setIsVisible(false) } /> dispatch({ type: 'BRUSH_SET', h }) } /> { LocalizeText('floor.editor.wall.height') } onWallHeightChange(state.wallHeight - 1) } /> onWallHeightChange(e.target.valueAsNumber) } /> onWallHeightChange(state.wallHeight + 1) } /> Area: { area.total } ({ area.walkable } tiles) setAutoPickup(v => !v) } title="On save: pick up furniture blocking the new floor plan and return it to its owner's inventory" > { autoPickup ? 'Pick up blocking furni ON' : 'Pick up blocking furni OFF' } setLiveSync(v => !v) } title="Local in-room preview while drawing (does not save to server)" > { liveSync ? 'Live preview ON' : 'Live preview OFF' } ) } { importExportVisible && ( setImportExportVisible(false) } onSaveFromText={ raw => { SendMessageComposer(new UpdateFloorPropertiesMessageComposer( raw, state.door.x, state.door.y, state.door.dir, convertNumbersForSaving(state.thickness.wall), convertNumbersForSaving(state.thickness.floor), state.wallHeight - 1, autoPickup )); } } onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) } /> ) } ); };