diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7553c95..e3a8cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/public/configuration/radio-stations.json5.example b/public/configuration/radio-stations.json5.example new file mode 100644 index 0000000..f79cd89 --- /dev/null +++ b/public/configuration/radio-stations.json5.example @@ -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' }, + ], +} diff --git a/public/configuration/rarevalues-texts-en.example b/public/configuration/rarevalues-texts-en.example new file mode 100644 index 0000000..e9d6368 --- /dev/null +++ b/public/configuration/rarevalues-texts-en.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Rare Values", + "rarevalues.loading": "Loading values…", + "rarevalues.empty": "No rares found", + "rarevalues.infostand.label": "Value:" +} diff --git a/public/configuration/rarevalues-texts-it.example b/public/configuration/rarevalues-texts-it.example new file mode 100644 index 0000000..62fef05 --- /dev/null +++ b/public/configuration/rarevalues-texts-it.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Valore Rari", + "rarevalues.loading": "Caricamento valori…", + "rarevalues.empty": "Nessun raro trovato", + "rarevalues.infostand.label": "Valore:" +} diff --git a/public/configuration/soundboard-sounds.json5.example b/public/configuration/soundboard-sounds.json5.example new file mode 100644 index 0000000..dffaaa8 --- /dev/null +++ b/public/configuration/soundboard-sounds.json5.example @@ -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' }, + ], +} diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example new file mode 100644 index 0000000..0ba7b3e --- /dev/null +++ b/public/configuration/wheel-texts-en.example @@ -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" +} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example new file mode 100644 index 0000000..dadcbc7 --- /dev/null +++ b/public/configuration/wheel-texts-it.example @@ -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" +} diff --git a/src/api/index.ts b/src/api/index.ts index 6108472..424bb4f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; diff --git a/src/api/soundboard/SoundboardRoomState.ts b/src/api/soundboard/SoundboardRoomState.ts new file mode 100644 index 0000000..cded2a1 --- /dev/null +++ b/src/api/soundboard/SoundboardRoomState.ts @@ -0,0 +1,7 @@ +let _soundboardEnabled = false; + +export const getSoundboardRoomEnabled = () => _soundboardEnabled; +export const setSoundboardRoomEnabled = (enabled: boolean) => +{ + _soundboardEnabled = enabled; +}; diff --git a/src/api/soundboard/index.ts b/src/api/soundboard/index.ts new file mode 100644 index 0000000..2ccb6a5 --- /dev/null +++ b/src/api/soundboard/index.ts @@ -0,0 +1 @@ +export * from './SoundboardRoomState'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 071abcb..aeb5d91 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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 => + + + + ); diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx index 5603025..a665fa3 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.test.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -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', () => diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 8094d1b..b8c52be 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -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, event => + { + dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap }); + }); + useMessageEvent(RoomEntryTileMessageEvent, event => { const parser = event.getParser(); diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts index 689e9ad..1fb84f8 100644 --- a/src/components/floorplan-editor/state/reducer.test.ts +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -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', () => diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts index 876afcb..c6d5840 100644 --- a/src/components/floorplan-editor/state/reducer.ts +++ b/src/components/floorplan-editor/state/reducer.ts @@ -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) { diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts index 1361e38..fb30bd8 100644 --- a/src/components/floorplan-editor/state/types.ts +++ b/src/components/floorplan-editor/state/types.ts @@ -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' } diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx index bb61ab5..a275776 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () => const dispatch = vi.fn(); const { container } = render(); 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', () => diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx index 55e9c60..3951ecd 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -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 = ({ 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(','); diff --git a/src/components/floorplan-editor/views/FloorplanTile.tsx b/src/components/floorplan-editor/views/FloorplanTile.tsx index 7fca484..5c0c8ce 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.tsx @@ -104,6 +104,17 @@ const FloorplanTileImpl: FC = ({ row, col, tile, selected, isDoor, southH stroke="#222" strokeWidth={ 0.5 } /> + { tile.occupied && ( + + ) } { selected && ( +{ + switch(prize.type) + { + case 'item': + return ; + case 'badge': + return ; + case 'credits': + return ( + + + { prize.amount } + ); + case 'points': + return ( + + + { prize.amount } + ); + case 'spin': + return +{ prize.amount }; + default: + return ; + } +}; + +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([]); + 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 ( + + setIsVisible(false) } /> + + + +
+
+
{ if(isSpinning) finishSpin(); } }> + { prizes.map((_, i) => ( +
+ )) } + { prizes.map((prize, i) => + { + const centerAngle = ((i + 0.5) * sliceAngle); + return ( +
+
+ { renderPrizeIcon(prize) } +
+
); + }) } +
+
+
+ { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } + { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } + + + + + + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + + + ); +}; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx index 5d151d3..d82e0ba 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx @@ -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(YouTubeRoomSettingsEvent, event => { @@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC setCooldown(false), 300); }; + const toggleSoundboard = (enabled: boolean) => + { + if (cooldown) return; + setSoundboardEnabled(enabled); + setCooldown(true); + setTimeout(() => setCooldown(false), 300); + }; + return ( <>
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC
+
+
+
+
🔊 { LocalizeText('soundboard.title') }
+
{ LocalizeText('soundboard.room.setting.desc') }
+
+ +
+
); diff --git a/src/components/radio/RadioView.tsx b/src/components/radio/RadioView.tsx new file mode 100644 index 0000000..15e7c95 --- /dev/null +++ b/src/components/radio/RadioView.tsx @@ -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(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 ( +
+ + +
+ + { LocalizeText('radio.title') } +
+ +
+
+ +
+ +
+
{ selected ? selected.name : LocalizeText('radio.title') }
+
+ { selectedPlaying && + + Live + } + { selected?.genre && + { selected.genre } } +
+
+ +
+ + { selectedPlaying && +
+ 🔊 + setVolume(e.target.valueAsNumber) } + className="radio-vol h-1 grow cursor-pointer" + /> +
} + + { open && +
+ { loadError && +
{ LocalizeText('radio.error') }
} + { !loadError && !stations.length && +
{ LocalizeText('radio.empty') }
} + { /* ~3 rows tall, scrolls when there are more */ } +
+ { stations.map(station => + { + const isActive = station.id === selectedId; + const playingThis = (currentId === station.id) && isPlaying; + return ( +
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 + ? + :
+ { playingThis ? : } +
} +
+
{ station.name }
+ { station.genre && +
{ station.genre }
} +
+ { playingThis && +
+ +
} +
+ ); + }) } +
+
} +
+ ); +}; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx new file mode 100644 index 0000000..5cb4ce7 --- /dev/null +++ b/src/components/rare-values/RareValuesView.tsx @@ -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([]); + + 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(() => + { + 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(() => + { + 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) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + setIsVisible(false) } /> + { canEdit && + + setTab('values') }> + { LocalizeText('rarevalues.title') } + + setTab('editor') }> + { LocalizeText('rarevalues.editor.tab') } + + } + + { (tab === 'values' || !canEdit) && + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + + + + )) } + + } + + { (tab === 'editor' && canEdit) && + + + { LocalizeText('rarevalues.editor.type') } + { LocalizeText('rarevalues.editor.value') } + { LocalizeText('rarevalues.editor.weight') } + { LocalizeText('rarevalues.editor.label') } + + + { editRows.map(row => ( + + + 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" /> + 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]" /> + 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]" /> + + )) } + + + } + + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index da4db6c..d2a861c 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -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 = 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 = props X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }
} + { (rareValue && rareValue.points > 0) && + <> +
+ + { LocalizeText('rarevalues.infostand.label') } + + { rareValue.points } + + + + } { godMode && <>
diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx new file mode 100644 index 0000000..76d7a80 --- /dev/null +++ b/src/components/soundboard/SoundboardView.tsx @@ -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 ( + + setIsVisible(false) } /> + + + { !sounds.length && + { LocalizeText('soundboard.empty') } } + { !!sounds.length && +
+ { sounds.map(sound => ( + + )) } +
} + { lastPlayed && + + + { LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) } + + } +
+
+
+ ); +}; diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 6bcc9a3..adf3285 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -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) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + { (isInRoom && showToolbarButton) && @@ -262,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> @@ -358,6 +370,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 167bdb2..17a903f 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -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; diff --git a/src/hooks/fortune-wheel/useFortuneWheel.ts b/src/hooks/fortune-wheel/useFortuneWheel.ts new file mode 100644 index 0000000..c280289 --- /dev/null +++ b/src/hooks/fortune-wheel/useFortuneWheel.ts @@ -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([]); + const [ recentWins, setRecentWins ] = useState([]); + const [ pendingPrizeId, setPendingPrizeId ] = useState(-1); + const [ isSpinning, setIsSpinning ] = useState(false); + const [ adminPrizes, setAdminPrizes ] = useState([]); + + useMessageEvent(WheelAdminPrizesEvent, event => + { + setAdminPrizes(event.getParser().prizes); + }); + + useMessageEvent(WheelDataEvent, event => + { + const parser = event.getParser(); + setFreeSpins(parser.freeSpins); + setExtraSpins(parser.extraSpins); + setSpinCost(parser.spinCost); + setSpinCostType(parser.spinCostType); + setPrizes(parser.prizes); + }); + + useMessageEvent(WheelResultEvent, event => + { + setPendingPrizeId(event.getParser().prizeId); + setIsSpinning(true); + }); + + useMessageEvent(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); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a369b37..8a85f2a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; diff --git a/src/hooks/radio/useRadio.ts b/src/hooks/radio/useRadio.ts new file mode 100644 index 0000000..88bff81 --- /dev/null +++ b/src/hooks/radio/useRadio.ts @@ -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([]); + const [ currentId, setCurrentId ] = useState(null); + const [ isPlaying, setIsPlaying ] = useState(false); + const [ loadError, setLoadError ] = useState(null); + const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive + const audioRef = useRef(null); + const loadStartedRef = useRef(false); + const autoStartedRef = useRef(false); + + useEffect(() => + { + if(loadStartedRef.current) return; + loadStartedRef.current = true; + + const url = GetConfigurationValue('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); diff --git a/src/hooks/rare-values/useRareValues.ts b/src/hooks/rare-values/useRareValues.ts new file mode 100644 index 0000000..d048db1 --- /dev/null +++ b/src/hooks/rare-values/useRareValues.ts @@ -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>(() => new Map()); + const [ loaded, setLoaded ] = useState(false); + + useMessageEvent(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); diff --git a/src/hooks/soundboard/useSoundboard.ts b/src/hooks/soundboard/useSoundboard.ts new file mode 100644 index 0000000..e671e59 --- /dev/null +++ b/src/hooks/soundboard/useSoundboard.ts @@ -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('soundboard.url.prefix') || GetConfigurationValue('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([]); + const [ fileSounds, setFileSounds ] = useState([]); + const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); + const fileLoadStartedRef = useRef(false); + + useMessageEvent(SoundboardSettingsEvent, event => + { + const parser = event.getParser(); + setEnabled(parser.enabled); + setServerSounds(parser.sounds); + setSoundboardRoomEnabled(parser.enabled); + }); + + useMessageEvent(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('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);