Merge pull request #21 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-03-19 09:50:40 +01:00
committed by GitHub
31 changed files with 3838 additions and 354 deletions
+1
View File
@@ -21,6 +21,7 @@
"framer-motion": "^11.2.12",
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"react-icons": "^5.5.0",
"react-slider": "^2.0.6",
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -1,6 +1,6 @@
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { GetUIVersion } from './api';
import { GetUIVersion, UiSettingsProvider } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
import { MainView } from './components/MainView';
@@ -89,11 +89,13 @@ export const App: FC<{}> = props =>
}, []);
return (
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady &&
<LoadingView /> }
{ isReady && <MainView /> }
<Base id="draggable-windows-container" />
</Base>
<UiSettingsProvider>
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady &&
<LoadingView /> }
{ isReady && <MainView /> }
<Base id="draggable-windows-container" />
</Base>
</UiSettingsProvider>
);
};
+21
View File
@@ -0,0 +1,21 @@
export interface IUiSettings
{
colorMode: 'color' | 'image' | 'default';
headerColor: string;
headerImageUrl: string;
headerAlpha: number;
}
export const DEFAULT_UI_SETTINGS: IUiSettings = {
colorMode: 'default',
headerColor: '#1E7295',
headerImageUrl: '',
headerAlpha: 100
};
export const PRESET_COLORS: string[] = [
'#000000', '#444444', '#888888', '#CCCCCC', '#660000', '#CC3333', '#FF6666', '#CC6600',
'#FF3333', '#FF6633', '#FF9933', '#FFCC00', '#FFFF00', '#66FF00', '#00CC00', '#009900',
'#00FFCC', '#33CCFF', '#3366FF', '#0000CC', '#6633CC', '#9933FF', '#CC33FF', '#FF66CC',
'#FF99CC', '#1E7295', '#185D79', '#2DABC2', '#2B91A7', '#283F5D'
];
+164
View File
@@ -0,0 +1,164 @@
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react';
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
const STORAGE_KEY = 'nitro.ui.settings';
interface IUiSettingsContext
{
settings: IUiSettings;
isCustomActive: boolean;
updateSettings: (partial: Partial<IUiSettings>) => void;
resetSettings: () => void;
getHeaderStyle: () => React.CSSProperties;
getTabsStyle: () => React.CSSProperties;
getAccentColor: () => string;
}
const UiSettingsContext = createContext<IUiSettingsContext>({
settings: DEFAULT_UI_SETTINGS,
isCustomActive: false,
updateSettings: () => {},
resetSettings: () => {},
getHeaderStyle: () => ({}),
getTabsStyle: () => ({}),
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
});
const darkenColor = (hex: string, amount: number): string =>
{
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, ((num >> 16) & 0xFF) - amount);
const g = Math.max(0, ((num >> 8) & 0xFF) - amount);
const b = Math.max(0, (num & 0xFF) - amount);
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
};
const loadSettings = (): IUiSettings =>
{
try
{
const stored = localStorage.getItem(STORAGE_KEY);
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
}
catch(e) {}
return { ...DEFAULT_UI_SETTINGS };
};
const saveSettings = (settings: IUiSettings): void =>
{
try
{
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
catch(e) {}
};
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
{
setSettings(prev =>
{
const updated = { ...prev, ...partial };
saveSettings(updated);
return updated;
});
}, []);
const resetSettings = useCallback(() =>
{
setSettings({ ...DEFAULT_UI_SETTINGS });
saveSettings(DEFAULT_UI_SETTINGS);
}, []);
const getHeaderStyle = useCallback((): React.CSSProperties =>
{
if(settings.colorMode === 'color')
{
return { backgroundColor: settings.headerColor };
}
if(settings.colorMode === 'image' && settings.headerImageUrl)
{
return {
backgroundImage: `url(${ settings.headerImageUrl })`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'repeat'
};
}
return {};
}, [ settings ]);
const getTabsStyle = useCallback((): React.CSSProperties =>
{
if(settings.colorMode === 'color')
{
return { backgroundColor: darkenColor(settings.headerColor, 30) };
}
if(settings.colorMode === 'image' && settings.headerImageUrl)
{
return {
backgroundImage: `url(${ settings.headerImageUrl })`,
backgroundSize: 'cover',
backgroundPosition: 'center bottom',
backgroundRepeat: 'repeat'
};
}
return {};
}, [ settings ]);
const getAccentColor = useCallback((): string =>
{
if(settings.colorMode === 'color') return settings.headerColor;
return DEFAULT_UI_SETTINGS.headerColor;
}, [ settings ]);
const isCustomActive = settings.colorMode !== 'default';
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
useEffect(() =>
{
const root = document.documentElement;
if(settings.colorMode === 'color')
{
const c = settings.headerColor;
root.style.setProperty('--ui-accent-color', c);
root.style.setProperty('--ui-accent-dark', darkenColor(c, 30));
root.style.setProperty('--ui-ctx-bg', darkenColor(c, 50));
root.style.setProperty('--ui-ctx-header-bg', darkenColor(c, 20));
root.style.setProperty('--ui-ctx-item-bg1', darkenColor(c, 60));
root.style.setProperty('--ui-ctx-item-bg2', darkenColor(c, 70));
root.style.setProperty('--ui-btn-primary-bg', c);
root.style.setProperty('--ui-btn-primary-border', darkenColor(c, 20));
root.style.setProperty('--ui-dark-bg', darkenColor(c, 55));
root.style.setProperty('--ui-dark-border', darkenColor(c, 60));
}
else
{
ALL_CSS_VARS.forEach(v => root.style.removeProperty(v));
}
}, [ settings ]);
return (
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
{ children }
</UiSettingsContext.Provider>
);
};
export const useUiSettings = () => useContext(UiSettingsContext);
+2
View File
@@ -0,0 +1,2 @@
export * from './IUiSettings';
export * from './UiSettingsContext';
+29 -11
View File
@@ -12,20 +12,16 @@ export interface ButtonProps extends FlexProps
export const Button: FC<ButtonProps> = props =>
{
const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], ...rest } = props;
const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], style = {}, ...rest } = props;
const getClassNames = useMemo(() =>
{
// fucked up method i know (i dont have a clue what im doing because im a ninja)
const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ];
if(variant)
{
if(variant == 'primary')
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white');
if(variant == 'success')
newClassNames.push('text-white bg-[#00800b] border-[#00800b] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#006d09] hover:border-[#006609]');
@@ -43,11 +39,10 @@ export const Button: FC<ButtonProps> = props =>
newClassNames.push('text-white bg-[#185d79] border-[#185d79] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#144f67] hover:border-[#134a61]');
if(variant == 'dark')
newClassNames.push('text-white bg-dark [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#18181bfb] hover:border-[#161619fb]');
if(variant == 'gray')
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white');
if(variant == 'gray')
newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white');
}
if(size)
@@ -67,5 +62,28 @@ export const Button: FC<ButtonProps> = props =>
return newClassNames;
}, [ variant, size, active, disabled, classNames ]);
return <Flex center classNames={ getClassNames } { ...rest } />;
const getStyle = useMemo(() =>
{
if(variant === 'primary' || variant === 'gray')
{
return {
backgroundColor: 'var(--ui-btn-primary-bg, #1e7295)',
borderColor: 'var(--ui-btn-primary-border, #1e7295)',
...style
};
}
if(variant === 'dark')
{
return {
backgroundColor: 'var(--ui-dark-bg, rgba(28, 28, 32, .98))',
borderColor: 'var(--ui-dark-border, rgba(28, 28, 32, .98))',
...style
};
}
return style;
}, [ variant, style ]);
return <Flex center classNames={ getClassNames } style={ getStyle } { ...rest } />;
};
+7 -3
View File
@@ -1,5 +1,6 @@
import { FC, MouseEvent } from 'react';
import { FaFlag } from 'react-icons/fa';
import { useUiSettings } from '../../api';
import { Base, Column, ColumnProps, Flex } from '..';
interface NitroCardHeaderViewProps extends ColumnProps
@@ -16,8 +17,7 @@ interface NitroCardHeaderViewProps extends ColumnProps
export const NitroCardHeaderView: FC<NitroCardHeaderViewProps> = props =>
{
const { headerText = null, isGalleryPhoto = false, noCloseButton = false, isInfoToHabboPages = false, onReportPhoto = null, onClickInfoHabboPages = null, onCloseClick = null, justifyContent = 'center', alignItems = 'center', classNames = [], children = null, ...rest } = props;
const { isCustomActive, getHeaderStyle } = useUiSettings();
const onMouseDown = (event: MouseEvent<HTMLDivElement>) =>
{
@@ -25,8 +25,12 @@ export const NitroCardHeaderView: FC<NitroCardHeaderViewProps> = props =>
event.nativeEvent.stopImmediatePropagation();
};
const headerClassName = isCustomActive
? 'relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header'
: 'relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header bg-card-header';
return (
<Column center className={ 'relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header bg-card-header' } { ...rest }>
<Column center className={ headerClassName } style={ getHeaderStyle() } { ...rest }>
<Flex center fullWidth>
<span className="text-xl text-white drop-shadow-lg">{ headerText }</span>
{ isGalleryPhoto &&
+9 -3
View File
@@ -1,21 +1,27 @@
import { FC, useMemo } from 'react';
import { useUiSettings } from '../../../api';
import { Flex, FlexProps } from '../..';
export const NitroCardTabsView: FC<FlexProps> = props =>
{
const { justifyContent = 'center', gap = 1, classNames = [], children = null, ...rest } = props;
const { isCustomActive, getTabsStyle } = useUiSettings();
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'justify-center gap-0.5 flex bg-card-tabs min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px' ];
const base = isCustomActive
? 'justify-center gap-0.5 flex min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px'
: 'justify-center gap-0.5 flex bg-card-tabs min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px';
const newClassNames: string[] = [ base ];
if(classNames.length) newClassNames.push(...classNames);
return newClassNames;
}, [ classNames ]);
}, [ classNames, isCustomActive ]);
return (
<Flex classNames={ getClassNames } gap={ gap } justifyContent={ justifyContent } { ...rest }>
<Flex classNames={ getClassNames } gap={ gap } justifyContent={ justifyContent } style={ getTabsStyle() } { ...rest }>
{ children }
</Flex>
);
+6
View File
@@ -9,6 +9,7 @@ import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView';
import { GameCenterView } from './game-center/GameCenterView';
import { GroupsView } from './groups/GroupsView';
@@ -21,10 +22,12 @@ import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
import { NitropediaView } from './nitropedia/NitropediaView';
import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
import { UserProfileView } from './user-profile/UserProfileView';
import { InterfaceSettingsView } from './interface-settings/InterfaceSettingsView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
import { YoutubeTvView } from './youtube-tv/YoutubeTvView';
@@ -106,6 +109,7 @@ export const MainView: FC<{}> = props =>
<FriendsView />
<RightSideView />
<UserSettingsView />
<InterfaceSettingsView />
<UserProfileView />
<GroupsView />
<CameraWidgetView />
@@ -116,7 +120,9 @@ export const MainView: FC<{}> = props =>
<CampaignView />
<GameCenterView />
<FloorplanEditorView />
<FurniEditorView />
<YoutubeTvView />
<ExternalPluginLoader />
</>
);
};
@@ -8,13 +8,25 @@ interface IFloorplanEditorContext
setOriginalFloorplanSettings: Dispatch<SetStateAction<IFloorplanSettings>>;
visualizationSettings: IVisualizationSettings;
setVisualizationSettings: Dispatch<SetStateAction<IVisualizationSettings>>;
floorHeight: number;
setFloorHeight: Dispatch<SetStateAction<number>>;
floorAction: number;
setFloorAction: Dispatch<SetStateAction<number>>;
tilemapVersion: number;
areaInfo: { total: number; walkable: number };
}
const FloorplanEditorContext = createContext<IFloorplanEditorContext>({
originalFloorplanSettings: null,
setOriginalFloorplanSettings: null,
visualizationSettings: null,
setVisualizationSettings: null
setVisualizationSettings: null,
floorHeight: 0,
setFloorHeight: null,
floorAction: 3,
setFloorAction: null,
tilemapVersion: 0,
areaInfo: { total: 0, walkable: 0 }
});
export const FloorplanEditorContextProvider: FC<ProviderProps<IFloorplanEditorContext>> = props => <FloorplanEditorContext.Provider { ...props } />;
@@ -1,19 +1,22 @@
import { AddLinkEventTracker, FloorHeightMapEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { Button, ButtonGroup, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent, useNitroEvent } from '../../hooks';
import { FloorplanEditorContextProvider } from './FloorplanEditorContext';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving, convertSettingToNumber } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving, convertSettingToNumber, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
import { FloorplanCanvasView } from './views/FloorplanCanvasView';
import { FloorplanImportExportView } from './views/FloorplanImportExportView';
import { FloorplanOptionsView } from './views/FloorplanOptionsView';
import { FloorplanHeightSelector } from './views/FloorplanHeightSelector';
import { FloorplanPreviewView } from './views/FloorplanPreviewView';
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
const MIN_WALL_HEIGHT = 0;
const MAX_WALL_HEIGHT = 16;
export const FloorplanEditorView: FC<{}> = props =>
{
@@ -34,7 +37,65 @@ export const FloorplanEditorView: FC<{}> = props =>
thicknessWall: 1,
thicknessFloor: 1
});
const [ canvasScrollHandler, setCanvasScrollHandler ] = useState<((direction: ScrollDirection) => void) | null>(null);
const [ floorHeight, setFloorHeight ] = useState(0);
const [ floorAction, setFloorAction ] = useState(FloorAction.SET);
const [ tilemapVersion, setTilemapVersion ] = useState(0);
const [ areaInfo, setAreaInfo ] = useState({ total: 0, walkable: 0 });
const calculateArea = useCallback(() =>
{
const tilemap = FloorplanEditor.instance.tilemap;
if(!tilemap || tilemap.length === 0)
{
setAreaInfo({ total: 0, walkable: 0 });
return;
}
let total = 0;
let walkable = 0;
for(let y = 0; y < tilemap.length; y++)
{
if(!tilemap[y]) continue;
for(let x = 0; x < tilemap[y].length; x++)
{
if(!tilemap[y][x] || tilemap[y][x].height === 'x') continue;
total++;
if(!tilemap[y][x].isBlocked) walkable++;
}
}
setAreaInfo({ total, walkable });
}, []);
// sync floorHeight/floorAction changes to the FloorplanEditor instance
useEffect(() =>
{
FloorplanEditor.instance.actionSettings.currentAction = floorAction;
FloorplanEditor.instance.actionSettings.currentHeight = floorHeight.toString(36);
}, [ floorHeight, floorAction ]);
// register onTilemapChange callback
useEffect(() =>
{
if(!isVisible) return;
FloorplanEditor.instance.onTilemapChange = () =>
{
setTilemapVersion(prev => prev + 1);
calculateArea();
};
return () =>
{
FloorplanEditor.instance.onTilemapChange = null;
};
}, [ isVisible, calculateArea ]);
const saveFloorChanges = () =>
{
@@ -47,16 +108,50 @@ export const FloorplanEditorView: FC<{}> = props =>
convertNumbersForSaving(visualizationSettings.thicknessFloor),
(visualizationSettings.wallHeight - 1)
));
}
};
const revertChanges = () =>
{
setVisualizationSettings({ wallHeight: originalFloorplanSettings.wallHeight, thicknessWall: originalFloorplanSettings.thicknessWall, thicknessFloor: originalFloorplanSettings.thicknessFloor, entryPointDir: originalFloorplanSettings.entryPointDir });
FloorplanEditor.instance.doorLocation = { x: originalFloorplanSettings.entryPoint[0], y: originalFloorplanSettings.entryPoint[1] };
FloorplanEditor.instance.setTilemap(originalFloorplanSettings.tilemap, originalFloorplanSettings.reservedTiles);
FloorplanEditor.instance.renderTiles();
}
};
const onWallHeightChange = (value: number) =>
{
if(isNaN(value) || (value <= 0)) value = MIN_WALL_HEIGHT;
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.wallHeight = value;
return newValue;
});
};
const increaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight + 1);
if(height > MAX_WALL_HEIGHT) height = MAX_WALL_HEIGHT;
onWallHeightChange(height);
};
const decreaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight - 1);
if(height <= 0) height = MIN_WALL_HEIGHT;
onWallHeightChange(height);
};
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, event => setIsVisible(false));
@@ -117,7 +212,7 @@ export const FloorplanEditorView: FC<{}> = props =>
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
@@ -140,17 +235,42 @@ export const FloorplanEditorView: FC<{}> = props =>
}, []);
return (
<FloorplanEditorContextProvider value={ { originalFloorplanSettings: originalFloorplanSettings, setOriginalFloorplanSettings: setOriginalFloorplanSettings, visualizationSettings: visualizationSettings, setVisualizationSettings: setVisualizationSettings } }>
<FloorplanEditorContextProvider value={ {
originalFloorplanSettings,
setOriginalFloorplanSettings,
visualizationSettings,
setVisualizationSettings,
floorHeight,
setFloorHeight,
floorAction,
setFloorAction,
tilemapVersion,
areaInfo
} }>
{ isVisible &&
<NitroCardView uniqueKey="floorpan-editor" className="w-[760px] h-[500px]" theme="primary-slim">
<NitroCardView uniqueKey="floorpan-editor" className="w-[1100px] h-[600px]" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView overflow="hidden">
<FloorplanOptionsView onCanvasScroll={ direction => canvasScrollHandler && canvasScrollHandler(direction) } />
<FloorplanCanvasView overflow="hidden" setScrollHandler={ setCanvasScrollHandler } />
<NitroCardContentView overflow="hidden" className="flex flex-col">
<FloorplanOptionsView />
<Flex gap={ 2 } className="flex-1 min-h-0">
<FloorplanHeightSelector />
<FloorplanCanvasView overflow="hidden" />
<Column gap={ 2 } className="w-[380px] min-w-[380px]">
<FloorplanPreviewView />
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.editor.wall.height') }</Text>
<FaCaretLeft className="cursor-pointer fa-icon" onClick={ decreaseWallHeight } />
<input type="number" className="form-control form-control-sm w-[49px]" value={ visualizationSettings.wallHeight } onChange={ event => onWallHeightChange(event.target.valueAsNumber) } />
<FaCaretRight className="cursor-pointer fa-icon" onClick={ increaseWallHeight } />
</Flex>
<Text bold small className="text-center">
Area: { areaInfo.total } ({ areaInfo.walkable } caselle)
</Text>
</Column>
</Flex>
<Flex justifyContent="between">
<Button onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
<Button variant="danger" onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
<ButtonGroup>
<Button disabled={ true }>{ LocalizeText('floor.plan.editor.preview') }</Button>
<Button onClick={ event => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
<Button onClick={ saveFloorChanges }>{ LocalizeText('floor.plan.editor.save') }</Button>
</ButtonGroup>
@@ -161,4 +281,4 @@ export const FloorplanEditorView: FC<{}> = props =>
<FloorplanImportExportView onCloseClick={ () => setImportExportVisible(false) } /> }
</FloorplanEditorContextProvider>
);
}
};
@@ -1,25 +1,25 @@
import { GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { FaPlus, FaMinus } from 'react-icons/fa';
import { SendMessageComposer } from '../../../api';
import { Base, Column, ColumnProps } from '../../../common';
import { useMessageEvent } from '../../../hooks';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
interface FloorplanCanvasViewProps extends ColumnProps
{
setScrollHandler(handler: ((direction: ScrollDirection) => void) | null): void;
}
export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
{
const { gap = 1, children = null, setScrollHandler = null, ...rest } = props;
const [ occupiedTilesReceived , setOccupiedTilesReceived ] = useState(false);
const { gap = 1, children = null, ...rest } = props;
const [ occupiedTilesReceived, setOccupiedTilesReceived ] = useState(false);
const [ entryTileReceived, setEntryTileReceived ] = useState(false);
const [ zoomLevel, setZoomLevel ] = useState(1.0);
const { originalFloorplanSettings = null, setOriginalFloorplanSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext();
const elementRef = useRef<HTMLDivElement>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
{
@@ -37,7 +37,7 @@ export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
});
setOccupiedTilesReceived(true);
elementRef.current.scrollTo((FloorplanEditor.instance.renderer.canvas.width / 3), 0);
});
@@ -63,39 +63,16 @@ export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
return newValue;
});
FloorplanEditor.instance.doorLocation = { x: parser.x, y: parser.y };
setEntryTileReceived(true);
});
const onClickArrowButton = (scrollDirection: ScrollDirection) =>
{
const element = elementRef.current;
if(!element) return;
switch(scrollDirection)
{
case 'up':
element.scrollBy({ top: -10 });
break;
case 'down':
element.scrollBy({ top: 10 });
break;
case 'left':
element.scrollBy({ left: -10 });
break;
case 'right':
element.scrollBy({ left: 10 });
break;
}
}
const onPointerEvent = (event: PointerEvent) =>
{
event.preventDefault();
switch(event.type)
{
case 'pointerout':
@@ -109,7 +86,10 @@ export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
FloorplanEditor.instance.onPointerMove(event);
break;
}
}
};
const zoomIn = () => setZoomLevel(prev => Math.min(prev + 0.25, 2.0));
const zoomOut = () => setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
useEffect(() =>
{
@@ -124,15 +104,15 @@ export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
thicknessWall: originalFloorplanSettings.thicknessWall,
thicknessFloor: originalFloorplanSettings.thicknessFloor,
entryPointDir: prevValue.entryPointDir
}
};
});
}
};
}, [ originalFloorplanSettings.thicknessFloor, originalFloorplanSettings.thicknessWall, originalFloorplanSettings.wallHeight, setVisualizationSettings ]);
useEffect(() =>
{
if(!entryTileReceived || !occupiedTilesReceived) return;
FloorplanEditor.instance.renderTiles();
}, [ entryTileReceived, occupiedTilesReceived ]);
@@ -144,45 +124,56 @@ export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
const currentElement = elementRef.current;
if(!currentElement) return;
currentElement.appendChild(FloorplanEditor.instance.renderer.canvas);
const wrapper = canvasWrapperRef.current;
if(wrapper) wrapper.appendChild(FloorplanEditor.instance.renderer.canvas);
currentElement.addEventListener('pointerup', onPointerEvent);
currentElement.addEventListener('pointerout', onPointerEvent);
currentElement.addEventListener('pointerdown', onPointerEvent);
currentElement.addEventListener('pointermove', onPointerEvent);
return () =>
return () =>
{
if(currentElement)
{
currentElement.removeEventListener('pointerup', onPointerEvent);
currentElement.removeEventListener('pointerout', onPointerEvent);
currentElement.removeEventListener('pointerdown', onPointerEvent);
currentElement.removeEventListener('pointermove', onPointerEvent);
}
}
};
}, []);
useEffect(() =>
{
if(!setScrollHandler) return;
setScrollHandler(() => onClickArrowButton);
return () => setScrollHandler(null);
}, [ setScrollHandler ]);
return (
<Column gap={ gap } { ...rest }>
<Base overflow="auto" innerRef={ elementRef } />
<Column gap={ gap } { ...rest } className="relative flex-1">
<Base overflow="auto" innerRef={ elementRef } className="flex-1">
<div
ref={ canvasWrapperRef }
style={ {
transform: `scale(${ zoomLevel })`,
transformOrigin: '0 0'
} }
/>
</Base>
<div className="absolute top-2 right-2 flex flex-col gap-1 z-10">
<button
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
onClick={ zoomIn }
title="Zoom in"
>
<FaPlus size={ 10 } />
</button>
<button
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
onClick={ zoomOut }
title="Zoom out"
>
<FaMinus size={ 10 } />
</button>
</div>
{ children }
</Column>
);
}
};
@@ -0,0 +1,54 @@
import { FC } from 'react';
import { COLORMAP, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { Column, Text } from '../../../common';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
const colormap = COLORMAP as Record<string, string>;
export const FloorplanHeightSelector: FC<{}> = () =>
{
const { floorHeight, setFloorHeight, setFloorAction } = useFloorplanEditorContext();
const onSelectHeight = (height: number) =>
{
setFloorHeight(height);
setFloorAction(FloorAction.SET);
FloorplanEditor.instance.actionSettings.currentAction = FloorAction.SET;
FloorplanEditor.instance.actionSettings.currentHeight = height.toString(36);
};
const heights: number[] = [];
for(let i = 26; i >= 0; i--) heights.push(i);
return (
<Column className="h-full w-[30px] min-w-[30px] select-none">
<Text bold small center>{ floorHeight }</Text>
<div className="flex flex-col flex-1 rounded overflow-hidden border-2 border-muted">
{ heights.map(h =>
{
const char = HEIGHT_SCHEME[h + 1];
const color = colormap[char] || '101010';
const isActive = (floorHeight === h);
return (
<div
key={ h }
className="flex-1 cursor-pointer relative flex items-center justify-center"
style={ {
backgroundColor: `#${ color }`,
outline: isActive ? '2px solid #fff' : 'none',
outlineOffset: '-2px',
zIndex: isActive ? 1 : 0
} }
onClick={ () => onSelectHeight(h) }
title={ `${ h }` }
/>
);
}) }
</div>
</Column>
);
};
@@ -1,45 +1,32 @@
import { FC, useState } from 'react';
import { FaArrowDown, FaArrowLeft, FaArrowRight, FaArrowUp, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { FC } from 'react';
import { LocalizeText } from '../../../api';
import { Button, Column, Flex, LayoutGridItem, Slider, Text } from '../../../common';
import { COLORMAP, FloorAction } from '@nitrots/nitro-renderer';
import { Flex, LayoutGridItem, Text } from '../../../common';
import { FloorAction } from '@nitrots/nitro-renderer';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
const MIN_WALL_HEIGHT: number = 0;
const MAX_WALL_HEIGHT: number = 16;
const MIN_FLOOR_HEIGHT: number = 0;
const MAX_FLOOR_HEIGHT: number = 26;
type ScrollDirection = 'up' | 'down' | 'left' | 'right';
interface FloorplanOptionsViewProps
{
onCanvasScroll?(direction: ScrollDirection): void;
}
export const FloorplanOptionsView: FC<FloorplanOptionsViewProps> = props =>
{
const { onCanvasScroll = () => {} } = props;
const { visualizationSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext();
const [ floorAction, setFloorAction ] = useState(FloorAction.SET);
const [ floorHeight, setFloorHeight ] = useState(0);
const [ isSquareSelectMode, setSquareSelectMode ] = useState(false);
const { visualizationSettings = null, setVisualizationSettings = null, floorAction, setFloorAction } = useFloorplanEditorContext();
const isSquareSelectMode = FloorplanEditor.instance.isSquareSelectMode;
const selectAction = (action: number) =>
{
setFloorAction(action);
FloorplanEditor.instance.actionSettings.currentAction = action;
}
};
const toggleSquareSelectMode = () =>
{
const nextValue = FloorplanEditor.instance.toggleSquareSelectMode();
setSquareSelectMode(nextValue);
}
FloorplanEditor.instance.toggleSquareSelectMode();
// force re-render by toggling action to same value
setFloorAction(prev => prev);
};
const changeDoorDirection = () =>
{
@@ -58,18 +45,19 @@ export const FloorplanOptionsView: FC<FloorplanOptionsViewProps> = props =>
return newValue;
});
}
};
const onFloorHeightChange = (value: number) =>
const onWallThicknessChange = (value: number) =>
{
if(isNaN(value) || (value <= 0)) value = 0;
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
if(value > 26) value = 26;
newValue.thicknessWall = value;
setFloorHeight(value);
FloorplanEditor.instance.actionSettings.currentHeight = value.toString(36);
}
return newValue;
});
};
const onFloorThicknessChange = (value: number) =>
{
@@ -81,157 +69,54 @@ export const FloorplanOptionsView: FC<FloorplanOptionsViewProps> = props =>
return newValue;
});
}
const onWallThicknessChange = (value: number) =>
{
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.thicknessWall = value;
return newValue;
});
}
const onWallHeightChange = (value: number) =>
{
if(isNaN(value) || (value <= 0)) value = MIN_WALL_HEIGHT;
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.wallHeight = value;
return newValue;
});
}
const increaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight + 1);
if(height > MAX_WALL_HEIGHT) height = MAX_WALL_HEIGHT;
onWallHeightChange(height);
}
const decreaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight - 1);
if(height <= 0) height = MIN_WALL_HEIGHT;
onWallHeightChange(height);
}
};
return (
<Column>
<Flex gap={ 1 }>
<Column size={ 5 } gap={ 1 }>
<Text bold>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
<Flex gap={ 3 }>
<Flex gap={ 1 }>
<LayoutGridItem itemActive={ (floorAction === FloorAction.SET) } onClick={ event => selectAction(FloorAction.SET) }>
<i className="nitro-icon icon-set-tile" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UNSET) } onClick={ event => selectAction(FloorAction.UNSET) }>
<i className="nitro-icon icon-unset-tile" />
</LayoutGridItem>
</Flex>
<Flex gap={ 1 }>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UP) } onClick={ event => selectAction(FloorAction.UP) }>
<i className="nitro-icon icon-increase-height" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOWN) } onClick={ event => selectAction(FloorAction.DOWN) }>
<i className="nitro-icon icon-decrease-height" />
</LayoutGridItem>
</Flex>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOOR) } onClick={ event => selectAction(FloorAction.DOOR) }>
<i className="nitro-icon icon-set-door" />
</LayoutGridItem>
<LayoutGridItem onClick={ event => FloorplanEditor.instance.toggleSelectAll() }>
<i className={ `nitro-icon ${ floorAction === FloorAction.UNSET ? 'icon-set-deselect' : 'icon-set-select' }` } />
</LayoutGridItem>
<LayoutGridItem itemActive={ isSquareSelectMode } onClick={ toggleSquareSelectMode }>
<i className={ `nitro-icon ${ isSquareSelectMode ? 'icon-set-active-squaresselect' : 'icon-set-squaresselect' }` } />
</LayoutGridItem>
</Flex>
</Column>
<Column alignItems="center" size={ 4 }>
<Text bold>{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
<i className={ `nitro-icon icon-door-direction-${ visualizationSettings.entryPointDir } cursor-pointer` } onClick={ changeDoorDirection } />
</Column>
<Column size={ 3 }>
<Text bold>{ LocalizeText('floor.editor.wall.height') }</Text>
<Flex alignItems="center" gap={ 1 }>
<FaCaretLeft className="cursor-pointer fa-icon" onClick={ decreaseWallHeight } />
<input type="number" className="form-control form-control-sm w-[49px]" value={ visualizationSettings.wallHeight } onChange={ event => onWallHeightChange(event.target.valueAsNumber) } />
<FaCaretRight className="cursor-pointer fa-icon" onClick={ increaseWallHeight } />
</Flex>
</Column>
<Column size={ 6 }>
<Text bold>{ LocalizeText('floor.plan.editor.room.options') }</Text>
<Flex className="align-items-center">
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessWall } onChange={ event => onWallThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
</select>
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessFloor } onChange={ event => onFloorThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
</select>
</Flex>
</Column>
<Flex gap={ 2 } alignItems="center">
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
<Flex gap={ 1 }>
<LayoutGridItem itemActive={ (floorAction === FloorAction.SET) } onClick={ () => selectAction(FloorAction.SET) }>
<i className="nitro-icon icon-set-tile" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UNSET) } onClick={ () => selectAction(FloorAction.UNSET) }>
<i className="nitro-icon icon-unset-tile" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UP) } onClick={ () => selectAction(FloorAction.UP) }>
<i className="nitro-icon icon-increase-height" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOWN) } onClick={ () => selectAction(FloorAction.DOWN) }>
<i className="nitro-icon icon-decrease-height" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOOR) } onClick={ () => selectAction(FloorAction.DOOR) }>
<i className="nitro-icon icon-set-door" />
</LayoutGridItem>
<LayoutGridItem onClick={ () => FloorplanEditor.instance.toggleSelectAll() }>
<i className={ `nitro-icon ${ floorAction === FloorAction.UNSET ? 'icon-set-deselect' : 'icon-set-select' }` } />
</LayoutGridItem>
<LayoutGridItem itemActive={ isSquareSelectMode } onClick={ toggleSquareSelectMode }>
<i className={ `nitro-icon ${ isSquareSelectMode ? 'icon-set-active-squaresselect' : 'icon-set-squaresselect' }` } />
</LayoutGridItem>
</Flex>
</Flex>
<Flex gap={ 2 } alignItems="center" justifyContent="between">
<Column size={ 6 }>
<Text bold>{ LocalizeText('floor.plan.editor.tile.height') }: { floorHeight }</Text>
<div style={ { width: '100%', maxWidth: 240 } }>
<Slider
min={ MIN_FLOOR_HEIGHT }
max={ MAX_FLOOR_HEIGHT }
step={ 1 }
value={ floorHeight }
onChange={ event => onFloorHeightChange(event) }
renderThumb={ (props, state) =>
{
const { key, style, ...rest } = (props as Record<string, any>);
return <div key={ key } style={ { backgroundColor: `#${ COLORMAP[state.valueNow.toString(33)] }`, ...style } } { ...rest }>{ state.valueNow }</div>;
} } />
</div>
</Column>
<Column gap={ 1 }>
<Flex justifyContent="center">
<Button shrink onClick={ event => onCanvasScroll('up') }>
<FaArrowUp className="fa-icon" />
</Button>
</Flex>
<Flex alignItems="center" justifyContent="center" gap={ 1 }>
<Button shrink onClick={ event => onCanvasScroll('left') }>
<FaArrowLeft className="fa-icon" />
</Button>
<div style={ { width: 28 } } />
<Button shrink onClick={ event => onCanvasScroll('right') }>
<FaArrowRight className="fa-icon" />
</Button>
</Flex>
<Flex justifyContent="center">
<Button shrink onClick={ event => onCanvasScroll('down') }>
<FaArrowDown className="fa-icon" />
</Button>
</Flex>
</Column>
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
<i className={ `nitro-icon icon-door-direction-${ visualizationSettings.entryPointDir } cursor-pointer` } onClick={ changeDoorDirection } />
</Flex>
</Column>
<Flex gap={ 1 } alignItems="center" className="ml-auto">
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessWall } onChange={ event => onWallThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
</select>
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessFloor } onChange={ event => onFloorThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
</select>
</Flex>
</Flex>
);
}
};
@@ -0,0 +1,328 @@
import { FC, useEffect, useRef } from 'react';
import { COLORMAP, HEIGHT_SCHEME, FloorplanEditor } from '@nitrots/nitro-renderer';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
const colormap = COLORMAP as Record<string, string>;
const PREVIEW_TILE_W = 16;
const PREVIEW_TILE_H = 8;
const PREVIEW_BLOCK_H = 5;
const WALL_HEIGHT_PX = 40;
const WALL_COLOR = '#6B7B5E';
const WALL_SIDE_COLOR = '#5A6A4F';
const WALL_TOP_COLOR = '#7D8E6F';
function hexToRgb(hex: string): [number, number, number]
{
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return [ r, g, b ];
}
function rgbToHex(r: number, g: number, b: number): string
{
return `#${ ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) }`;
}
function darken(hex: string, factor: number): string
{
const [ r, g, b ] = hexToRgb(hex);
return rgbToHex(
Math.floor(r * factor),
Math.floor(g * factor),
Math.floor(b * factor)
);
}
function getTilemapBounds(tilemap: any[][]): { minX: number; minY: number; maxX: number; maxY: number }
{
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for(let y = 0; y < tilemap.length; y++)
{
if(!tilemap[y]) continue;
for(let x = 0; x < tilemap[y].length; x++)
{
if(!tilemap[y][x] || tilemap[y][x].height === 'x') continue;
if(x < minX) minX = x;
if(x > maxX) maxX = x;
if(y < minY) minY = y;
if(y > maxY) maxY = y;
}
}
if(minX === Infinity) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
function renderPreview(canvas: HTMLCanvasElement, wallHeight: number): void
{
const ctx = canvas.getContext('2d');
const tilemap = FloorplanEditor.instance.tilemap;
if(!ctx || !tilemap || tilemap.length === 0)
{
if(ctx)
{
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return;
}
const bounds = getTilemapBounds(tilemap);
const tilesW = bounds.maxX - bounds.minX + 1;
const tilesH = bounds.maxY - bounds.minY + 1;
// find max height for offset calculation
let maxTileHeight = 0;
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!tilemap[y] || !tilemap[y][x] || tilemap[y][x].height === 'x') continue;
const hi = HEIGHT_SCHEME.indexOf(tilemap[y][x].height) - 1;
if(hi > maxTileHeight) maxTileHeight = hi;
}
}
// calculate isometric bounds
const isoW = (tilesW + tilesH) * PREVIEW_TILE_W;
const isoH = (tilesW + tilesH) * PREVIEW_TILE_H + maxTileHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX;
// scale to fit canvas
const scaleX = (canvas.width - 20) / isoW;
const scaleY = (canvas.height - 20) / isoH;
const scale = Math.min(scaleX, scaleY, 3);
const offsetX = (canvas.width - isoW * scale) / 2;
const offsetY = (canvas.height - isoH * scale) / 2 + WALL_HEIGHT_PX * scale * 0.5;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
const tw = PREVIEW_TILE_W;
const th = PREVIEW_TILE_H;
function isoX(gx: number, gy: number): number
{
return (gx - bounds.minX - gy + bounds.minY) * tw + (tilesH - 1) * tw;
}
function isoY(gx: number, gy: number): number
{
return (gx - bounds.minX + gy - bounds.minY) * th;
}
function hasActiveTile(gx: number, gy: number): boolean
{
return tilemap[gy] && tilemap[gy][gx] && tilemap[gy][gx].height !== 'x';
}
function getTileHeight(gx: number, gy: number): number
{
if(!hasActiveTile(gx, gy)) return 0;
return Math.max(0, HEIGHT_SCHEME.indexOf(tilemap[gy][gx].height) - 1);
}
// draw walls on north and west edges
const wallH = wallHeight > 0 ? wallHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX * 0.3 : WALL_HEIGHT_PX * 0.6;
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!hasActiveTile(x, y)) continue;
const tileH = getTileHeight(x, y) * PREVIEW_BLOCK_H;
const cx = isoX(x, y);
const cy = isoY(x, y) - tileH;
// west wall (no tile to the left)
if(!hasActiveTile(x - 1, y))
{
ctx.beginPath();
ctx.moveTo(cx, cy + th);
ctx.lineTo(cx, cy + th - wallH);
ctx.lineTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw, cy);
ctx.closePath();
ctx.fillStyle = WALL_SIDE_COLOR;
ctx.fill();
ctx.strokeStyle = '#4A5A3F';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// north wall (no tile above)
if(!hasActiveTile(x, y - 1))
{
ctx.beginPath();
ctx.moveTo(cx + tw, cy);
ctx.lineTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw * 2, cy + th - wallH);
ctx.lineTo(cx + tw * 2, cy + th);
ctx.closePath();
ctx.fillStyle = WALL_COLOR;
ctx.fill();
ctx.strokeStyle = '#4A5A3F';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// wall top cap - corner
if(!hasActiveTile(x - 1, y) && !hasActiveTile(x, y - 1))
{
ctx.beginPath();
ctx.moveTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw + tw * 0.3, cy - wallH - th * 0.3);
ctx.lineTo(cx + tw, cy - wallH - th * 0.6);
ctx.lineTo(cx + tw - tw * 0.3, cy - wallH - th * 0.3);
ctx.closePath();
ctx.fillStyle = WALL_TOP_COLOR;
ctx.fill();
}
}
}
// draw tiles back-to-front
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!hasActiveTile(x, y)) continue;
const tile = tilemap[y][x];
const heightIndex = HEIGHT_SCHEME.indexOf(tile.height) - 1;
const tileH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
const cx = isoX(x, y);
const cy = isoY(x, y) - tileH;
const heightChar = tile.height;
const baseColor = colormap[heightChar] || 'aaaaaa';
const topColor = `#${ baseColor }`;
const leftColor = darken(baseColor, 0.65);
const rightColor = darken(baseColor, 0.80);
// draw side faces if tile has height
const blockH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
// left face (visible when no neighbor to south or neighbor is shorter)
const southH = getTileHeight(x, y + 1);
const leftExpose = hasActiveTile(x, y + 1) ? Math.max(0, heightIndex - southH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
if(leftExpose > 0)
{
ctx.beginPath();
ctx.moveTo(cx, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx + tw, cy + th * 2 + leftExpose);
ctx.lineTo(cx, cy + th + leftExpose);
ctx.closePath();
ctx.fillStyle = leftColor;
ctx.fill();
}
// right face
const eastH = getTileHeight(x + 1, y);
const rightExpose = hasActiveTile(x + 1, y) ? Math.max(0, heightIndex - eastH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
if(rightExpose > 0)
{
ctx.beginPath();
ctx.moveTo(cx + tw * 2, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx + tw, cy + th * 2 + rightExpose);
ctx.lineTo(cx + tw * 2, cy + th + rightExpose);
ctx.closePath();
ctx.fillStyle = rightColor;
ctx.fill();
}
// top face
ctx.beginPath();
ctx.moveTo(cx + tw, cy);
ctx.lineTo(cx + tw * 2, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx, cy + th);
ctx.closePath();
ctx.fillStyle = topColor;
ctx.fill();
// door indicator
const door = FloorplanEditor.instance.doorLocation;
if(door.x === x && door.y === y)
{
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
ctx.restore();
}
export const FloorplanPreviewView: FC<{}> = () =>
{
const { tilemapVersion, visualizationSettings } = useFloorplanEditorContext();
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number>(0);
useEffect(() =>
{
if(!canvasRef.current) return;
if(rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() =>
{
const canvas = canvasRef.current;
if(!canvas) return;
const parent = canvas.parentElement;
if(parent)
{
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
renderPreview(canvas, visualizationSettings?.wallHeight ?? 0);
});
return () =>
{
if(rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [ tilemapVersion, visualizationSettings?.wallHeight ]);
return (
<div className="flex-1 relative rounded overflow-hidden border-2 border-muted" style={ { minHeight: 200, backgroundColor: '#1a1a1a' } }>
<canvas
ref={ canvasRef }
className="w-full h-full"
/>
</div>
);
};
@@ -0,0 +1,179 @@
import { RgbaColorPicker, RgbaColor } from 'react-colorful';
import { FC, useCallback, useMemo, useState } from 'react';
import { FaUndo, FaTrash, FaPen, FaFillDrip, FaSave } from 'react-icons/fa';
import { PRESET_COLORS, useUiSettings } from '../../api';
import { Flex, Text } from '../../common';
const hexToRgba = (hex: string, a = 1): RgbaColor =>
{
const num = parseInt(hex.replace('#', ''), 16);
return { r: (num >> 16) & 0xFF, g: (num >> 8) & 0xFF, b: num & 0xFF, a };
};
const rgbaToHex = (rgba: RgbaColor): string =>
{
return '#' + ((1 << 24) + (rgba.r << 16) + (rgba.g << 8) + rgba.b).toString(16).slice(1);
};
export const InterfaceColorTabView: FC<{}> = () =>
{
const { settings, updateSettings, resetSettings } = useUiSettings();
const [ color, setColor ] = useState<RgbaColor>(() => hexToRgba(settings.headerColor, settings.headerAlpha / 100));
const hexColor = useMemo(() => rgbaToHex(color), [ color ]);
const alphaPercent = useMemo(() => Math.round((color.a ?? 1) * 100), [ color ]);
const onHexInput = useCallback((value: string) =>
{
const clean = value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
if(clean.length === 6)
{
const rgba = hexToRgba('#' + clean, color.a);
setColor(rgba);
}
}, [ color.a ]);
const onRgbInput = useCallback((channel: 'r' | 'g' | 'b', value: number) =>
{
const clamped = Math.max(0, Math.min(255, value || 0));
setColor(prev => ({ ...prev, [channel]: clamped }));
}, []);
const onAlphaInput = useCallback((value: number) =>
{
const clamped = Math.max(0, Math.min(100, value || 0));
setColor(prev => ({ ...prev, a: clamped / 100 }));
}, []);
const onPresetClick = useCallback((presetHex: string) =>
{
setColor(hexToRgba(presetHex, color.a));
}, [ color.a ]);
const onSave = useCallback(() =>
{
updateSettings({
colorMode: 'color',
headerColor: hexColor,
headerAlpha: alphaPercent
});
}, [ updateSettings, hexColor, alphaPercent ]);
const onReset = useCallback(() =>
{
resetSettings();
setColor(hexToRgba('#1E7295', 1));
}, [ resetSettings ]);
const onDelete = useCallback(() =>
{
updateSettings({ colorMode: 'default' });
setColor(hexToRgba('#1E7295', 1));
}, [ updateSettings ]);
return (
<Flex column gap={ 2 } className="items-center p-2">
<div className="w-[280px]">
<RgbaColorPicker color={ color } onChange={ setColor } style={ { width: '100%', height: '180px' } } />
</div>
<Flex gap={ 1 } className="items-center mt-1">
<Flex column className="items-center">
<input
className="form-control form-control-sm text-center w-[70px]"
value={ hexColor.replace('#', '').toUpperCase() }
onChange={ e => onHexInput(e.target.value) }
maxLength={ 6 }
/>
<Text small className="text-black">Hex</Text>
</Flex>
<Flex column className="items-center">
<input
type="number"
className="form-control form-control-sm text-center w-[45px]"
value={ color.r }
onChange={ e => onRgbInput('r', parseInt(e.target.value)) }
min={ 0 } max={ 255 }
/>
<Text small className="text-black">R</Text>
</Flex>
<Flex column className="items-center">
<input
type="number"
className="form-control form-control-sm text-center w-[45px]"
value={ color.g }
onChange={ e => onRgbInput('g', parseInt(e.target.value)) }
min={ 0 } max={ 255 }
/>
<Text small className="text-black">G</Text>
</Flex>
<Flex column className="items-center">
<input
type="number"
className="form-control form-control-sm text-center w-[45px]"
value={ color.b }
onChange={ e => onRgbInput('b', parseInt(e.target.value)) }
min={ 0 } max={ 255 }
/>
<Text small className="text-black">B</Text>
</Flex>
<Flex column className="items-center">
<input
type="number"
className="form-control form-control-sm text-center w-[45px]"
value={ alphaPercent }
onChange={ e => onAlphaInput(parseInt(e.target.value)) }
min={ 0 } max={ 100 }
/>
<Text small className="text-black">A</Text>
</Flex>
</Flex>
<div className="grid grid-cols-10 gap-0.5 mt-1">
{ PRESET_COLORS.map((presetHex, i) => (
<div
key={ i }
className="w-[24px] h-[24px] rounded cursor-pointer border border-black/20 hover:scale-110 transition-transform"
style={ { backgroundColor: presetHex } }
onClick={ () => onPresetClick(presetHex) }
/>
)) }
</div>
<Flex gap={ 1 } className="w-full mt-2">
<button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded cursor-pointer text-white"
style={ { backgroundColor: '#5f9ea0' } }
onClick={ onReset }
>
<FaUndo size={ 14 } />
</button>
<button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded cursor-pointer text-white"
style={ { backgroundColor: '#5f9ea0' } }
onClick={ onDelete }
>
<FaTrash size={ 14 } />
</button>
<button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded cursor-pointer text-white"
style={ { backgroundColor: '#b0b0b0' } }
>
<FaPen size={ 14 } />
</button>
<button
className="flex-1 flex items-center justify-center gap-1 py-2 rounded cursor-pointer text-white"
style={ { backgroundColor: '#5f9ea0' } }
onClick={ onSave }
>
<FaFillDrip size={ 14 } />
</button>
</Flex>
<button
className="w-full py-2 rounded cursor-pointer text-white font-bold flex items-center justify-center gap-2"
style={ { backgroundColor: '#008000' } }
onClick={ onSave }
>
<FaSave size={ 14 } />
Salva colore
</button>
</Flex>
);
};
@@ -0,0 +1,52 @@
import { FC, useCallback, useMemo } from 'react';
import { GetConfigurationValue, useUiSettings } from '../../api';
export const InterfaceImageTabView: FC<{}> = () =>
{
const { settings, updateSettings } = useUiSettings();
const imageCount = useMemo(() =>
{
return GetConfigurationValue<number>('ui.header.images.count', 30);
}, []);
const baseUrl = useMemo(() =>
{
return GetConfigurationValue<string>('ui.header.images.url', 'https://image.webbo.city/image/headerImage/image{id}.gif');
}, []);
const images = useMemo(() =>
{
const result: string[] = [];
for(let i = 1; i <= imageCount; i++)
{
result.push(baseUrl.replace('{id}', String(i)));
}
return result;
}, [ imageCount, baseUrl ]);
const onImageSelect = useCallback((url: string) =>
{
updateSettings({
colorMode: 'image',
headerImageUrl: url
});
}, [ updateSettings ]);
return (
<div className="grid grid-cols-8 gap-1 p-2 overflow-auto max-h-[400px]">
{ images.map((url, i) => (
<div
key={ i }
className={ `w-[75px] h-[75px] rounded cursor-pointer border-2 transition-all hover:scale-105 ${ (settings.colorMode === 'image' && settings.headerImageUrl === url) ? 'border-white shadow-lg' : 'border-transparent' }` }
style={ {
backgroundImage: `url(${ url })`,
backgroundSize: 'cover',
backgroundPosition: 'center'
} }
onClick={ () => onImageSelect(url) }
/>
)) }
</div>
);
};
@@ -0,0 +1,107 @@
import { GetSessionDataManager, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { GetClubMemberLevel, GetConfigurationValue } from '../../api';
import { Base, Flex, Grid, LayoutCurrencyIcon, NitroCardTabsItemView, NitroCardTabsView, Text } from '../../common';
import { useRoom } from '../../hooks';
interface ItemData
{
id: number;
isHcOnly: boolean;
minRank: number;
isAmbassadorOnly: boolean;
selectable: boolean;
}
const SUB_TABS = [ 'backgrounds', 'stands', 'overlays' ] as const;
type SubTabType = typeof SUB_TABS[number];
const SUB_TAB_LABELS: Record<SubTabType, string> = {
backgrounds: 'Sfondi',
stands: 'Basi',
overlays: 'Overlay'
};
export const InterfaceProfileTabView: FC<{}> = () =>
{
const [ activeSubTab, setActiveSubTab ] = useState<SubTabType>('backgrounds');
const [ selectedBackground, setSelectedBackground ] = useState<number>(0);
const [ selectedStand, setSelectedStand ] = useState<number>(0);
const [ selectedOverlay, setSelectedOverlay ] = useState<number>(0);
const { roomSession } = useRoom();
const userData = useMemo(() => ({
isHcMember: GetClubMemberLevel() >= HabboClubLevelEnum.CLUB,
securityLevel: GetSessionDataManager().canChangeName,
isAmbassador: GetSessionDataManager().isAmbassador
}), []);
const processData = useCallback((configData: any[], dataType: string): ItemData[] =>
{
if(!configData?.length) return [];
return configData
.filter(item =>
{
const meetsRank = userData.securityLevel >= item.minRank;
const ambassadorEligible = !item.isAmbassadorOnly || userData.isAmbassador;
return item.isHcOnly || (meetsRank && ambassadorEligible);
})
.map(item => ({ id: item[`${ dataType }Id`], ...item, selectable: !item.isHcOnly || userData.isHcMember }));
}, [ userData ]);
const allData = useMemo(() => ({
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'background'),
stands: processData(GetConfigurationValue('stands.data'), 'stand'),
overlays: processData(GetConfigurationValue('overlays.data'), 'overlay')
}), [ processData ]);
const handleSelection = useCallback((id: number) =>
{
if(!roomSession) return;
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay };
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay };
setters[activeSubTab](id);
const newValues = { ...currentValues, [activeSubTab]: id };
roomSession.sendBackgroundMessage(newValues.backgrounds, newValues.stands, newValues.overlays);
}, [ activeSubTab, roomSession, selectedBackground, selectedStand, selectedOverlay ]);
const renderItem = useCallback((item: ItemData, type: string) => (
<Flex
pointer
position="relative"
key={ item.id }
onClick={ () => item.selectable && handleSelection(item.id) }
className={ item.selectable ? '' : 'non-selectable' }
>
<Base className={ `profile-${ type } ${ type }-${ item.id }` } />
{ item.isHcOnly && <LayoutCurrencyIcon position="absolute" className="top-1 inset-e-1" type="hc" /> }
</Flex>
), [ handleSelection ]);
return (
<Flex column gap={ 1 }>
<Flex gap={ 1 } className="justify-center">
{ SUB_TABS.map(tab => (
<button
key={ tab }
className={ `px-3 py-1 rounded text-sm cursor-pointer transition-colors ${ activeSubTab === tab ? 'bg-primary text-white' : 'bg-card-grid-item text-black hover:bg-card-grid-item-active' }` }
onClick={ () => setActiveSubTab(tab) }
>
{ SUB_TAB_LABELS[tab] }
</button>
)) }
</Flex>
{ !roomSession && (
<Text bold center className="text-black py-4">Entra in una stanza per modificare il profilo</Text>
) }
{ roomSession && (
<Grid gap={ 1 } columnCount={ 7 } overflow="auto" className="max-h-[300px]">
{ allData[activeSubTab].map(item => renderItem(item, activeSubTab.slice(0, -1))) }
</Grid>
) }
</Flex>
);
};
@@ -0,0 +1,74 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardView } from '../../common';
import { InterfaceColorTabView } from './InterfaceColorTabView';
import { InterfaceProfileTabView } from './InterfaceProfileTabView';
const TABS = [ 'color', 'profile' ] as const;
type TabType = typeof TABS[number];
const TAB_LABELS: Record<TabType, string> = {
color: 'Colore',
profile: 'Sfondo profilo'
};
export const InterfaceSettingsView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ currentTab, setCurrentTab ] = useState<TabType>('color');
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(prev => !prev);
return;
case 'profile':
setCurrentTab('profile');
setIsVisible(true);
return;
}
},
eventUrlPrefix: 'interface-settings/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
if(!isVisible) return null;
return (
<NitroCardView uniqueKey="interface-settings" className="min-w-[535px] max-w-[700px]">
<NitroCardHeaderView headerText="Interfaccia" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView>
{ TABS.map(tab => (
<NitroCardTabsItemView
key={ tab }
isActive={ currentTab === tab }
onClick={ () => setCurrentTab(tab) }
>
{ TAB_LABELS[tab] }
</NitroCardTabsItemView>
)) }
</NitroCardTabsView>
<NitroCardContentView>
{ currentTab === 'color' && <InterfaceColorTabView /> }
{ currentTab === 'profile' && <InterfaceProfileTabView /> }
</NitroCardContentView>
</NitroCardView>
);
};
@@ -45,7 +45,8 @@ const BadgeMiniPicker: FC<{
return (
<div
ref={ ref }
className="absolute right-[calc(100%+8px)] top-0 z-50 bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
className="absolute right-[calc(100%+8px)] top-0 z-50 border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
style={ { backgroundColor: 'var(--ui-dark-bg, rgba(28,28,32,0.97))' } }
onClick={ e => e.stopPropagation() }>
<input
autoFocus
@@ -220,8 +220,8 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
canUse = true;
isCrackable = true;
crackableHits = stuffData.hits;
crackableTarget = stuffData.target;
crackableHits = stuffData?.hits ?? 0;
crackableTarget = stuffData?.target ?? 0;
}
else if(avatarInfo.extraParam === RoomWidgetEnumItemExtradataParameter.JUKEBOX)
@@ -458,7 +458,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
return (
<Column alignItems="end" gap={ 1 }>
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded">
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded" style={ { backgroundColor: 'var(--ui-dark-bg, rgba(28,28,32,.95))' } }>
<Column className="h-full p-[8px] overflow-auto" gap={ 1 } overflow="visible">
<div className="flex flex-col gap-1">
<Flex alignItems="center" gap={ 1 } justifyContent="between">
@@ -527,7 +527,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ isCrackable &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
<Text small wrap variant="white">{ LocalizeText('infostand.crackable_furni.hits_remaining', [ 'hits', 'target' ], [ crackableHits.toString(), crackableTarget.toString() ]) }</Text>
<Text small wrap variant="white">{ LocalizeText('infostand.crackable_furni.hits_remaining', [ 'hits', 'target' ], [ (crackableHits ?? 0).toString(), (crackableTarget ?? 0).toString() ]) }</Text>
</> }
{ avatarInfo.groupId > 0 &&
<>
@@ -552,7 +552,21 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ godMode &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
{ canSeeFurniId && <Text small wrap variant="white">ID: { avatarInfo.id }</Text> }
{ canSeeFurniId &&
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#7ec8e3]">
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
</svg>
<Text small wrap variant="white">ID: { avatarInfo.id }</Text>
</div>
<div className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#7ec8e3]">
<path d="M5.127 3.502 5.25 3.5h9.5c.041 0 .082 0 .123.002A2.251 2.251 0 0 0 12.75 2h-5.5a2.25 2.25 0 0 0-2.123 1.502ZM1 10.25A2.25 2.25 0 0 1 3.25 8h13.5A2.25 2.25 0 0 1 19 10.25v5.5A2.25 2.25 0 0 1 16.75 18H3.25A2.25 2.25 0 0 1 1 15.75v-5.5ZM3.25 6.5c-.04 0-.082 0-.123.002A2.25 2.25 0 0 1 5.25 5h9.5c.98 0 1.814.627 2.123 1.502a3.819 3.819 0 0 0-.123-.002H3.25Z" />
</svg>
<Text small wrap variant="white">Sprite: { (() => { const ro = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR); return ro?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID) ?? '?'; })() }</Text>
</div>
</div> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
@@ -560,6 +574,19 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</button>
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button>
{ dropdownOpen &&
<div className="flex gap-[4px] w-full">
{ /* Left panel: position + rotation */ }
@@ -1,4 +1,4 @@
import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { CreateLinkEvent, GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react';
import { FaPencilAlt, FaTimes } from 'react-icons/fa';
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
@@ -7,7 +7,6 @@ import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView';
import { BackgroundsView } from '../../../../backgrounds/BackgroundsView';
interface InfoStandWidgetUserViewProps {
avatarInfo: AvatarInfoUser;
@@ -32,7 +31,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); CreateLinkEvent('interface-settings/profile'); }, []);
const saveMotto = (motto: string) => {
if (!isEditingMotto || motto.length > GetConfigurationValue<number>('motto.max.length', 38) || !roomSession) return;
@@ -127,7 +126,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
return (
<>
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto bg-[rgba(28,28,32,0.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded">
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded" style={ { backgroundColor: 'var(--ui-dark-bg, rgba(28,28,32,0.95))' } }>
<Column className="h-full p-[8px] overflow-auto" gap={1} overflow="visible">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -257,19 +256,6 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
)}
</Column>
</Column>
{isVisible && avatarInfo.type === AvatarInfoUser.OWN_USER && (
<div className="backgrounds-view-container">
<BackgroundsView
setIsVisible={setIsVisible}
selectedBackground={backgroundId}
setSelectedBackground={setBackgroundId}
selectedStand={standId}
setSelectedStand={setStandId}
selectedOverlay={overlayId}
setSelectedOverlay={setOverlayId}
/>
</div>
)}
</>
);
};
@@ -3,16 +3,21 @@ import { Flex, FlexProps } from '../../../../common';
export const ContextMenuHeaderView: FC<FlexProps> = props =>
{
const { justifyContent = 'center', alignItems = 'center', classNames = [], ...rest } = props;
const { justifyContent = 'center', alignItems = 'center', classNames = [], style = {}, ...rest } = props;
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'bg-[#3d5f6e] text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ];
const newClassNames: string[] = [ 'text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ];
if(classNames.length) newClassNames.push(...classNames);
return newClassNames;
}, [ classNames ]);
return <Flex alignItems={ alignItems } classNames={ getClassNames } justifyContent={ justifyContent } { ...rest } />;
const mergedStyle = useMemo(() => ({
backgroundColor: 'var(--ui-ctx-header-bg, #3d5f6e)',
...style
}), [ style ]);
return <Flex alignItems={ alignItems } classNames={ getClassNames } justifyContent={ justifyContent } style={ mergedStyle } { ...rest } />;
};
@@ -8,7 +8,7 @@ interface ContextMenuListItemViewProps extends FlexProps
export const ContextMenuListItemView: FC<ContextMenuListItemViewProps> = props =>
{
const { disabled = false, fullWidth = true, justifyContent = 'center', alignItems = 'center', classNames = [], onClick = null, ...rest } = props;
const { disabled = false, fullWidth = true, justifyContent = 'center', alignItems = 'center', classNames = [], style = {}, onClick = null, ...rest } = props;
const handleClick = (event: MouseEvent<HTMLDivElement>) =>
{
@@ -19,7 +19,7 @@ export const ContextMenuListItemView: FC<ContextMenuListItemViewProps> = props =
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] bg-[repeating-linear-gradient(#131e25,#131e25_50%,#0d171d_50%,#0d171d_100%)] cursor-pointer' ];
const newClassNames: string[] = [ 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] cursor-pointer' ];
if(disabled) newClassNames.push('disabled');
@@ -28,5 +28,10 @@ export const ContextMenuListItemView: FC<ContextMenuListItemViewProps> = props =
return newClassNames;
}, [ disabled, classNames ]);
return <Flex alignItems={ alignItems } classNames={ getClassNames } fullWidth={ fullWidth } justifyContent={ justifyContent } onClick={ handleClick } { ...rest } />;
const mergedStyle = useMemo(() => ({
background: 'repeating-linear-gradient(var(--ui-ctx-item-bg1, #131e25), var(--ui-ctx-item-bg1, #131e25) 50%, var(--ui-ctx-item-bg2, #0d171d) 50%, var(--ui-ctx-item-bg2, #0d171d) 100%)',
...style
}), [ style ]);
return <Flex alignItems={ alignItems } classNames={ getClassNames } fullWidth={ fullWidth } justifyContent={ justifyContent } onClick={ handleClick } style={ mergedStyle } { ...rest } />;
};
@@ -76,7 +76,6 @@ export const ContextMenuView: FC<ContextMenuViewProps> = ({
const getClassNames = useMemo(() => {
const classes = [
'p-[2px]!',
'bg-[#1c323f]',
'border-2',
'border-[solid]',
'border-[rgba(255,255,255,.5)]',
@@ -98,6 +97,7 @@ export const ContextMenuView: FC<ContextMenuViewProps> = ({
top: pos.y ?? 0,
transition: isFading ? 'opacity 75ms linear' : undefined,
opacity,
backgroundColor: 'var(--ui-ctx-bg, #1c323f)',
...style,
}),
[pos, opacity, isFading, style]
+30 -30
View File
@@ -69,38 +69,38 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> )}
</AnimatePresence>
<Flex alignItems="center" className="absolute bottom-0 left-0 w-full h-[55px] bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 } justifyContent="between">
<Flex alignItems="center" gap={ 2 }>
<Flex alignItems="center" gap={ 2 }>
<Flex center pointer className={ 'relative w-[50px] h-[45px] overflow-hidden ' + (isMeExpanded ? 'active ' : '') } onClick={ event =>
{
setMeExpanded(!isMeExpanded);
event.stopPropagation();
} }>
<LayoutAvatarImageView className="-ml-[5px] mt-[25px]" direction={ 2 } figure={ userFigure } position="absolute" />
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } /> }
</Flex>
{ isInRoom &&
<ToolbarItemView icon="habbo" onClick={ event => VisitDesktop() } /> }
{ !isInRoom &&
<ToolbarItemView icon="house" onClick={ event => CreateLinkEvent('navigator/goto/home') } /> }
<ToolbarItemView icon="rooms" onClick={ event => CreateLinkEvent('navigator/toggle') } />
{ GetConfigurationValue('game.center.enabled') &&
<ToolbarItemView icon="game" onClick={ event => CreateLinkEvent('games/toggle') } /> }
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('catalog/toggle') } />
<ToolbarItemView icon="inventory" onClick={ event => CreateLinkEvent('inventory/toggle') }>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } /> }
</ToolbarItemView>
{ isInRoom &&
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
<Flex alignItems="center" className="absolute bottom-0 left-0 w-full h-[55px] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 } style={ { backgroundColor: 'var(--ui-dark-bg, rgba(28,28,32,.95))' } }>
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex center pointer className={ 'relative w-[50px] h-[45px] overflow-hidden ' + (isMeExpanded ? 'active ' : '') } onClick={ event =>
{
setMeExpanded(!isMeExpanded);
event.stopPropagation();
} }>
<LayoutAvatarImageView className="-ml-[5px] mt-[25px]" direction={ 2 } figure={ userFigure } position="absolute" />
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } /> }
</Flex>
<Flex alignItems="center" id="toolbar-chat-input-container" />
{ isInRoom &&
<ToolbarItemView icon="habbo" onClick={ event => VisitDesktop() } /> }
{ !isInRoom &&
<ToolbarItemView icon="house" onClick={ event => CreateLinkEvent('navigator/goto/home') } /> }
<ToolbarItemView icon="rooms" onClick={ event => CreateLinkEvent('navigator/toggle') } />
{ GetConfigurationValue('game.center.enabled') &&
<ToolbarItemView icon="game" onClick={ event => CreateLinkEvent('games/toggle') } /> }
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('catalog/toggle') } />
<ToolbarItemView icon="inventory" onClick={ event => CreateLinkEvent('inventory/toggle') }>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } /> }
</ToolbarItemView>
{ isInRoom &&
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
</Flex>
<Flex alignItems="center" gap={ 2 }>
<Flex alignItems="center" justifyContent="center" className="flex-1 min-w-0 max-w-[600px] mx-auto" id="toolbar-chat-input-container" />
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex gap={ 2 }>
<ToolbarItemView icon="friendall" onClick={ event => CreateLinkEvent('friends/toggle') }>
{ (requests.length > 0) &&
+7 -7
View File
@@ -24,8 +24,8 @@ input[type=number] {
.btn-primary {
color: #fff;
background-color: #3c6d82;
border: 2px solid #1a617f;
background-color: var(--ui-btn-primary-bg, #3c6d82);
border: 2px solid var(--ui-btn-primary-border, #1a617f);
padding: 0.25rem 0.5rem;
font-size: .7875rem;
border-radius: 0.5rem;
@@ -33,7 +33,7 @@ input[type=number] {
}
.btn-primary:hover {
border: 2px solid #1a617f;
border: 2px solid var(--ui-btn-primary-border, #1a617f);
box-shadow: none!important;
}
@@ -81,16 +81,16 @@ input[type=number] {
.btn-dark {
color: #fff;
background-color: #212131;
border: 2px solid #1c1c2a;
background-color: var(--ui-dark-bg, #212131);
border: 2px solid var(--ui-dark-border, #1c1c2a);
box-shadow: none!important;
border-radius: 8px;
padding: 4px 11px 4px 11px;
}
.btn-dark:hover{
background-color: #212131;
border: 2px solid #1c1c2a;
background-color: var(--ui-dark-bg, #212131);
border: 2px solid var(--ui-dark-border, #1c1c2a);
box-shadow: none!important;
border-radius: 8px;
padding: 4px 11px 4px 11px;
+2 -2
View File
@@ -22,7 +22,7 @@
pointer-events: all;
}
.borderhccontent{
background-color: #212131;
background-color: var(--ui-dark-bg, #212131);
border-radius: 0.5rem!important;
border: 2px solid #383853;
height: calc(100% - 3px);
@@ -46,7 +46,7 @@
}
.nitro-purse-seasonal-currency {
background-color: #212131;
background-color: var(--ui-dark-bg, #212131);
background: linear-gradient(to right, #5f5f8d, transparent);
height: 30px;
margin-bottom: 4px;
+1 -1
View File
@@ -27,7 +27,7 @@
width: clamp(160px, 20vw, 190px); /* Responsive width */
z-index: 30;
pointer-events: auto;
background: #212131;
background: var(--ui-dark-bg, #212131);
box-shadow: inset 0 5px rgba(38, 38, 57, 0.6), inset 0 -4px rgba(25, 25, 37, 0.6);
border-radius: 0.5rem;
padding: 10px;
+3 -3
View File
@@ -4,7 +4,7 @@
left: 15px;
.nitro-room-tools {
background: #212131;
background: var(--ui-dark-bg, #212131);
box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4);
border-top-right-radius: .25rem;
border-bottom-right-radius: .25rem;
@@ -54,7 +54,7 @@
}
.nitro-room-history {
background: #212131;
background: var(--ui-dark-bg, #212131);
box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4);
transition: all .2s ease;
width: 150px;
@@ -63,7 +63,7 @@
}
.nitro-room-tools-info {
background: #212131;
background: var(--ui-dark-bg, #212131);
box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4);
transition: all .2s ease;
max-width: 250px;