mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'upstream/main'
# Conflicts: # public/UITexts.example # src/api/wired/WiredActionLayoutCode.ts # src/api/wired/WiredConditionLayoutCode.ts # src/api/wired/WiredTriggerLayoutCode.ts # src/components/wired/views/WiredBaseView.tsx # src/components/wired/views/WiredSourcesSelector.tsx # src/components/wired/views/actions/WiredActionLayoutView.tsx # src/components/wired/views/conditions/WiredConditionLayoutView.tsx # src/components/wired/views/conditions/WiredConditionTriggererMatchView.tsx # src/components/wired/views/triggers/WiredTriggerClickFurniView.tsx # src/components/wired/views/triggers/WiredTriggerClickTileView.tsx # src/components/wired/views/triggers/WiredTriggerClickUserView.tsx # src/components/wired/views/triggers/WiredTriggerLayoutView.tsx # src/components/wired/views/triggers/WiredTriggerToggleFurniView.tsx
This commit is contained in:
@@ -27,3 +27,4 @@ Thumbs.db
|
||||
/build
|
||||
*.zip
|
||||
.env
|
||||
.claude/
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<base href="/client/" />
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Room Builder Plugin - Menu Costruzioni
|
||||
*
|
||||
* Plugin esterno per Nitro Client.
|
||||
* Richiede il RoomBuilderPlugin.jar lato server (Arcturus).
|
||||
*
|
||||
* Se rimuovi questo file, il bottone scompare automaticamente dalla UI.
|
||||
*
|
||||
* Colori e stili uniformati al tema Nitro Client.
|
||||
*/
|
||||
(function ()
|
||||
{
|
||||
'use strict';
|
||||
|
||||
// ─── Nitro Theme Colors ───
|
||||
var THEME = {
|
||||
// Card / Window
|
||||
headerBg: '#1E7295',
|
||||
headerText: '#FFFFFF',
|
||||
tabsBg: '#185D79',
|
||||
cardBorder: '#283F5D',
|
||||
contentBg: '#DFDFDF',
|
||||
|
||||
// Buttons
|
||||
btnPrimary: '#3c6d82',
|
||||
btnPrimaryBrd: '#1a617f',
|
||||
btnPrimaryHov: '#4a8199',
|
||||
btnActive: '#185D79',
|
||||
btnActiveBrd: '#0f4a63',
|
||||
btnActiveHov: '#1E7295',
|
||||
btnDanger: '#a81a12',
|
||||
btnDangerBrd: '#b9322a',
|
||||
btnDangerHov: '#c43a32',
|
||||
btnSuccess: '#00800b',
|
||||
btnSuccessBrd: '#006d09',
|
||||
btnWarning: '#ffc107',
|
||||
btnWarningBrd: '#f3c12a',
|
||||
|
||||
// Dark panel (infostand style)
|
||||
darkBg: '#212131',
|
||||
darkBorder: '#383853',
|
||||
darkShadow: 'inset 0 5px rgba(38,38,57,0.6), inset 0 -4px rgba(25,25,37,0.6)',
|
||||
|
||||
// Grid items
|
||||
gridBg: '#CDD3D9',
|
||||
gridBorder: '#B6BEC5',
|
||||
gridActiveBg: '#ECECEC',
|
||||
gridActiveBrd: '#FFFFFF',
|
||||
|
||||
// Text
|
||||
textLight: '#FFFFFF',
|
||||
textDark: '#212529',
|
||||
textMuted: '#B6BEC5',
|
||||
|
||||
// Typography
|
||||
fontFamily: 'Ubuntu, sans-serif',
|
||||
fontSm: '0.7875rem',
|
||||
fontBase: '0.9rem',
|
||||
|
||||
// Misc
|
||||
borderRadius: '0.5rem',
|
||||
borderRadiusSm: '0.25rem',
|
||||
scrollThumb: 'rgba(30, 114, 149, 0.4)',
|
||||
scrollThumbHov: 'rgba(30, 114, 149, 0.8)'
|
||||
};
|
||||
|
||||
function waitForApi(callback, maxRetries)
|
||||
{
|
||||
if (maxRetries === undefined) maxRetries = 50;
|
||||
if (window.NitroPlugins)
|
||||
{
|
||||
callback(window.NitroPlugins);
|
||||
return;
|
||||
}
|
||||
if (maxRetries <= 0)
|
||||
{
|
||||
console.warn('[RoomBuilder] NitroPlugins API not found after retries');
|
||||
return;
|
||||
}
|
||||
setTimeout(function () { waitForApi(callback, maxRetries - 1); }, 200);
|
||||
}
|
||||
|
||||
// ─── Constants ───
|
||||
var FLOOR = 10;
|
||||
var WALL = 20;
|
||||
|
||||
// ─── Send chat command to server via proper API ───
|
||||
function sendCommand(api, command)
|
||||
{
|
||||
try
|
||||
{
|
||||
api.sendChat(':' + command);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn('[RoomBuilder] sendCommand error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section Label Helper ───
|
||||
function createSectionLabel(container, text)
|
||||
{
|
||||
var label = document.createElement('div');
|
||||
label.textContent = text;
|
||||
label.style.cssText = 'font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;color:' + THEME.textDark + ';margin:10px 0 6px 0;padding-bottom:4px;border-bottom:1px solid ' + THEME.gridBorder + ';text-transform:uppercase;letter-spacing:0.5px';
|
||||
container.appendChild(label);
|
||||
}
|
||||
|
||||
// ─── Slider Helper ───
|
||||
function createSlider(container, label, min, max, value, step, onChange)
|
||||
{
|
||||
var row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px 6px;background:' + THEME.gridBg + ';border:1px solid ' + THEME.gridBorder + ';border-radius:' + THEME.borderRadiusSm;
|
||||
|
||||
var lbl = document.createElement('span');
|
||||
lbl.textContent = label;
|
||||
lbl.style.cssText = 'width:70px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;flex-shrink:0';
|
||||
|
||||
var slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = min;
|
||||
slider.max = max;
|
||||
slider.step = step || 1;
|
||||
slider.value = value;
|
||||
slider.style.cssText = 'flex:1;height:6px;cursor:pointer;accent-color:' + THEME.headerBg;
|
||||
|
||||
var valDisplay = document.createElement('span');
|
||||
valDisplay.textContent = value;
|
||||
valDisplay.style.cssText = 'width:28px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';text-align:center;font-weight:bold';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.innerHTML = '💾';
|
||||
saveBtn.title = 'Salva';
|
||||
saveBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnSuccess + ';border:2px solid ' + THEME.btnSuccessBrd + ';border-radius:' + THEME.borderRadius + ';cursor:pointer;font-size:11px;color:' + THEME.textLight;
|
||||
|
||||
var resetBtn = document.createElement('button');
|
||||
resetBtn.innerHTML = '↩';
|
||||
resetBtn.title = 'Ripristina';
|
||||
resetBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnDanger + ';border:2px solid ' + THEME.btnDangerBrd + ';border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';cursor:pointer;font-size:13px;font-weight:bold';
|
||||
|
||||
slider.addEventListener('input', function ()
|
||||
{
|
||||
valDisplay.textContent = slider.value;
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', function ()
|
||||
{
|
||||
if (onChange) onChange(Number(slider.value));
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', function ()
|
||||
{
|
||||
slider.value = value;
|
||||
valDisplay.textContent = value;
|
||||
if (onChange) onChange(Number(value));
|
||||
});
|
||||
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(slider);
|
||||
row.appendChild(valDisplay);
|
||||
row.appendChild(saveBtn);
|
||||
row.appendChild(resetBtn);
|
||||
container.appendChild(row);
|
||||
|
||||
return { slider: slider, valDisplay: valDisplay };
|
||||
}
|
||||
|
||||
// ─── Button Helper ───
|
||||
function createButton(container, label, onClick, opts)
|
||||
{
|
||||
opts = opts || {};
|
||||
var isActive = opts.active || false;
|
||||
var isDanger = opts.danger || false;
|
||||
|
||||
var bgColor = isDanger ? THEME.btnDanger : (isActive ? THEME.btnActive : THEME.btnPrimary);
|
||||
var borderColor = isDanger ? THEME.btnDangerBrd : (isActive ? THEME.btnActiveBrd : THEME.btnPrimaryBrd);
|
||||
var hoverColor = isDanger ? THEME.btnDangerHov : (isActive ? THEME.btnActiveHov : THEME.btnPrimaryHov);
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.cssText = 'padding:0.25rem 0.5rem;border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:500;cursor:pointer;border:2px solid ' + borderColor + ';transition:background .15s;background:' + bgColor + ';min-height:28px;box-shadow:none;' + (opts.fullWidth ? 'width:100%;' : '') + (opts.extraStyle || '');
|
||||
|
||||
btn.addEventListener('mouseenter', function () { btn.style.background = hoverColor; });
|
||||
btn.addEventListener('mouseleave', function () { btn.style.background = bgColor; });
|
||||
btn.addEventListener('click', function ()
|
||||
{
|
||||
if (onClick) onClick(btn);
|
||||
});
|
||||
if (container) container.appendChild(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ─── Grid Row Helper ───
|
||||
function createButtonRow(container, cols, buttons)
|
||||
{
|
||||
var row = document.createElement('div');
|
||||
row.style.cssText = 'display:grid;grid-template-columns:repeat(' + cols + ',1fr);gap:6px;margin-bottom:6px';
|
||||
buttons.forEach(function (b) { createButton(row, b.label, b.onClick, b); });
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
// ─── Utility: iterate room floor objects ───
|
||||
function forEachFloorObject(api, callback)
|
||||
{
|
||||
var session = api.getRoomSession();
|
||||
var engine = api.getRoomEngine();
|
||||
if (!session || !engine) return;
|
||||
var objects = engine.getRoomObjects(session.roomId, FLOOR);
|
||||
for (var i = 0; i < objects.length; i++)
|
||||
{
|
||||
var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR);
|
||||
if (obj) callback(obj, objects[i].id, session.roomId, engine);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── State ───
|
||||
var state = {
|
||||
hidePyramids: false,
|
||||
hideCarpets: false,
|
||||
hideWalls: false,
|
||||
hideWired: false,
|
||||
frozen: false,
|
||||
teleporting: false
|
||||
};
|
||||
|
||||
// ─── Stack clipboard (client-side memory) ───
|
||||
var stackClipboard = null;
|
||||
|
||||
// ─── Plugin Init ───
|
||||
waitForApi(function (api)
|
||||
{
|
||||
api.register({
|
||||
name: 'room-builder',
|
||||
label: 'Menu costruzioni',
|
||||
icon: 'icon-cog',
|
||||
|
||||
onOpen: function ()
|
||||
{
|
||||
var content = api.createWindow('room-builder', 'Menu costruzioni', 440);
|
||||
if (!content) return;
|
||||
|
||||
// Apply Nitro content area style to the content container
|
||||
content.style.cssText = 'padding:10px;font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontBase;
|
||||
|
||||
// ─── Warning banner (Nitro tabs style) ───
|
||||
var banner = document.createElement('div');
|
||||
banner.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:6px;background:' + THEME.tabsBg + ';border:1px solid ' + THEME.cardBorder + ';border-radius:' + THEME.borderRadiusSm + ';padding:8px 12px;margin-bottom:12px';
|
||||
banner.innerHTML = '<span style="font-size:16px">⚠</span><span style="color:' + THEME.textLight + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + '">Assicurati di non spammare per non essere <b>mutato</b></span><span style="font-size:16px">⚠</span>';
|
||||
content.appendChild(banner);
|
||||
|
||||
// ─── Sliders Section ───
|
||||
createSectionLabel(content, 'Controlli');
|
||||
|
||||
var slidersDiv = document.createElement('div');
|
||||
slidersDiv.style.marginBottom = '8px';
|
||||
|
||||
createSlider(slidersDiv, 'Altezza', -10, 40, 0, 1, function (val)
|
||||
{
|
||||
try
|
||||
{
|
||||
sendCommand(api, 'autotile ' + val);
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder] Height:', e); }
|
||||
});
|
||||
|
||||
createSlider(slidersDiv, 'Velocita', 0, 10, 4, 1, function (val)
|
||||
{
|
||||
sendCommand(api, 'rb_speed ' + val);
|
||||
});
|
||||
|
||||
content.appendChild(slidersDiv);
|
||||
|
||||
// ─── Screenshot ───
|
||||
var ssDiv = document.createElement('div');
|
||||
ssDiv.style.marginBottom = '8px';
|
||||
createButton(ssDiv, 'Fai lo screenshot della stanza', function ()
|
||||
{
|
||||
api.takeScreenshot();
|
||||
}, { fullWidth: true, extraStyle: 'padding:6px 12px;' });
|
||||
content.appendChild(ssDiv);
|
||||
|
||||
// ─── Avatar Section ───
|
||||
createSectionLabel(content, 'Avatar');
|
||||
|
||||
createButtonRow(content, 2, [
|
||||
{
|
||||
label: state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar',
|
||||
active: state.frozen,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.frozen = !state.frozen;
|
||||
btn.textContent = state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar';
|
||||
btn.style.background = state.frozen ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.frozen ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
sendCommand(api, 'blocca');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto',
|
||||
active: state.teleporting,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.teleporting = !state.teleporting;
|
||||
btn.textContent = state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto';
|
||||
btn.style.background = state.teleporting ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.teleporting ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
sendCommand(api, 'rb_teleport');
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Visibility Section ───
|
||||
createSectionLabel(content, 'Visibilita');
|
||||
|
||||
createButtonRow(content, 3, [
|
||||
{
|
||||
label: state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi',
|
||||
active: state.hidePyramids,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.hidePyramids = !state.hidePyramids;
|
||||
btn.style.background = state.hidePyramids ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.hidePyramids ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
btn.textContent = state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi';
|
||||
try
|
||||
{
|
||||
forEachFloorObject(api, function (obj, objId, roomId, engine)
|
||||
{
|
||||
if (obj.type && obj.type.toLowerCase().indexOf('pyramid') >= 0)
|
||||
{
|
||||
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hidePyramids ? 0 : 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti',
|
||||
active: state.hideCarpets,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.hideCarpets = !state.hideCarpets;
|
||||
btn.style.background = state.hideCarpets ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.hideCarpets ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
btn.textContent = state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti';
|
||||
try
|
||||
{
|
||||
forEachFloorObject(api, function (obj, objId, roomId, engine)
|
||||
{
|
||||
if (obj.model)
|
||||
{
|
||||
var sizeZ = obj.model.getValue('furniture_size_z');
|
||||
if (sizeZ !== undefined && sizeZ <= 0.01)
|
||||
{
|
||||
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideCarpets ? 0 : 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: state.hideWalls ? 'Mostra mura' : 'Nascondi mura',
|
||||
active: state.hideWalls,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.hideWalls = !state.hideWalls;
|
||||
btn.style.background = state.hideWalls ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.hideWalls ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
btn.textContent = state.hideWalls ? 'Mostra mura' : 'Nascondi mura';
|
||||
try
|
||||
{
|
||||
var session = api.getRoomSession();
|
||||
var engine = api.getRoomEngine();
|
||||
if (!session || !engine) return;
|
||||
var objects = engine.getRoomObjects(session.roomId, WALL);
|
||||
for (var i = 0; i < objects.length; i++)
|
||||
{
|
||||
engine.changeObjectModelData(session.roomId, objects[i].id, WALL, 'furniture_alpha_multiplier', state.hideWalls ? 0 : 1);
|
||||
}
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Stack Section ───
|
||||
createSectionLabel(content, 'Pila (Stack)');
|
||||
|
||||
createButtonRow(content, 4, [
|
||||
{
|
||||
label: 'Annulla pila',
|
||||
onClick: function ()
|
||||
{
|
||||
sendCommand(api, 'autotile');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Seleziona pila',
|
||||
onClick: function ()
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = api.getRoomSession();
|
||||
var engine = api.getRoomEngine();
|
||||
if (!session || !engine) return;
|
||||
var objects = engine.getRoomObjects(session.roomId, FLOOR);
|
||||
stackClipboard = [];
|
||||
for (var i = 0; i < objects.length; i++)
|
||||
{
|
||||
var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR);
|
||||
if (obj && obj.location)
|
||||
{
|
||||
stackClipboard.push({
|
||||
id: objects[i].id,
|
||||
x: Math.floor(obj.location.x),
|
||||
y: Math.floor(obj.location.y),
|
||||
z: obj.location.z,
|
||||
dir: obj.direction ? obj.direction.x : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[RoomBuilder] Pila selezionata: ' + stackClipboard.length + ' oggetti');
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Copia pila',
|
||||
onClick: function ()
|
||||
{
|
||||
if (!stackClipboard || stackClipboard.length === 0)
|
||||
{
|
||||
console.log('[RoomBuilder] Nessuna pila selezionata');
|
||||
return;
|
||||
}
|
||||
console.log('[RoomBuilder] Pila copiata: ' + stackClipboard.length + ' oggetti');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Posiziona pila',
|
||||
onClick: function ()
|
||||
{
|
||||
if (!stackClipboard || stackClipboard.length === 0)
|
||||
{
|
||||
console.log('[RoomBuilder] Nessuna pila da posizionare');
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < stackClipboard.length; i++)
|
||||
{
|
||||
var item = stackClipboard[i];
|
||||
api.sendStackHeight(item.id, Math.round(item.z * 100));
|
||||
}
|
||||
console.log('[RoomBuilder] Pila posizionata: ' + stackClipboard.length + ' oggetti');
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Room Management Section ───
|
||||
createSectionLabel(content, 'Gestione stanza');
|
||||
|
||||
createButtonRow(content, 3, [
|
||||
{
|
||||
label: 'Impostazioni',
|
||||
onClick: function () { api.createLinkEvent('navigator/toggle-room-info'); }
|
||||
},
|
||||
{
|
||||
label: 'Reload stanza',
|
||||
onClick: function ()
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = api.getRoomSession();
|
||||
if (session) api.createLinkEvent('navigator/goto/' + session.roomId);
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Unload stanza',
|
||||
onClick: function ()
|
||||
{
|
||||
api.visitDesktop();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Floor Tools Section ───
|
||||
createSectionLabel(content, 'Strumenti pavimento');
|
||||
|
||||
createButtonRow(content, 4, [
|
||||
{
|
||||
label: 'Max Tile',
|
||||
onClick: function ()
|
||||
{
|
||||
sendCommand(api, 'maxtile');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Auto Tile',
|
||||
onClick: function ()
|
||||
{
|
||||
sendCommand(api, 'autotile');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'No Item Floor',
|
||||
danger: true,
|
||||
onClick: function ()
|
||||
{
|
||||
if (confirm('Sei sicuro? Tutti i furni verranno rimossi dal pavimento!'))
|
||||
{
|
||||
sendCommand(api, 'noitemfloor');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Edit Floorplan',
|
||||
onClick: function () { api.createLinkEvent('floor-editor/toggle'); }
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Wired Section ───
|
||||
createSectionLabel(content, 'Wired');
|
||||
|
||||
createButtonRow(content, 2, [
|
||||
{
|
||||
label: state.hideWired ? 'Mostra wired' : 'Nascondi wired',
|
||||
active: state.hideWired,
|
||||
onClick: function (btn)
|
||||
{
|
||||
state.hideWired = !state.hideWired;
|
||||
btn.style.background = state.hideWired ? THEME.btnActive : THEME.btnPrimary;
|
||||
btn.style.borderColor = state.hideWired ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
|
||||
btn.textContent = state.hideWired ? 'Mostra wired' : 'Nascondi wired';
|
||||
try
|
||||
{
|
||||
forEachFloorObject(api, function (obj, objId, roomId, engine)
|
||||
{
|
||||
if (obj.type && (obj.type.toLowerCase().indexOf('wf_') >= 0 || obj.type.toLowerCase().indexOf('wired') >= 0))
|
||||
{
|
||||
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideWired ? 0 : 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) { console.warn('[RoomBuilder]', e); }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Prendi tutti gli wired',
|
||||
danger: true,
|
||||
onClick: function ()
|
||||
{
|
||||
sendCommand(api, 'pickwired');
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Spacer ───
|
||||
var spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:1px;background:' + THEME.gridBorder + ';margin:10px 0';
|
||||
content.appendChild(spacer);
|
||||
|
||||
// ─── Back button (danger style like close button) ───
|
||||
var backDiv = document.createElement('div');
|
||||
createButton(backDiv, 'Torna indietro', function ()
|
||||
{
|
||||
api.destroyWindow('room-builder');
|
||||
}, { fullWidth: true, danger: true, extraStyle: 'padding:6px 12px;' });
|
||||
content.appendChild(backDiv);
|
||||
},
|
||||
|
||||
onClose: function ()
|
||||
{
|
||||
if (window.NitroPlugins) window.NitroPlugins.destroyWindow('room-builder');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[NitroPlugins] Room Builder plugin loaded (Nitro theme)');
|
||||
});
|
||||
})();
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"socket.url": "ws://## YOUR HOST ##:2096",
|
||||
"asset.url": "http://## YOUR HOST ##/gamedata",
|
||||
"image.library.url": "http://## YOUR HOST ##/gamedata/c_images/",
|
||||
"hof.furni.url": "http://## YOUR HOST ##",
|
||||
"socket.url": "ws:localhost:2097",
|
||||
"asset.url": "http://localhost:3000/public\nitro-assets\gamedata",
|
||||
"image.library.url": "http://localhost:3000/swf/gamedata/c_images/",
|
||||
"hof.furni.url": "http://localhost:3000/",
|
||||
"images.url": "${asset.url}/images",
|
||||
"gamedata.url": "${asset.url}",
|
||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||
@@ -585,4 +585,4 @@
|
||||
"${images.url}/clear_icon.png",
|
||||
"${images.url}/big_arrow.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"socket.url": "ws://localhost:2097",
|
||||
"asset.url": "http://localhost:3000/nitro-assets",
|
||||
"image.library.url": "http://localhost:3000/swf/c_images/",
|
||||
"hof.furni.url": "http://localhost:3000/swf/dcr/hof_furni",
|
||||
"images.url": "${asset.url}/images",
|
||||
"gamedata.url": "${asset.url}/gamedata",
|
||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
||||
"external.texts.url": [ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ],
|
||||
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
|
||||
"furnidata.url": "${gamedata.url}/FurnitureData.json",
|
||||
"productdata.url": "${gamedata.url}/ProductData.json",
|
||||
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json",
|
||||
"avatar.figuredata.url": "${gamedata.url}/FigureData.json",
|
||||
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json",
|
||||
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json",
|
||||
"avatar.asset.url": "${asset.url}/bundled/figure/%libname%.nitro",
|
||||
"avatar.asset.effect.url": "${asset.url}/bundled/effect/%libname%.nitro",
|
||||
"furni.asset.url": "${asset.url}/bundled/furniture/%libname%.nitro",
|
||||
"furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png",
|
||||
"pet.asset.url": "${asset.url}/bundled/pet/%libname%.nitro",
|
||||
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
|
||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
||||
"furni.rotation.bounce.steps": 20,
|
||||
"furni.rotation.bounce.height": 0.0625,
|
||||
"enable.avatar.arrow": false,
|
||||
"system.log.debug": false,
|
||||
"system.log.warn": false,
|
||||
"system.log.error": false,
|
||||
"system.log.events": false,
|
||||
"system.log.packets": false,
|
||||
"system.fps.animation": 24,
|
||||
"system.fps.max": 60,
|
||||
"system.pong.manually": true,
|
||||
"system.pong.interval.ms": 20000,
|
||||
"room.color.skip.transition": true,
|
||||
"room.landscapes.enabled": true,
|
||||
"avatar.mandatory.libraries": [
|
||||
"bd:1",
|
||||
"li:0"
|
||||
],
|
||||
"avatar.mandatory.effect.libraries": [
|
||||
"dance.1",
|
||||
"dance.2",
|
||||
"dance.3",
|
||||
"dance.4"
|
||||
],
|
||||
"avatar.default.figuredata": {"palettes":[{"id":1,"colors":[{"id":99999,"index":1001,"club":0,"selectable":false,"hexCode":"DDDDDD"},{"id":99998,"index":1001,"club":0,"selectable":false,"hexCode":"FAFAFA"}]},{"id":3,"colors":[{"id":10001,"index":1001,"club":0,"selectable":false,"hexCode":"EEEEEE"},{"id":10002,"index":1002,"club":0,"selectable":false,"hexCode":"FA3831"},{"id":10003,"index":1003,"club":0,"selectable":false,"hexCode":"FD92A0"},{"id":10004,"index":1004,"club":0,"selectable":false,"hexCode":"2AC7D2"},{"id":10005,"index":1005,"club":0,"selectable":false,"hexCode":"35332C"},{"id":10006,"index":1006,"club":0,"selectable":false,"hexCode":"EFFF92"},{"id":10007,"index":1007,"club":0,"selectable":false,"hexCode":"C6FF98"},{"id":10008,"index":1008,"club":0,"selectable":false,"hexCode":"FF925A"},{"id":10009,"index":1009,"club":0,"selectable":false,"hexCode":"9D597E"},{"id":10010,"index":1010,"club":0,"selectable":false,"hexCode":"B6F3FF"},{"id":10011,"index":1011,"club":0,"selectable":false,"hexCode":"6DFF33"},{"id":10012,"index":1012,"club":0,"selectable":false,"hexCode":"3378C9"},{"id":10013,"index":1013,"club":0,"selectable":false,"hexCode":"FFB631"},{"id":10014,"index":1014,"club":0,"selectable":false,"hexCode":"DFA1E9"},{"id":10015,"index":1015,"club":0,"selectable":false,"hexCode":"F9FB32"},{"id":10016,"index":1016,"club":0,"selectable":false,"hexCode":"CAAF8F"},{"id":10017,"index":1017,"club":0,"selectable":false,"hexCode":"C5C6C5"},{"id":10018,"index":1018,"club":0,"selectable":false,"hexCode":"47623D"},{"id":10019,"index":1019,"club":0,"selectable":false,"hexCode":"8A8361"},{"id":10020,"index":1020,"club":0,"selectable":false,"hexCode":"FF8C33"},{"id":10021,"index":1021,"club":0,"selectable":false,"hexCode":"54C627"},{"id":10022,"index":1022,"club":0,"selectable":false,"hexCode":"1E6C99"},{"id":10023,"index":1023,"club":0,"selectable":false,"hexCode":"984F88"},{"id":10024,"index":1024,"club":0,"selectable":false,"hexCode":"77C8FF"},{"id":10025,"index":1025,"club":0,"selectable":false,"hexCode":"FFC08E"},{"id":10026,"index":1026,"club":0,"selectable":false,"hexCode":"3C4B87"},{"id":10027,"index":1027,"club":0,"selectable":false,"hexCode":"7C2C47"},{"id":10028,"index":1028,"club":0,"selectable":false,"hexCode":"D7FFE3"},{"id":10029,"index":1029,"club":0,"selectable":false,"hexCode":"8F3F1C"},{"id":10030,"index":1030,"club":0,"selectable":false,"hexCode":"FF6393"},{"id":10031,"index":1031,"club":0,"selectable":false,"hexCode":"1F9B79"},{"id":10032,"index":1032,"club":0,"selectable":false,"hexCode":"FDFF33"}]}],"setTypes":[{"type":"hd","paletteId":1,"mandatory_f_0":true,"mandatory_f_1":true,"mandatory_m_0":true,"mandatory_m_1":true,"sets":[{"id":99999,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":1,"type":"bd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"hd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"lh","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"rh","colorable":true,"index":0,"colorindex":1}]}]},{"type":"bds","paletteId":1,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10001,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"bds","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"lhs","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"rhs","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"bd"},{"partType":"rh"},{"partType":"lh"}]}]},{"type":"ss","paletteId":3,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10010,"gender":"F","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]},{"id":10011,"gender":"M","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10002,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]}]}]},
|
||||
"avatar.default.actions": {
|
||||
"actions": [
|
||||
{
|
||||
"id": "Default",
|
||||
"state": "std",
|
||||
"precedence": 1000,
|
||||
"main": true,
|
||||
"isDefault": true,
|
||||
"geometryType": "vertical",
|
||||
"activePartSet": "figure",
|
||||
"assetPartDefinition": "std"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pet.types": [
|
||||
"dog",
|
||||
"cat",
|
||||
"croco",
|
||||
"terrier",
|
||||
"bear",
|
||||
"pig",
|
||||
"lion",
|
||||
"rhino",
|
||||
"spider",
|
||||
"turtle",
|
||||
"chicken",
|
||||
"frog",
|
||||
"dragon",
|
||||
"monster",
|
||||
"monkey",
|
||||
"horse",
|
||||
"monsterplant",
|
||||
"bunnyeaster",
|
||||
"bunnyevil",
|
||||
"bunnydepressed",
|
||||
"bunnylove",
|
||||
"pigeongood",
|
||||
"pigeonevil",
|
||||
"demonmonkey",
|
||||
"bearbaby",
|
||||
"terrierbaby",
|
||||
"gnome",
|
||||
"leprechaun",
|
||||
"kittenbaby",
|
||||
"puppybaby",
|
||||
"pigletbaby",
|
||||
"haloompa",
|
||||
"fools",
|
||||
"pterosaur",
|
||||
"velociraptor",
|
||||
"cow",
|
||||
"dragondog"
|
||||
],
|
||||
"preload.assets.urls": [
|
||||
"${asset.url}/bundled/generic/avatar_additions.nitro",
|
||||
"${asset.url}/bundled/generic/group_badge.nitro",
|
||||
"${asset.url}/bundled/generic/floor_editor.nitro",
|
||||
"${images.url}/loading_icon.png",
|
||||
"${images.url}/clear_icon.png",
|
||||
"${images.url}/big_arrow.png"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
||||
"camera.url": "http://## YOUR HOST ##/camera/photo/",
|
||||
"thumbnails.url": "http://## YOUR HOST ##/camera/photo/temp/thumb/%thumbnail%.png",
|
||||
"camera.url": "http://localhost:3000/camera/photo/",
|
||||
"thumbnails.url": "http://localhost:3000/camera/photo/temp/thumb/%thumbnail%.png",
|
||||
"url.prefix": "",
|
||||
"habbopages.url": "/gamedata/habbopages/",
|
||||
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { GetUIVersion } from './api';
|
||||
import { Base } from './common';
|
||||
import { LoadingView } from './components/loading/LoadingView';
|
||||
import { MainView } from './components/MainView';
|
||||
import { ReconnectView } from './components/reconnect/ReconnectView';
|
||||
import { useMessageEvent } from './hooks';
|
||||
|
||||
NitroVersion.UI_VERSION = GetUIVersion();
|
||||
@@ -93,6 +94,7 @@ export const App: FC<{}> = props =>
|
||||
{ !isReady &&
|
||||
<LoadingView /> }
|
||||
{ isReady && <MainView /> }
|
||||
<ReconnectView />
|
||||
<Base id="draggable-windows-container" />
|
||||
</Base>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IPrefixItem
|
||||
{
|
||||
id: number;
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
effect: string;
|
||||
active: boolean;
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export class UnseenItemCategory
|
||||
public static BADGE: number = 4;
|
||||
public static BOT: number = 5;
|
||||
public static GAMES: number = 6;
|
||||
public static PREFIX: number = 7;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './GroupItem';
|
||||
export * from './IBotItem';
|
||||
export * from './IFurnitureItem';
|
||||
export * from './IPetItem';
|
||||
export * from './IPrefixItem';
|
||||
export * from './IUnseenItemTracker';
|
||||
export * from './InventoryUtilities';
|
||||
export * from './PetUtilities';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetRoomSessionManager } from '@nitrots/nitro-renderer';
|
||||
import { GetRoomSessionManager, NitroLogger } from '@nitrots/nitro-renderer';
|
||||
import { GetRoomSession } from './GetRoomSession';
|
||||
import { GoToDesktop } from './GoToDesktop';
|
||||
|
||||
@@ -6,6 +6,8 @@ export const VisitDesktop = () =>
|
||||
{
|
||||
if(!GetRoomSession()) return;
|
||||
|
||||
NitroLogger.log('[VisitDesktop] Called (isReconnecting=' + GetRoomSessionManager().isReconnecting + ')');
|
||||
|
||||
GoToDesktop();
|
||||
GetRoomSessionManager().removeSession(-1);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,10 @@ export class ChatBubbleMessage
|
||||
public height: number = 0;
|
||||
public elementRef: HTMLDivElement = null;
|
||||
public skipMovement: boolean = false;
|
||||
public prefixText: string = '';
|
||||
public prefixColor: string = '';
|
||||
public prefixIcon: string = '';
|
||||
public prefixEffect: string = '';
|
||||
|
||||
private _top: number = 0;
|
||||
private _left: number = 0;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface CommandDefinition
|
||||
{
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export * from './AvatarInfoUser';
|
||||
export * from './AvatarInfoUtilities';
|
||||
export * from './BotSkillsEnum';
|
||||
export * from './ChatBubbleMessage';
|
||||
export * from './CommandDefinition';
|
||||
export * from './ChatBubbleUtilities';
|
||||
export * from './ChatMessageTypeEnum';
|
||||
export * from './DimmerFurnitureWidgetPresetItem';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [
|
||||
{ id: '', label: 'None', icon: '—' },
|
||||
{ id: 'glow', label: 'Glow', icon: '✨' },
|
||||
{ id: 'shadow', label: 'Shadow', icon: '🌑' },
|
||||
{ id: 'italic', label: 'Italic', icon: '𝑰' },
|
||||
{ id: 'outline', label: 'Outline', icon: '🔲' },
|
||||
{ id: 'pulse', label: 'Pulse', icon: '💫' },
|
||||
{ id: 'bold-glow', label: 'Neon', icon: '💡' },
|
||||
];
|
||||
|
||||
export const parsePrefixColors = (text: string, colorStr: string): string[] =>
|
||||
{
|
||||
if(!colorStr || !text) return [];
|
||||
|
||||
const colors = colorStr.split(',');
|
||||
return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]);
|
||||
};
|
||||
|
||||
export const getPrefixEffectStyle = (effect: string, color?: string): Record<string, string | number> =>
|
||||
{
|
||||
const baseColor = color || '#FFFFFF';
|
||||
|
||||
switch(effect)
|
||||
{
|
||||
case 'glow':
|
||||
return { textShadow: `0 0 6px ${ baseColor }, 0 0 12px ${ baseColor }80` };
|
||||
case 'shadow':
|
||||
return { textShadow: '2px 2px 4px rgba(0,0,0,0.7), 1px 1px 2px rgba(0,0,0,0.5)' };
|
||||
case 'italic':
|
||||
return { fontStyle: 'italic' };
|
||||
case 'outline':
|
||||
return {
|
||||
WebkitTextStroke: '0.5px rgba(0,0,0,0.6)',
|
||||
textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)'
|
||||
};
|
||||
case 'pulse':
|
||||
return { animation: 'prefix-pulse 1.5s ease-in-out infinite' };
|
||||
case 'bold-glow':
|
||||
return {
|
||||
textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`,
|
||||
fontWeight: 900
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const PREFIX_EFFECT_KEYFRAMES = `
|
||||
@keyframes prefix-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ export * from './LocalizeFormattedNumber';
|
||||
export * from './LocalizeShortNumber';
|
||||
export * from './LocalizeText';
|
||||
export * from './PlaySound';
|
||||
export * from './PrefixUtils';
|
||||
export * from './ProductImageUtility';
|
||||
export * from './Randomizer';
|
||||
export * from './RoomChatFormatter';
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -1,4 +1,4 @@
|
||||
import { AddLinkEventTracker, GetCommunication, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useNitroEvent } from '../hooks';
|
||||
@@ -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,6 +22,7 @@ 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';
|
||||
@@ -42,6 +44,8 @@ export const MainView: FC<{}> = props =>
|
||||
{
|
||||
setIsReady(true);
|
||||
|
||||
GetRoomSessionManager().tryRestoreSession();
|
||||
|
||||
GetCommunication().connection.ready();
|
||||
}, []);
|
||||
|
||||
@@ -86,7 +90,6 @@ export const MainView: FC<{}> = props =>
|
||||
<AnimatePresence>
|
||||
{ landingViewVisible &&
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
initial={ { opacity: 0 }}
|
||||
animate={ { opacity: 1 }}
|
||||
exit={ { opacity: 0 }}>
|
||||
@@ -118,7 +121,9 @@ export const MainView: FC<{}> = props =>
|
||||
<CampaignView />
|
||||
<GameCenterView />
|
||||
<FloorplanEditorView />
|
||||
<FurniEditorView />
|
||||
<YoutubeTvView />
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { PurchasePrefixComposer } from '@nitrots/nitro-renderer';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
|
||||
const PRESET_COLORS: string[] = [
|
||||
'#FF0000', '#FF6600', '#FFCC00', '#33CC00', '#00CCFF',
|
||||
'#0066FF', '#9933FF', '#FF33CC', '#FFFFFF', '#CCCCCC',
|
||||
'#999999', '#333333', '#FF9999', '#99FF99', '#9999FF',
|
||||
'#FFD700', '#FF4500', '#00CED1', '#8A2BE2', '#DC143C'
|
||||
];
|
||||
|
||||
export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
|
||||
{
|
||||
const { page = null, hideNavigation = null } = props;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
hideNavigation();
|
||||
}, [ page, hideNavigation ]);
|
||||
|
||||
const [ prefixText, setPrefixText ] = useState('');
|
||||
const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('single');
|
||||
const [ singleColor, setSingleColor ] = useState('#FFFFFF');
|
||||
const [ letterColors, setLetterColors ] = useState<Record<number, string>>({});
|
||||
const [ selectedLetterIndex, setSelectedLetterIndex ] = useState<number | null>(null);
|
||||
const [ customColorInput, setCustomColorInput ] = useState('#FFFFFF');
|
||||
const [ selectedIcon, setSelectedIcon ] = useState('');
|
||||
const [ showIconPicker, setShowIconPicker ] = useState(false);
|
||||
const [ selectedEffect, setSelectedEffect ] = useState('');
|
||||
const [ purchased, setPurchased ] = useState(false);
|
||||
const pickerContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Inject style into emoji-mart Shadow DOM to remove backdrop-filter blur
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!showIconPicker) return;
|
||||
|
||||
const timer = setTimeout(() =>
|
||||
{
|
||||
const container = pickerContainerRef.current;
|
||||
if(!container) return;
|
||||
|
||||
const emPicker = container.querySelector('em-emoji-picker');
|
||||
if(!emPicker?.shadowRoot) return;
|
||||
|
||||
const existing = emPicker.shadowRoot.querySelector('#no-blur-fix');
|
||||
if(existing) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'no-blur-fix';
|
||||
style.textContent = `.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; } .menu { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; }`;
|
||||
emPicker.shadowRoot.appendChild(style);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [ showIconPicker ]);
|
||||
|
||||
const colorString = useMemo(() =>
|
||||
{
|
||||
if(colorMode === 'single') return singleColor;
|
||||
|
||||
if(!prefixText.length) return singleColor;
|
||||
|
||||
return [ ...prefixText ].map((_, i) => letterColors[i] || singleColor).join(',');
|
||||
}, [ colorMode, singleColor, letterColors, prefixText ]);
|
||||
|
||||
const previewColors = useMemo(() =>
|
||||
{
|
||||
return parsePrefixColors(prefixText || '...', colorString || '#FFFFFF');
|
||||
}, [ prefixText, colorString ]);
|
||||
|
||||
const isValid = useMemo(() =>
|
||||
{
|
||||
if(!prefixText.trim().length || prefixText.trim().length > 15) return false;
|
||||
|
||||
if(colorMode === 'single') return /^#[0-9A-Fa-f]{6}$/.test(singleColor);
|
||||
|
||||
const colors = colorString.split(',');
|
||||
return colors.every(c => /^#[0-9A-Fa-f]{6}$/.test(c));
|
||||
}, [ prefixText, colorMode, singleColor, colorString ]);
|
||||
|
||||
const handlePurchase = () =>
|
||||
{
|
||||
if(!isValid) return;
|
||||
|
||||
SendMessageComposer(new PurchasePrefixComposer(prefixText.trim(), colorString, selectedIcon, selectedEffect));
|
||||
setPurchased(true);
|
||||
setTimeout(() => setPurchased(false), 2000);
|
||||
};
|
||||
|
||||
const handleColorSelect = (color: string) =>
|
||||
{
|
||||
if(colorMode === 'single')
|
||||
{
|
||||
setSingleColor(color);
|
||||
setCustomColorInput(color);
|
||||
}
|
||||
else if(selectedLetterIndex !== null)
|
||||
{
|
||||
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color }));
|
||||
setCustomColorInput(color);
|
||||
|
||||
// Auto-advance to next letter
|
||||
if(selectedLetterIndex < prefixText.length - 1)
|
||||
{
|
||||
const nextIdx = selectedLetterIndex + 1;
|
||||
setSelectedLetterIndex(nextIdx);
|
||||
setCustomColorInput(letterColors[nextIdx] || singleColor);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (value: string) =>
|
||||
{
|
||||
setCustomColorInput(value);
|
||||
if(/^#[0-9A-Fa-f]{6}$/.test(value))
|
||||
{
|
||||
if(colorMode === 'single')
|
||||
{
|
||||
setSingleColor(value);
|
||||
}
|
||||
else if(selectedLetterIndex !== null)
|
||||
{
|
||||
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (newText: string) =>
|
||||
{
|
||||
setPrefixText(newText);
|
||||
if(selectedLetterIndex !== null && selectedLetterIndex >= newText.length)
|
||||
{
|
||||
setSelectedLetterIndex(newText.length > 0 ? newText.length - 1 : null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyColorToAll = () =>
|
||||
{
|
||||
if(!prefixText.length) return;
|
||||
|
||||
const newColors: Record<number, string> = {};
|
||||
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
|
||||
setLetterColors(newColors);
|
||||
};
|
||||
|
||||
const hasMultiColor = colorMode === 'perLetter' && previewColors.length > 1 && new Set(previewColors).size > 1;
|
||||
|
||||
const currentActiveColor = colorMode === 'single'
|
||||
? singleColor
|
||||
: (selectedLetterIndex !== null ? (letterColors[selectedLetterIndex] || singleColor) : singleColor);
|
||||
|
||||
const effectStyle = getPrefixEffectStyle(selectedEffect, previewColors[0] || '#FFFFFF');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full overflow-auto p-1">
|
||||
<style>{ PREFIX_EFFECT_KEYFRAMES }</style>
|
||||
|
||||
{ /* Header */ }
|
||||
{ page.localization.getImage(0) &&
|
||||
<img alt="" className="w-full rounded" src={ page.localization.getImage(0) } /> }
|
||||
{ page.localization.getText(0) &&
|
||||
<div className="text-sm mb-1" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> }
|
||||
|
||||
{ /* Live Preview */ }
|
||||
<div className="relative flex items-center justify-center p-4 rounded-lg min-h-[56px]"
|
||||
style={ {
|
||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05), 0 2px 8px rgba(0,0,0,0.3)'
|
||||
} }>
|
||||
<div className="absolute inset-0 rounded-lg opacity-20"
|
||||
style={ { background: 'radial-gradient(ellipse at center, rgba(100,149,237,0.3) 0%, transparent 70%)' } } />
|
||||
<span className="relative text-xl font-bold tracking-wide" style={ effectStyle }>
|
||||
{ selectedIcon && <span className="mr-1">{ selectedIcon }</span> }
|
||||
<span style={ hasMultiColor ? effectStyle : { ...effectStyle, color: previewColors[0] || '#FFFFFF' } }>
|
||||
{'{'}
|
||||
{ hasMultiColor
|
||||
? [ ...(prefixText || '...') ].map((char, i) => (
|
||||
<span key={ i } style={ { color: previewColors[i] || previewColors[previewColors.length - 1], ...getPrefixEffectStyle(selectedEffect, previewColors[i]) } }>{ char }</span>
|
||||
))
|
||||
: (prefixText || '...')
|
||||
}
|
||||
{'}'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="relative ml-2 text-white/80 text-lg font-medium">Username</span>
|
||||
</div>
|
||||
|
||||
{ /* Text + Icon Row */ }
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Text</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full px-3 py-1.5 rounded-md text-sm focus:outline-none transition-all"
|
||||
maxLength={ 15 }
|
||||
placeholder="Enter text..."
|
||||
style={ {
|
||||
background: 'rgba(0,0,0,0.15)',
|
||||
border: '1px solid rgba(0,0,0,0.15)',
|
||||
color: 'inherit'
|
||||
} }
|
||||
type="text"
|
||||
value={ prefixText }
|
||||
onChange={ e => handleTextChange(e.target.value) } />
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-30 font-mono">
|
||||
{ prefixText.length }/15
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 relative">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Icon</label>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="flex items-center justify-center gap-1 px-3 py-1.5 rounded-md text-sm transition-all min-w-[70px]"
|
||||
style={ {
|
||||
background: selectedIcon ? 'rgba(59,130,246,0.15)' : 'rgba(0,0,0,0.15)',
|
||||
border: selectedIcon ? '1px solid rgba(59,130,246,0.3)' : '1px solid rgba(0,0,0,0.15)'
|
||||
} }
|
||||
onClick={ () => setShowIconPicker(!showIconPicker) }>
|
||||
{ selectedIcon
|
||||
? <><span className="text-base">{ selectedIcon }</span><span className="text-[10px] opacity-40">▼</span></>
|
||||
: <span className="opacity-40 text-xs">Emoji ▼</span>
|
||||
}
|
||||
</button>
|
||||
{ selectedIcon &&
|
||||
<button
|
||||
className="flex items-center justify-center px-1.5 rounded-md text-xs transition-all"
|
||||
style={ { background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)' } }
|
||||
title="Remove icon"
|
||||
onClick={ () => setSelectedIcon('') }>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ }
|
||||
{ showIconPicker && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0" style={ { zIndex: 9998 } } onClick={ () => setShowIconPicker(false) } />
|
||||
<div ref={ pickerContainerRef } className="fixed rounded-xl overflow-hidden" style={ { zIndex: 9999, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: '#2b2f35' } }>
|
||||
<Picker
|
||||
data={ data }
|
||||
locale="en"
|
||||
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={ 8 }
|
||||
maxFrequentRows={ 2 }
|
||||
emojiSize={ 22 }
|
||||
emojiButtonSize={ 30 }
|
||||
dynamicWidth={ false }
|
||||
set="native"
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) }
|
||||
|
||||
{ /* Effect Selector */ }
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Effect</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ PRESET_PREFIX_EFFECTS.map(fx => (
|
||||
<button
|
||||
key={ fx.id }
|
||||
className="px-2 py-1 rounded-md text-[11px] font-semibold transition-all"
|
||||
style={ {
|
||||
background: selectedEffect === fx.id ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
|
||||
border: selectedEffect === fx.id ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(0,0,0,0.1)',
|
||||
opacity: selectedEffect === fx.id ? 1 : 0.7
|
||||
} }
|
||||
onClick={ () => setSelectedEffect(fx.id) }>
|
||||
<span className="mr-0.5">{ fx.icon }</span> { fx.label }
|
||||
</button>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Color Mode Toggle */ }
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Color</label>
|
||||
<div className="flex rounded-md overflow-hidden" style={ { border: '1px solid rgba(0,0,0,0.15)' } }>
|
||||
<button
|
||||
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
|
||||
style={ {
|
||||
background: colorMode === 'single' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
|
||||
borderRight: '1px solid rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'single' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
|
||||
🎨 Single
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
|
||||
style={ {
|
||||
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
|
||||
opacity: colorMode === 'perLetter' ? 1 : 0.6
|
||||
} }
|
||||
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
|
||||
🌈 Per Letter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Per-Letter Selector */ }
|
||||
{ colorMode === 'perLetter' && prefixText.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] opacity-50">
|
||||
Select a letter, then choose a color. Auto-advances.
|
||||
</span>
|
||||
<button
|
||||
className="text-[10px] px-1.5 py-0.5 rounded transition-all"
|
||||
style={ {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
border: '1px solid rgba(0,0,0,0.1)'
|
||||
} }
|
||||
title="Apply current color to all letters"
|
||||
onClick={ applyColorToAll }>
|
||||
Apply to all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 p-2 rounded-lg"
|
||||
style={ {
|
||||
background: 'rgba(0,0,0,0.12)',
|
||||
border: '1px solid rgba(0,0,0,0.1)'
|
||||
} }>
|
||||
{ [ ...prefixText ].map((char, i) =>
|
||||
{
|
||||
const charColor = letterColors[i] || singleColor;
|
||||
const isSelected = selectedLetterIndex === i;
|
||||
return (
|
||||
<div
|
||||
key={ i }
|
||||
className="relative flex items-center justify-center cursor-pointer transition-all"
|
||||
style={ {
|
||||
width: '28px',
|
||||
height: '34px',
|
||||
borderRadius: '6px',
|
||||
background: isSelected
|
||||
? 'rgba(59,130,246,0.2)'
|
||||
: 'rgba(0,0,0,0.12)',
|
||||
border: isSelected
|
||||
? '2px solid rgba(59,130,246,0.6)'
|
||||
: '1px solid rgba(0,0,0,0.08)',
|
||||
transform: isSelected ? 'scale(1.15)' : 'scale(1)',
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
|
||||
} }
|
||||
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
|
||||
<span className="text-sm font-black" style={ { color: charColor } }>
|
||||
{ char }
|
||||
</span>
|
||||
<div
|
||||
className="absolute bottom-0.5 left-1/2 -translate-x-1/2 rounded-full"
|
||||
style={ {
|
||||
width: '14px',
|
||||
height: '3px',
|
||||
backgroundColor: charColor,
|
||||
boxShadow: `0 0 4px ${ charColor }`
|
||||
} } />
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ /* Color Palette */ }
|
||||
<div className="flex flex-col gap-1">
|
||||
{ colorMode === 'perLetter' && selectedLetterIndex !== null &&
|
||||
<span className="text-[10px] opacity-50 italic">
|
||||
Selected letter: "{ prefixText[selectedLetterIndex] || '' }"
|
||||
</span>
|
||||
}
|
||||
<div className="grid grid-cols-10 gap-[3px]">
|
||||
{ PRESET_COLORS.map((color, idx) =>
|
||||
{
|
||||
const isActive = currentActiveColor === color;
|
||||
return (
|
||||
<div
|
||||
key={ idx }
|
||||
className="cursor-pointer transition-all"
|
||||
style={ {
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: color,
|
||||
border: isActive ? '2px solid #fff' : '1px solid rgba(0,0,0,0.15)',
|
||||
boxShadow: isActive ? `0 0 6px ${ color }, 0 0 0 1px rgba(0,0,0,0.2)` : 'inset 0 1px 0 rgba(255,255,255,0.2)',
|
||||
transform: isActive ? 'scale(1.2)' : 'scale(1)',
|
||||
zIndex: isActive ? 5 : 1
|
||||
} }
|
||||
onClick={ () => handleColorSelect(color) } />
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<label
|
||||
className="relative cursor-pointer"
|
||||
style={ {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: customColorInput,
|
||||
border: '2px solid rgba(0,0,0,0.2)',
|
||||
boxShadow: `0 0 6px ${ customColorInput }40, inset 0 1px 0 rgba(255,255,255,0.3)`
|
||||
} }>
|
||||
<input
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
style={ { width: '100%', height: '100%' } }
|
||||
type="color"
|
||||
value={ customColorInput }
|
||||
onChange={ e => handleColorSelect(e.target.value) } />
|
||||
</label>
|
||||
<input
|
||||
className="flex-1 px-2 py-0.5 text-xs font-mono focus:outline-none transition-all"
|
||||
maxLength={ 7 }
|
||||
placeholder="#FFFFFF"
|
||||
style={ {
|
||||
background: 'rgba(0,0,0,0.15)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
color: 'inherit',
|
||||
maxWidth: '80px',
|
||||
borderRadius: '5px'
|
||||
} }
|
||||
type="text"
|
||||
value={ customColorInput }
|
||||
onChange={ e => handleCustomColorChange(e.target.value) } />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Purchase Footer */ }
|
||||
<div className="flex items-center justify-between mt-auto pt-2"
|
||||
style={ { borderTop: '1px solid rgba(0,0,0,0.1)' } }>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs opacity-60">Price:</span>
|
||||
<span className="text-sm font-bold">5 Credits</span>
|
||||
</div>
|
||||
<button
|
||||
className="px-5 py-1.5 rounded-md text-sm font-bold transition-all"
|
||||
disabled={ !isValid || purchased }
|
||||
style={ {
|
||||
background: !isValid
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: purchased
|
||||
? 'linear-gradient(135deg, #22c55e, #16a34a)'
|
||||
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
|
||||
color: !isValid ? 'rgba(0,0,0,0.3)' : '#fff',
|
||||
cursor: !isValid ? 'not-allowed' : 'pointer',
|
||||
border: !isValid ? '1px solid rgba(0,0,0,0.1)' : 'none',
|
||||
boxShadow: isValid && !purchased ? '0 2px 8px rgba(59,130,246,0.3)' : 'none',
|
||||
borderRadius: '6px'
|
||||
} }
|
||||
onClick={ handlePurchase }>
|
||||
{ purchased ? '✓ Purchased!' : 'Purchase' }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { ICatalogPage } from '../../../../../api';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
|
||||
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
|
||||
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
|
||||
import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView';
|
||||
import { CatalogLayouGuildCustomFurniView } from './CatalogLayoutGuildCustomFurniView';
|
||||
import { CatalogLayouGuildForumView } from './CatalogLayoutGuildForumView';
|
||||
@@ -72,6 +73,8 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
|
||||
return <CatalogLayoutColorGroupingView { ...layoutProps } />;
|
||||
case 'soundmachine':
|
||||
return <CatalogLayoutSoundMachineView { ...layoutProps } />;
|
||||
case 'custom_prefix':
|
||||
return <CatalogLayoutCustomPrefixView { ...layoutProps } />;
|
||||
case 'bots':
|
||||
case 'default_3x3':
|
||||
default:
|
||||
|
||||
@@ -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,140 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useFurniEditor } from '../../hooks/furni-editor';
|
||||
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
||||
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
|
||||
|
||||
const TAB_SEARCH = 0;
|
||||
const TAB_EDIT = 1;
|
||||
|
||||
export const FurniEditorView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ activeTab, setActiveTab ] = useState(TAB_SEARCH);
|
||||
|
||||
const {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, catalogItems, furniDataEntry,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||
} = useFurniEditor();
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'furni-editor/'
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isVisible) loadInteractions();
|
||||
}, [ isVisible ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const handler = async (e: CustomEvent<{ spriteId: number }>) =>
|
||||
{
|
||||
const { spriteId } = e.detail;
|
||||
|
||||
const ok = await loadBySpriteId(spriteId);
|
||||
|
||||
if(ok) setActiveTab(TAB_EDIT);
|
||||
};
|
||||
|
||||
window.addEventListener('furni-editor:open', handler as EventListener);
|
||||
|
||||
return () => window.removeEventListener('furni-editor:open', handler as EventListener);
|
||||
}, [ loadBySpriteId ]);
|
||||
|
||||
const handleSelect = useCallback(async (id: number) =>
|
||||
{
|
||||
const ok = await loadDetail(id);
|
||||
|
||||
if(ok) setActiveTab(TAB_EDIT);
|
||||
}, [ loadDetail ]);
|
||||
|
||||
const handleBack = useCallback(() =>
|
||||
{
|
||||
setActiveTab(TAB_SEARCH);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() =>
|
||||
{
|
||||
setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
|
||||
<NitroCardHeaderView headerText="Furni Editor" onCloseClick={ handleClose } />
|
||||
<NitroCardTabsView>
|
||||
<NitroCardTabsItemView isActive={ activeTab === TAB_SEARCH } onClick={ () => setActiveTab(TAB_SEARCH) }>
|
||||
Search
|
||||
</NitroCardTabsItemView>
|
||||
<NitroCardTabsItemView isActive={ activeTab === TAB_EDIT } onClick={ () => selectedItem && setActiveTab(TAB_EDIT) }>
|
||||
Edit
|
||||
</NitroCardTabsItemView>
|
||||
</NitroCardTabsView>
|
||||
<NitroCardContentView>
|
||||
{ error &&
|
||||
<div className="bg-[#f8d7da] border border-[#f5c6cb] rounded p-2 text-[#721c24] text-xs mb-1 flex justify-between items-center">
|
||||
<span>{ error }</span>
|
||||
<span className="cursor-pointer font-bold" onClick={ clearError }>x</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
{ activeTab === TAB_SEARCH &&
|
||||
<FurniEditorSearchView
|
||||
items={ items }
|
||||
total={ total }
|
||||
page={ page }
|
||||
loading={ loading }
|
||||
onSearch={ searchItems }
|
||||
onSelect={ handleSelect }
|
||||
/>
|
||||
}
|
||||
|
||||
{ activeTab === TAB_EDIT && selectedItem &&
|
||||
<FurniEditorEditView
|
||||
item={ selectedItem }
|
||||
catalogItems={ catalogItems }
|
||||
furniDataEntry={ furniDataEntry }
|
||||
interactions={ interactions }
|
||||
loading={ loading }
|
||||
onUpdate={ updateItem }
|
||||
onDelete={ deleteItem }
|
||||
onBack={ handleBack }
|
||||
onRefresh={ loadDetail }
|
||||
/>
|
||||
}
|
||||
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { FC, useCallback, useState } from 'react';
|
||||
import { Button, Column, Flex, Text } from '../../../common';
|
||||
|
||||
interface FurniEditorCreateViewProps
|
||||
{
|
||||
interactions: string[];
|
||||
loading: boolean;
|
||||
onCreate: (fields: Record<string, unknown>) => Promise<number | null>;
|
||||
onCreated: (id: number) => void;
|
||||
}
|
||||
|
||||
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
|
||||
{
|
||||
const { interactions, loading, onCreate, onCreated } = props;
|
||||
const [ success, setSuccess ] = useState<number | null>(null);
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
itemName: '',
|
||||
publicName: '',
|
||||
spriteId: 0,
|
||||
type: 's' as 's' | 'i',
|
||||
width: 1,
|
||||
length: 1,
|
||||
stackHeight: 0,
|
||||
allowStack: true,
|
||||
allowSit: false,
|
||||
allowLay: false,
|
||||
allowWalk: false,
|
||||
allowGift: true,
|
||||
allowTrade: true,
|
||||
allowRecycle: true,
|
||||
allowMarketplaceSell: true,
|
||||
allowInventoryStack: true,
|
||||
interactionType: '',
|
||||
interactionModesCount: 1,
|
||||
customparams: '',
|
||||
});
|
||||
|
||||
const setField = useCallback((key: string, value: unknown) =>
|
||||
{
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
setSuccess(null);
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async () =>
|
||||
{
|
||||
if(!form.itemName || !form.publicName) return;
|
||||
|
||||
const id = await onCreate(form);
|
||||
|
||||
if(id)
|
||||
{
|
||||
setSuccess(id);
|
||||
setTimeout(() => onCreated(id), 1000);
|
||||
}
|
||||
}, [ form, onCreate, onCreated ]);
|
||||
|
||||
const inputClass = 'form-control form-control-sm';
|
||||
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
|
||||
|
||||
return (
|
||||
<Column gap={ 1 } className="h-full overflow-auto">
|
||||
{ success &&
|
||||
<div className="bg-[#d4edda] border border-[#c3e6cb] rounded p-2 text-[#155724] text-xs">
|
||||
Item created with ID #{ success }!
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Item Name *</label>
|
||||
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } placeholder="my_custom_furni" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Public Name *</label>
|
||||
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } placeholder="My Custom Furni" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Sprite ID</label>
|
||||
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
||||
<option value="s">Floor (s)</option>
|
||||
<option value="i">Wall (i)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Width</label>
|
||||
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Length</label>
|
||||
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Stack Height</label>
|
||||
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
|
||||
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
|
||||
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={ (form as any)[key] }
|
||||
onChange={ e => setField(key, e.target.checked) }
|
||||
/>
|
||||
{ key.replace('allow', '') }
|
||||
</label>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
||||
<option value="">none</option>
|
||||
{ interactions.map(i => (
|
||||
<option key={ i } value={ i }>{ i }</option>
|
||||
)) }
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Modes</label>
|
||||
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<label className={ labelClass }>Custom Params</label>
|
||||
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Flex className="mt-1">
|
||||
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
|
||||
{ loading ? 'Creating...' : 'Create Item' }
|
||||
</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Column, Flex, Text } from '../../../common';
|
||||
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor';
|
||||
|
||||
interface FurniEditorEditViewProps
|
||||
{
|
||||
item: FurniDetail;
|
||||
catalogItems: CatalogRef[];
|
||||
furniDataEntry: Record<string, unknown> | null;
|
||||
interactions: string[];
|
||||
loading: boolean;
|
||||
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
|
||||
onDelete: (id: number) => Promise<boolean>;
|
||||
onBack: () => void;
|
||||
onRefresh: (id: number) => void;
|
||||
}
|
||||
|
||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
{
|
||||
const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props;
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
itemName: '',
|
||||
publicName: '',
|
||||
spriteId: 0,
|
||||
type: 's',
|
||||
width: 1,
|
||||
length: 1,
|
||||
stackHeight: 0,
|
||||
allowStack: true,
|
||||
allowWalk: false,
|
||||
allowSit: false,
|
||||
allowLay: false,
|
||||
allowGift: true,
|
||||
allowTrade: true,
|
||||
allowRecycle: true,
|
||||
allowMarketplaceSell: true,
|
||||
allowInventoryStack: true,
|
||||
interactionType: '',
|
||||
interactionModesCount: 0,
|
||||
customparams: '',
|
||||
});
|
||||
|
||||
const [ confirmDelete, setConfirmDelete ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!item) return;
|
||||
|
||||
setForm({
|
||||
itemName: item.itemName || '',
|
||||
publicName: item.publicName || '',
|
||||
spriteId: item.spriteId || 0,
|
||||
type: item.type || 's',
|
||||
width: item.width || 1,
|
||||
length: item.length || 1,
|
||||
stackHeight: item.stackHeight || 0,
|
||||
allowStack: !!item.allowStack,
|
||||
allowWalk: !!item.allowWalk,
|
||||
allowSit: !!item.allowSit,
|
||||
allowLay: !!item.allowLay,
|
||||
allowGift: !!item.allowGift,
|
||||
allowTrade: !!item.allowTrade,
|
||||
allowRecycle: !!item.allowRecycle,
|
||||
allowMarketplaceSell: !!item.allowMarketplaceSell,
|
||||
allowInventoryStack: !!item.allowInventoryStack,
|
||||
interactionType: item.interactionType || '',
|
||||
interactionModesCount: item.interactionModesCount || 0,
|
||||
customparams: item.customparams || '',
|
||||
});
|
||||
|
||||
setConfirmDelete(false);
|
||||
}, [ item ]);
|
||||
|
||||
const setField = useCallback((key: string, value: unknown) =>
|
||||
{
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () =>
|
||||
{
|
||||
const ok = await onUpdate(item.id, form);
|
||||
|
||||
if(ok) onRefresh(item.id);
|
||||
}, [ item, form, onUpdate, onRefresh ]);
|
||||
|
||||
const handleDelete = useCallback(async () =>
|
||||
{
|
||||
if(!confirmDelete) return setConfirmDelete(true);
|
||||
|
||||
const ok = await onDelete(item.id);
|
||||
|
||||
if(ok) onBack();
|
||||
}, [ confirmDelete, item, onDelete, onBack ]);
|
||||
|
||||
const inputClass = 'form-control form-control-sm';
|
||||
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
|
||||
|
||||
return (
|
||||
<Column gap={ 1 } className="h-full overflow-auto">
|
||||
<Flex gap={ 1 } alignItems="center" className="mb-1">
|
||||
<Button variant="secondary" onClick={ onBack }>Back</Button>
|
||||
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
||||
<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 bold className="text-[12px]">{ item.id }</Text>
|
||||
<span className="text-[#999] mx-0.5">|</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
||||
<path d="M12.586 2.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5ZM7.414 17.414a2 2 0 1 1-2.828-2.828l3-3a2 2 0 0 1 2.828 0 1 1 0 0 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 0 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5Z" />
|
||||
</svg>
|
||||
<Text bold className="text-[12px]">{ item.spriteId }</Text>
|
||||
</Flex>
|
||||
<Text small variant="gray">({ item.usageCount } in use)</Text>
|
||||
</Flex>
|
||||
|
||||
{ /* Basic Info */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Item Name</label>
|
||||
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Public Name</label>
|
||||
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Sprite ID</label>
|
||||
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
||||
<option value="s">Floor (s)</option>
|
||||
<option value="i">Wall (i)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Dimensions */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Width</label>
|
||||
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Length</label>
|
||||
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Stack Height</label>
|
||||
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Permissions */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
|
||||
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
|
||||
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={ (form as any)[key] }
|
||||
onChange={ e => setField(key, e.target.checked) }
|
||||
/>
|
||||
{ key.replace('allow', '') }
|
||||
</label>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Interaction */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
||||
<option value="">none</option>
|
||||
{ interactions.map(i => (
|
||||
<option key={ i } value={ i }>{ i }</option>
|
||||
)) }
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Modes</label>
|
||||
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<label className={ labelClass }>Custom Params</label>
|
||||
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Catalog References */ }
|
||||
{ catalogItems.length > 0 &&
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Catalog ({ catalogItems.length })</Text>
|
||||
<div className="text-[10px] space-y-0.5">
|
||||
{ catalogItems.map(ci => (
|
||||
<div key={ ci.id } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
||||
<span>{ ci.catalogName } (page: { ci.pageName })</span>
|
||||
<span>{ ci.costCredits }c + { ci.costPoints }p</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{ /* FurniData.json Entry */ }
|
||||
{ furniDataEntry &&
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
|
||||
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
|
||||
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
||||
<span className="font-bold text-[#555]">{ key }</span>
|
||||
<span className="text-[#333] truncate ml-1 max-w-[120px] text-right">{ String(value ?? '') }</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{ /* Actions */ }
|
||||
<Flex gap={ 1 } justifyContent="between" className="mt-1">
|
||||
<Button variant="success" disabled={ loading } onClick={ handleSave }>
|
||||
{ loading ? 'Saving...' : 'Save' }
|
||||
</Button>
|
||||
<Button
|
||||
variant={ confirmDelete ? 'danger' : 'warning' }
|
||||
disabled={ loading || item.usageCount > 0 }
|
||||
onClick={ handleDelete }
|
||||
>
|
||||
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
|
||||
</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Column, Flex, Text } from '../../../common';
|
||||
import { FurniItem } from '../../../hooks/furni-editor';
|
||||
|
||||
interface FurniEditorSearchViewProps
|
||||
{
|
||||
items: FurniItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
onSearch: (query: string, type: string, page: number) => void;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
||||
{
|
||||
const { items, total, page, loading, onSearch, onSelect } = props;
|
||||
const [ query, setQuery ] = useState('');
|
||||
const [ typeFilter, setTypeFilter ] = useState('');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
onSearch('', '', 1);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(() =>
|
||||
{
|
||||
onSearch(query, typeFilter, 1);
|
||||
}, [ query, typeFilter, onSearch ]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) =>
|
||||
{
|
||||
if(e.key === 'Enter') handleSearch();
|
||||
}, [ handleSearch ]);
|
||||
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
return (
|
||||
<Column gap={ 1 } className="h-full">
|
||||
<Flex gap={ 1 } alignItems="end">
|
||||
<Column gap={ 0 } className="flex-1">
|
||||
<Text small bold>Search</Text>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="ID, name or sprite ID..."
|
||||
value={ query }
|
||||
onChange={ e => setQuery(e.target.value) }
|
||||
onKeyDown={ handleKeyDown }
|
||||
/>
|
||||
</Column>
|
||||
<Column gap={ 0 } className="w-[80px]">
|
||||
<Text small bold>Type</Text>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={ typeFilter }
|
||||
onChange={ e => setTypeFilter(e.target.value) }
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="s">Floor (s)</option>
|
||||
<option value="i">Wall (i)</option>
|
||||
</select>
|
||||
</Column>
|
||||
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
|
||||
{ loading ? '...' : 'Search' }
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#e8e8e8] sticky top-0">
|
||||
<th className="px-2 py-1 text-left">ID</th>
|
||||
<th className="px-2 py-1 text-left">Sprite</th>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Public Name</th>
|
||||
<th className="px-2 py-1 text-center">Type</th>
|
||||
<th className="px-2 py-1 text-left">Interaction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ items.map(item => (
|
||||
<tr
|
||||
key={ item.id }
|
||||
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
|
||||
onClick={ () => onSelect(item.id) }
|
||||
>
|
||||
<td className="px-2 py-1 font-mono">{ item.id }</td>
|
||||
<td className="px-2 py-1 font-mono">{ item.spriteId }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]">{ item.itemName }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]">{ item.publicName }</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<span className={ `px-1 rounded text-white text-[10px] ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
|
||||
{ item.type === 's' ? 'Floor' : 'Wall' }
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-[10px]">{ item.interactionType || '-' }</td>
|
||||
</tr>
|
||||
)) }
|
||||
{ items.length === 0 && !loading &&
|
||||
<tr>
|
||||
<td colSpan={ 6 } className="px-2 py-4 text-center text-[#999]">No items found</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</Column>
|
||||
|
||||
{ totalPages > 1 &&
|
||||
<Flex gap={ 1 } justifyContent="between" alignItems="center">
|
||||
<Text small variant="gray">
|
||||
{ total } items - Page { page }/{ totalPages }
|
||||
</Text>
|
||||
<Flex gap={ 1 }>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={ page <= 1 }
|
||||
onClick={ () => onSearch(query, typeFilter, page - 1) }
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={ page >= totalPages }
|
||||
onClick={ () => onSearch(query, typeFilter, page + 1) }
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { AddLinkEventTracker, BadgePointLimitsEvent, GetLocalizationManager, Get
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GroupItem, LocalizeText, UnseenItemCategory, isObjectMoverRequested, setObjectMoverRequested } from '../../api';
|
||||
import { NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useInventoryBadges, useInventoryFurni, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks';
|
||||
import { useInventoryBadges, useInventoryFurni, useInventoryPrefixes, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks';
|
||||
import { InventoryCategoryFilterView } from './views/InventoryCategoryFilterView';
|
||||
import { InventoryBadgeView } from './views/badge/InventoryBadgeView';
|
||||
import { InventoryBotView } from './views/bot/InventoryBotView';
|
||||
@@ -10,13 +10,15 @@ import { InventoryFurnitureDeleteView } from './views/furniture/InventoryFurnitu
|
||||
import { InventoryFurnitureView } from './views/furniture/InventoryFurnitureView';
|
||||
import { InventoryTradeView } from './views/furniture/InventoryTradeView';
|
||||
import { InventoryPetView } from './views/pet/InventoryPetView';
|
||||
import { InventoryPrefixView } from './views/prefix/InventoryPrefixView';
|
||||
|
||||
const TAB_FURNITURE: string = 'inventory.furni';
|
||||
const TAB_BOTS: string = 'inventory.bots';
|
||||
const TAB_PETS: string = 'inventory.furni.tab.pets';
|
||||
const TAB_BADGES: string = 'inventory.badges';
|
||||
const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_BOTS ];
|
||||
const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.BOT ];
|
||||
const TAB_PREFIXES: string = 'inventory.prefixes';
|
||||
const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_PREFIXES, TAB_BOTS ];
|
||||
const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.PREFIX, UnseenItemCategory.BOT ];
|
||||
|
||||
export const InventoryView: FC<{}> = props =>
|
||||
{
|
||||
@@ -165,6 +167,8 @@ export const InventoryView: FC<{}> = props =>
|
||||
<InventoryPetView roomPreviewer={ roomPreviewer } roomSession={ roomSession } /> }
|
||||
{ (currentTab === TAB_BADGES) &&
|
||||
<InventoryBadgeView filteredBadgeCodes={ filteredBadgeCodes } /> }
|
||||
{ (currentTab === TAB_PREFIXES) &&
|
||||
<InventoryPrefixView /> }
|
||||
{ (currentTab === TAB_BOTS) &&
|
||||
<InventoryBotView roomPreviewer={ roomPreviewer } roomSession={ roomSession } /> }
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaTrashAlt } from 'react-icons/fa';
|
||||
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
|
||||
import { useInventoryPrefixes, useNotification } from '../../../../hooks';
|
||||
import { NitroButton } from '../../../../layout';
|
||||
|
||||
const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) =>
|
||||
{
|
||||
const colors = parsePrefixColors(text, color);
|
||||
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
|
||||
const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF');
|
||||
|
||||
return (
|
||||
<span className={ `font-bold ${ textSize } ${ className }` } style={ fxStyle }>
|
||||
{ effect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
|
||||
{ icon && <span className="mr-0.5">{ icon }</span> }
|
||||
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
|
||||
{'{'}
|
||||
{ hasMultiColor
|
||||
? [ ...text ].map((char, i) => (
|
||||
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
|
||||
))
|
||||
: text
|
||||
}
|
||||
{'}'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PrefixItemView: FC<{
|
||||
prefix: IPrefixItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ prefix, isSelected, onClick }) =>
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer p-2 transition-colors
|
||||
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
|
||||
${ prefix.active ? 'ring-2 ring-green-400' : '' }` }
|
||||
onClick={ onClick }>
|
||||
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } icon={ prefix.icon } text={ prefix.text } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InventoryPrefixView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes();
|
||||
const { showConfirm = null } = useNotification();
|
||||
|
||||
const attemptDeletePrefix = () =>
|
||||
{
|
||||
if(!selectedPrefix) return;
|
||||
|
||||
showConfirm(
|
||||
`Are you sure you want to delete the prefix {${selectedPrefix.text}}?`,
|
||||
() => deletePrefix(selectedPrefix.id),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
LocalizeText('inventory.delete.confirm_delete.title')
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
const id = activate();
|
||||
|
||||
return () => deactivate(id);
|
||||
}, [ isVisible, activate, deactivate ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setIsVisible(true);
|
||||
|
||||
return () => setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-12 gap-2">
|
||||
<div className="flex flex-col col-span-7 gap-1 overflow-auto">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{ prefixes.map(prefix => (
|
||||
<PrefixItemView
|
||||
key={ prefix.id }
|
||||
isSelected={ selectedPrefix?.id === prefix.id }
|
||||
prefix={ prefix }
|
||||
onClick={ () => setSelectedPrefix(prefix) } />
|
||||
)) }
|
||||
</div>
|
||||
{ (!prefixes || prefixes.length === 0) &&
|
||||
<div className="flex items-center justify-center h-full text-sm opacity-50">
|
||||
{ LocalizeText('inventory.empty.title') }
|
||||
</div> }
|
||||
</div>
|
||||
<div className="flex flex-col justify-between col-span-5 overflow-auto">
|
||||
{ activePrefix &&
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
|
||||
<div className="flex items-center justify-center p-3 rounded-md border-2 border-green-400 bg-card-grid-item">
|
||||
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
|
||||
</div>
|
||||
</div> }
|
||||
{ !activePrefix &&
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
|
||||
<div className="flex items-center justify-center p-3 rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item opacity-50">
|
||||
<span className="text-sm">No active prefix</span>
|
||||
</div>
|
||||
</div> }
|
||||
{ !!selectedPrefix &&
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="flex items-center justify-center gap-2 p-2 rounded bg-card-grid-item">
|
||||
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NitroButton
|
||||
className="grow"
|
||||
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
|
||||
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
|
||||
</NitroButton>
|
||||
{ !selectedPrefix.active &&
|
||||
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
|
||||
<FaTrashAlt className="fa-icon" />
|
||||
</NitroButton> }
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,21 +2,29 @@ import { FC } from 'react';
|
||||
import { Base, Column, Text } from '../../common';
|
||||
|
||||
interface LoadingViewProps {
|
||||
isError: boolean;
|
||||
message: string;
|
||||
isError?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingView: FC<LoadingViewProps> = props => {
|
||||
const { isError = false, message = '' } = props;
|
||||
|
||||
|
||||
return (
|
||||
<Column fullHeight position="relative" className="relative z-[100] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
||||
<Base fullHeight className="container h-100">
|
||||
<Column fullHeight alignItems="center" justifyContent="center">
|
||||
<Base className="absolute inset-0 m-auto w-[84px] h-[84px] [zoom:1.5] [image-rendering:pixelated] bg-[url('@/assets/images/loading/loading.gif')] bg-no-repeat bg-left-top" />
|
||||
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/coolui.png')] bg-no-repeat bg-left-top" />
|
||||
{ !isError &&
|
||||
<Base className="absolute inset-0 m-auto w-[84px] h-[84px] [zoom:1.5] [image-rendering:pixelated] bg-[url('@/assets/images/loading/loading.gif')] bg-no-repeat bg-left-top" /> }
|
||||
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
|
||||
{ isError && (message && message.length) ?
|
||||
<Base className="fs-4 absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">{ message }</Base>
|
||||
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
||||
Something went wrong while loading
|
||||
</Text>
|
||||
<Base className="px-4 py-3 rounded-lg bg-black/40 text-[#ff6b6b] text-sm font-mono text-center break-words whitespace-pre-wrap max-w-[600px]">
|
||||
{ message }
|
||||
</Base>
|
||||
</Column>
|
||||
:
|
||||
<Text fontSizeCustom={32} variant="white" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
||||
The hotel is loading ...
|
||||
|
||||
@@ -10,6 +10,6 @@ export const GetConfirmLayout = (item: NotificationConfirmItem, onClose: () => v
|
||||
switch(item.confirmType)
|
||||
{
|
||||
default:
|
||||
return <NotificationDefaultConfirmView { ...props } />;
|
||||
return <NotificationDefaultConfirmView key={ item.id } item={ item } onClose={ onClose } />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { subscribePlugins } from './NitroPluginApi';
|
||||
|
||||
// Force the global API to be initialized
|
||||
import './NitroPluginApi';
|
||||
|
||||
export const ExternalPluginLoader: FC<{}> = () =>
|
||||
{
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return subscribePlugins(() => forceUpdate(n => n + 1));
|
||||
}, []);
|
||||
|
||||
// MainView only renders after isReady=true in App.tsx,
|
||||
// so the configuration is guaranteed to be loaded at this point.
|
||||
useEffect(() =>
|
||||
{
|
||||
const scripts: HTMLScriptElement[] = [];
|
||||
|
||||
let pluginUrls: string[] = [];
|
||||
|
||||
try
|
||||
{
|
||||
pluginUrls = GetConfigurationValue<string[]>('external.plugins', []);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn('[NitroPlugins] Could not read external.plugins config:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginUrls || pluginUrls.length === 0)
|
||||
{
|
||||
console.log('[NitroPlugins] No external plugins configured');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[NitroPlugins] Loading external plugins:', pluginUrls);
|
||||
|
||||
for (const url of pluginUrls)
|
||||
{
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => console.log(`[NitroPlugins] Loaded: ${url}`);
|
||||
script.onerror = () => console.warn(`[NitroPlugins] Failed to load: ${url}`);
|
||||
document.head.appendChild(script);
|
||||
scripts.push(script);
|
||||
}
|
||||
|
||||
return () =>
|
||||
{
|
||||
scripts.forEach(s => s.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { FurnitureStackHeightComposer, GetRoomEngine, TextureUtils } from '@nitrots/nitro-renderer';
|
||||
import { CreateLinkEvent, GetRoomSession, SendMessageComposer, VisitDesktop } from '../../api';
|
||||
|
||||
/**
|
||||
* Plugin descriptor registered by external plugin scripts.
|
||||
*/
|
||||
export interface INitroPlugin
|
||||
{
|
||||
/** Unique plugin name */
|
||||
name: string;
|
||||
/** Label shown on the button in room tools */
|
||||
label: string;
|
||||
/** CSS class for the icon (nitro-icon class) */
|
||||
icon?: string;
|
||||
/** Called when the plugin button is clicked */
|
||||
onOpen: () => void;
|
||||
/** Called to close/destroy the plugin UI */
|
||||
onClose?: () => void;
|
||||
/** Called when the plugin is first loaded, receives the Nitro API */
|
||||
onInit?: (api: INitroPluginApi) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* API exposed to external plugins via window.NitroPlugins
|
||||
*/
|
||||
export interface INitroPluginApi
|
||||
{
|
||||
/** Register a plugin */
|
||||
register: (plugin: INitroPlugin) => void;
|
||||
/** Unregister a plugin by name */
|
||||
unregister: (name: string) => void;
|
||||
/** Get all registered plugins */
|
||||
getPlugins: () => INitroPlugin[];
|
||||
/** Fire a Nitro link event (e.g., 'navigator/toggle-room-info') */
|
||||
createLinkEvent: (link: string) => void;
|
||||
/** Get the room engine instance */
|
||||
getRoomEngine: () => ReturnType<typeof GetRoomEngine>;
|
||||
/** Get the current room session */
|
||||
getRoomSession: () => ReturnType<typeof GetRoomSession>;
|
||||
/** Send a message composer to the server */
|
||||
sendMessage: typeof SendMessageComposer;
|
||||
/** Send a chat message to the server (processed as command if starts with ':') */
|
||||
sendChat: (text: string, styleId?: number) => void;
|
||||
/** Send stack height update for a furniture item (objectId, heightInCentimeters) */
|
||||
sendStackHeight: (objectId: number, height: number) => void;
|
||||
/** Take a screenshot of the room and download it as PNG */
|
||||
takeScreenshot: () => Promise<void>;
|
||||
/** Leave the room and go to hotel view */
|
||||
visitDesktop: () => void;
|
||||
/** Create a draggable floating window and return its container element */
|
||||
createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
|
||||
/** Destroy a floating window by id */
|
||||
destroyWindow: (id: string) => void;
|
||||
}
|
||||
|
||||
// Internal plugin storage
|
||||
const _plugins: INitroPlugin[] = [];
|
||||
const _listeners: Array<() => void> = [];
|
||||
|
||||
function notifyListeners()
|
||||
{
|
||||
_listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
const pluginApi: INitroPluginApi = {
|
||||
register(plugin: INitroPlugin)
|
||||
{
|
||||
if (_plugins.some(p => p.name === plugin.name)) return;
|
||||
_plugins.push(plugin);
|
||||
plugin.onInit?.(pluginApi);
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
unregister(name: string)
|
||||
{
|
||||
const index = _plugins.findIndex(p => p.name === name);
|
||||
if (index >= 0)
|
||||
{
|
||||
_plugins[index].onClose?.();
|
||||
_plugins.splice(index, 1);
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
|
||||
getPlugins()
|
||||
{
|
||||
return [..._plugins];
|
||||
},
|
||||
|
||||
createLinkEvent(link: string)
|
||||
{
|
||||
CreateLinkEvent(link);
|
||||
},
|
||||
|
||||
getRoomEngine()
|
||||
{
|
||||
return GetRoomEngine();
|
||||
},
|
||||
|
||||
getRoomSession()
|
||||
{
|
||||
return GetRoomSession();
|
||||
},
|
||||
|
||||
sendMessage: SendMessageComposer,
|
||||
|
||||
sendChat(text: string, styleId: number = 0)
|
||||
{
|
||||
const session = GetRoomSession();
|
||||
if (!session) return;
|
||||
session.sendChatMessage(text, styleId, '');
|
||||
},
|
||||
|
||||
sendStackHeight(objectId: number, height: number)
|
||||
{
|
||||
SendMessageComposer(new FurnitureStackHeightComposer(objectId, height));
|
||||
},
|
||||
|
||||
async takeScreenshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
const session = GetRoomSession();
|
||||
if (!session) return;
|
||||
|
||||
const texture = GetRoomEngine().createTextureFromRoom(session.roomId, 1);
|
||||
if (!texture) return;
|
||||
|
||||
const imageUrl = await TextureUtils.generateImageUrl(texture);
|
||||
if (!imageUrl) return;
|
||||
|
||||
// Download the image
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `room_${session.roomId}_${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn('[NitroPlugins] Screenshot failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
visitDesktop()
|
||||
{
|
||||
VisitDesktop();
|
||||
},
|
||||
|
||||
createWindow(id: string, title: string, width: number): HTMLDivElement | null
|
||||
{
|
||||
// Remove existing window with same id
|
||||
pluginApi.destroyWindow(id);
|
||||
|
||||
// Create overlay container
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = `nitro-plugin-window-${id}`;
|
||||
overlay.style.cssText = `position:fixed;z-index:500;top:50%;left:50%;transform:translate(-50%,-50%)`;
|
||||
|
||||
// Card wrapper
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = `width:${width}px;background:#2c3e50;border:1px solid #283F5D;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.5);overflow:hidden;font-family:Ubuntu,sans-serif`;
|
||||
|
||||
// Header (draggable)
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = `display:flex;align-items:center;justify-content:center;position:relative;min-height:33px;background:linear-gradient(180deg,#3c6a8e 0%,#2a4f6e 100%);cursor:move;user-select:none`;
|
||||
|
||||
const titleEl = document.createElement('span');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.cssText = `color:#fff;font-size:16px;text-shadow:0 1px 2px rgba(0,0,0,0.5)`;
|
||||
|
||||
const closeBtn = document.createElement('div');
|
||||
closeBtn.style.cssText = `position:absolute;right:8px;width:20px;height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;border-radius:50%;background:rgba(255,255,255,0.1)`;
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.addEventListener('click', () => pluginApi.destroyWindow(id));
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
// Make draggable
|
||||
let isDragging = false;
|
||||
let offsetX = 0, offsetY = 0;
|
||||
|
||||
header.addEventListener('mousedown', (e: MouseEvent) =>
|
||||
{
|
||||
isDragging = true;
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
offsetX = e.clientX - rect.left;
|
||||
offsetY = e.clientY - rect.top;
|
||||
overlay.style.transform = 'none';
|
||||
overlay.style.left = rect.left + 'px';
|
||||
overlay.style.top = rect.top + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e: MouseEvent) =>
|
||||
{
|
||||
if (!isDragging) return;
|
||||
overlay.style.left = (e.clientX - offsetX) + 'px';
|
||||
overlay.style.top = (e.clientY - offsetY) + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => { isDragging = false; });
|
||||
|
||||
// Content area
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `padding:16px`;
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(content);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
return content;
|
||||
},
|
||||
|
||||
destroyWindow(id: string)
|
||||
{
|
||||
const existing = document.getElementById(`nitro-plugin-window-${id}`);
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to plugin list changes. Returns unsubscribe function.
|
||||
*/
|
||||
export function subscribePlugins(listener: () => void): () => void
|
||||
{
|
||||
_listeners.push(listener);
|
||||
return () =>
|
||||
{
|
||||
const idx = _listeners.indexOf(listener);
|
||||
if (idx >= 0) _listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function getRegisteredPlugins(): INitroPlugin[]
|
||||
{
|
||||
return [..._plugins];
|
||||
}
|
||||
|
||||
// Expose globally so external scripts can use it
|
||||
(window as any).NitroPlugins = pluginApi;
|
||||
|
||||
export { pluginApi };
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NitroEventType, ReconnectEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useState } from 'react';
|
||||
import { Base, Column, Text } from '../../common';
|
||||
import { useNitroEvent } from '../../hooks';
|
||||
|
||||
export const ReconnectView: FC<{}> = props =>
|
||||
{
|
||||
const [ isReconnecting, setIsReconnecting ] = useState(false);
|
||||
const [ attempt, setAttempt ] = useState(0);
|
||||
const [ maxAttempts, setMaxAttempts ] = useState(0);
|
||||
const [ hasFailed, setHasFailed ] = useState(false);
|
||||
|
||||
const onReconnecting = useCallback((event: ReconnectEvent) =>
|
||||
{
|
||||
setIsReconnecting(true);
|
||||
setHasFailed(false);
|
||||
setAttempt(event.attempt);
|
||||
setMaxAttempts(event.maxAttempts);
|
||||
}, []);
|
||||
|
||||
const onReconnected = useCallback(() =>
|
||||
{
|
||||
// Socket is open but not yet re-authenticated.
|
||||
// Update attempt display but keep the overlay visible until
|
||||
// re-authentication completes (SOCKET_REAUTHENTICATED).
|
||||
setHasFailed(false);
|
||||
}, []);
|
||||
|
||||
const onReauthenticated = useCallback(() =>
|
||||
{
|
||||
// Fully re-authenticated — dismiss the overlay so the room view
|
||||
// (which stayed alive behind the overlay) is visible again.
|
||||
setIsReconnecting(false);
|
||||
setHasFailed(false);
|
||||
setAttempt(0);
|
||||
}, []);
|
||||
|
||||
const onReconnectFailed = useCallback(() =>
|
||||
{
|
||||
setIsReconnecting(false);
|
||||
setHasFailed(true);
|
||||
}, []);
|
||||
|
||||
useNitroEvent<ReconnectEvent>(NitroEventType.SOCKET_RECONNECTING, onReconnecting);
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTED, onReconnected);
|
||||
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, onReauthenticated);
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECT_FAILED, onReconnectFailed);
|
||||
|
||||
const handleReload = useCallback(() =>
|
||||
{
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleGoHome = useCallback(() =>
|
||||
{
|
||||
sessionStorage.removeItem('nitro.session.lastRoomId');
|
||||
sessionStorage.removeItem('nitro.session.lastRoomPassword');
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if(!isReconnecting && !hasFailed) return null;
|
||||
|
||||
return (
|
||||
<Column
|
||||
fullHeight
|
||||
position="fixed"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
>
|
||||
<Column alignItems="center" gap={ 3 } className="p-6 rounded-xl bg-[#1a1a2e]/90 border border-white/10 shadow-2xl max-w-[400px]">
|
||||
{ isReconnecting && (
|
||||
<>
|
||||
<Base className="w-[48px] h-[48px] border-4 border-white/20 border-t-[#4dabf7] rounded-full animate-spin" />
|
||||
<Text fontSizeCustom={ 18 } variant="white" className="text-center font-semibold">
|
||||
Connection lost
|
||||
</Text>
|
||||
<Text fontSizeCustom={ 14 } variant="white" className="text-center opacity-70">
|
||||
Reconnecting to server... (attempt { attempt }/{ maxAttempts })
|
||||
</Text>
|
||||
<Base className="w-full h-[4px] rounded-full bg-white/10 overflow-hidden mt-1">
|
||||
<Base
|
||||
className="h-full bg-[#4dabf7] rounded-full transition-all duration-300"
|
||||
style={ { width: `${ (attempt / maxAttempts) * 100 }%` } }
|
||||
/>
|
||||
</Base>
|
||||
<Text fontSizeCustom={ 12 } variant="white" className="text-center opacity-50">
|
||||
Please wait, your session will be restored automatically
|
||||
</Text>
|
||||
</>
|
||||
) }
|
||||
|
||||
{ hasFailed && (
|
||||
<>
|
||||
<Text fontSizeCustom={ 36 } className="text-center text-red-500">⚠</Text>
|
||||
<Text fontSizeCustom={ 18 } variant="white" className="text-center font-semibold">
|
||||
Connection failed
|
||||
</Text>
|
||||
<Text fontSizeCustom={ 14 } variant="white" className="text-center opacity-70">
|
||||
Unable to reconnect to the server after multiple attempts.
|
||||
</Text>
|
||||
<Base className="mt-2 flex gap-3">
|
||||
<Base
|
||||
className="px-6 py-2 rounded-lg bg-[#4dabf7] text-white font-semibold cursor-pointer hover:bg-[#339af0] transition-colors"
|
||||
onClick={ handleReload }
|
||||
>
|
||||
Reload Page
|
||||
</Base>
|
||||
<Base
|
||||
className="px-6 py-2 rounded-lg bg-white/10 text-white font-semibold cursor-pointer hover:bg-white/20 transition-colors"
|
||||
onClick={ handleGoHome }
|
||||
>
|
||||
Go to Home
|
||||
</Base>
|
||||
</Base>
|
||||
</>
|
||||
) }
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { CommandDefinition } from '../../../../api';
|
||||
|
||||
interface ChatInputCommandSelectorViewProps
|
||||
{
|
||||
commands: CommandDefinition[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: CommandDefinition) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps> = props =>
|
||||
{
|
||||
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!listRef.current) return;
|
||||
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if(selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [ selectedIndex ]);
|
||||
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
{ commands.map((cmd, index) => (
|
||||
<div
|
||||
key={ cmd.key }
|
||||
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
|
||||
onClick={ () => onSelect(cmd) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<span className="font-bold">:{ cmd.key }</span>
|
||||
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
@@ -14,6 +15,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const { selectedUsername = '', floodBlocked = false, floodBlockedSeconds = 0, setIsTyping = null, setIsIdle = null, sendChat = null } = useChatInputWidget();
|
||||
const { roomSession = null } = useRoom();
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
|
||||
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||
@@ -133,6 +135,40 @@ export const ChatInputView: FC<{}> = props =>
|
||||
|
||||
if(document.activeElement !== inputRef.current) setInputFocus();
|
||||
|
||||
if(commandSelectorVisible)
|
||||
{
|
||||
switch(event.key)
|
||||
{
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveUp();
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveDown();
|
||||
return;
|
||||
case 'Tab':
|
||||
event.preventDefault();
|
||||
// fall through
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const selected = selectCurrent();
|
||||
|
||||
if(selected)
|
||||
{
|
||||
event.preventDefault();
|
||||
setChatValue(':' + selected.key + ' ');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
closeCommandSelector();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
switch(event.key)
|
||||
@@ -158,7 +194,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue ]);
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]);
|
||||
|
||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -243,7 +279,14 @@ export const ChatInputView: FC<{}> = props =>
|
||||
|
||||
return (
|
||||
createPortal(
|
||||
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-hidden rounded-lg">
|
||||
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-visible rounded-lg">
|
||||
{ commandSelectorVisible &&
|
||||
<ChatInputCommandSelectorView
|
||||
commands={ filteredCommands }
|
||||
selectedIndex={ selectedIndex }
|
||||
onSelect={ (cmd) => { setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); } }
|
||||
onHover={ setSelectedIndex }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
{ !floodBlocked &&
|
||||
<input ref={ inputRef } className="[font-size:inherit] placeholder-[#6c757d] bg-transparent border-none focus:border-current focus:shadow-none focus:ring-0 " maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChatBubbleMessage } from '../../../../api';
|
||||
import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
|
||||
import { useOnClickChat } from '../../../../hooks';
|
||||
|
||||
interface ChatWidgetMessageViewProps
|
||||
@@ -90,6 +90,27 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
|
||||
) }
|
||||
</div>
|
||||
<div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]">
|
||||
{ chat.prefixEffect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
|
||||
{ chat.prefixText && (() => {
|
||||
const colors = parsePrefixColors(chat.prefixText, chat.prefixColor);
|
||||
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
|
||||
const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF');
|
||||
return (
|
||||
<span className="prefix font-bold mr-1" style={ fxStyle }>
|
||||
{ chat.prefixIcon && <span className="mr-0.5 text-[13px]">{ chat.prefixIcon }</span> }
|
||||
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
|
||||
{'{'}
|
||||
{ hasMultiColor
|
||||
? [ ...chat.prefixText ].map((char, i) => (
|
||||
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(chat.prefixEffect, colors[i]) } }>{ char }</span>
|
||||
))
|
||||
: chat.prefixText
|
||||
}
|
||||
{'}'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})() }
|
||||
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.username }: ` } } />
|
||||
<span className={ `message${ chat.type === 1 ? ' italic text-[#595959]' : '' }` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
|
||||
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
|
||||
|
||||
export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
|
||||
@@ -15,12 +16,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
|
||||
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
|
||||
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
|
||||
const { navigatorData = null } = useNavigator();
|
||||
const { roomSession = null } = useRoom();
|
||||
|
||||
// Subscribe to external plugin changes
|
||||
useEffect(() =>
|
||||
{
|
||||
setPlugins(getRegisteredPlugins());
|
||||
return subscribePlugins(() => setPlugins(getRegisteredPlugins()));
|
||||
}, []);
|
||||
|
||||
const handleToolClick = (action: string, value?: string) => {
|
||||
if (!roomSession) return;
|
||||
|
||||
|
||||
switch (action) {
|
||||
case 'settings':
|
||||
CreateLinkEvent('navigator/toggle-room-info');
|
||||
@@ -114,12 +123,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
|
||||
<div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
|
||||
|
||||
|
||||
{navigatorData.canRate && (
|
||||
<div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} />
|
||||
)}
|
||||
<div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} />
|
||||
<div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} />
|
||||
{plugins.map(plugin => (
|
||||
<div
|
||||
key={plugin.name}
|
||||
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`}
|
||||
title={plugin.label}
|
||||
onClick={() => plugin.onOpen()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<AnimatePresence>
|
||||
@@ -159,4 +176,4 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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] bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 }>
|
||||
<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) &&
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { LocalizeBadgeDescription, LocalizeBadgeName } from '../../api';
|
||||
import { Flex, LayoutBadgeImageView } from '../../common';
|
||||
|
||||
interface BadgeInfoViewProps
|
||||
{
|
||||
badgeCode: string;
|
||||
}
|
||||
|
||||
export const BadgeInfoView: FC<BadgeInfoViewProps> = props =>
|
||||
{
|
||||
const { badgeCode } = props;
|
||||
const [ isHovered, setIsHovered ] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex center
|
||||
className="w-[45px] h-[45px] rounded bg-white/50 relative cursor-pointer"
|
||||
onMouseEnter={ () => setIsHovered(true) }
|
||||
onMouseLeave={ () => setIsHovered(false) }
|
||||
>
|
||||
<LayoutBadgeImageView badgeCode={ badgeCode } />
|
||||
{ isHovered && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 z-50 bg-white text-black rounded shadow-lg py-1 px-2 text-xs w-[180px] pointer-events-none">
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-white rotate-45" />
|
||||
<div className="font-bold mb-0.5">{ LocalizeBadgeName(badgeCode) }</div>
|
||||
<div className="text-gray-600">{ LocalizeBadgeDescription(badgeCode) }</div>
|
||||
</div>
|
||||
) }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const UserContainerView: FC<{
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0">
|
||||
<p className="leading-tight">{ userProfile.username }</p>
|
||||
<p className="leading-tight font-bold">{ userProfile.username }</p>
|
||||
<p className="text-sm italic leading-tight">{ userProfile.motto }</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { CreateLinkEvent, ExtendedProfileChangedMessageEvent, GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
||||
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, NavigatorSearchEvent, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomDataParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { Flex, Grid, LayoutBadgeImageView, Text } from '../../common';
|
||||
import { CreateRoomSession, GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { Flex, Text } from '../../common';
|
||||
import { BadgeInfoView } from './BadgeInfoView';
|
||||
import { useMessageEvent, useNitroEvent } from '../../hooks';
|
||||
import { NitroCard } from '../../layout';
|
||||
import { FriendsContainerView } from './FriendsContainerView';
|
||||
import { GroupsContainerView } from './GroupsContainerView';
|
||||
import { UserContainerView } from './UserContainerView';
|
||||
|
||||
type ProfileTab = 'badge' | 'amici' | 'stanze' | 'gruppi';
|
||||
|
||||
export const UserProfileView: FC<{}> = props =>
|
||||
{
|
||||
const [ userProfile, setUserProfile ] = useState<UserProfileParser>(null);
|
||||
const [ userBadges, setUserBadges ] = useState<string[]>([]);
|
||||
const [ userRelationships, setUserRelationships ] = useState<RelationshipStatusInfoMessageParser>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<ProfileTab>('badge');
|
||||
const [ userRooms, setUserRooms ] = useState<RoomDataParser[]>(null);
|
||||
|
||||
const onClose = () =>
|
||||
{
|
||||
setUserProfile(null);
|
||||
setUserBadges([]);
|
||||
setUserRelationships(null);
|
||||
setActiveTab('badge');
|
||||
setUserRooms(null);
|
||||
};
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
@@ -28,6 +35,16 @@ export const UserProfileView: FC<{}> = props =>
|
||||
GetUserProfile(userProfile.id);
|
||||
};
|
||||
|
||||
const onTabClick = (tab: ProfileTab) =>
|
||||
{
|
||||
setActiveTab(tab);
|
||||
|
||||
if(tab === 'stanze' && !userRooms && userProfile)
|
||||
{
|
||||
SendMessageComposer(new NavigatorSearchComposer('hotel_view', `owner:${ userProfile.username }`));
|
||||
}
|
||||
};
|
||||
|
||||
useMessageEvent<UserCurrentBadgesEvent>(UserCurrentBadgesEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -63,6 +80,8 @@ export const UserProfileView: FC<{}> = props =>
|
||||
{
|
||||
setUserBadges([]);
|
||||
setUserRelationships(null);
|
||||
setActiveTab('badge');
|
||||
setUserRooms(null);
|
||||
}
|
||||
|
||||
SendMessageComposer(new UserCurrentBadgesComposer(parser.id));
|
||||
@@ -78,6 +97,28 @@ export const UserProfileView: FC<{}> = props =>
|
||||
GetUserProfile(parser.userId);
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
|
||||
{
|
||||
if(!userProfile || activeTab !== 'stanze') return;
|
||||
|
||||
const parser = event.getParser();
|
||||
const result = parser.result;
|
||||
|
||||
if(!result) return;
|
||||
|
||||
const rooms: RoomDataParser[] = [];
|
||||
|
||||
for(const resultList of result.results)
|
||||
{
|
||||
if(resultList.rooms && resultList.rooms.length)
|
||||
{
|
||||
for(const room of resultList.rooms) rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
setUserRooms(rooms);
|
||||
});
|
||||
|
||||
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.SELECTED, event =>
|
||||
{
|
||||
if(!userProfile) return;
|
||||
@@ -98,27 +139,79 @@ export const UserProfileView: FC<{}> = props =>
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('extendedprofile.caption') }
|
||||
onCloseClick={ onClose } />
|
||||
<NitroCard.Content
|
||||
className="overflow-hidden">
|
||||
<Grid fullHeight={ false } gap={ 2 }>
|
||||
<div className="flex flex-col col-span-7 gap-1 border-r border-r-gray pe-2">
|
||||
<UserContainerView userProfile={ userProfile } />
|
||||
<div className="flex items-center justify-center w-full gap-3 p-2 rounded bg-muted">
|
||||
{ userBadges && (userBadges.length > 0) && userBadges.map((badge, index) => <LayoutBadgeImageView key={ badge } badgeCode={ badge } />) }
|
||||
<NitroCard.Content className="overflow-hidden !p-0 flex flex-col">
|
||||
<div className="p-2">
|
||||
<UserContainerView userProfile={ userProfile } />
|
||||
</div>
|
||||
<NitroCard.Tabs>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
|
||||
Badge
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
|
||||
Amici
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
|
||||
Stanze
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
|
||||
Gruppi
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{ activeTab === 'badge' && (
|
||||
<div className="flex flex-wrap content-start gap-2 p-2 rounded bg-muted h-full">
|
||||
{ userBadges && (userBadges.length > 0)
|
||||
? userBadges.map((badge, index) => (
|
||||
<BadgeInfoView key={ badge + index } badgeCode={ badge } />
|
||||
))
|
||||
: (
|
||||
<Flex center fullWidth className="h-full">
|
||||
<Text small variant="muted">Nessun badge da mostrare</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col col-span-5">
|
||||
{ userRelationships &&
|
||||
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } /> }
|
||||
</div>
|
||||
</Grid>
|
||||
<Flex alignItems="center" className="px-2 py-1 border-t border-b border-t-gray border-b-gray">
|
||||
<Flex alignItems="center" gap={ 1 } onClick={ event => CreateLinkEvent(`navigator/search/hotel_view/owner:${ userProfile.username }`) }>
|
||||
<i className="nitro-icon icon-rooms" />
|
||||
<Text bold pointer underline>{ LocalizeText('extendedprofile.rooms') }</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
|
||||
) }
|
||||
{ activeTab === 'amici' && (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{ userRelationships ? (
|
||||
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
|
||||
) : (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Caricamento...</Text>
|
||||
</Flex>
|
||||
) }
|
||||
</div>
|
||||
) }
|
||||
{ activeTab === 'stanze' && (
|
||||
<div className="flex flex-col gap-1 h-full">
|
||||
{ !userRooms && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Caricamento stanze...</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length === 0 && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Nessuna stanza trovata</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
|
||||
<Flex key={ room.roomId } alignItems="center" gap={ 2 } className="px-2 py-1.5 rounded bg-white/50 cursor-pointer hover:bg-white/80" onClick={ () => CreateRoomSession(room.roomId) }>
|
||||
<div className="flex flex-col min-w-0 grow">
|
||||
<Text bold small truncate>{ room.roomName }</Text>
|
||||
{ room.description && <Text small truncate variant="muted">{ room.description }</Text> }
|
||||
</div>
|
||||
<Text small variant="muted" className="shrink-0">{ room.userCount }/{ room.maxUserCount }</Text>
|
||||
</Flex>
|
||||
)) }
|
||||
</div>
|
||||
) }
|
||||
{ activeTab === 'gruppi' && (
|
||||
<div className="h-full">
|
||||
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
|
||||
.alertView_nitro-coolui-logo {
|
||||
width: 150px;
|
||||
height: 78px;
|
||||
height: 73px;
|
||||
position: relative;
|
||||
background-image: url("@/assets/images/notifications/coolui.png");
|
||||
background-image: url("@/assets/images/notifications/nitro_v3.png");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useFurniEditor';
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface FurniItem
|
||||
{
|
||||
id: number;
|
||||
spriteId: number;
|
||||
itemName: string;
|
||||
publicName: string;
|
||||
type: string;
|
||||
width: number;
|
||||
length: number;
|
||||
stackHeight: number;
|
||||
allowStack: boolean;
|
||||
allowWalk: boolean;
|
||||
allowSit: boolean;
|
||||
allowLay: boolean;
|
||||
interactionType: string;
|
||||
interactionModesCount: number;
|
||||
}
|
||||
|
||||
export interface FurniDetail extends FurniItem
|
||||
{
|
||||
allowGift: boolean;
|
||||
allowTrade: boolean;
|
||||
allowRecycle: boolean;
|
||||
allowMarketplaceSell: boolean;
|
||||
allowInventoryStack: boolean;
|
||||
vendingIds: string;
|
||||
customparams: string;
|
||||
effectIdMale: number;
|
||||
effectIdFemale: number;
|
||||
clothingOnWalk: string;
|
||||
multiheight: string;
|
||||
description: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
export interface CatalogRef
|
||||
{
|
||||
id: number;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
pageId: number;
|
||||
pageName: string;
|
||||
}
|
||||
|
||||
const API_BASE = '/api/admin/furni-editor';
|
||||
|
||||
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T>
|
||||
{
|
||||
const res = await fetch(url, { credentials: 'include', ...options });
|
||||
const data = await res.json();
|
||||
|
||||
if(!res.ok || data.error) throw new Error(data.error || 'API error');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useFurniEditor = () =>
|
||||
{
|
||||
const [ items, setItems ] = useState<FurniItem[]>([]);
|
||||
const [ total, setTotal ] = useState(0);
|
||||
const [ page, setPage ] = useState(1);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ selectedItem, setSelectedItem ] = useState<FurniDetail | null>(null);
|
||||
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
|
||||
const [ interactions, setInteractions ] = useState<string[]>([]);
|
||||
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const searchItems = useCallback(async (query: string, type: string, pg: number) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) });
|
||||
|
||||
if(type) params.set('type', type);
|
||||
|
||||
const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`);
|
||||
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (id: number): Promise<boolean> =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record<string, unknown> | null }>(`${ API_BASE }/detail?id=${ id }`);
|
||||
|
||||
setSelectedItem(data.item);
|
||||
setCatalogItems(data.catalogItems);
|
||||
setFurniDataEntry(data.furniDataEntry);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: number, fields: Record<string, unknown>) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
await apiFetch(`${ API_BASE }/update?id=${ id }`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields)
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (fields: Record<string, unknown>) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ id: number }>(`${ API_BASE }`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields)
|
||||
});
|
||||
|
||||
return data.id;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: number) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' });
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadInteractions = useCallback(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
|
||||
|
||||
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
|
||||
}
|
||||
catch {}
|
||||
}, []);
|
||||
|
||||
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`);
|
||||
|
||||
return await loadDetail(data.id);
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [ loadDetail ]);
|
||||
|
||||
return {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
|
||||
};
|
||||
};
|
||||
@@ -2,5 +2,6 @@ export * from './useInventoryBadges';
|
||||
export * from './useInventoryBots';
|
||||
export * from './useInventoryFurni';
|
||||
export * from './useInventoryPets';
|
||||
export * from './useInventoryPrefixes';
|
||||
export * from './useInventoryTrade';
|
||||
export * from './useInventoryUnseenTracker';
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useSharedVisibility } from '../useSharedVisibility';
|
||||
import { useInventoryUnseenTracker } from './useInventoryUnseenTracker';
|
||||
|
||||
const useInventoryPrefixesState = () =>
|
||||
{
|
||||
const [ needsUpdate, setNeedsUpdate ] = useState(true);
|
||||
const [ prefixes, setPrefixes ] = useState<IPrefixItem[]>([]);
|
||||
const [ activePrefix, setActivePrefix ] = useState<IPrefixItem | null>(null);
|
||||
const [ selectedPrefix, setSelectedPrefix ] = useState<IPrefixItem | null>(null);
|
||||
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
|
||||
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
||||
|
||||
useMessageEvent<UserPrefixesEvent>(UserPrefixesEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const newPrefixes: IPrefixItem[] = parser.prefixes.map(p => ({
|
||||
id: p.id,
|
||||
text: p.text,
|
||||
color: p.color,
|
||||
icon: p.icon || '',
|
||||
effect: p.effect || '',
|
||||
active: p.active
|
||||
}));
|
||||
|
||||
setPrefixes(newPrefixes);
|
||||
|
||||
const active = newPrefixes.find(p => p.active) || null;
|
||||
setActivePrefix(active);
|
||||
});
|
||||
|
||||
useMessageEvent<PrefixReceivedEvent>(PrefixReceivedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const newPrefix: IPrefixItem = {
|
||||
id: parser.id,
|
||||
text: parser.text,
|
||||
color: parser.color,
|
||||
icon: parser.icon || '',
|
||||
effect: parser.effect || '',
|
||||
active: false
|
||||
};
|
||||
|
||||
setPrefixes(prevValue => [ newPrefix, ...prevValue ]);
|
||||
});
|
||||
|
||||
useMessageEvent<ActivePrefixUpdatedEvent>(ActivePrefixUpdatedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setPrefixes(prevValue =>
|
||||
{
|
||||
return prevValue.map(p => ({
|
||||
...p,
|
||||
active: p.id === parser.prefixId
|
||||
}));
|
||||
});
|
||||
|
||||
if(parser.prefixId === 0)
|
||||
{
|
||||
setActivePrefix(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
setActivePrefix(prev =>
|
||||
{
|
||||
const found = prefixes.find(p => p.id === parser.prefixId);
|
||||
if(found) return { ...found, active: true };
|
||||
return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', active: true };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const activatePrefix = (prefixId: number) =>
|
||||
{
|
||||
SendMessageComposer(new SetActivePrefixComposer(prefixId));
|
||||
};
|
||||
|
||||
const deactivatePrefix = () =>
|
||||
{
|
||||
SendMessageComposer(new SetActivePrefixComposer(0));
|
||||
};
|
||||
|
||||
const deletePrefix = (prefixId: number) =>
|
||||
{
|
||||
SendMessageComposer(new DeletePrefixComposer(prefixId));
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!prefixes || !prefixes.length) return;
|
||||
|
||||
setSelectedPrefix(prevValue =>
|
||||
{
|
||||
if(prevValue && prefixes.find(p => p.id === prevValue.id)) return prevValue;
|
||||
return prefixes[0];
|
||||
});
|
||||
}, [ prefixes ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
return () =>
|
||||
{
|
||||
resetCategory(UnseenItemCategory.PREFIX);
|
||||
};
|
||||
}, [ isVisible, resetCategory ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible || !needsUpdate) return;
|
||||
|
||||
SendMessageComposer(new RequestPrefixesComposer());
|
||||
|
||||
setNeedsUpdate(false);
|
||||
}, [ isVisible, needsUpdate ]);
|
||||
|
||||
return { prefixes, activePrefix, selectedPrefix, setSelectedPrefix, activatePrefix, deactivatePrefix, deletePrefix, activate, deactivate };
|
||||
};
|
||||
|
||||
export const useInventoryPrefixes = () => useBetween(useInventoryPrefixesState);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { CreateRoomSession, DoorStateType, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNotification } from '../notification';
|
||||
|
||||
const useNavigatorState = () =>
|
||||
@@ -373,6 +373,15 @@ const useNavigatorState = () =>
|
||||
CreateRoomSession(parser.roomId);
|
||||
});
|
||||
|
||||
// When reconnection starts, reset settingsReceived so the login sequence's
|
||||
// NavigatorHomeRoomEvent is treated as a fresh login. Without this, the
|
||||
// prevSettingsReceived check blocks home room navigation after reconnection,
|
||||
// leaving the user stuck on hotel view.
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () =>
|
||||
{
|
||||
setNavigatorData(prevValue => ({ ...prevValue, settingsReceived: false }));
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -397,6 +406,10 @@ const useNavigatorState = () =>
|
||||
return;
|
||||
}
|
||||
|
||||
// If a room session was already restored (from a network disconnect reload),
|
||||
// skip the normal home room navigation to avoid overriding it.
|
||||
if(GetRoomSessionManager().viewerSession) return;
|
||||
|
||||
let forwardType = -1;
|
||||
let forwardId = -1;
|
||||
|
||||
@@ -456,6 +469,11 @@ const useNavigatorState = () =>
|
||||
break;
|
||||
}
|
||||
|
||||
// During reconnection, don't navigate to desktop — the reconnection guard
|
||||
// will handle retrying or cleaning up. Calling VisitDesktop here would
|
||||
// remove the session from the map and send the user to hotel view.
|
||||
if(GetRoomSessionManager().isReconnecting) return;
|
||||
|
||||
VisitDesktop();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './furniture';
|
||||
export * from './useAvatarInfoWidget';
|
||||
export * from './useChatCommandSelector';
|
||||
export * from './useChatInputWidget';
|
||||
export * from './useChatWidget';
|
||||
export * from './useDoorbellWidget';
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CommandDefinition } from '../../../api';
|
||||
import { useMessageEvent } from '../../events';
|
||||
|
||||
const CLIENT_COMMANDS: CommandDefinition[] = [
|
||||
// Effetti stanza
|
||||
{ key: 'shake', description: 'Scuoti la stanza' },
|
||||
{ key: 'rotate', description: 'Ruota la stanza' },
|
||||
{ key: 'zoom', description: 'Zoom stanza' },
|
||||
{ key: 'flip', description: 'Reset zoom' },
|
||||
{ key: 'iddqd', description: 'Reset zoom' },
|
||||
{ key: 'screenshot', description: 'Screenshot stanza' },
|
||||
{ key: 'togglefps', description: 'Toggle FPS' },
|
||||
// Espressioni
|
||||
{ key: 'd', description: 'Ridi (VIP)' },
|
||||
{ key: 'kiss', description: 'Manda un bacio (VIP)' },
|
||||
{ key: 'jump', description: 'Salta (VIP)' },
|
||||
{ key: 'idle', description: 'Vai in idle' },
|
||||
{ key: 'sign', description: 'Mostra cartello' },
|
||||
// Gestione stanza
|
||||
{ key: 'furni', description: 'Furni chooser' },
|
||||
{ key: 'chooser', description: 'User chooser' },
|
||||
{ key: 'floor', description: 'Floor editor' },
|
||||
{ key: 'bcfloor', description: 'Floor editor' },
|
||||
{ key: 'pickall', description: 'Raccogli tutti i furni' },
|
||||
{ key: 'ejectall', description: 'Espelli tutti i furni' },
|
||||
{ key: 'settings', description: 'Impostazioni stanza' },
|
||||
// Info
|
||||
{ key: 'client', description: 'Info client' },
|
||||
{ key: 'nitro', description: 'Info client' },
|
||||
];
|
||||
|
||||
// Module-level cache: cattura i comandi dal server anche prima che React monti
|
||||
let cachedServerCommands: CommandDefinition[] = [];
|
||||
let globalListenerRegistered = false;
|
||||
|
||||
function ensureGlobalListener(): void
|
||||
{
|
||||
if(globalListenerRegistered) return;
|
||||
globalListenerRegistered = true;
|
||||
|
||||
try
|
||||
{
|
||||
const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
cachedServerCommands = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description }));
|
||||
});
|
||||
|
||||
GetCommunication().registerMessageEvent(event);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
// Communication not ready yet, will retry on hook mount
|
||||
globalListenerRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to register immediately at module load
|
||||
ensureGlobalListener();
|
||||
|
||||
export const useChatCommandSelector = (chatValue: string) =>
|
||||
{
|
||||
const [ serverCommands, setServerCommands ] = useState<CommandDefinition[]>(cachedServerCommands);
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
const [ dismissed, setDismissed ] = useState(false);
|
||||
|
||||
// Ensure global listener is registered
|
||||
useEffect(() =>
|
||||
{
|
||||
ensureGlobalListener();
|
||||
|
||||
// If cache already has data (from login), use it
|
||||
if(cachedServerCommands.length > 0 && serverCommands.length === 0)
|
||||
{
|
||||
setServerCommands(cachedServerCommands);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Also listen via React hook for any future updates (e.g. rank change)
|
||||
useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const cmds = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description }));
|
||||
cachedServerCommands = cmds;
|
||||
setServerCommands(cmds);
|
||||
});
|
||||
|
||||
const allCommands = useMemo(() =>
|
||||
{
|
||||
const merged = [ ...serverCommands ];
|
||||
|
||||
for(const clientCmd of CLIENT_COMMANDS)
|
||||
{
|
||||
if(!merged.some(cmd => cmd.key === clientCmd.key))
|
||||
{
|
||||
merged.push(clientCmd);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}, [ serverCommands ]);
|
||||
|
||||
const filterText = useMemo(() =>
|
||||
{
|
||||
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return '';
|
||||
|
||||
return chatValue.slice(1).toLowerCase();
|
||||
}, [ chatValue ]);
|
||||
|
||||
const filteredCommands = useMemo(() =>
|
||||
{
|
||||
if(!filterText && !chatValue.startsWith(':')) return [];
|
||||
|
||||
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
|
||||
}, [ allCommands, filterText, chatValue ]);
|
||||
|
||||
const isVisible = useMemo(() =>
|
||||
{
|
||||
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
|
||||
}, [ chatValue, filteredCommands, dismissed ]);
|
||||
|
||||
const moveUp = useCallback(() =>
|
||||
{
|
||||
setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1));
|
||||
}, [ filteredCommands.length ]);
|
||||
|
||||
const moveDown = useCallback(() =>
|
||||
{
|
||||
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
|
||||
}, [ filteredCommands.length ]);
|
||||
|
||||
const selectCurrent = useCallback((): CommandDefinition | null =>
|
||||
{
|
||||
if(selectedIndex >= 0 && selectedIndex < filteredCommands.length)
|
||||
{
|
||||
return filteredCommands[selectedIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ selectedIndex, filteredCommands ]);
|
||||
|
||||
const close = useCallback(() =>
|
||||
{
|
||||
setDismissed(true);
|
||||
}, []);
|
||||
|
||||
// Reset dismissed when chatValue changes to a new command start
|
||||
useEffect(() =>
|
||||
{
|
||||
if(chatValue === ':' || chatValue === '') setDismissed(false);
|
||||
}, [ chatValue ]);
|
||||
|
||||
// Reset selectedIndex when filtered list changes
|
||||
useEffect(() =>
|
||||
{
|
||||
setSelectedIndex(0);
|
||||
}, [ filterText ]);
|
||||
|
||||
return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
|
||||
};
|
||||
@@ -116,12 +116,22 @@ const useChatInputWidgetState = () =>
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const image = new Image();
|
||||
try
|
||||
{
|
||||
const imageUrl = await TextureUtils.generateImageUrl(texture);
|
||||
if (!imageUrl) return;
|
||||
|
||||
image.src = await TextureUtils.generateImageUrl(texture);
|
||||
|
||||
const newWindow = window.open('');
|
||||
newWindow.document.write(image.outerHTML);
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn('[Screenshot] Failed:', e);
|
||||
}
|
||||
})();
|
||||
return null;
|
||||
case ':pickall':
|
||||
|
||||
@@ -149,6 +149,11 @@ const useChatWidgetState = () =>
|
||||
imageUrl,
|
||||
color);
|
||||
|
||||
chatMessage.prefixText = event.prefixText || '';
|
||||
chatMessage.prefixColor = event.prefixColor || '';
|
||||
chatMessage.prefixIcon = event.prefixIcon || '';
|
||||
chatMessage.prefixEffect = event.prefixEffect || '';
|
||||
|
||||
setChatMessages(prevValue =>
|
||||
{
|
||||
const newValue = [ ...prevValue, chatMessage ];
|
||||
|
||||
+36
-2
@@ -3,12 +3,46 @@ import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const renderer3 = resolve(__dirname, '..', 'renderer3');
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [ react(), tsconfigPaths() ],
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
resolve(__dirname), // nitro3 itself
|
||||
renderer3, // renderer3 source + packages
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'~': resolve(__dirname, 'node_modules')
|
||||
'~': resolve(__dirname, 'node_modules'),
|
||||
// Renderer3 workspace packages → point to their src/index.ts
|
||||
'@nitrots/api': resolve(renderer3, 'packages/api/src/index.ts'),
|
||||
'@nitrots/assets': resolve(renderer3, 'packages/assets/src/index.ts'),
|
||||
'@nitrots/avatar': resolve(renderer3, 'packages/avatar/src/index.ts'),
|
||||
'@nitrots/camera': resolve(renderer3, 'packages/camera/src/index.ts'),
|
||||
'@nitrots/communication': resolve(renderer3, 'packages/communication/src/index.ts'),
|
||||
'@nitrots/configuration': resolve(renderer3, 'packages/configuration/src/index.ts'),
|
||||
'@nitrots/events': resolve(renderer3, 'packages/events/src/index.ts'),
|
||||
'@nitrots/localization': resolve(renderer3, 'packages/localization/src/index.ts'),
|
||||
'@nitrots/room': resolve(renderer3, 'packages/room/src/index.ts'),
|
||||
'@nitrots/session': resolve(renderer3, 'packages/session/src/index.ts'),
|
||||
'@nitrots/sound': resolve(renderer3, 'packages/sound/src/index.ts'),
|
||||
'@nitrots/utils/src': resolve(renderer3, 'packages/utils/src'),
|
||||
'@nitrots/utils': resolve(renderer3, 'packages/utils/src/index.ts'),
|
||||
// Resolve pixi.js and pixi-filters from renderer3's node_modules
|
||||
'pixi.js': resolve(renderer3, 'node_modules/pixi.js'),
|
||||
'pixi-filters': resolve(renderer3, 'node_modules/pixi-filters'),
|
||||
'howler': resolve(renderer3, 'node_modules/howler'),
|
||||
}
|
||||
},
|
||||
build: {
|
||||
@@ -21,7 +55,7 @@ export default defineConfig({
|
||||
{
|
||||
if(id.includes('node_modules'))
|
||||
{
|
||||
if(id.includes('@nitrots/nitro-renderer')) return 'nitro-renderer';
|
||||
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
|
||||
|
||||
return 'vendor';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user