mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
+44
-11
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SoundboardRoomState';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user