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:
filemon
2026-04-07 11:54:27 -03:00
parent 96387d9941
commit 3b72cd88cc
10 changed files with 1073 additions and 1 deletions
+10 -1
View File
@@ -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>
);
}
+53
View File
@@ -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;
}
+33
View File
@@ -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);
}
+160
View File
@@ -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,
},
};
}
+96
View File
@@ -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>>;