5180 fichiers 38.5 Mo 0 téléchargements 208 vues mis à jour il y a 5 heures ui
FloorplanEditorView.tsx 21 Ko Télécharger le fichier
import {
    AddLinkEventTracker,
    convertNumbersForSaving,
    convertSettingToNumber,
    FloorHeightMapEvent,
    GetOccupiedTilesMessageComposer,
    GetRoomEntryTileMessageComposer,
    ILinkEventTracker,
    RemoveLinkEventTracker,
    RoomEngineEvent,
    RoomEntryTileMessageEvent,
    RoomId,
    RoomOccupiedTilesMessageEvent,
    RoomVisualizationSettingsEvent,
    UpdateFloorPropertiesMessageComposer
} from '@octane/renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../api';
import { Base, OctaneCardContentView, OctaneCardHeaderView, OctaneCardView } from '../../common';
import { useMessageEvent, useOctaneEvent } from '../../hooks';
import { isTextEntryTarget } from '../../hooks/rooms/useRoomKeyboardMovement';
import { useFloorplanLiveSync } from '../../hooks/rooms/widgets/useFloorplanLiveSync';
import { useFloorplanReducer } from './hooks/useFloorplanReducer';
import { MAX_WALL_HEIGHT, MIN_WALL_HEIGHT } from './state/constants';
import { serializeTilemap } from './state/encoding';
import { localizeOr } from './state/localize';
import { areaCount } from './state/selectors';
import { EntryDir, ThicknessLevel } from './state/types';
import { FloorplanCanvasSVG } from './views/FloorplanCanvasSVG';
import { FloorplanHeightPicker } from './views/FloorplanHeightPicker';
import { FloorplanImportExport } from './views/FloorplanImportExport';
import { FloorplanOptionsPanel } from './views/FloorplanOptionsPanel';
import { FloorplanAirPreview } from './views/FloorplanAirPreview';
import { FloorplanToolbar } from './views/FloorplanToolbar';
import { FloorplanWallHeightSlider } from './views/FloorplanWallHeightSlider';
import { BaseButton } from '../../common/BaseButton';
import { Hint } from '../../common/Tooltip';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

export type FloorplanEditorExternalSession = {
    tilemap: string;
    occupiedTiles?: boolean[][];
    title?: string;
    onClose: () => void;
    onSave: (tilemap: string) => void;
};

type Props = {
    externalSession?: FloorplanEditorExternalSession;
};

const clampThickness = (v: number): ThicknessLevel => {
    if (v <= 0) return 0;
    if (v >= 3) return 3;
    return (v | 0) as ThicknessLevel;
};

export const FloorplanEditorView: FC<Props> = ({ externalSession }) => {
    const [roomVisible, setRoomVisible] = useState(false);
    const [importExportVisible, setImportExportVisible] = useState(false);
    const [liveSync, setLiveSync] = useState(true);
    const [panMode, setPanMode] = useState(false);
    const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer();
    const isExternal = !!externalSession;
    const isVisible = isExternal || roomVisible;
    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: !isExternal && liveSync && isVisible, state });

    useOctaneEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, (event) => {
        // A closed inventory/catalog preview disposes its own room; only leaving the real room hides the editor.
        if (RoomId.isRoomPreviewerId(event.roomId)) return;
        if (!isExternal) setRoomVisible(false);
    });

    useEffect(() => {
        if (!externalSession) return;

        const rows = externalSession.tilemap.split(/\r\n|\r|\n/).filter((row) => row.length > 0);
        let entryPoint: [number, number] = [0, 0];
        outer: for (let y = 0; y < rows.length; y++) {
            for (let x = 0; x < rows[y].length; x++) {
                if (rows[y].charAt(x).toLowerCase() === 'x') continue;
                entryPoint = [x, y];
                break outer;
            }
        }

        const settings = {
            tilemap: externalSession.tilemap,
            entryPoint,
            entryPointDir: 2,
            thicknessWall: 1 as ThicknessLevel,
            thicknessFloor: 1 as ThicknessLevel,
            wallHeight: MIN_WALL_HEIGHT
        };
        originalRef.current = settings;
        loadFromServer(settings);
        if (externalSession.occupiedTiles) dispatch({ type: 'SET_OCCUPIED_TILES', map: externalSession.occupiedTiles });
    }, [dispatch, externalSession?.occupiedTiles, externalSession?.tilemap, loadFromServer]);

    useEffect(() => {
        if (!isVisible || isExternal) return;
        SendMessageComposer(new GetRoomEntryTileMessageComposer());
        SendMessageComposer(new GetOccupiedTilesMessageComposer());
    }, [isExternal, isVisible]);

    useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, (event) => {
        if (isExternal) return;
        dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap });
    });

    useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, (event) => {
        if (isExternal) return;
        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>(FloorHeightMapEvent, (event) => {
        if (isExternal) return;
        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>(RoomVisualizationSettingsEvent, (event) => {
        if (isExternal) return;
        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;
            if (isTextEntryTarget(e.target)) 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(() => {
        if (isExternal) return;
        const linkTracker: ILinkEventTracker = {
            linkReceived: (url: string) => {
                const parts = url.split('/');
                if (parts.length < 2) return;
                switch (parts[1]) {
                    case 'show':
                        setRoomVisible(true);
                        return;
                    case 'hide':
                        setRoomVisible(false);
                        return;
                    case 'toggle':
                        setRoomVisible((v) => !v);
                        return;
                }
            },
            eventUrlPrefix: 'floor-editor/'
        };
        AddLinkEventTracker(linkTracker);
        return () => RemoveLinkEventTracker(linkTracker);
    }, [isExternal]);

    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 = () => {
        if (externalSession) {
            externalSession.onSave(serializeTilemap(state.tiles));
            externalSession.onClose();
            return;
        }

        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,
                state.pickUpBlocking
            )
        );
    };

    const revertChanges = () => {
        const o = originalRef.current;
        if (!o) return;
        loadFromServer(o);
        if (!isExternal && liveSync) revertLivePreview();
    };

    const closeEditor = () => {
        setImportExportVisible(false);
        if (externalSession) externalSession.onClose();
        else setRoomVisible(false);
    };

    return (
        <NativeTextTree>
            <>
                {isVisible && (
                    // AIR floor_plan_editor_bc: a 662x600 frame with the subtitle strip, the plan panel (draw mode, tile height,
                    // heightmap) on the left, the preview panel (enter direction, room options, wall height, preview) on the
                    // right and the reload / import-export / cancel / save buttons at the bottom.
                    <OctaneCardView
                        uniqueKey="floorpan-editor"
                        className="w-[662px] h-[600px] max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)]"
                        classNames={['octane-floorplan-window']}
                        theme="primary-slim"
                        isResizable={false}
                    >
                        <OctaneCardHeaderView headerText={externalSession?.title ?? LocalizeText('floor.plan.editor.title')} onCloseClick={closeEditor} />
                        <OctaneCardContentView overflow="hidden" className="flex flex-col">
                            <div className="fp-body">
                                <div className="fp-banner">
                                    <span className="fp-banner-logo" />
                                    <span className="fp-banner-text">
                                        {localizeOr('floor.plan.editor.subtitle', 'Edit the floor plan of your room to build more complex rooms and games.')}
                                    </span>
                                    {!isExternal && (
                                        <div className="fp-banner-toggles">
                                            <Base component={BaseButton} type="button"
                                                pointer
                                                data-testid="floorplan-live-sync"
                                                data-active={liveSync ? 'true' : 'false'}
                                                className={`fp-toggle ${liveSync ? 'is-on' : ''}`}
                                                onClick={() => setLiveSync((v) => !v)}
                                                title="Local in-room preview while drawing (does not save to server)"
                                            >
                                                <span className="fp-toggle-dot" />
                                                {liveSync ? 'Live preview ON' : 'Live preview OFF'}
                                            </Base>
                                            <Base component={BaseButton} type="button"
                                                pointer
                                                data-testid="floorplan-auto-pickup"
                                                data-active={state.pickUpBlocking ? 'true' : 'false'}
                                                className={`fp-toggle is-warm ${state.pickUpBlocking ? 'is-on' : ''}`}
                                                onClick={() => dispatch({ type: 'SET_PICK_UP_BLOCKING', value: !state.pickUpBlocking })}
                                                title="On save: pick up furniture blocking the new floor plan and return it to its owner's inventory"
                                            >
                                                <span className="fp-toggle-dot" />
                                                {state.pickUpBlocking ? 'Pick up blocking furni ON' : 'Pick up blocking furni OFF'}
                                            </Base>
                                        </div>
                                    )}
                                </div>
                                <div className="fp-panels">
                                    <div className="fp-panel is-plan" data-testid="floorplan-plan-panel">
                                        <FloorplanToolbar
                                            state={state}
                                            dispatch={dispatch}
                                            canUndo={canUndo}
                                            canRedo={canRedo}
                                            onUndo={undo}
                                            onRedo={redo}
                                            panMode={panMode}
                                            setPanMode={setPanMode}
                                            includeDoor={!isExternal}
                                        />
                                        <div className="fp-panel-head">
                                            <span className="fp-panel-title">{localizeOr('floor.plan.editor.tile.height', 'Set height')}</span>
                                            <Hint content="Brush height">
                                                <div aria-label={`Brush height ${state.brush.h}`} className="fp-badge is-brush" data-testid="brush-height-badge" role="img">
                                                    {state.brush.h}
                                                </div>
                                            </Hint>
                                            <span className="fp-panel-info" data-testid="floorplan-area">
                                                {localizeOr('floor.plan.editor.area', `Area: ${area.total} (${area.walkable} tiles)`, ['total', 'walkable'], [String(area.total), String(area.walkable)])}
                                            </span>
                                        </div>
                                        <FloorplanHeightPicker selectedH={state.brush.h} onSelect={(h) => dispatch({ type: 'BRUSH_SET', h })} />
                                        <div className="fp-panel-body">
                                            <FloorplanCanvasSVG state={state} dispatch={dispatch} panMode={panMode} />
                                        </div>
                                    </div>
                                    <div className="fp-panel is-preview" data-testid="floorplan-preview-panel">
                                        {!isExternal && (
                                            <div className="fp-options">
                                                <FloorplanOptionsPanel state={state} dispatch={dispatch} />
                                            </div>
                                        )}
                                        <div className="fp-panel-head">
                                            <span className="fp-panel-title">{LocalizeText('floor.editor.wall.height')}</span>
                                            <Hint content="Wall height">
                                                <div aria-label={`Wall height ${state.wallHeight}`} className="fp-badge" data-testid="wall-height-badge" role="img">
                                                    {state.wallHeight}
                                                </div>
                                            </Hint>
                                            <FloorplanWallHeightSlider value={state.wallHeight} onChange={onWallHeightChange} />
                                        </div>
                                        <div className="fp-panel-body">
                                            <FloorplanAirPreview state={state} />
                                        </div>
                                    </div>
                                </div>
                                <div className="fp-footer">
                                    <Base component={BaseButton} type="button" pointer className="fp-btn" data-testid="floorplan-revert" onClick={revertChanges}>
                                        {LocalizeText('floor.plan.editor.reload')}
                                    </Base>
                                    <div className="fp-footer-right">
                                        <Base component={BaseButton} type="button" pointer className="fp-btn" data-testid="floorplan-import-export" onClick={() => setImportExportVisible(true)}>
                                            {LocalizeText('floor.plan.editor.import.export')}
                                        </Base>
                                        <Base component={BaseButton} type="button" pointer className="fp-btn" data-testid="floorplan-cancel" onClick={closeEditor}>
                                            {localizeOr('floor.plan.editor.cancel', 'Cancel')}
                                        </Base>
                                        <Base component={BaseButton} type="button" pointer className="fp-btn is-blue" data-testid="floorplan-save" onClick={saveFloorChanges}>
                                            {LocalizeText('floor.plan.editor.save')}
                                        </Base>
                                    </div>
                                </div>
                            </div>
                        </OctaneCardContentView>
                    </OctaneCardView>
                )}
                {importExportVisible && (
                    <FloorplanImportExport
                        state={state}
                        dispatch={dispatch}
                        onClose={() => setImportExportVisible(false)}
                        onSaveFromText={(raw) => {
                            if (externalSession) {
                                externalSession.onSave(raw);
                                setImportExportVisible(false);
                                externalSession.onClose();
                                return;
                            }
                            SendMessageComposer(
                                new UpdateFloorPropertiesMessageComposer(
                                    raw,
                                    state.door.x,
                                    state.door.y,
                                    state.door.dir,
                                    convertNumbersForSaving(state.thickness.wall),
                                    convertNumbersForSaving(state.thickness.floor),
                                    state.wallHeight - 1,
                                    state.pickUpBlocking
                                )
                            );
                        }}
                        onRevertText={() => originalRef.current?.tilemap ?? serializeTilemap(state.tiles)}
                    />
                )}
            </>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser