From 61aceaa42209a9a8b11fa84444beada02c172e07 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 02:39:02 +0200 Subject: [PATCH 1/5] feat: rare values panel + fortune wheel UI + prize editor Toolbar buttons, FortuneWheelView (animated wheel, prize icons, recent winners), RareValuesView (diamond price guide + staff prize-editor tab), furni infostand value line, useFortuneWheel/useRareValues hooks, it/en text examples. --- .../configuration/rarevalues-texts-en.example | 6 + .../configuration/rarevalues-texts-it.example | 6 + public/configuration/wheel-texts-en.example | 9 + public/configuration/wheel-texts-it.example | 9 + src/components/MainView.tsx | 4 + .../fortune-wheel/FortuneWheelView.tsx | 191 ++++++++++++++ src/components/rare-values/RareValuesView.tsx | 234 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 17 +- src/components/toolbar/ToolbarView.tsx | 12 + src/css/icons/icons.css | 14 ++ src/hooks/fortune-wheel/useFortuneWheel.ts | 69 ++++++ src/hooks/index.ts | 2 + src/hooks/rare-values/useRareValues.ts | 31 +++ 13 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 public/configuration/rarevalues-texts-en.example create mode 100644 public/configuration/rarevalues-texts-it.example create mode 100644 public/configuration/wheel-texts-en.example create mode 100644 public/configuration/wheel-texts-it.example create mode 100644 src/components/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/rare-values/RareValuesView.tsx create mode 100644 src/hooks/fortune-wheel/useFortuneWheel.ts create mode 100644 src/hooks/rare-values/useRareValues.ts 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/wheel-texts-en.example b/public/configuration/wheel-texts-en.example new file mode 100644 index 0000000..71b3b1b --- /dev/null +++ b/public/configuration/wheel-texts-en.example @@ -0,0 +1,9 @@ +{ + "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" +} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example new file mode 100644 index 0000000..f5bb934 --- /dev/null +++ b/public/configuration/wheel-texts-it.example @@ -0,0 +1,9 @@ +{ + "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" +} diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 071abcb..494e948 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -24,6 +24,8 @@ 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 { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -176,6 +178,8 @@ export const MainView: FC<{}> = props => + + ); diff --git a/src/components/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx new file mode 100644 index 0000000..a66af12 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -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 ; + 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/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/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 6bcc9a3..e088a62 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -250,6 +250,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) && @@ -358,6 +364,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + +{ + 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..4b3c1c3 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,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; export * from './rooms/promotes'; 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); From 7a65e5bf6dbd936b98d1c4de56e12a6c1c6c1d81 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:04:17 +0200 Subject: [PATCH 2/5] feat: soundboard (room-scoped custom audio pads) Client side of the soundboard. Room owners enable it in Room Settings > Misc (next to the YouTube TV toggle). When enabled, a soundboard icon appears in the toolbar for everyone in the room; pressing a pad broadcasts the sound so all occupants hear it. Incoming SoundboardPlay is played via the HTML5 Audio API. Also: fix FloorplanCanvasSVG to use ReactElement instead of the removed global JSX namespace (React 19), and pair the client Dev branch with the renderer fork that carries the custom features in CI. How sounds are managed (works with any CMS): Sounds are rows in the `soundboard_sounds` table: id, name, url, enabled, sort_order The emulator loads every row with enabled=1 (ordered by sort_order, id) and sends the list to clients on room enter; the client plays `url` directly, so any publicly reachable audio URL works (mp3/ogg/wav). To add a sound from an admin/housekeeping panel of any CMS: 1. Upload the audio file to wherever the CMS stores public assets (same approach as custom badge images). 2. INSERT a row into `soundboard_sounds` with the display name and the public URL of the uploaded file, enabled = 1. 3. Reload the emulator soundboard (or restart) to pick it up. Relative urls resolve against the `soundboard.url.prefix` config key (falls back to `asset.url`); absolute urls are used as-is. --- .github/workflows/ci.yml | 8 ++ public/configuration/wheel-texts-en.example | 6 +- public/configuration/wheel-texts-it.example | 6 +- src/api/index.ts | 1 + src/api/soundboard/SoundboardRoomState.ts | 7 ++ src/api/soundboard/index.ts | 1 + src/components/MainView.tsx | 2 + .../views/FloorplanCanvasSVG.tsx | 4 +- .../NavigatorRoomSettingsMiscTabView.tsx | 28 ++++++- src/components/soundboard/SoundboardView.tsx | 74 +++++++++++++++++++ src/components/toolbar/ToolbarView.tsx | 14 +++- src/css/icons/icons.css | 7 ++ src/hooks/index.ts | 1 + src/hooks/soundboard/useSoundboard.ts | 73 ++++++++++++++++++ 14 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/api/soundboard/SoundboardRoomState.ts create mode 100644 src/api/soundboard/index.ts create mode 100644 src/components/soundboard/SoundboardView.tsx create mode 100644 src/hooks/soundboard/useSoundboard.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..2760f66 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: @@ -93,6 +94,13 @@ jobs: 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" diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example index 71b3b1b..f10f302 100644 --- a/public/configuration/wheel-texts-en.example +++ b/public/configuration/wheel-texts-en.example @@ -5,5 +5,9 @@ "wheel.spin": "SPIN", "wheel.buy": "Buy spin", "wheel.winners": "Latest winners", - "wheel.winners.empty": "No winners yet" + "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" } diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example index f5bb934..0cd342e 100644 --- a/public/configuration/wheel-texts-it.example +++ b/public/configuration/wheel-texts-it.example @@ -5,5 +5,9 @@ "wheel.spin": "GIRA", "wheel.buy": "Compra giro", "wheel.winners": "Ultimi vincitori", - "wheel.winners.empty": "Ancora nessun vincitore" + "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" } 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 494e948..39ae1b8 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -26,6 +26,7 @@ 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 { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -180,6 +181,7 @@ export const MainView: FC<{}> = props => + ); 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/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/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx new file mode 100644 index 0000000..6508d78 --- /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 e088a62..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(() => { @@ -268,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" /> @@ -386,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = 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 0a81781..17a903f 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -216,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/index.ts b/src/hooks/index.ts index 4b3c1c3..dc3d9a6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,6 +22,7 @@ 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/soundboard/useSoundboard.ts b/src/hooks/soundboard/useSoundboard.ts new file mode 100644 index 0000000..d436962 --- /dev/null +++ b/src/hooks/soundboard/useSoundboard.ts @@ -0,0 +1,73 @@ +import { ISoundboardSound, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api'; +import { useMessageEvent } from '../events'; + +// 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('//')) 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 [ sounds, setSounds ] = useState([]); + const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); + + useMessageEvent(SoundboardSettingsEvent, event => + { + const parser = event.getParser(); + setEnabled(parser.enabled); + setSounds(parser.sounds); + setSoundboardRoomEnabled(parser.enabled); + }); + + useMessageEvent(SoundboardPlayEvent, event => + { + const parser = event.getParser(); + const url = resolveUrl(parser.url); + + if(url) + { + try + { + const audio = new Audio(url); + audio.volume = 0.8; + void audio.play().catch(() => {}); + } + catch {} + } + + setLastPlayed({ soundId: parser.soundId, username: parser.username }); + }); + + const play = useCallback((soundId: number) => SendMessageComposer(new SoundboardPlayComposer(soundId)), []); + 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); + setSounds([]); + setLastPlayed(null); + setSoundboardRoomEnabled(false); + }, []); + + return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset }; +}; + +export const useSoundboard = () => useBetween(useSoundboardState); From 48ed3ad7ba56ae5e6c062b0d103c805374344f40 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:20:20 +0200 Subject: [PATCH 3/5] fix: show furniture-occupied tiles in the floor plan editor The editor never requested occupied tiles, so tiles holding furniture were indistinguishable from empty floor and could be edited/voided. - request GetOccupiedTilesMessageComposer when the editor opens - handle RoomOccupiedTilesMessageEvent -> SET_OCCUPIED_TILES - new Tile.occupied flag (kept separate from `blocked`/void): occupied tiles render with a distinct marker and are protected from PAINT/ ERASE/ADJUST and brush-to-selection edits - occupied is purely informational and never changes the saved tilemap (no accidental voiding of floor under furni) Tests: reducer cases for SET_OCCUPIED_TILES + edit protection; container test asserts the occupied event is non-destructive on save; route the canvas pointer test through elementFromPoint (jsdom has no getScreenCTM). --- .../FloorplanEditorView.test.tsx | 7 +++-- .../floorplan-editor/FloorplanEditorView.tsx | 10 ++++++- .../floorplan-editor/state/reducer.test.ts | 30 +++++++++++++++++++ .../floorplan-editor/state/reducer.ts | 21 ++++++++++++- .../floorplan-editor/state/types.ts | 6 +++- .../views/FloorplanCanvasSVG.test.tsx | 9 +++++- .../floorplan-editor/views/FloorplanTile.tsx | 11 +++++++ 7 files changed, 87 insertions(+), 7 deletions(-) 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/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 && ( Date: Thu, 28 May 2026 10:19:16 +0200 Subject: [PATCH 4/5] feat: soundboard pads can load from a JSON5 file (DB fallback) When the server (soundboard_sounds table) returns no pads, the client now loads them from a JSON5 config file (loadGamedata accepts plain JSON and JSON5). Useful when the DB / CMS isn't set up yet. File-defined pads play locally for the clicker; DB-backed pads still go through the server broadcast so everyone in the room hears them. Ships a radio-style soundboard-sounds.json5.example template. --- .../soundboard-sounds.json5.example | 20 +++++ src/components/soundboard/SoundboardView.tsx | 2 +- src/hooks/soundboard/useSoundboard.ts | 87 ++++++++++++++----- 3 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 public/configuration/soundboard-sounds.json5.example 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/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx index 6508d78..76d7a80 100644 --- a/src/components/soundboard/SoundboardView.tsx +++ b/src/components/soundboard/SoundboardView.tsx @@ -53,7 +53,7 @@ export const SoundboardView: FC<{}> = () => { sounds.map(sound => ( +
+
{ 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/hooks/index.ts b/src/hooks/index.ts index dc3d9a6..8a85f2a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -15,6 +15,7 @@ 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'; 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);