Merge pull request #171 from medievalshell/Dev

feat: rare values panel + fortune wheel UI + prize editor + feat: soundboard (room-scoped custom audio pads) + feat: hotel radio widget (client-side, multi-station)
This commit is contained in:
DuckieTM
2026-05-28 13:49:52 +02:00
committed by GitHub
32 changed files with 1338 additions and 25 deletions
+44 -11
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- Dev
- 'feat/**'
pull_request:
workflow_dispatch:
@@ -71,19 +72,51 @@ jobs:
VAR_REPO: ${{ vars.RENDERER_REPO }}
VAR_REF: ${{ vars.RENDERER_REF }}
run: |
set -euo pipefail
REPO="${{ github.event.inputs.renderer_repo }}"
REF="${{ github.event.inputs.renderer_ref }}"
# Branch context of the *client* build.
case "${GITHUB_EVENT_NAME}" in
pull_request) CTX="${GITHUB_BASE_REF}" ;;
*) CTX="${GITHUB_REF_NAME}" ;;
esac
if [ -z "$REPO" ] || [ -z "$REF" ]; then
# For PRs we usually pair against the base ref, but the HK
# PR specifically needs to pair against its OWN head ref —
# the renderer companion PR is named identically
# (`feat/housekeeping-packets`) and lives on the same fork.
case "${GITHUB_EVENT_NAME}" in
pull_request)
if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then
CTX="${GITHUB_HEAD_REF}"
else
CTX="${GITHUB_BASE_REF}"
fi
;;
*)
CTX="${GITHUB_REF_NAME}"
;;
esac
# Upstream fallback ref depends on client context.
if [ "$CTX" = "main" ]; then
DEFAULT_REF="main"
else
DEFAULT_REF="Dev"
case "$CTX" in
main)
AUTO_REPO="duckietm/Nitro_Render_V3"
AUTO_REF="main"
;;
Dev)
# The client `Dev` branch carries the custom features
# (rare values, fortune wheel, soundboard); they live on
# the matching renderer fork branch, not upstream.
AUTO_REPO="medievalshell/Nitro_Render_V3"
AUTO_REF="dev"
;;
feat/housekeeping-panel)
AUTO_REPO="simoleo89/Nitro_Render_V3"
AUTO_REF="feat/housekeeping-packets"
;;
*)
AUTO_REPO="duckietm/Nitro_Render_V3"
AUTO_REF="Dev"
;;
esac
[ -z "$REPO" ] && REPO="$AUTO_REPO"
[ -z "$REF" ] && REF="$AUTO_REF"
fi
# Precedence: dispatch input → repo variable → upstream default.
@@ -0,0 +1,19 @@
{
// Hotel radio stations. Copy this file to `radio-stations.json5` (without the
// .example suffix) and add your own stations — each entry is just a streaming
// URL the client plays with the HTML5 Audio API. JSON5: // comments and
// trailing commas are allowed. Add / remove / reorder freely, no rebuild needed.
//
// Fields:
// id - unique key (string)
// name - label shown in the radio widget
// genre - optional subtitle
// url - the audio stream URL (mp3/aac/ogg Icecast or Shoutcast)
// logo - optional image URL shown next to the station
//
// The first station autostarts (quietly) on client load. The list can later
// be moved to the CMS (website_settings) so it's editable from the admin.
stations: [
// { id: 'mystation', name: 'My Station', genre: 'Hotel Radio', url: 'https://your-stream-host/stream' },
],
}
@@ -0,0 +1,6 @@
{
"rarevalues.title": "Rare Values",
"rarevalues.loading": "Loading values…",
"rarevalues.empty": "No rares found",
"rarevalues.infostand.label": "Value:"
}
@@ -0,0 +1,6 @@
{
"rarevalues.title": "Valore Rari",
"rarevalues.loading": "Caricamento valori…",
"rarevalues.empty": "Nessun raro trovato",
"rarevalues.infostand.label": "Valore:"
}
@@ -0,0 +1,20 @@
{
// Soundboard pads loaded from a file — used as a FALLBACK when the server
// (soundboard_sounds DB table) returns no sounds. Copy this file to
// `soundboard-sounds.json5` (without .example) and add your sounds. JSON5:
// // comments and trailing commas are allowed.
//
// Fields:
// id - unique number (pad key)
// name - label shown on the pad
// url - audio file URL (mp3/ogg/wav). Relative urls resolve against
// `soundboard.url.prefix` (falls back to `asset.url`).
//
// NOTE: file-defined pads play LOCALLY for the person who clicks them. To
// broadcast a pad to everyone in the room, the sound must exist server-side
// in the soundboard_sounds table (same flow as custom badges). The file is
// the no-DB / offline option; the DB is the multiplayer one.
sounds: [
// { id: 1, name: 'Airhorn', url: 'https://your-host/airhorn.mp3' },
],
}
@@ -0,0 +1,17 @@
{
"wheel.title": "Fortune Wheel",
"wheel.free.today": "You have %count% free spins today!",
"wheel.extra": "Extra spins: %count%",
"wheel.spin": "SPIN",
"wheel.buy": "Buy spin",
"wheel.winners": "Latest winners",
"wheel.winners.empty": "No winners yet",
"soundboard.title": "Soundboard",
"soundboard.empty": "No sounds available",
"soundboard.lastplayed": "Played by %user%",
"soundboard.room.setting.desc": "Let people in this room play sound effects",
"radio.title": "Radio",
"radio.empty": "No stations",
"radio.error": "Couldn't load stations",
"radio.stop": "Stop"
}
@@ -0,0 +1,17 @@
{
"wheel.title": "Ruota della Fortuna",
"wheel.free.today": "Hai %count% giri gratis oggi!",
"wheel.extra": "Giri extra: %count%",
"wheel.spin": "GIRA",
"wheel.buy": "Compra giro",
"wheel.winners": "Ultimi vincitori",
"wheel.winners.empty": "Ancora nessun vincitore",
"soundboard.title": "Soundboard",
"soundboard.empty": "Nessun suono disponibile",
"soundboard.lastplayed": "Suonato da %user%",
"soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza",
"radio.title": "Radio",
"radio.empty": "Nessuna stazione",
"radio.error": "Impossibile caricare le stazioni",
"radio.stop": "Stop"
}
+1
View File
@@ -27,6 +27,7 @@ export * from './purse';
export * from './room';
export * from './room/events';
export * from './room/widgets';
export * from './soundboard';
export * from './ui-settings';
export * from './user';
export * from './utils';
@@ -0,0 +1,7 @@
let _soundboardEnabled = false;
export const getSoundboardRoomEnabled = () => _soundboardEnabled;
export const setSoundboardRoomEnabled = (enabled: boolean) =>
{
_soundboardEnabled = enabled;
};
+1
View File
@@ -0,0 +1 @@
export * from './SoundboardRoomState';
+8
View File
@@ -24,6 +24,10 @@ import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView';
import { HotelView } from './hotel-view/HotelView';
import { HousekeepingView } from './housekeeping/HousekeepingView';
import { RareValuesView } from './rare-values/RareValuesView';
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
import { SoundboardView } from './soundboard/SoundboardView';
import { RadioView } from './radio/RadioView';
import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
@@ -176,6 +180,10 @@ export const MainView: FC<{}> = props =>
<GameCenterView />
<FloorplanEditorView />
<FurniEditorView />
<RareValuesView />
<FortuneWheelView />
<SoundboardView />
<RadioView />
<ExternalPluginLoader />
</>
);
@@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () =>
expect(composer.thicknessFloor).toBe(1);
});
it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () =>
it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () =>
{
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
@@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () =>
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
// Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x'
expect(composer.tilemap.split(/\r/)[0]).toBe('0x');
// Occupied is purely informational: the tile stays walkable and the
// saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x').
expect(composer.tilemap.split(/\r/)[0]).toBe('00');
});
it('RoomEngineEvent.DISPOSED hides the editor', () =>
@@ -1,4 +1,4 @@
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
@@ -50,8 +50,16 @@ export const FloorplanEditorView: FC = () =>
{
if(!isVisible) return;
SendMessageComposer(new GetRoomEntryTileMessageComposer());
// Ask the server which tiles currently hold furniture so they can be
// shown (and protected from editing) in the grid.
SendMessageComposer(new GetOccupiedTilesMessageComposer());
}, [ isVisible ]);
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
{
dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap });
});
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
{
const parser = event.getParser();
@@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () =>
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next).toBe(start);
});
it('is a no-op on occupied tiles', () =>
{
const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next).toBe(start);
});
});
describe('reducer — SET_OCCUPIED_TILES', () =>
{
it('marks tiles occupied per the map without touching h or blocked', () =>
{
const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]);
const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] });
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true });
// already-unoccupied tile is left untouched (no spurious occupied key)
expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true });
});
it('does not block editing of non-occupied tiles', () =>
{
const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]);
const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] });
// col 0 (not occupied) can still be painted; col 1 (occupied) cannot
const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' });
expect(painted.tiles[0][0].h).toBe(5);
const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' });
expect(blocked).toBe(occupied);
});
});
describe('reducer — SET_DOOR', () =>
@@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
{
const row = clamp64(action.row);
const col = clamp64(action.col);
if(state.tiles[row]?.[col]?.occupied) return state;
const tiles = ensureRect(state.tiles, row + 1, col + 1);
const target = { h: clampHeight(action.h), blocked: false };
const next = setTile(tiles, row, col, target);
@@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
if(current.occupied) return state;
const target = { h: current.h, blocked: true };
const next = setTile(state.tiles, row, col, target);
if(next === state.tiles) return state;
@@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
if(current.blocked) return state;
if(current.blocked || current.occupied) return state;
const newH = clampHeight(current.h + action.delta);
if(newH === current.h) return state;
const next = setTile(state.tiles, row, col, { h: newH, blocked: false });
@@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
if(value === state.wallHeight) return state;
return { ...state, wallHeight: value };
}
case 'SET_OCCUPIED_TILES':
{
// Mark tiles that currently hold furniture (server-reported). Leaves
// height + blocked untouched so it never alters the saved tilemap.
const map = action.map ?? [];
let changed = false;
const tiles = state.tiles.map((r, ri) => r.map((tile, ci) =>
{
const occ = !!map[ri]?.[ci];
if((tile.occupied ?? false) === occ) return tile;
changed = true;
return { ...tile, occupied: occ };
}));
if(!changed) return state;
return { ...state, tiles };
}
case 'BRUSH_SET':
{
const h = action.h ?? state.brush.h;
@@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = parseInt(cStr, 10);
const current = tiles[row]?.[col];
if(!current) continue;
if(current.occupied) continue;
switch(state.brush.action)
{
@@ -1,4 +1,7 @@
export type Tile = { h: number; blocked: boolean };
// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that
// currently has furniture on it (reported by the server); kept separate so it
// stays visible and is NOT voided on save — it just can't be edited.
export type Tile = { h: number; blocked: boolean; occupied?: boolean };
export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type ThicknessLevel = 0 | 1 | 2 | 3;
@@ -39,6 +42,7 @@ export type FloorplanAction =
| { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource }
| { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource }
| { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource }
| { type: 'SET_OCCUPIED_TILES'; map: boolean[][] }
| { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode }
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
| { type: 'SELECT_ALL' }
@@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () =>
const dispatch = vi.fn();
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
const svg = container.querySelector('svg') as SVGSVGElement;
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
// usePointerToTile resolves the tile via document.elementFromPoint first
// (the tile polygons carry data-row/data-col). jsdom returns null and has
// no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon.
const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element;
// jsdom's document has no elementFromPoint at all — define it for this test.
const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint;
(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly;
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
expect(dispatch).toHaveBeenCalled();
const call = dispatch.mock.calls[0][0];
expect(call.type).toBe('PAINT_TILE');
(document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp;
});
it('zoom in/out buttons adjust the viewBox', () =>
@@ -1,4 +1,4 @@
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { Dispatch, FC, PointerEvent as ReactPointerEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa';
import { FloorplanAction, FloorplanState } from '../state/types';
import { FloorplanTile } from './FloorplanTile';
@@ -140,7 +140,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
const quarter = TILE_SIZE / 4;
const tilesRows = state.tiles.length;
const tilesCols = state.tiles[0]?.length ?? 0;
const out: JSX.Element[] = [];
const out: ReactElement[] = [];
for(const key of state.selection)
{
const [ rStr, cStr ] = key.split(',');
@@ -104,6 +104,17 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH
stroke="#222"
strokeWidth={ 0.5 }
/>
{ tile.occupied && (
<polygon
data-testid="occupied-marker"
points={ points }
fill="rgba(249, 115, 22, 0.40)"
stroke="#f97316"
strokeWidth={ 1 }
strokeDasharray="2 2"
pointerEvents="none"
/>
) }
{ selected && (
<polygon
data-testid="selection-ring"
@@ -0,0 +1,191 @@
import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useFortuneWheel } from '../../hooks';
import { NitroCard } from '../../layout';
// Stock UI palette (white / light-blue / grey / black).
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
const RIM = '#4c606c';
const WHEEL_SIZE = 420;
const ICON_RADIUS = 150;
const FULL_TURNS = 5;
const renderPrizeIcon = (prize: IWheelPrize) =>
{
switch(prize.type)
{
case 'item':
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />;
case 'badge':
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />;
case 'credits':
return (
<Column alignItems="center" gap={ 0 }>
<LayoutCurrencyIcon type={ -1 } />
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
</Column>);
case 'points':
return (
<Column alignItems="center" gap={ 0 }>
<LayoutCurrencyIcon type={ prize.pointsType } />
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
</Column>);
case 'spin':
return <span className="text-xs font-bold text-[#2a3a42]">+{ prize.amount }</span>;
default:
return <span className="text-xs font-bold text-[#2a3a42]/60"></span>;
}
};
export const FortuneWheelView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
const [ rotation, setRotation ] = useState(0);
const rotationRef = useRef(0);
const prizesRef = useRef<IWheelPrize[]>([]);
prizesRef.current = prizes;
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: 'fortune-wheel/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(isVisible) open();
}, [ isVisible, open ]);
// Drive the spin animation when the server reports the winning slice.
useEffect(() =>
{
if(pendingPrizeId < 0) return;
const list = prizesRef.current;
const idx = list.findIndex(prize => prize.id === pendingPrizeId);
if(!list.length || (idx < 0))
{
finishSpin();
return;
}
const sliceAngle = 360 / list.length;
const centerAngle = ((idx + 0.5) * sliceAngle);
const current = rotationRef.current;
const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle);
rotationRef.current = target;
setRotation(target);
}, [ pendingPrizeId, finishSpin ]);
const sliceAngle = prizes.length ? (360 / prizes.length) : 0;
const background = useMemo(() =>
{
if(!prizes.length) return SLICE_COLORS[0];
const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', ');
return `conic-gradient(${ stops })`;
}, [ prizes, sliceAngle ]);
if(!isVisible) return null;
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
return (
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel">
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<Flex gap={ 3 }>
<Column alignItems="center" gap={ 2 } className="shrink-0">
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }>
<div
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
<div
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: isSpinning ? 'transform 4.5s cubic-bezier(0.15,0.85,0.25,1)' : 'none' } }
onTransitionEnd={ () => { if(isSpinning) finishSpin(); } }>
{ prizes.map((_, i) => (
<div
key={ `divider-${ i }` }
className="absolute bottom-1/2 left-1/2 origin-bottom"
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
)) }
{ prizes.map((prize, i) =>
{
const centerAngle = ((i + 0.5) * sliceAngle);
return (
<div
key={ prize.id }
className="absolute left-1/2 top-1/2"
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
<div className="-translate-x-1/2 -translate-y-1/2">
{ renderPrizeIcon(prize) }
</div>
</div>);
}) }
</div>
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#eef2f5] shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }` } } />
</div>
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
<Flex gap={ 2 } alignItems="center">
<button
disabled={ !canSpin }
onClick={ () => spin() }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
{ LocalizeText('wheel.spin') }
</button>
<button
onClick={ () => buySpin() }
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
{ LocalizeText('wheel.buy') } { spinCost }
<LayoutCurrencyIcon type={ spinCostType } />
</button>
</Flex>
</Column>
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
<Column gap={ 1 } overflow="auto" className="h-[440px]">
{ recentWins.map((win, i) => (
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
</div>
<Column gap={ 0 } className="min-w-0">
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
</Column>
</Flex>
)) }
{ !recentWins.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
</Column>
</Column>
</Flex>
</NitroCard.Content>
</NitroCard>
);
};
@@ -1,7 +1,7 @@
import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { useMessageEvent, useSoundboard } from '../../../../hooks';
interface NavigatorRoomSettingsMiscTabViewProps
{
@@ -13,6 +13,7 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
const { roomData = null } = props;
const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
const [ cooldown, setCooldown ] = useState(false);
const { enabled: soundboardEnabled, setRoomEnabled: setSoundboardEnabled } = useSoundboard();
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
{
@@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
setTimeout(() => setCooldown(false), 300);
};
const toggleSoundboard = (enabled: boolean) =>
{
if (cooldown) return;
setSoundboardEnabled(enabled);
setCooldown(true);
setTimeout(() => setCooldown(false), 300);
};
return (
<>
<div className="mb-3">
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
</label>
</div>
</div>
<div className={`p-3 rounded transition-colors ${cooldown ? 'bg-gray-200 opacity-60' : 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-sm">🔊 { LocalizeText('soundboard.title') }</div>
<div className="text-xs text-gray-500">{ LocalizeText('soundboard.room.setting.desc') }</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={ soundboardEnabled }
disabled={ cooldown }
onChange={ e => toggleSoundboard(e.target.checked) }
className="w-5 h-5"
/>
</label>
</div>
</div>
</div>
</>
);
+146
View File
@@ -0,0 +1,146 @@
import { FC, useEffect, useState } from 'react';
import { FaBroadcastTower, FaChevronDown, FaPlay, FaStop } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { LayoutImage } from '../../common';
import { RadioStation, useRadio } from '../../hooks';
const RADIO_STYLES = `
.radio-widget { font-feature-settings: "tnum"; }
.radio-eq { display: flex; align-items: flex-end; gap: 2px; height: 12px; }
.radio-eq span { width: 3px; height: 30%; border-radius: 2px; background: #38bdf8; opacity: .55; }
.radio-eq.is-live span { opacity: 1; animation: radioEq .9s ease-in-out infinite; }
.radio-eq span:nth-child(2) { animation-delay: .18s; }
.radio-eq span:nth-child(3) { animation-delay: .36s; }
.radio-eq span:nth-child(4) { animation-delay: .12s; }
@keyframes radioEq { 0%, 100% { height: 22%; } 50% { height: 100%; } }
.radio-scroll::-webkit-scrollbar { width: 6px; }
.radio-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 3px; }
.radio-scroll::-webkit-scrollbar-track { background: transparent; }
.radio-vol { accent-color: #38bdf8; }
`;
// Compact, polished top-left radio widget. Shows the selected station with a
// dropdown (3 visible, scrolls if more) to switch. Nudged down so it clears the
// CMS top bar most hotels render there.
export const RadioView: FC<{}> = () =>
{
const { stations, currentId, isPlaying, volume, loadError, play, stop, setVolume } = useRadio();
const [ open, setOpen ] = useState(false);
const [ selectedId, setSelectedId ] = useState<string | null>(null);
useEffect(() =>
{
if(!selectedId && stations.length) setSelectedId(stations[0].id);
}, [ stations, selectedId ]);
const selected: RadioStation | null = stations.find(s => s.id === selectedId) ?? stations[0] ?? null;
const selectedPlaying = !!selected && (currentId === selected.id) && isPlaying;
const onPlayToggle = () =>
{
if(!selected) return;
if(selectedPlaying) stop();
else play(selected);
};
const onPick = (station: RadioStation) =>
{
setSelectedId(station.id);
setOpen(false);
play(station);
};
return (
<div className="radio-widget fixed left-2 top-12 z-40 w-[244px] max-w-[64vw] select-none overflow-hidden rounded-xl border border-white/10 bg-gradient-to-b from-[rgba(22,24,30,0.94)] to-[rgba(10,11,14,0.94)] text-white shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm">
<style>{ RADIO_STYLES }</style>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-1.5">
<FaBroadcastTower className={ `text-[11px] ${ isPlaying ? 'text-sky-400' : 'text-white/45' }` } />
<span className="grow text-[10px] font-bold uppercase tracking-[0.14em] text-white/55">{ LocalizeText('radio.title') }</span>
<div className={ `radio-eq ${ isPlaying ? 'is-live' : '' }` }>
<span /><span /><span /><span />
</div>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<button
type="button"
onClick={ onPlayToggle }
disabled={ !selected }
title={ selectedPlaying ? LocalizeText('radio.stop') : LocalizeText('radio.title') }
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-xs text-white shadow-inner transition-all hover:bg-emerald-400 disabled:opacity-40">
{ selectedPlaying ? <FaStop /> : <FaPlay className="translate-x-px" /> }
</button>
<div className="min-w-0 grow">
<div className="truncate text-sm font-bold leading-tight">{ selected ? selected.name : LocalizeText('radio.title') }</div>
<div className="mt-0.5 flex items-center gap-1.5">
{ selectedPlaying &&
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide text-sky-400">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> Live
</span> }
{ selected?.genre &&
<span className="truncate text-[10px] text-white/45">{ selected.genre }</span> }
</div>
</div>
<button
type="button"
onClick={ () => setOpen(value => !value) }
title={ LocalizeText('radio.title') }
className={ `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors ${ open ? 'bg-white/20' : 'bg-white/8 hover:bg-white/15' }` }>
<FaChevronDown className={ `text-[10px] transition-transform ${ open ? 'rotate-180' : '' }` } />
</button>
</div>
{ selectedPlaying &&
<div className="flex items-center gap-2 px-3 pb-2.5">
<span className="text-xs text-white/55">🔊</span>
<input
type="range"
min={ 0 }
max={ 1 }
step={ 0.01 }
value={ volume }
onChange={ e => setVolume(e.target.valueAsNumber) }
className="radio-vol h-1 grow cursor-pointer"
/>
</div> }
{ open &&
<div className="border-t border-white/10 bg-black/20 p-1.5">
{ loadError &&
<div className="px-2 py-2 text-[11px] text-red-400">{ LocalizeText('radio.error') }</div> }
{ !loadError && !stations.length &&
<div className="px-2 py-2 text-[11px] text-white/50">{ LocalizeText('radio.empty') }</div> }
{ /* ~3 rows tall, scrolls when there are more */ }
<div className="radio-scroll flex max-h-[156px] flex-col gap-1 overflow-y-auto pr-0.5">
{ stations.map(station =>
{
const isActive = station.id === selectedId;
const playingThis = (currentId === station.id) && isPlaying;
return (
<div
key={ station.id }
onClick={ () => onPick(station) }
className={ `flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors ${ isActive ? 'bg-sky-500/15 ring-1 ring-sky-400/40' : 'hover:bg-white/8' }` }>
{ station.logo
? <LayoutImage imageUrl={ station.logo } className="h-7 w-7 shrink-0 rounded bg-contain bg-center bg-no-repeat" />
: <div className={ `flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[11px] ${ playingThis ? 'bg-sky-500/80' : 'bg-white/10' }` }>
{ playingThis ? <FaStop /> : <FaPlay className="translate-x-px" /> }
</div> }
<div className="min-w-0 grow">
<div className="truncate text-xs font-bold leading-tight">{ station.name }</div>
{ station.genre &&
<div className="truncate text-[10px] text-white/45">{ station.genre }</div> }
</div>
{ playingThis &&
<div className="radio-eq is-live shrink-0">
<span /><span /><span /><span />
</div> }
</div>
);
}) }
</div>
</div> }
</div>
);
};
@@ -0,0 +1,234 @@
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks';
import { NitroCard, NitroInput } from '../../layout';
interface RareValueRow
{
spriteId: number;
name: string;
iconUrl: string;
value: IRareValue;
}
interface EditRow
{
id: number;
category: string;
num: number;
weight: number;
label: string;
}
const CATEGORIES: { key: string; label: string }[] = [
{ key: 'item', label: 'Raro (ID)' },
{ key: 'diamanti', label: 'Diamanti' },
{ key: 'duckets', label: 'Duckets' },
{ key: 'crediti', label: 'Crediti' },
{ key: 'giri', label: 'Giri extra' },
{ key: 'nulla', label: 'Nulla' }
];
const prizeToCategory = (prize: IWheelAdminPrize): string =>
{
switch(prize.type)
{
case 'item': return 'item';
case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets';
case 'credits': return 'crediti';
case 'spin': return 'giri';
default: return 'nulla';
}
};
const prizeToNum = (prize: IWheelAdminPrize): number =>
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
{
const base = { id: row.id, weight: row.weight, label: row.label };
switch(row.category)
{
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
}
};
export const RareValuesView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ tab, setTab ] = useState<'values' | 'editor'>('values');
const [ searchValue, setSearchValue ] = useState('');
const { values = null, loaded = false } = useRareValues();
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
const canEdit = useHasPermission('acc_supporttool');
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
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: 'rare-values/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes();
}, [ isVisible, tab, canEdit, loadAdminPrizes ]);
useEffect(() =>
{
setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label })));
}, [ adminPrizes ]);
const rows = useMemo<RareValueRow[]>(() =>
{
if(!values) return [];
const list: RareValueRow[] = [];
values.forEach((value, spriteId) =>
{
if(value.points <= 0) return;
const floorData = GetSessionDataManager().getFloorItemData(spriteId);
const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId);
const data = (floorData ?? wallData);
if(!data) return;
const iconUrl = (floorData
? GetRoomEngine().getFurnitureFloorIconUrl(spriteId)
: GetRoomEngine().getFurnitureWallIconUrl(spriteId));
list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value });
});
list.sort((a, b) => (b.value.points - a.value.points));
return list;
}, [ values ]);
const filtered = useMemo<RareValueRow[]>(() =>
{
const query = searchValue.trim().toLocaleLowerCase();
if(!query) return rows;
return rows.filter(row => row.name.toLocaleLowerCase().includes(query));
}, [ rows, searchValue ]);
if(!isVisible) return null;
const updateRow = (id: number, patch: Partial<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
return (
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
<NitroCard.Header
headerText={ LocalizeText('rarevalues.title') }
onCloseClick={ () => setIsVisible(false) } />
{ canEdit &&
<NitroCard.Tabs>
<NitroCard.TabItem isActive={ tab === 'values' } onClick={ () => setTab('values') }>
<Text>{ LocalizeText('rarevalues.title') }</Text>
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ tab === 'editor' } onClick={ () => setTab('editor') }>
<Text>{ LocalizeText('rarevalues.editor.tab') }</Text>
</NitroCard.TabItem>
</NitroCard.Tabs> }
<NitroCard.Content>
{ (tab === 'values' || !canEdit) &&
<Column gap={ 2 } className="h-full p-1">
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } />
<Column gap={ 0 } overflow="auto" className="grow">
{ !loaded &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
{ (loaded && !filtered.length) &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
{ filtered.map(row => (
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex>
</Flex>
)) }
</Column>
</Column> }
{ (tab === 'editor' && canEdit) &&
<Column gap={ 1 } className="h-full p-1">
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
</Flex>
<Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => (
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
<select
value={ row.category }
onChange={ event => updateRow(row.id, { category: event.target.value }) }
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
{ CATEGORIES.map(cat => <option key={ cat.key } value={ cat.key }>{ cat.label }</option>) }
</select>
<input
type="number"
value={ row.num }
disabled={ row.category === 'nulla' }
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
<input
type="number"
value={ row.weight }
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
<input
type="text"
value={ row.label }
onChange={ event => updateRow(row.id, { label: event.target.value }) }
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
</Flex>
)) }
</Column>
<button
type="button"
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3]">
{ LocalizeText('rarevalues.editor.save') }
</button>
</Column> }
</NitroCard.Content>
</NitroCard>
);
};
@@ -3,8 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
interface InfoStandWidgetFurniViewProps
@@ -23,6 +23,8 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
const { roomSession = null } = useRoom();
const { openInspectionForFurni, showInspectButton } = useWiredTools();
const isModerator = useHasPermission('acc_anyroomowner');
const { getValue: getRareValue } = useRareValues();
const rareValue = useMemo(() => (avatarInfo ? getRareValue(avatarInfo.spriteId) : null), [ avatarInfo, getRareValue ]);
const [ pickupMode, setPickupMode ] = useState(0);
const [ canMove, setCanMove ] = useState(false);
@@ -563,6 +565,17 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
<Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
</div>
</> }
{ (rareValue && rareValue.points > 0) &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
<Flex alignItems="center" gap={ 2 }>
<Text small variant="white">{ LocalizeText('rarevalues.infostand.label') }</Text>
<Flex alignItems="center" gap={ 1 }>
<Text small variant="white">{ rareValue.points }</Text>
<LayoutCurrencyIcon type={ rareValue.pointsType } />
</Flex>
</Flex>
</> }
{ godMode &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
@@ -0,0 +1,74 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, Text } from '../../common';
import { useSoundboard } from '../../hooks';
import { NitroCard } from '../../layout';
export const SoundboardView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { enabled, sounds, lastPlayed, play } = useSoundboard();
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: 'soundboard/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
// The soundboard belongs to the room — close it when the room turns it off.
useEffect(() =>
{
if(!enabled) setIsVisible(false);
}, [ enabled ]);
if(!isVisible || !enabled) return null;
return (
<NitroCard className="w-[420px] max-w-[96vw]" uniqueKey="soundboard">
<NitroCard.Header headerText={ LocalizeText('soundboard.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<Column gap={ 2 }>
{ !sounds.length &&
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
{ !!sounds.length &&
<div className="grid grid-cols-3 gap-2">
{ sounds.map(sound => (
<button
key={ sound.id }
onClick={ () => play(sound) }
title={ sound.name }
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
<span className="text-2xl leading-none">🔊</span>
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
</button>
)) }
</div> }
{ lastPlayed &&
<Flex alignItems="center" justifyContent="center" className="pt-1">
<Text small className="text-[#2f6f95]">
{ LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) }
</Text>
</Flex> }
</Column>
</NitroCard.Content>
</NitroCard>
);
};
+24 -2
View File
@@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks';
import { ToolbarItemView } from './ToolbarItemView';
import { ToolbarMeView } from './ToolbarMeView';
import { YouTubePlayerView } from './YouTubePlayerView';
@@ -42,6 +42,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const { requests = [] } = useFriends();
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
const { openMonitor, showToolbarButton } = useWiredTools();
const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard();
const isMod = useHasPermission('acc_supporttool');
const isHk = useHasPermission('acc_housekeeping');
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
@@ -99,8 +100,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{
setYoutubeEnabled(false);
setYoutubeRoomEnabled(false);
resetSoundboard();
}
}, [ isInRoom ]);
}, [ isInRoom, resetSoundboard ]);
useEffect(() =>
{
@@ -250,6 +252,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
{ (isInRoom && showToolbarButton) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
@@ -262,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> }
{ (isInRoom && soundboardEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
@@ -358,6 +370,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
</motion.div>
<motion.div
variants={ containerVariants }
@@ -374,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> }
{ (isInRoom && soundboardEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
+21
View File
@@ -77,6 +77,20 @@
height: 34px;
}
.nitro-icon.icon-rare-values {
background-image: url("@/assets/images/toolbar/icons/catalog.png");
width: 37px;
height: 36px;
filter: hue-rotate(280deg) saturate(1.4);
}
.nitro-icon.icon-fortune-wheel {
background-image: url("@/assets/images/toolbar/icons/game.png");
width: 44px;
height: 25px;
filter: hue-rotate(300deg) saturate(1.6);
}
.nitro-icon.icon-housekeeping {
background-image: url("@/assets/images/toolbar/icons/modtools.png");
width: 29px;
@@ -202,6 +216,13 @@
height: 36px;
}
.nitro-icon.icon-soundboard {
background-image: url("@/assets/images/toolbar/icons/game.png");
width: 44px;
height: 25px;
filter: hue-rotate(90deg) saturate(1.5);
}
.nitro-icon.icon-message {
background-image: url("@/assets/images/toolbar/icons/message.png");
width: 36px;
@@ -0,0 +1,69 @@
import { IWheelAdminPrize, IWheelAdminPrizeEdit, IWheelPrize, IWheelRecentWin, WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer, WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
// Fortune wheel state + actions. Shared via useBetween so the event listeners
// register once regardless of how many components read it.
const useFortuneWheelState = () =>
{
const [ freeSpins, setFreeSpins ] = useState(0);
const [ extraSpins, setExtraSpins ] = useState(0);
const [ spinCost, setSpinCost ] = useState(0);
const [ spinCostType, setSpinCostType ] = useState(-1);
const [ prizes, setPrizes ] = useState<IWheelPrize[]>([]);
const [ recentWins, setRecentWins ] = useState<IWheelRecentWin[]>([]);
const [ pendingPrizeId, setPendingPrizeId ] = useState<number>(-1);
const [ isSpinning, setIsSpinning ] = useState(false);
const [ adminPrizes, setAdminPrizes ] = useState<IWheelAdminPrize[]>([]);
useMessageEvent<WheelAdminPrizesEvent>(WheelAdminPrizesEvent, event =>
{
setAdminPrizes(event.getParser().prizes);
});
useMessageEvent<WheelDataEvent>(WheelDataEvent, event =>
{
const parser = event.getParser();
setFreeSpins(parser.freeSpins);
setExtraSpins(parser.extraSpins);
setSpinCost(parser.spinCost);
setSpinCostType(parser.spinCostType);
setPrizes(parser.prizes);
});
useMessageEvent<WheelResultEvent>(WheelResultEvent, event =>
{
setPendingPrizeId(event.getParser().prizeId);
setIsSpinning(true);
});
useMessageEvent<WheelRecentWinsEvent>(WheelRecentWinsEvent, event =>
{
setRecentWins(event.getParser().wins);
});
const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []);
const spin = useCallback(() =>
{
setIsSpinning(prev =>
{
if(!prev) SendMessageComposer(new WheelSpinComposer());
return prev;
});
}, []);
const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []);
const finishSpin = useCallback(() =>
{
setIsSpinning(false);
setPendingPrizeId(-1);
}, []);
const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []);
const saveAdminPrizes = useCallback((prizes: IWheelAdminPrizeEdit[]) => SendMessageComposer(new WheelAdminSavePrizesComposer(prizes)), []);
return { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin, adminPrizes, loadAdminPrizes, saveAdminPrizes };
};
export const useFortuneWheel = () => useBetween(useFortuneWheelState);
+4
View File
@@ -4,6 +4,7 @@ export * from './camera';
export * from './catalog';
export * from './chat-history';
export * from './events';
export * from './fortune-wheel/useFortuneWheel';
export * from './friends';
export * from './game-center';
export * from './groups';
@@ -14,12 +15,15 @@ export * from './mod-tools';
export * from './navigator';
export * from './notification';
export * from './purse';
export * from './radio/useRadio';
export * from './rare-values/useRareValues';
export * from './rooms';
export * from './rooms/engine';
export * from './rooms/promotes';
export * from './rooms/widgets';
export * from './rooms/widgets/furniture';
export * from './session';
export * from './soundboard/useSoundboard';
export * from './translation';
export * from './useLocalStorage';
export * from './useSharedVisibility';
+147
View File
@@ -0,0 +1,147 @@
import { loadGamedata } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue } from '../../api';
export type RadioStation = {
id: string;
name: string;
genre?: string;
url: string;
logo?: string;
};
// Hotel radio: a list of streaming URLs played client-side with HTML5 Audio.
// The station list comes from a JSON5 config file (loadGamedata accepts plain
// JSON and JSON5). Shared via useBetween so playback is a single instance no
// matter how many components read it.
const useRadioState = () =>
{
const [ stations, setStations ] = useState<RadioStation[]>([]);
const [ currentId, setCurrentId ] = useState<string | null>(null);
const [ isPlaying, setIsPlaying ] = useState(false);
const [ loadError, setLoadError ] = useState<string | null>(null);
const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive
const audioRef = useRef<HTMLAudioElement | null>(null);
const loadStartedRef = useRef(false);
const autoStartedRef = useRef(false);
useEffect(() =>
{
if(loadStartedRef.current) return;
loadStartedRef.current = true;
const url = GetConfigurationValue<string>('radio.stations.url') || 'configuration/radio-stations.json5';
(async () =>
{
try
{
const json = await loadGamedata<{ stations?: RadioStation[] }>(url);
const list = Array.isArray(json?.stations)
? json.stations.filter(s => s && s.id && s.url)
: [];
setStations(list);
}
catch(error)
{
setLoadError(String((error as Error)?.message ?? error));
}
})();
}, []);
// Tear down the stream when the hook instance goes away.
useEffect(() => () =>
{
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
}, []);
const stop = useCallback(() =>
{
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
setIsPlaying(false);
setCurrentId(null);
}, []);
// Browsers block audio that starts without a user gesture (autoplay policy),
// so the startup autostart may be refused. When that happens, resume on the
// very first click / keypress anywhere.
const armResumeOnGesture = useCallback(() =>
{
const resume = () =>
{
window.removeEventListener('pointerdown', resume);
window.removeEventListener('keydown', resume);
if(audioRef.current) void audioRef.current.play().then(() => setIsPlaying(true)).catch(() => {});
};
window.addEventListener('pointerdown', resume, { once: true });
window.addEventListener('keydown', resume, { once: true });
}, []);
const play = useCallback((station: RadioStation) =>
{
if(!station?.url) return;
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
try
{
const audio = new Audio(station.url);
audio.volume = volume;
audioRef.current = audio;
setCurrentId(station.id);
void audio.play().then(() => setIsPlaying(true)).catch(() =>
{
// Likely autoplay-blocked — keep the station selected and resume
// on the first user interaction instead of dropping it.
setIsPlaying(false);
armResumeOnGesture();
});
}
catch
{
setIsPlaying(false);
setCurrentId(null);
}
}, [ volume, armResumeOnGesture ]);
// Autostart the first station once on client load (quiet, see initial volume).
useEffect(() =>
{
if(autoStartedRef.current || !stations.length) return;
autoStartedRef.current = true;
play(stations[0]);
}, [ stations, play ]);
const toggle = useCallback((station: RadioStation) =>
{
if(currentId === station.id) stop();
else play(station);
}, [ currentId, play, stop ]);
const setVolume = useCallback((value: number) =>
{
const v = Math.max(0, Math.min(1, value));
setVolumeState(v);
if(audioRef.current) audioRef.current.volume = v;
}, []);
return { stations, currentId, isPlaying, volume, loadError, play, stop, toggle, setVolume };
};
export const useRadio = () => useBetween(useRadioState);
+31
View File
@@ -0,0 +1,31 @@
import { IRareValue, RareValuesEvent, RequestRareValuesComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
// spriteId -> catalog value, fetched once from the server (RareValuesComposer).
// Shared across all consumers via useBetween so the request fires a single time.
// Read by both the furni infostand and the toolbar "Valore Rari" panel.
const useRareValuesState = () =>
{
const [ values, setValues ] = useState<Map<number, IRareValue>>(() => new Map());
const [ loaded, setLoaded ] = useState(false);
useMessageEvent<RareValuesEvent>(RareValuesEvent, event =>
{
setValues(event.getParser().values);
setLoaded(true);
});
useEffect(() =>
{
SendMessageComposer(new RequestRareValuesComposer());
}, []);
const getValue = useCallback((spriteId: number): IRareValue => (values.get(spriteId) ?? null), [ values ]);
return { values, loaded, getValue };
};
export const useRareValues = () => useBetween(useRareValuesState);
+120
View File
@@ -0,0 +1,120 @@
import { ISoundboardSound, loadGamedata, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api';
import { useMessageEvent } from '../events';
// A pad as the client uses it. `local` marks pads that came from the JSON5 file
// fallback rather than the server (DB) — those play locally on click because the
// server can't resolve their id to broadcast them.
export type ClientSoundboardSound = ISoundboardSound & { local?: boolean };
const playLocal = (url: string) =>
{
if(!url) return;
try
{
const audio = new Audio(url);
audio.volume = 0.8;
void audio.play().catch(() => {});
}
catch {}
};
// Resolve a stored sound url (which may be relative, like custom badges) to an
// absolute one against the asset host.
const resolveUrl = (url: string): string =>
{
if(!url) return '';
if(/^https?:\/\//i.test(url) || url.startsWith('//') || url.startsWith('/')) return url;
const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('asset.url') || '').replace(/\/+$/, '');
return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url;
};
// Soundboard state + actions. Shared via useBetween so the event listeners
// register once regardless of how many components read it (toolbar + view).
const useSoundboardState = () =>
{
const [ enabled, setEnabled ] = useState(false);
const [ serverSounds, setServerSounds ] = useState<ISoundboardSound[]>([]);
const [ fileSounds, setFileSounds ] = useState<ClientSoundboardSound[]>([]);
const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null);
const fileLoadStartedRef = useRef(false);
useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event =>
{
const parser = event.getParser();
setEnabled(parser.enabled);
setServerSounds(parser.sounds);
setSoundboardRoomEnabled(parser.enabled);
});
useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event =>
{
const parser = event.getParser();
playLocal(resolveUrl(parser.url));
setLastPlayed({ soundId: parser.soundId, username: parser.username });
});
// Fallback: when the soundboard is on but the server (DB) provided no pads,
// load them from the JSON5 file once. loadGamedata accepts plain JSON and
// JSON5 (// comments) — same loader used for the avatar effect map.
useEffect(() =>
{
if(!enabled || serverSounds.length || fileLoadStartedRef.current) return;
fileLoadStartedRef.current = true;
const url = GetConfigurationValue<string>('soundboard.sounds.url') || 'configuration/soundboard-sounds.json5';
(async () =>
{
try
{
const json = await loadGamedata<{ sounds?: ISoundboardSound[] }>(url);
const list = Array.isArray(json?.sounds)
? json.sounds
.filter(s => s && s.url)
.map(s => ({ id: s.id, name: s.name, url: s.url, local: true }))
: [];
setFileSounds(list);
}
catch {}
})();
}, [ enabled, serverSounds.length ]);
const sounds: ClientSoundboardSound[] = serverSounds.length ? serverSounds : fileSounds;
const play = useCallback((sound: ClientSoundboardSound) =>
{
if(!sound) return;
// File-defined pad: the server doesn't know it, so play it locally.
if(sound.local)
{
playLocal(resolveUrl(sound.url));
return;
}
// DB-backed pad: let the server broadcast it to everyone in the room.
SendMessageComposer(new SoundboardPlayComposer(sound.id));
}, []);
const setRoomEnabled = useCallback((value: boolean) =>
{
setEnabled(value);
setSoundboardRoomEnabled(value);
SendMessageComposer(new SoundboardSetEnabledComposer(value));
}, []);
// Local-only clear (e.g. when leaving the room) — does not notify the server.
const reset = useCallback(() =>
{
setEnabled(false);
setServerSounds([]);
setLastPlayed(null);
setSoundboardRoomEnabled(false);
}, []);
return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset };
};
export const useSoundboard = () => useBetween(useSoundboardState);