Add room scene foundation for Blobbi room customization (Phase 1 POC)
Implement the initial room-scene architecture for the home room: - Declarative wall types (paint, wallpaper, brick) and floor types (wood, tile, carpet) - Floor rendering with CSS 3D perspective transform for realistic depth - Optional theme-based color derivation (palette input, not full replacement) - Safe persistence in kind 11125 content under 'roomCustomization' section - Scene layer renders behind Blobbi in the center content area only Architecture: types, defaults, resolver, content helpers, render components, and a useRoomScene hook — all isolated under src/blobbi/rooms/scene/.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* BlobbiHomeRoom — The main living / play room.
|
||||
*
|
||||
* Layout:
|
||||
* - Room scene background (wall + floor with perspective)
|
||||
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
|
||||
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
|
||||
* - Inline activity (music player, sing card) above the bottom bar
|
||||
@@ -21,6 +22,7 @@ import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
import { RoomSceneLayer, useRoomScene } from '../scene';
|
||||
|
||||
interface BlobbiHomeRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
@@ -29,6 +31,7 @@ interface BlobbiHomeRoomProps {
|
||||
|
||||
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
const {
|
||||
profile,
|
||||
isActiveFloatingCompanion,
|
||||
setShowPhotoModal,
|
||||
isCurrentCompanion,
|
||||
@@ -52,6 +55,9 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
actionInProgress,
|
||||
} = ctx;
|
||||
|
||||
// ── Room Scene (wall + floor behind Blobbi) ──
|
||||
const roomScene = useRoomScene('home', profile?.event?.content ?? '');
|
||||
|
||||
// Build carousel entries: toys + music + sing
|
||||
const carouselItems = useMemo<CarouselEntry[]>(() => {
|
||||
const toys = getLiveShopItems()
|
||||
@@ -87,7 +93,10 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex flex-col flex-1 min-h-0 relative">
|
||||
{/* ── Room Scene Background ── */}
|
||||
<RoomSceneLayer scene={roomScene} />
|
||||
|
||||
{/* ── Hero (Blobbi + stats) ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// src/blobbi/rooms/scene/components/FloorLayer.tsx
|
||||
|
||||
/**
|
||||
* FloorLayer — Renders the floor surface with visual depth.
|
||||
*
|
||||
* The floor receives CSS 3D perspective from its parent container
|
||||
* (RoomSceneLayer). This component renders the surface pattern only.
|
||||
* Different floor types produce different textures:
|
||||
*
|
||||
* - wood: Horizontal planks with grain lines and color variation
|
||||
* - tile: Checkerboard/grid pattern
|
||||
* - carpet: Solid textured surface
|
||||
*
|
||||
* The component fills its parent container entirely.
|
||||
*/
|
||||
|
||||
import { useMemo, useId } from 'react';
|
||||
import { darkenHex, lightenHex, blendHex } from '@/lib/colorUtils';
|
||||
import type { FloorConfig } from '../types';
|
||||
|
||||
interface FloorLayerProps {
|
||||
config: FloorConfig;
|
||||
}
|
||||
|
||||
export function FloorLayer({ config }: FloorLayerProps) {
|
||||
const { type, color, accentColor } = config;
|
||||
|
||||
switch (type) {
|
||||
case 'wood':
|
||||
return <WoodFloor color={color} accentColor={accentColor} />;
|
||||
case 'tile':
|
||||
return <TileFloor color={color} accentColor={accentColor} />;
|
||||
case 'carpet':
|
||||
return <CarpetFloor color={color} />;
|
||||
default:
|
||||
return <WoodFloor color={color} accentColor={accentColor} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wood Floor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WoodFloor({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const patternId = useId();
|
||||
const grainColor = accentColor ?? darkenHex(color, 0.18);
|
||||
const plankGap = darkenHex(color, 0.3);
|
||||
|
||||
// Alternate plank colors for natural variation
|
||||
const plankColors = useMemo(() => [
|
||||
color,
|
||||
lightenHex(color, 0.05),
|
||||
darkenHex(color, 0.04),
|
||||
blendHex(color, grainColor, 0.15),
|
||||
lightenHex(color, 0.03),
|
||||
darkenHex(color, 0.07),
|
||||
], [color, grainColor]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
{/* Base fill */}
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* SVG plank pattern for realistic wood */}
|
||||
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width="100%"
|
||||
height="240"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* 6 planks, each 38px tall with 2px gap */}
|
||||
{plankColors.map((pc, i) => (
|
||||
<g key={i}>
|
||||
{/* Plank body */}
|
||||
<rect
|
||||
x="0"
|
||||
y={i * 40}
|
||||
width="100%"
|
||||
height="38"
|
||||
fill={pc}
|
||||
/>
|
||||
{/* Subtle grain lines within plank */}
|
||||
<line
|
||||
x1="0" y1={i * 40 + 12}
|
||||
x2="100%" y2={i * 40 + 13}
|
||||
stroke={grainColor}
|
||||
strokeWidth="0.5"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<line
|
||||
x1="0" y1={i * 40 + 26}
|
||||
x2="100%" y2={i * 40 + 25}
|
||||
stroke={grainColor}
|
||||
strokeWidth="0.3"
|
||||
opacity="0.1"
|
||||
/>
|
||||
{/* Plank gap line */}
|
||||
<rect
|
||||
x="0"
|
||||
y={i * 40 + 38}
|
||||
width="100%"
|
||||
height="2"
|
||||
fill={plankGap}
|
||||
opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${CSS.escape(patternId)})`} />
|
||||
</svg>
|
||||
|
||||
{/* Subtle light gradient: lighter near wall, darker in distance */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 30%, rgba(0,0,0,0.12) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tile Floor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TileFloor({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const groutColor = accentColor ?? darkenHex(color, 0.2);
|
||||
const altTile = lightenHex(color, 0.06);
|
||||
|
||||
// Checkerboard tile pattern via CSS gradients
|
||||
const tilePattern = useMemo(() => {
|
||||
const size = 50; // tile size in px
|
||||
|
||||
return {
|
||||
backgroundImage: [
|
||||
// Checkerboard: conic gradient creates four quadrants
|
||||
`conic-gradient(${altTile} 0.25turn, ${color} 0.25turn 0.5turn, ${altTile} 0.5turn 0.75turn, ${color} 0.75turn)`,
|
||||
].join(', '),
|
||||
backgroundSize: `${size * 2}px ${size * 2}px`,
|
||||
};
|
||||
}, [color, altTile]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0" style={tilePattern} />
|
||||
|
||||
{/* Grout lines overlay */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
`repeating-linear-gradient(0deg, ${groutColor} 0px, ${groutColor} 1px, transparent 1px, transparent 50px)`,
|
||||
`repeating-linear-gradient(90deg, ${groutColor} 0px, ${groutColor} 1px, transparent 1px, transparent 50px)`,
|
||||
].join(', '),
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Light gradient for depth */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 40%, rgba(0,0,0,0.10) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Carpet Floor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CarpetFloor({ color }: { color: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
{/* Carpet texture: very subtle noise */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.06] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '150px 150px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Light gradient for depth */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.05) 0%, transparent 40%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// src/blobbi/rooms/scene/components/RoomSceneLayer.tsx
|
||||
|
||||
/**
|
||||
* RoomSceneLayer — The composite room background behind Blobbi.
|
||||
*
|
||||
* Renders as an absolutely-positioned layer that fills its parent entirely.
|
||||
* Must be placed inside a container with `position: relative`.
|
||||
*
|
||||
* Visual structure (top to bottom):
|
||||
* ┌──────────────────────────┐
|
||||
* │ │ Wall (~62% of height)
|
||||
* │ WallLayer │ Flat, front-facing
|
||||
* │ │
|
||||
* ├──────────────────────────┤ Baseboard shadow
|
||||
* │ ╲ ╱ │
|
||||
* │ ╲ FloorLayer ╱ │ Floor (~38% of height)
|
||||
* │ ╲ ╱ │ CSS 3D perspective transform
|
||||
* └──────────────────────────┘
|
||||
*
|
||||
* The floor uses CSS `perspective` + `rotateX` with `transform-origin: top center`
|
||||
* to create depth. The top edge of the floor stays at the wall-floor junction
|
||||
* while the surface recedes into the distance, creating a natural room feel.
|
||||
*
|
||||
* The baseboard is a subtle shadow gradient at the junction line.
|
||||
*
|
||||
* A soft vignette around the edges adds subtle depth framing.
|
||||
*/
|
||||
|
||||
import type { ResolvedRoomScene } from '../types';
|
||||
import { WallLayer } from './WallLayer';
|
||||
import { FloorLayer } from './FloorLayer';
|
||||
|
||||
interface RoomSceneLayerProps {
|
||||
scene: ResolvedRoomScene;
|
||||
}
|
||||
|
||||
/** Wall occupies the top portion, floor the bottom. */
|
||||
const WALL_PERCENT = 62;
|
||||
const FLOOR_PERCENT = 100 - WALL_PERCENT; // 38%
|
||||
|
||||
export function RoomSceneLayer({ scene }: RoomSceneLayerProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none select-none"
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
{/* ── Wall Area ── */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0"
|
||||
style={{ height: `${WALL_PERCENT}%` }}
|
||||
>
|
||||
<WallLayer config={scene.wall} />
|
||||
</div>
|
||||
|
||||
{/* ── Baseboard / Junction Shadow ── */}
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
top: `calc(${WALL_PERCENT}% - 6px)`,
|
||||
height: '12px',
|
||||
background: 'linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.08) 40%, rgba(0,0,0,0.12) 60%, rgba(0,0,0,0.04) 100%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Floor Area with Perspective ── */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: `${WALL_PERCENT}%`,
|
||||
height: `${FLOOR_PERCENT}%`,
|
||||
// Perspective container: the vanishing point is at the center
|
||||
// of the wall-floor junction line.
|
||||
perspective: '500px',
|
||||
perspectiveOrigin: '50% 0%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
// Tilt the floor plane backward to create depth.
|
||||
// transform-origin at top center keeps the junction line fixed.
|
||||
transformOrigin: 'top center',
|
||||
transform: 'rotateX(28deg)',
|
||||
// Extend 80% taller to cover any gaps from the perspective
|
||||
// foreshortening at the bottom edge.
|
||||
height: '180%',
|
||||
}}
|
||||
>
|
||||
<FloorLayer config={scene.floor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Soft Vignette ── */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 85% 70% at 50% 45%, transparent 55%, rgba(0,0,0,0.06) 100%)',
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/blobbi/rooms/scene/components/WallLayer.tsx
|
||||
|
||||
/**
|
||||
* WallLayer — Renders the wall surface behind Blobbi.
|
||||
*
|
||||
* The wall is always front-facing (no perspective transform). Different
|
||||
* wall types produce different visual textures on top of the base color:
|
||||
*
|
||||
* - paint: Solid color with a subtle depth gradient
|
||||
* - wallpaper: Repeating pattern overlay (diamond/dots)
|
||||
* - brick: Brick masonry pattern via CSS gradients
|
||||
*
|
||||
* The component fills its parent container entirely.
|
||||
*/
|
||||
|
||||
import { useMemo, useId } from 'react';
|
||||
import { darkenHex, lightenHex } from '@/lib/colorUtils';
|
||||
import type { WallConfig } from '../types';
|
||||
|
||||
interface WallLayerProps {
|
||||
config: WallConfig;
|
||||
}
|
||||
|
||||
export function WallLayer({ config }: WallLayerProps) {
|
||||
const { type, color, accentColor } = config;
|
||||
|
||||
switch (type) {
|
||||
case 'paint':
|
||||
return <PaintWall color={color} />;
|
||||
case 'wallpaper':
|
||||
return <WallpaperWall color={color} accentColor={accentColor} />;
|
||||
case 'brick':
|
||||
return <BrickWall color={color} accentColor={accentColor} />;
|
||||
default:
|
||||
return <PaintWall color={color} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Paint Wall ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PaintWall({ color }: { color: string }) {
|
||||
// Subtle gradient from slightly lighter at top to slightly darker at bottom
|
||||
// simulates the natural light fall-off in a room.
|
||||
const topColor = lightenHex(color, 0.04);
|
||||
const bottomColor = darkenHex(color, 0.06);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${topColor} 0%, ${color} 40%, ${bottomColor} 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Very subtle noise texture for a painted-surface feel */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '200px 200px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wallpaper Wall ───────────────────────────────────────────────────────────
|
||||
|
||||
function WallpaperWall({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const patternId = useId();
|
||||
const patternColor = accentColor ?? darkenHex(color, 0.15);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
{/* SVG diamond trellis pattern */}
|
||||
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width="24"
|
||||
height="24"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* Small diamond at center */}
|
||||
<path
|
||||
d="M12 2 L22 12 L12 22 L2 12 Z"
|
||||
fill="none"
|
||||
stroke={patternColor}
|
||||
strokeWidth="0.6"
|
||||
opacity="0.15"
|
||||
/>
|
||||
{/* Tiny dot at intersections */}
|
||||
<circle cx="12" cy="12" r="1" fill={patternColor} opacity="0.1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${CSS.escape(patternId)})`} />
|
||||
</svg>
|
||||
{/* Same subtle depth gradient as paint */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 40%, rgba(0,0,0,0.05) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brick Wall ───────────────────────────────────────────────────────────────
|
||||
|
||||
function BrickWall({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const mortarColor = accentColor ?? darkenHex(color, 0.25);
|
||||
|
||||
// CSS-only brick pattern using repeating-linear-gradient
|
||||
// Creates the characteristic offset-row masonry look.
|
||||
const brickPattern = useMemo(() => {
|
||||
const brickH = 20; // brick height in px
|
||||
const mortarW = 2; // mortar line width
|
||||
const brickW = 50; // brick width in px
|
||||
|
||||
return {
|
||||
backgroundImage: [
|
||||
// Horizontal mortar lines
|
||||
`repeating-linear-gradient(
|
||||
180deg,
|
||||
${mortarColor} 0px,
|
||||
${mortarColor} ${mortarW}px,
|
||||
transparent ${mortarW}px,
|
||||
transparent ${brickH + mortarW}px
|
||||
)`,
|
||||
// Vertical mortar lines (even rows)
|
||||
`repeating-linear-gradient(
|
||||
90deg,
|
||||
${mortarColor} 0px,
|
||||
${mortarColor} ${mortarW}px,
|
||||
transparent ${mortarW}px,
|
||||
transparent ${brickW + mortarW}px
|
||||
)`,
|
||||
].join(', '),
|
||||
backgroundSize: `${brickW + mortarW}px ${(brickH + mortarW) * 2}px`,
|
||||
// Offset odd rows by half a brick width
|
||||
backgroundPosition: `0 0, ${(brickW + mortarW) / 2}px ${brickH + mortarW}px`,
|
||||
};
|
||||
}, [mortarColor]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={brickPattern}
|
||||
/>
|
||||
{/* Subtle depth gradient */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.02) 0%, transparent 50%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/blobbi/rooms/scene/defaults.ts
|
||||
|
||||
/**
|
||||
* Default room scenes — the initial visual configuration for each room.
|
||||
*
|
||||
* These defaults are used when a room has no persisted customization.
|
||||
* Only the `home` room is defined for the Phase 1 POC; other rooms
|
||||
* will get defaults as customization is rolled out to them.
|
||||
*
|
||||
* Design notes:
|
||||
* - Colors are warm, neutral, and cozy — a pleasant default that works
|
||||
* in both light and dark app themes.
|
||||
* - The home room uses a cream/off-white wall with warm wood flooring,
|
||||
* evoking a comfortable living room.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
import type { RoomScene } from './types';
|
||||
|
||||
// ─── Home Room Default ────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_HOME_SCENE: RoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: {
|
||||
type: 'paint',
|
||||
color: '#f5f0eb', // warm cream
|
||||
},
|
||||
floor: {
|
||||
type: 'wood',
|
||||
color: '#c4a882', // warm medium wood
|
||||
accentColor: '#a08060', // darker wood grain
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default Scene Registry ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default scenes keyed by room ID.
|
||||
*
|
||||
* Not every room needs a scene default right now — only rooms that
|
||||
* have scene rendering enabled. Future phases will add more entries.
|
||||
*/
|
||||
export const DEFAULT_ROOM_SCENES: Partial<Record<BlobbiRoomId, RoomScene>> = {
|
||||
home: DEFAULT_HOME_SCENE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default scene for a room, or undefined if the room
|
||||
* has no default (scene not yet available for that room).
|
||||
*/
|
||||
export function getDefaultScene(roomId: BlobbiRoomId): RoomScene | undefined {
|
||||
return DEFAULT_ROOM_SCENES[roomId];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// src/blobbi/rooms/scene/hooks/useRoomScene.ts
|
||||
|
||||
/**
|
||||
* useRoomScene — Hook that resolves the active room scene for a given room.
|
||||
*
|
||||
* Data flow:
|
||||
* 1. Read persisted `roomCustomization` from the profile event content
|
||||
* 2. Look up the scene for the requested room ID (fallback to default)
|
||||
* 3. If `useThemeColors` is true, resolve colors from the active app theme
|
||||
* 4. Return the fully resolved scene, ready for rendering
|
||||
*
|
||||
* The hook is memoized to avoid unnecessary re-renders. It only recomputes
|
||||
* when the profile content, room ID, or theme config changes.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { BlobbiRoomId } from '../../lib/room-config';
|
||||
import type { ResolvedRoomScene } from '../types';
|
||||
import { getDefaultScene } from '../defaults';
|
||||
import { DEFAULT_HOME_SCENE } from '../defaults';
|
||||
import { parseRoomCustomization } from '../lib/room-scene-content';
|
||||
import { getActiveThemeColors, resolveRoomScene } from '../resolver';
|
||||
|
||||
/**
|
||||
* Resolve the active room scene for a given room.
|
||||
*
|
||||
* @param roomId - The room to get the scene for
|
||||
* @param eventContent - The raw kind 11125 event content string (or empty)
|
||||
* @returns The fully resolved scene with concrete colors
|
||||
*/
|
||||
export function useRoomScene(
|
||||
roomId: BlobbiRoomId,
|
||||
eventContent: string,
|
||||
): ResolvedRoomScene {
|
||||
const { config } = useAppContext();
|
||||
|
||||
// Parse persisted room customization from content
|
||||
const customization = useMemo(
|
||||
() => parseRoomCustomization(eventContent),
|
||||
[eventContent],
|
||||
);
|
||||
|
||||
// Get the scene for this room: persisted → default → ultimate fallback
|
||||
const scene = useMemo(
|
||||
() => customization?.[roomId] ?? getDefaultScene(roomId) ?? DEFAULT_HOME_SCENE,
|
||||
[customization, roomId],
|
||||
);
|
||||
|
||||
// Get current theme colors for potential theme-based resolution
|
||||
const themeColors = useMemo(
|
||||
() => getActiveThemeColors(config),
|
||||
// Only the fields that affect color resolution
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[config.theme, config.customTheme?.colors, config.themes],
|
||||
);
|
||||
|
||||
// Resolve final colors (applies theme if enabled)
|
||||
const resolved = useMemo(
|
||||
() => resolveRoomScene(scene, themeColors),
|
||||
[scene, themeColors],
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// src/blobbi/rooms/scene/index.ts — barrel export
|
||||
|
||||
// ── Types ──
|
||||
export type {
|
||||
WallType,
|
||||
FloorType,
|
||||
WallConfig,
|
||||
FloorConfig,
|
||||
RoomScene,
|
||||
ResolvedRoomScene,
|
||||
RoomCustomizationMap,
|
||||
} from './types';
|
||||
|
||||
// ── Defaults ──
|
||||
export { DEFAULT_HOME_SCENE, DEFAULT_ROOM_SCENES, getDefaultScene } from './defaults';
|
||||
|
||||
// ── Resolver ──
|
||||
export { resolveRoomScene, getActiveThemeColors } from './resolver';
|
||||
|
||||
// ── Persistence ──
|
||||
export {
|
||||
parseRoomCustomization,
|
||||
updateRoomSceneContent,
|
||||
removeRoomSceneContent,
|
||||
} from './lib/room-scene-content';
|
||||
|
||||
// ── Hook ──
|
||||
export { useRoomScene } from './hooks/useRoomScene';
|
||||
|
||||
// ── Components ──
|
||||
export { RoomSceneLayer } from './components/RoomSceneLayer';
|
||||
export { WallLayer } from './components/WallLayer';
|
||||
export { FloorLayer } from './components/FloorLayer';
|
||||
@@ -0,0 +1,197 @@
|
||||
// src/blobbi/rooms/scene/lib/room-scene-content.ts
|
||||
|
||||
/**
|
||||
* Room Scene Persistence — Read/write helpers for the `roomCustomization`
|
||||
* section inside kind 11125 content JSON.
|
||||
*
|
||||
* ── Content Safety ────────────────────────────────────────────────────────
|
||||
*
|
||||
* These helpers follow the same safety contract as the existing content
|
||||
* helpers in `content-json.ts` and `blobbonaut-content.ts`:
|
||||
*
|
||||
* 1. Only the `roomCustomization` key is modified
|
||||
* 2. All sibling sections (dailyMissions, progression, unknown keys)
|
||||
* are preserved untouched
|
||||
* 3. Within `roomCustomization`, only the targeted room ID is modified
|
||||
* 4. Other rooms within the map are preserved
|
||||
* 5. Invalid/corrupt content is handled gracefully
|
||||
*
|
||||
* ── Persisted Shape ──────────────────────────────────────────────────────
|
||||
*
|
||||
* {
|
||||
* "roomCustomization": {
|
||||
* "home": {
|
||||
* "useThemeColors": false,
|
||||
* "wall": { "type": "paint", "color": "#f5f0eb" },
|
||||
* "floor": { "type": "wood", "color": "#c4a882", "accentColor": "#a08060" }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
import { safeParseContent, updateContentSection } from '@/blobbi/core/lib/content-json';
|
||||
import type { RoomScene, WallConfig, FloorConfig, RoomCustomizationMap } from '../types';
|
||||
|
||||
// ─── Validation Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const VALID_WALL_TYPES = new Set(['paint', 'wallpaper', 'brick']);
|
||||
const VALID_FLOOR_TYPES = new Set(['wood', 'tile', 'carpet']);
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
// ─── Validation Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHexColor(v: unknown): v is string {
|
||||
return typeof v === 'string' && HEX_COLOR_RE.test(v);
|
||||
}
|
||||
|
||||
function validateWallConfig(raw: unknown): WallConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.type !== 'string' || !VALID_WALL_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
|
||||
return {
|
||||
type: obj.type as WallConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateFloorConfig(raw: unknown): FloorConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.type !== 'string' || !VALID_FLOOR_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
|
||||
return {
|
||||
type: obj.type as FloorConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRoomScene(raw: unknown): RoomScene | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const wall = validateWallConfig(obj.wall);
|
||||
const floor = validateFloorConfig(obj.floor);
|
||||
if (!wall || !floor) return null;
|
||||
|
||||
return {
|
||||
useThemeColors: obj.useThemeColors === true,
|
||||
wall,
|
||||
floor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the `roomCustomization` section from kind 11125 content.
|
||||
*
|
||||
* Returns a validated map of room ID → RoomScene, or undefined
|
||||
* if the section is missing or entirely invalid. Individual rooms
|
||||
* with invalid data are silently dropped (not propagated).
|
||||
*/
|
||||
export function parseRoomCustomization(content: string): RoomCustomizationMap | undefined {
|
||||
const { data } = safeParseContent(content);
|
||||
const rc = data.roomCustomization;
|
||||
|
||||
if (!rc || typeof rc !== 'object' || Array.isArray(rc)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: RoomCustomizationMap = {};
|
||||
let hasEntries = false;
|
||||
|
||||
for (const [roomId, raw] of Object.entries(rc as Record<string, unknown>)) {
|
||||
const validated = validateRoomScene(raw);
|
||||
if (validated) {
|
||||
// Cast is safe: we only persist valid BlobbiRoomId keys, but we
|
||||
// also tolerate unknown room IDs gracefully (they're just ignored
|
||||
// during rendering but preserved during write-back).
|
||||
result[roomId as keyof RoomCustomizationMap] = validated;
|
||||
hasEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasEntries ? result : undefined;
|
||||
}
|
||||
|
||||
// ─── Writing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update a single room's scene in the `roomCustomization` content section.
|
||||
*
|
||||
* Safety guarantees:
|
||||
* 1. All other top-level content sections are preserved (dailyMissions,
|
||||
* progression, unknown keys)
|
||||
* 2. Other rooms within `roomCustomization` are preserved
|
||||
* 3. Only the specified room's scene is updated
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param roomId - The room to update
|
||||
* @param scene - The new scene for that room
|
||||
* @returns The serialized content string with the room's scene updated
|
||||
*/
|
||||
export function updateRoomSceneContent(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
scene: RoomScene,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Get existing roomCustomization map, or start fresh
|
||||
const existingMap = (
|
||||
data.roomCustomization &&
|
||||
typeof data.roomCustomization === 'object' &&
|
||||
!Array.isArray(data.roomCustomization)
|
||||
)
|
||||
? { ...(data.roomCustomization as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
// Update only the targeted room
|
||||
existingMap[roomId] = scene;
|
||||
|
||||
// Write back via the standard section updater (preserves all sibling sections)
|
||||
return updateContentSection(existingContent, 'roomCustomization', existingMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a room's scene from the `roomCustomization` content section.
|
||||
*
|
||||
* Used when resetting a room back to its default scene.
|
||||
* If this was the last room, the `roomCustomization` key is removed entirely.
|
||||
*
|
||||
* @param existingContent - The current `event.content` string
|
||||
* @param roomId - The room to remove
|
||||
* @returns The serialized content string
|
||||
*/
|
||||
export function removeRoomSceneContent(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
if (
|
||||
!data.roomCustomization ||
|
||||
typeof data.roomCustomization !== 'object' ||
|
||||
Array.isArray(data.roomCustomization)
|
||||
) {
|
||||
return existingContent; // Nothing to remove
|
||||
}
|
||||
|
||||
const existingMap = { ...(data.roomCustomization as Record<string, unknown>) };
|
||||
delete existingMap[roomId];
|
||||
|
||||
// If map is now empty, remove the section entirely
|
||||
if (Object.keys(existingMap).length === 0) {
|
||||
const { roomCustomization: _, ...rest } = data;
|
||||
return JSON.stringify(rest);
|
||||
}
|
||||
|
||||
return updateContentSection(existingContent, 'roomCustomization', existingMap);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// src/blobbi/rooms/scene/resolver.ts
|
||||
|
||||
/**
|
||||
* Room Scene Resolver — Applies optional theme-based colors to a scene.
|
||||
*
|
||||
* The resolver takes a declarative RoomScene and the current theme's
|
||||
* core colors, and produces a ResolvedRoomScene with final concrete colors.
|
||||
*
|
||||
* ── Theme as Palette Input ────────────────────────────────────────────────
|
||||
*
|
||||
* The theme does NOT replace the room scene. It only influences the
|
||||
* color palette when `scene.useThemeColors` is true:
|
||||
*
|
||||
* - Wall/floor *types* always come from the scene declaration
|
||||
* - Only the *colors* are derived from the theme
|
||||
* - If theme colors are unavailable, falls back to scene-local colors
|
||||
*
|
||||
* Color derivation strategy:
|
||||
* - Wall color: derived from the theme's background color (warmed slightly)
|
||||
* - Floor color: derived from the theme's primary color (earthy/muted version)
|
||||
* - Floor accent: a darker shade of the floor color
|
||||
*/
|
||||
|
||||
import type { CoreThemeColors } from '@/themes';
|
||||
import type { AppConfig, Theme } from '@/contexts/AppContext';
|
||||
import { builtinThemes, resolveTheme, resolveThemeConfig } from '@/themes';
|
||||
import {
|
||||
parseHsl,
|
||||
hslToRgb,
|
||||
rgbToHex,
|
||||
darkenHex,
|
||||
formatHsl,
|
||||
} from '@/lib/colorUtils';
|
||||
import type { RoomScene, ResolvedRoomScene } from './types';
|
||||
|
||||
// ─── Theme Color Extraction ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the currently active CoreThemeColors from the app config.
|
||||
*
|
||||
* Resolves through the full theme chain:
|
||||
* system → light/dark OS preference
|
||||
* custom → user's custom theme colors
|
||||
* light/dark → builtin or configured theme colors
|
||||
*/
|
||||
export function getActiveThemeColors(config: AppConfig): CoreThemeColors {
|
||||
const resolved: 'light' | 'dark' | 'custom' = resolveTheme(config.theme as Theme);
|
||||
|
||||
if (resolved === 'custom') {
|
||||
return config.customTheme?.colors ?? builtinThemes.dark;
|
||||
}
|
||||
|
||||
return resolveThemeConfig(resolved, config.themes).colors;
|
||||
}
|
||||
|
||||
// ─── HSL-to-Hex Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/** Convert an HSL string (e.g. "228 20% 10%") to a hex color. */
|
||||
function hslStringToHex(hsl: string): string {
|
||||
const { h, s, l } = parseHsl(hsl);
|
||||
const [r, g, b] = hslToRgb(h, s, l);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ─── Color Derivation from Theme ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive a wall color from the theme's background.
|
||||
*
|
||||
* Strategy: take the background hue, warm it slightly (shift toward yellow),
|
||||
* increase saturation gently, and push lightness toward a wall-appropriate
|
||||
* range (60-85% lightness for walls).
|
||||
*/
|
||||
function deriveWallColor(themeColors: CoreThemeColors): string {
|
||||
const bg = parseHsl(themeColors.background);
|
||||
|
||||
// Warm the hue: shift slightly toward 30 (warm/golden)
|
||||
const warmHue = bg.h + (30 - bg.h) * 0.15;
|
||||
// Gentle saturation: enough to feel warm, not garish
|
||||
const wallSat = Math.min(35, Math.max(10, bg.s * 0.6 + 8));
|
||||
// Lightness: walls should be light-ish regardless of dark/light theme
|
||||
const wallLit = Math.min(88, Math.max(65, bg.l * 0.3 + 60));
|
||||
|
||||
const [r, g, b] = hslToRgb(warmHue, wallSat, wallLit);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a floor color from the theme's primary color.
|
||||
*
|
||||
* Strategy: take the primary hue, shift it toward an earthy/warm tone,
|
||||
* significantly desaturate it, and bring lightness to a floor-appropriate
|
||||
* range (35-55% — darker than walls for visual grounding).
|
||||
*/
|
||||
function deriveFloorColor(themeColors: CoreThemeColors): string {
|
||||
const primary = parseHsl(themeColors.primary);
|
||||
|
||||
// Shift hue toward warm/brown (30°), more aggressively than wall
|
||||
const earthyHue = primary.h + (30 - primary.h) * 0.35;
|
||||
// Desaturate significantly for an earthy/natural feel
|
||||
const floorSat = Math.min(40, Math.max(15, primary.s * 0.35 + 10));
|
||||
// Lightness: middle range, grounding the room
|
||||
const floorLit = Math.min(55, Math.max(38, primary.l * 0.4 + 25));
|
||||
|
||||
const hsl = formatHsl(earthyHue, floorSat, floorLit);
|
||||
return hslStringToHex(hsl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a floor accent color (darker shade of the floor color).
|
||||
*/
|
||||
function deriveFloorAccent(floorHex: string): string {
|
||||
return darkenHex(floorHex, 0.2);
|
||||
}
|
||||
|
||||
// ─── Scene Resolver ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a room scene into final concrete colors.
|
||||
*
|
||||
* When `scene.useThemeColors` is true AND themeColors are provided,
|
||||
* the wall and floor colors are derived from the theme palette.
|
||||
* Wall/floor types are always preserved from the scene declaration.
|
||||
*
|
||||
* Falls back to scene-local colors when:
|
||||
* - `scene.useThemeColors` is false
|
||||
* - `themeColors` is undefined/null
|
||||
* - Color derivation produces invalid values (defensive)
|
||||
*/
|
||||
export function resolveRoomScene(
|
||||
scene: RoomScene,
|
||||
themeColors?: CoreThemeColors,
|
||||
): ResolvedRoomScene {
|
||||
// If theme colors not requested or not available, use scene-local colors
|
||||
if (!scene.useThemeColors || !themeColors) {
|
||||
return {
|
||||
wall: { ...scene.wall },
|
||||
floor: { ...scene.floor },
|
||||
};
|
||||
}
|
||||
|
||||
// Derive colors from theme
|
||||
const wallColor = deriveWallColor(themeColors);
|
||||
const floorColor = deriveFloorColor(themeColors);
|
||||
const floorAccent = deriveFloorAccent(floorColor);
|
||||
|
||||
return {
|
||||
wall: {
|
||||
...scene.wall,
|
||||
color: wallColor,
|
||||
// Accent color is also theme-derived when applicable
|
||||
...(scene.wall.accentColor ? { accentColor: darkenHex(wallColor, 0.1) } : {}),
|
||||
},
|
||||
floor: {
|
||||
...scene.floor,
|
||||
color: floorColor,
|
||||
accentColor: floorAccent,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// src/blobbi/rooms/scene/types.ts
|
||||
|
||||
/**
|
||||
* Room Scene Types — Declarative model for Blobbi room customization.
|
||||
*
|
||||
* A "room scene" defines the visual environment of a Blobbi room:
|
||||
* wall style, floor style, and optional theme color integration.
|
||||
*
|
||||
* The scene model is purely declarative — it describes *what* to render,
|
||||
* not *how*. Rendering is handled by the scene components (WallLayer,
|
||||
* FloorLayer, RoomSceneLayer). Resolution of theme-based colors is
|
||||
* handled by the resolver module.
|
||||
*
|
||||
* Designed for future expansion:
|
||||
* - More wall/floor types can be added to the unions
|
||||
* - Furniture slots can be added to RoomScene later
|
||||
* - Per-room scenes are keyed by BlobbiRoomId in the persistence map
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
|
||||
// ─── Wall Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Available wall surface types. */
|
||||
export type WallType = 'paint' | 'wallpaper' | 'brick';
|
||||
|
||||
/** Configuration for a room's wall. */
|
||||
export interface WallConfig {
|
||||
/** The wall surface type. */
|
||||
type: WallType;
|
||||
/** Primary wall color (hex, e.g. "#f5f0eb"). */
|
||||
color: string;
|
||||
/** Optional accent/pattern color (hex). Used by wallpaper and brick types. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
// ─── Floor Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Available floor surface types. */
|
||||
export type FloorType = 'wood' | 'tile' | 'carpet';
|
||||
|
||||
/** Configuration for a room's floor. */
|
||||
export interface FloorConfig {
|
||||
/** The floor surface type. */
|
||||
type: FloorType;
|
||||
/** Primary floor color (hex, e.g. "#c4a882"). */
|
||||
color: string;
|
||||
/** Optional accent color for patterns (hex). Used for wood grain, tile grout, etc. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
// ─── Room Scene ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A complete room scene declaration.
|
||||
*
|
||||
* This is the persisted shape — stored in kind 11125 content under the
|
||||
* `roomCustomization` section, keyed by room ID.
|
||||
*
|
||||
* When `useThemeColors` is true, the resolver derives wall/floor colors
|
||||
* from the active app theme. The wall/floor *types* always come from
|
||||
* the scene, only the *colors* are influenced by the theme.
|
||||
*
|
||||
* If the theme is missing or invalid, falls back to the scene's own colors.
|
||||
*/
|
||||
export interface RoomScene {
|
||||
/** Whether to derive colors from the active app theme instead of using local colors. */
|
||||
useThemeColors: boolean;
|
||||
/** Wall configuration. */
|
||||
wall: WallConfig;
|
||||
/** Floor configuration. */
|
||||
floor: FloorConfig;
|
||||
}
|
||||
|
||||
// ─── Resolved Scene ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A resolved room scene — final colors ready for rendering.
|
||||
*
|
||||
* This is the output of the resolver. Theme colors have been applied
|
||||
* (if enabled), and all values are concrete and ready to use.
|
||||
*/
|
||||
export interface ResolvedRoomScene {
|
||||
wall: WallConfig;
|
||||
floor: FloorConfig;
|
||||
}
|
||||
|
||||
// ─── Persistence Map ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The shape of the `roomCustomization` section in kind 11125 content.
|
||||
*
|
||||
* Maps room IDs to their scene configurations. Only rooms that have
|
||||
* been customized appear here — rooms without entries use defaults.
|
||||
*/
|
||||
export type RoomCustomizationMap = Partial<Record<BlobbiRoomId, RoomScene>>;
|
||||
Reference in New Issue
Block a user