5180 fichiers 38.5 Mo 0 téléchargements 378 vues mis à jour il y a 7 heures ui
nitro-v36-with-renderer-v36 / src / components / wired / views / selectors / WiredNeighborhoodSelectorView.tsx
WiredNeighborhoodSelectorView.tsx 14.9 Ko Télécharger le fichier
import { GetRoomEngine } from '@octane/renderer';
import { CSSProperties, FC, JSX, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaCompress, FaCrosshairs, FaExpand, FaMinus, FaPlus } from 'react-icons/fa';
import {
    decodeNeighborhood,
    encodeNeighborhood,
    LocalizeText,
    localizeWithFallback,
    NEIGHBORHOOD_MAX_ROOT,
    NEIGHBORHOOD_RADIUS,
    NEIGHBORHOOD_SMALL_RADIUS,
    NeighborhoodTile,
    neighborhoodFitsSmall,
    WiredFurniType
} from '../../../../api';
import sourceFurniIcon from '../../../../assets/images/wired/source_furni.png';
import sourceUserIcon from '../../../../assets/images/wired/source_user.png';
import { Button, Text } from '../../../../common';
import { useWired } from '../../../../hooks';
import { WiredFurniSelectionSourceRow } from '../WiredFurniSelectionSourceRow';
import { sortWiredSourceOptions, useAvailableUserSources } from '../WiredSourcesSelector';
import { WiredSelectorBaseView } from './WiredSelectorBaseView';
import { NumberInput } from '../../../../common/NumberInput';
import { BaseButton } from '../../../../common/BaseButton';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';

// Official source values (InputSourcesConf): 0 trigger, 11 clicked user, 100 picks, 201 signal.
const SOURCE_TRIGGER = 0;
const SOURCE_CLICKED_USER = 11;
const SOURCE_PICKED = 100;
const SOURCE_SIGNAL = 201;

const USER_SOURCES = sortWiredSourceOptions(
    [
        { value: SOURCE_TRIGGER, label: 'wiredfurni.params.sources.users.0' },
        { value: SOURCE_SIGNAL, label: 'wiredfurni.params.sources.users.201' },
        { value: SOURCE_CLICKED_USER, label: 'wiredfurni.params.sources.users.11' }
    ],
    'users'
);

const FURNI_SOURCES = sortWiredSourceOptions(
    [
        { value: SOURCE_TRIGGER, label: 'wiredfurni.params.sources.furni.0' },
        { value: SOURCE_PICKED, label: 'wiredfurni.params.sources.furni.100' },
        { value: SOURCE_SIGNAL, label: 'wiredfurni.params.sources.furni.201' }
    ],
    'furni'
);

// The official merged picker lists furni first, then users.
const SOURCE_GROUP_BUTTONS = [
    { key: 'furni', icon: sourceFurniIcon, isUser: false },
    { key: 'user', icon: sourceUserIcon, isUser: true }
] as const;

type DrawMode = 'add_tile' | 'remove_tile' | 'set_root_tile';

// FloorDrawingPreset: a tile is 16x8, at (8 * (x - y + 1), 4 * (x + y + 1)).
const TILE_W = 16;
const TILE_H = 8;

const tileKey = (x: number, y: number) => `${ x },${ y }`;

/** The grid tile (0..dimension-1) under a point of the drawing, or null outside it (FloorDrawingPreset's inverse). */
export const neighborhoodTileAt = (x: number, y: number, dimension: number): { gx: number; gy: number } =>
{
    const u = (x - TILE_W / 2 * dimension) / TILE_W;
    const v = y / TILE_H;
    const gx = Math.floor(v + u);
    const gy = Math.floor(v - u);

    return (gx >= 0 && gy >= 0 && gx < dimension && gy < dimension) ? { gx, gy } : null;
};

/** The tiles on a straight line between two tiles, both ends left out, so a fast drag leaves no gaps (as AIR). */
export const neighborhoodLineBetween = (ax: number, ay: number, bx: number, by: number): { x: number; y: number }[] =>
{
    const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay));
    const line: { x: number; y: number }[] = [];

    for(let step = 1; step < steps; step++)
    {
        line.push({ x: Math.round(ax + (bx - ax) * step / steps), y: Math.round(ay + (by - ay) * step / steps) });
    }

    return line;
};

interface NeighborhoodFloorProps
{
    radius: number;
    tiles: Set<string>;
    root: NeighborhoodTile;
    onDraw: (x: number, y: number) => void;
}

export const NeighborhoodFloor: FC<NeighborhoodFloorProps> = ({ radius, tiles, root, onDraw }) =>
{
    // The stroke: the last tile drawn, or null when no button is down.
    const lastTileRef = useRef<{ gx: number; gy: number }>(null);
    const dimension = radius * 2 + 1;
    const width = dimension * TILE_W + TILE_W;
    const height = dimension * TILE_H + TILE_H;

    useEffect(() =>
    {
        const stop = () => (lastTileRef.current = null);

        window.addEventListener('mouseup', stop);

        return () => window.removeEventListener('mouseup', stop);
    }, []);

    // One handler for the whole drawing, as AIR: the pointer is mapped to a tile, and the tiles between the last
    // one and this one are drawn too.
    const tileAt = (event: ReactMouseEvent<HTMLDivElement>) =>
    {
        const rect = event.currentTarget.getBoundingClientRect();
        const scaleX = rect.width ? (width / rect.width) : 1;
        const scaleY = rect.height ? (height / rect.height) : 1;

        return neighborhoodTileAt((event.clientX - rect.left) * scaleX, (event.clientY - rect.top) * scaleY, dimension);
    };

    const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) =>
    {
        const tile = tileAt(event);

        if(!tile) return;

        event.preventDefault();
        lastTileRef.current = tile;
        onDraw(tile.gx - radius, tile.gy - radius);
    };

    const onMouseMove = (event: ReactMouseEvent<HTMLDivElement>) =>
    {
        const last = lastTileRef.current;

        if(!last || !(event.buttons & 1)) return;

        const tile = tileAt(event);

        if(!tile || (tile.gx === last.gx && tile.gy === last.gy)) return;

        onDraw(tile.gx - radius, tile.gy - radius);

        for(const between of neighborhoodLineBetween(last.gx, last.gy, tile.gx, tile.gy)) onDraw(between.x - radius, between.y - radius);

        lastTileRef.current = tile;
    };

    const cells: JSX.Element[] = [];

    for(let gy = 0; gy < dimension; gy++)
    {
        for(let gx = 0; gx < dimension; gx++)
        {
            const x = gx - radius;
            const y = gy - radius;
            const taken = tiles.has(tileKey(x, y));
            const isRoot = root.x === x && root.y === y;
            const style: CSSProperties = {
                position: 'absolute',
                left: TILE_W / 2 * (gx - gy + dimension - 1),
                top: TILE_H / 2 * (gx + gy),
                width: TILE_W,
                height: TILE_H,
                clipPath: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
                backgroundColor: taken ? '#3fae49' : '#3a4152',
                pointerEvents: 'none',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center'
            };
            // The root (where the source stands) is a marker on top of the tile, so a drawn root still
            // shows green underneath, as the official entry-tile image does.
            const rootMarker: CSSProperties = {
                width: TILE_W / 2,
                height: TILE_H / 2,
                clipPath: 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
                backgroundColor: '#ffffff',
                pointerEvents: 'none'
            };

            cells.push(
                <div key={ tileKey(x, y) } data-tile={ tileKey(x, y) } style={ style }>
                    { isRoot && <div style={ rootMarker } /> }
                </div>
            );
        }
    }

    return (
        <NativeTextTree>
            <div
                data-testid="neighborhood-floor"
                style={ { position: 'relative', width, height, flexShrink: 0, cursor: 'pointer' } }
                onContextMenu={ event => event.preventDefault() }
                onMouseDown={ onMouseDown }
                onMouseMove={ onMouseMove }>
                { cells }
            </div>
        </NativeTextTree>
    );
};

export const WiredNeighborhoodSelectorView: FC<{}> = () =>
{
    const { trigger = null, furniIds = [], setIntParams, setSourceTypes } = useWired();
    const [ isUser, setIsUser ] = useState(false);
    const [ source, setSource ] = useState(SOURCE_TRIGGER);
    const [ root, setRoot ] = useState<NeighborhoodTile>({ x: 0, y: 0 });
    const [ tiles, setTiles ] = useState<Set<string>>(() => new Set());
    const [ drawMode, setDrawMode ] = useState<DrawMode>('add_tile');
    const [ bigMode, setBigMode ] = useState(false);
    const availableUserSources = useAvailableUserSources(trigger, USER_SOURCES);

    useEffect(() =>
    {
        GetRoomEngine().areaSelectionManager.clearHighlight();
        GetRoomEngine().areaSelectionManager.deactivate();
    }, []);

    // InNeighborhood.onEditStart: [isUser, rootX, rootY, ...drawing], the source from the merged slot.
    useEffect(() =>
    {
        if(!trigger) return;

        const params = trigger.intData ?? [];
        const user = params[0] === 1;
        const drawn = decodeNeighborhood(params.slice(3));
        const sources = user ? trigger.userSourceTypes : trigger.furniSourceTypes;

        setIsUser(user);
        setSource(sources?.[0] ?? SOURCE_TRIGGER);
        setRoot({ x: params[1] ?? 0, y: params[2] ?? 0 });
        setTiles(new Set(drawn.map(tile => tileKey(tile.x, tile.y))));
        setBigMode(!neighborhoodFitsSmall(drawn));
        setDrawMode('add_tile');
    }, [ trigger ]);

    const tileList = useMemo(() => [ ...tiles ].map(key =>
    {
        const [ x, y ] = key.split(',').map(Number);

        return { x, y };
    }), [ tiles ]);
    const fitsSmall = neighborhoodFitsSmall(tileList);
    const radius = bigMode ? NEIGHBORHOOD_RADIUS : NEIGHBORHOOD_SMALL_RADIUS;
    const options = isUser ? availableUserSources : FURNI_SOURCES;

    useEffect(() =>
    {
        if(!options.some(option => option.value === source)) setSource(options[0]?.value ?? SOURCE_TRIGGER);
    }, [ options, source ]);

    const draw = useCallback((x: number, y: number) =>
    {
        if(drawMode === 'set_root_tile')
        {
            setRoot({ x, y });
            return;
        }

        setTiles(previous =>
        {
            const key = tileKey(x, y);
            const has = previous.has(key);

            if((drawMode === 'add_tile') === has) return previous;

            const next = new Set(previous);

            if(has) next.delete(key);
            else next.add(key);

            return next;
        });
    }, [ drawMode ]);

    const clampRoot = (value: number) => Math.max(-NEIGHBORHOOD_MAX_ROOT, Math.min(NEIGHBORHOOD_MAX_ROOT, isNaN(value) ? 0 : value));

    const save = useCallback(() =>
    {
        setIntParams([ isUser ? 1 : 0, root.x, root.y, ...encodeNeighborhood(tileList) ]);
        setSourceTypes({ furni: [ isUser ? SOURCE_TRIGGER : source ], users: [ isUser ? source : SOURCE_TRIGGER ] });
    }, [ isUser, root, setIntParams, setSourceTypes, source, tileList ]);

    const modeButton = (mode: DrawMode, icon: JSX.Element, titleKey: string, fallback: string) => (
        <Button
            className="px-2 py-1"
            variant={ drawMode === mode ? 'success' : 'secondary' }
            aria-label={ localizeWithFallback(titleKey, fallback) }
            onClick={ () => setDrawMode(mode) }>
            { icon }
        </Button>
    );

    const picking = !isUser && source === SOURCE_PICKED;

    return (
        <NativeTextTree>
            <WiredSelectorBaseView
                hasSpecialInput={ true }
                requiresFurni={ picking ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID : WiredFurniType.STUFF_SELECTION_OPTION_NONE }
                save={ save }
                hideDelay={ true }
                cardStyle={ { width: bigMode ? '420px' : '360px' } }>
                <div className="flex flex-col gap-2">
                    <Text bold>{ LocalizeText('wiredfurni.params.neighborhood_selection') }</Text>

                    <div className="flex items-center gap-1">
                        { modeButton('add_tile', <FaPlus />, 'wiredfurni.params.neighborhood.add', 'Add tiles') }
                        { modeButton('remove_tile', <FaMinus />, 'wiredfurni.params.neighborhood.remove', 'Remove tiles') }
                        { modeButton('set_root_tile', <FaCrosshairs />, 'wiredfurni.params.neighborhood.reference', 'Set the root tile') }
                        <Button
                            className="ms-auto px-2 py-1"
                            variant="secondary"
                            disabled={ !fitsSmall }
                            aria-label={ localizeWithFallback(bigMode ? 'wiredfurni.params.neighborhood.reduce' : 'wiredfurni.params.neighborhood.enlarge', bigMode ? 'Smaller view' : 'Bigger view') }
                            onClick={ () => setBigMode(value => !value) }>
                            { bigMode ? <FaCompress /> : <FaExpand /> }
                        </Button>
                    </div>

                    <div className="flex justify-center">
                        <NeighborhoodFloor radius={ radius } tiles={ tiles } root={ root } onDraw={ draw } />
                    </div>

                    <div className="flex items-center justify-end gap-2">
                        <Text small>x:</Text>
                        <NumberInput className="form-control form-control-sm" style={ { width: 56 } } min={ -NEIGHBORHOOD_MAX_ROOT } max={ NEIGHBORHOOD_MAX_ROOT } value={ root.x } onChange={ event => setRoot({ x: clampRoot(parseInt(event.target.value)), y: root.y }) } />
                        <Text small>y:</Text>
                        <NumberInput className="form-control form-control-sm" style={ { width: 56 } } min={ -NEIGHBORHOOD_MAX_ROOT } max={ NEIGHBORHOOD_MAX_ROOT } value={ root.y } onChange={ event => setRoot({ x: root.x, y: clampRoot(parseInt(event.target.value)) }) } />
                    </div>


                    <hr className="m-0 bg-dark" />

                    <WiredFurniSelectionSourceRow
                        title="wiredfurni.params.sources.merged.title.neighborhood"
                        options={ options }
                        value={ source }
                        selectionKind={ isUser ? 'primary' : 'secondary' }
                        selectionActive={ picking }
                        selectionCount={ furniIds.length }
                        selectionLimit={ trigger?.maximumItemSelectionCount ?? 20 }
                        selectionEnabledValues={ [ SOURCE_PICKED ] }
                        showSelectionToggle={ false }
                        headerContent={
                            <div className="octane-wired__give-var-targets">
                                { SOURCE_GROUP_BUTTONS.map(button => (
                                    <BaseButton
                                        key={ button.key }
                                        type="button"
                                        className={ `octane-wired__give-var-target octane-wired__give-var-target--${ button.key } ${ isUser === button.isUser ? 'is-active' : '' }` }
                                        onClick={ () => setIsUser(button.isUser) }>
                                        <img src={ button.icon } alt={ button.key } />
                                    </BaseButton>
                                )) }
                            </div>
                        }
                        onChange={ value => setSource(value) } />
                </div>
            </WiredSelectorBaseView>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
3En 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