@chuzi/shared 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/api/index.d.ts +122 -0
  4. package/dist/api/index.js +108 -0
  5. package/dist/api/index.js.map +1 -0
  6. package/dist/config/index.d.ts +39 -0
  7. package/dist/config/index.js +404 -0
  8. package/dist/config/index.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +627 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/input/index.d.ts +70 -0
  13. package/dist/input/index.js +41 -0
  14. package/dist/input/index.js.map +1 -0
  15. package/dist/realms/cosmos/components/index.d.ts +68 -0
  16. package/dist/realms/cosmos/components/index.js +172 -0
  17. package/dist/realms/cosmos/components/index.js.map +1 -0
  18. package/dist/realms/cosmos/index.d.ts +8 -0
  19. package/dist/realms/cosmos/index.js +76 -0
  20. package/dist/realms/cosmos/index.js.map +1 -0
  21. package/dist/realms/index.d.ts +109 -0
  22. package/dist/realms/index.js +23 -0
  23. package/dist/realms/index.js.map +1 -0
  24. package/dist/realms/wilds/components/index.d.ts +88 -0
  25. package/dist/realms/wilds/components/index.js +359 -0
  26. package/dist/realms/wilds/components/index.js.map +1 -0
  27. package/dist/realms/wilds/index.d.ts +8 -0
  28. package/dist/realms/wilds/index.js +70 -0
  29. package/dist/realms/wilds/index.js.map +1 -0
  30. package/dist/themes/index.d.ts +46 -0
  31. package/dist/themes/index.js +63 -0
  32. package/dist/themes/index.js.map +1 -0
  33. package/dist/types/index.d.ts +292 -0
  34. package/dist/types/index.js +3 -0
  35. package/dist/types/index.js.map +1 -0
  36. package/dist/ui/index.d.ts +71 -0
  37. package/dist/ui/index.js +551 -0
  38. package/dist/ui/index.js.map +1 -0
  39. package/package.json +107 -0
@@ -0,0 +1,109 @@
1
+ import { StoryListItem, RealmId } from '../types/index.js';
2
+
3
+ /**
4
+ * Realm rendering contract. Each realm (cosmos, wilds, future depths/etc)
5
+ * implements this interface and ships as a self-contained subpath module
6
+ * (`@chuzi/shared/realms/cosmos`, `@chuzi/shared/realms/wilds`, ...).
7
+ *
8
+ * Apps don't import realms directly — they read `user.realm`, then dynamic-
9
+ * import the matching module. Adding a new realm is a one-package change
10
+ * with zero modifications to app code.
11
+ *
12
+ * The `Component` generic is left abstract so this contract works for any
13
+ * renderer. Web/TV apps satisfy it with `React.ComponentType`; React Native
14
+ * apps satisfy it with their own component type. chuzi-shared itself stays
15
+ * dependency-free.
16
+ */
17
+ /** State badge applied to an atom regardless of realm. Realms render it
18
+ * in their own visual language (orbit ring, marker post, glow, etc.). */
19
+ type AtomState = "default" | "watched" | "bookmarked" | "in_progress" | "new";
20
+ /** Continuous zoom levels. Camera flies smoothly between them; LOD swaps
21
+ * geometry/asset quality as it crosses thresholds. */
22
+ type ZoomLevel = "overview" | "sector" | "atom";
23
+ interface AtomVisualProps {
24
+ /** Position in realm-space (the realm decides the coordinate convention). */
25
+ position: [number, number, number];
26
+ /** Relative scale, 0–1 normalized. Realms map this to height/girth/etc. */
27
+ scale: number;
28
+ /** Realm-interpretable hue, 0–360. Cosmos: stellar class. Wilds: foliage. */
29
+ hue: number;
30
+ /** Realm-interpretable intensity, 0–1. Cosmos: luminosity. Wilds: density. */
31
+ intensity: number;
32
+ /** Cross-realm state badge. */
33
+ state: AtomState;
34
+ /** Pass-through metadata for realm-specific rendering. */
35
+ metadata: {
36
+ title: string;
37
+ runtime?: number;
38
+ popularity?: number;
39
+ mood?: string;
40
+ genre?: string | null;
41
+ };
42
+ }
43
+ type AtomMapping = (film: StoryListItem) => AtomVisualProps;
44
+ interface AudioPalette {
45
+ /** Looping ambient bed (deep-space rumble, forest wind, ocean swell, ...). */
46
+ ambientLoop?: string;
47
+ /** Plays when focus moves to a new atom. */
48
+ focusChime?: string;
49
+ /** Plays during the engage transition into the player. */
50
+ engageImpact?: string;
51
+ /** Plays during the back transition out of the player. */
52
+ backWhoosh?: string;
53
+ }
54
+ interface MotionTokens {
55
+ /** Free-flight responsiveness (pointer/touch). */
56
+ flightAcceleration: number;
57
+ flightDamping: number;
58
+ /** D-pad / focus-snap easing duration in ms. */
59
+ focusEaseMs: number;
60
+ /** Length of the engage transition (atom → player) in ms. */
61
+ engageDurationMs: number;
62
+ /** Length of the back transition (player → atom) in ms. Should equal
63
+ * engageDurationMs by default — symmetric transitions feel grounded. */
64
+ backDurationMs: number;
65
+ }
66
+ /** A geometric atom in realm-space, used by `focusSnap` to compute the
67
+ * best neighbor in a given direction. Realms get the bare minimum and
68
+ * decide how to weight (e.g. cosmos prefers angular alignment, wilds
69
+ * prefers walking distance along the floor plane). */
70
+ interface AtomLocation {
71
+ id: string;
72
+ position: [number, number, number];
73
+ }
74
+ type FocusSnap = (currentId: string, direction: "up" | "down" | "left" | "right", atoms: AtomLocation[]) => string | null;
75
+ /**
76
+ * The full realm module. `Component` is whatever component type the host
77
+ * renderer expects (see file-level doc).
78
+ */
79
+ interface RealmModule<Component = unknown> {
80
+ id: RealmId;
81
+ /** The environment: skybox / canopy / ocean / city. */
82
+ World: Component;
83
+ /** A single film embodied (Star, Tree, Bioluminescent organism, ...). */
84
+ Atom: Component;
85
+ /** A curated grouping (Constellation, Grove, Reef, ...). */
86
+ Group: Component;
87
+ /** Camera + motion controller. Receives an `IntentSource` prop and
88
+ * maps intents to realm-specific motion. */
89
+ NavRig: Component;
90
+ /** The signature transition: atom expands to fill the screen and
91
+ * hands off to the player surface. Reverses on close. */
92
+ EngageTransition: Component;
93
+ /** Maps shared catalog data → realm-specific visual props. */
94
+ mapping: AtomMapping;
95
+ /** Realm-specific audio bed and stingers. */
96
+ audio: AudioPalette;
97
+ /** Realm-specific timing/easing constants. */
98
+ motion: MotionTokens;
99
+ /** D-pad neighbor selection algorithm. Realms own the spatial weighting. */
100
+ focusSnap: FocusSnap;
101
+ }
102
+ /**
103
+ * Default focus-snap implementation: pick the atom closest to a 60°
104
+ * directional cone from the current atom. Realms can replace this when
105
+ * their geometry calls for different weighting.
106
+ */
107
+ declare function defaultFocusSnap(currentId: string, direction: "up" | "down" | "left" | "right", atoms: AtomLocation[]): string | null;
108
+
109
+ export { type AtomLocation, type AtomMapping, type AtomState, type AtomVisualProps, type AudioPalette, type FocusSnap, type MotionTokens, type RealmModule, type ZoomLevel, defaultFocusSnap };
@@ -0,0 +1,23 @@
1
+ // src/realms/index.ts
2
+ function defaultFocusSnap(currentId, direction, atoms) {
3
+ const current = atoms.find((a) => a.id === currentId);
4
+ if (!current) return null;
5
+ const dirVec = direction === "left" ? [-1, 0] : direction === "right" ? [1, 0] : direction === "up" ? [0, 1] : [0, -1];
6
+ let best = null;
7
+ for (const a of atoms) {
8
+ if (a.id === currentId) continue;
9
+ const dx = a.position[0] - current.position[0];
10
+ const dy = a.position[1] - current.position[1];
11
+ const dist = Math.hypot(dx, dy);
12
+ if (dist === 0) continue;
13
+ const dot = (dx * dirVec[0] + dy * dirVec[1]) / dist;
14
+ if (dot < 0.5) continue;
15
+ const score = dist / Math.max(dot, 1e-3);
16
+ if (!best || score < best.score) best = { id: a.id, score };
17
+ }
18
+ return best?.id ?? null;
19
+ }
20
+
21
+ export { defaultFocusSnap };
22
+ //# sourceMappingURL=index.js.map
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/realms/index.ts"],"names":[],"mappings":";AA6HO,SAAS,gBAAA,CACd,SAAA,EACA,SAAA,EACA,KAAA,EACe;AACf,EAAA,MAAM,UAAU,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,OAAO,SAAS,CAAA;AACpD,EAAA,IAAI,CAAC,SAAS,OAAO,IAAA;AAErB,EAAA,MAAM,MAAA,GACJ,cAAc,MAAA,GAAS,CAAC,IAAI,CAAC,CAAA,GAC3B,cAAc,OAAA,GAAU,CAAC,GAAG,CAAC,CAAA,GAC7B,cAAc,IAAA,GAAO,CAAC,GAAG,CAAC,CAAA,GAC1B,CAAC,CAAA,EAAG,EAAE,CAAA;AAEV,EAAA,IAAI,IAAA,GAA6C,IAAA;AACjD,EAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,IAAA,IAAI,CAAA,CAAE,OAAO,SAAA,EAAW;AACxB,IAAA,MAAM,KAAK,CAAA,CAAE,QAAA,CAAS,CAAC,CAAA,GAAI,OAAA,CAAQ,SAAS,CAAC,CAAA;AAC7C,IAAA,MAAM,KAAK,CAAA,CAAE,QAAA,CAAS,CAAC,CAAA,GAAI,OAAA,CAAQ,SAAS,CAAC,CAAA;AAC7C,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,KAAA,CAAM,EAAA,EAAI,EAAE,CAAA;AAC9B,IAAA,IAAI,SAAS,CAAA,EAAG;AAChB,IAAA,MAAM,GAAA,GAAA,CAAO,KAAK,MAAA,CAAO,CAAC,IAAI,EAAA,GAAK,MAAA,CAAO,CAAC,CAAA,IAAK,IAAA;AAEhD,IAAA,IAAI,MAAM,GAAA,EAAK;AAEf,IAAA,MAAM,KAAA,GAAQ,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,KAAK,IAAK,CAAA;AACxC,IAAA,IAAI,CAAC,IAAA,IAAQ,KAAA,GAAQ,IAAA,CAAK,KAAA,SAAc,EAAE,EAAA,EAAI,CAAA,CAAE,EAAA,EAAI,KAAA,EAAM;AAAA,EAC5D;AACA,EAAA,OAAO,MAAM,EAAA,IAAM,IAAA;AACrB","file":"index.js","sourcesContent":["import type { RealmId, StoryListItem } from \"../types/index.js\";\n\n/**\n * Realm rendering contract. Each realm (cosmos, wilds, future depths/etc)\n * implements this interface and ships as a self-contained subpath module\n * (`@chuzi/shared/realms/cosmos`, `@chuzi/shared/realms/wilds`, ...).\n *\n * Apps don't import realms directly — they read `user.realm`, then dynamic-\n * import the matching module. Adding a new realm is a one-package change\n * with zero modifications to app code.\n *\n * The `Component` generic is left abstract so this contract works for any\n * renderer. Web/TV apps satisfy it with `React.ComponentType`; React Native\n * apps satisfy it with their own component type. chuzi-shared itself stays\n * dependency-free.\n */\n\n/** State badge applied to an atom regardless of realm. Realms render it\n * in their own visual language (orbit ring, marker post, glow, etc.). */\nexport type AtomState = \"default\" | \"watched\" | \"bookmarked\" | \"in_progress\" | \"new\";\n\n/** Continuous zoom levels. Camera flies smoothly between them; LOD swaps\n * geometry/asset quality as it crosses thresholds. */\nexport type ZoomLevel = \"overview\" | \"sector\" | \"atom\";\n\nexport interface AtomVisualProps {\n /** Position in realm-space (the realm decides the coordinate convention). */\n position: [number, number, number];\n /** Relative scale, 0–1 normalized. Realms map this to height/girth/etc. */\n scale: number;\n /** Realm-interpretable hue, 0–360. Cosmos: stellar class. Wilds: foliage. */\n hue: number;\n /** Realm-interpretable intensity, 0–1. Cosmos: luminosity. Wilds: density. */\n intensity: number;\n /** Cross-realm state badge. */\n state: AtomState;\n /** Pass-through metadata for realm-specific rendering. */\n metadata: {\n title: string;\n runtime?: number;\n popularity?: number;\n mood?: string;\n genre?: string | null;\n };\n}\n\nexport type AtomMapping = (film: StoryListItem) => AtomVisualProps;\n\nexport interface AudioPalette {\n /** Looping ambient bed (deep-space rumble, forest wind, ocean swell, ...). */\n ambientLoop?: string;\n /** Plays when focus moves to a new atom. */\n focusChime?: string;\n /** Plays during the engage transition into the player. */\n engageImpact?: string;\n /** Plays during the back transition out of the player. */\n backWhoosh?: string;\n}\n\nexport interface MotionTokens {\n /** Free-flight responsiveness (pointer/touch). */\n flightAcceleration: number;\n flightDamping: number;\n /** D-pad / focus-snap easing duration in ms. */\n focusEaseMs: number;\n /** Length of the engage transition (atom → player) in ms. */\n engageDurationMs: number;\n /** Length of the back transition (player → atom) in ms. Should equal\n * engageDurationMs by default — symmetric transitions feel grounded. */\n backDurationMs: number;\n}\n\n/** A geometric atom in realm-space, used by `focusSnap` to compute the\n * best neighbor in a given direction. Realms get the bare minimum and\n * decide how to weight (e.g. cosmos prefers angular alignment, wilds\n * prefers walking distance along the floor plane). */\nexport interface AtomLocation {\n id: string;\n position: [number, number, number];\n}\n\nexport type FocusSnap = (\n currentId: string,\n direction: \"up\" | \"down\" | \"left\" | \"right\",\n atoms: AtomLocation[],\n) => string | null;\n\n/**\n * The full realm module. `Component` is whatever component type the host\n * renderer expects (see file-level doc).\n */\nexport interface RealmModule<Component = unknown> {\n id: RealmId;\n\n /** The environment: skybox / canopy / ocean / city. */\n World: Component;\n /** A single film embodied (Star, Tree, Bioluminescent organism, ...). */\n Atom: Component;\n /** A curated grouping (Constellation, Grove, Reef, ...). */\n Group: Component;\n /** Camera + motion controller. Receives an `IntentSource` prop and\n * maps intents to realm-specific motion. */\n NavRig: Component;\n /** The signature transition: atom expands to fill the screen and\n * hands off to the player surface. Reverses on close. */\n EngageTransition: Component;\n\n /** Maps shared catalog data → realm-specific visual props. */\n mapping: AtomMapping;\n\n /** Realm-specific audio bed and stingers. */\n audio: AudioPalette;\n\n /** Realm-specific timing/easing constants. */\n motion: MotionTokens;\n\n /** D-pad neighbor selection algorithm. Realms own the spatial weighting. */\n focusSnap: FocusSnap;\n}\n\n/**\n * Default focus-snap implementation: pick the atom closest to a 60°\n * directional cone from the current atom. Realms can replace this when\n * their geometry calls for different weighting.\n */\nexport function defaultFocusSnap(\n currentId: string,\n direction: \"up\" | \"down\" | \"left\" | \"right\",\n atoms: AtomLocation[],\n): string | null {\n const current = atoms.find((a) => a.id === currentId);\n if (!current) return null;\n\n const dirVec: [number, number] =\n direction === \"left\" ? [-1, 0]\n : direction === \"right\" ? [1, 0]\n : direction === \"up\" ? [0, 1]\n : [0, -1];\n\n let best: { id: string; score: number } | null = null;\n for (const a of atoms) {\n if (a.id === currentId) continue;\n const dx = a.position[0] - current.position[0];\n const dy = a.position[1] - current.position[1];\n const dist = Math.hypot(dx, dy);\n if (dist === 0) continue;\n const dot = (dx * dirVec[0] + dy * dirVec[1]) / dist;\n // Cone of acceptance: dot > 0.5 ≈ within 60° of direction.\n if (dot < 0.5) continue;\n // Score: closer is better, more aligned is better.\n const score = dist / Math.max(dot, 0.001);\n if (!best || score < best.score) best = { id: a.id, score };\n }\n return best?.id ?? null;\n}\n"]}
@@ -0,0 +1,88 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { AtomVisualProps } from '../../index.js';
3
+ import { ReactNode } from 'react';
4
+ import { StoryListItem } from '../../../types/index.js';
5
+
6
+ interface TreeProps {
7
+ visual: AtomVisualProps;
8
+ onSelect?: () => void;
9
+ }
10
+ /**
11
+ * A single film-tree with a lush, multi-clustered canopy. Foliage is built
12
+ * from overlapping dodecahedrons arranged around the crown — reads as a
13
+ * dense, heavily-branched tree. Trees within a genre clump share the same
14
+ * hue; brightness scales with popularity.
15
+ */
16
+ declare function Tree({ visual, onSelect }: TreeProps): react_jsx_runtime.JSX.Element;
17
+
18
+ interface WildsWorldProps {
19
+ children: ReactNode;
20
+ /** Pixel device ratio cap. Default [1,2] — keeps perf bounded on retina laptops. */
21
+ dpr?: number | [number, number];
22
+ }
23
+ /**
24
+ * Wilds environment shell. A lush forest under a cosmic sky — the stars
25
+ * visible overhead are the same cosmos the director may have just left.
26
+ * Bright hemisphere + directional lighting keeps the canopy vivid even
27
+ * against the dark backdrop.
28
+ */
29
+ declare function WildsWorld({ children, dpr }: WildsWorldProps): react_jsx_runtime.JSX.Element;
30
+
31
+ interface ForestBackdropProps {
32
+ /** Number of ambient backdrop trees. Default 320. */
33
+ count?: number;
34
+ /** Inner radius — backdrop starts beyond this. */
35
+ innerRadius?: number;
36
+ /** Outer radius — backdrop fades out by here. */
37
+ outerRadius?: number;
38
+ /** Deterministic seed; identical seeds produce identical forests. */
39
+ seed?: number;
40
+ }
41
+ /**
42
+ * Ambient surrounding forest. Three overlapping canopy layers per tree
43
+ * (dodecahedrons at staggered offsets) give the backdrop lush, full
44
+ * silhouettes. Four instanced draw calls total (trunk + 3 canopy layers)
45
+ * keep the cost constant regardless of count.
46
+ */
47
+ declare function ForestBackdrop({ count, innerRadius, outerRadius, seed, }: ForestBackdropProps): react_jsx_runtime.JSX.Element;
48
+
49
+ interface WildsSandboxProps {
50
+ films: StoryListItem[];
51
+ onFilmSelect?: (film: StoryListItem) => void;
52
+ /** Layout seed — same seed + same films = same forest. */
53
+ seed?: number;
54
+ }
55
+ /**
56
+ * Drop-in 3D wilds sandbox: hand a list of films, get back a navigable
57
+ * forest where each film is a tree. Trees are clustered into clumps
58
+ * (groves) rather than uniformly distributed — mirrors how real forests
59
+ * grow, and reads better visually than a tree grid. Mirrors `CosmosSandbox`.
60
+ */
61
+ declare function WildsSandbox({ films, onFilmSelect, seed, }: WildsSandboxProps): react_jsx_runtime.JSX.Element;
62
+
63
+ /**
64
+ * Spatial distribution helpers for the wilds realm. Trees grow in clumps
65
+ * (groves), not on a uniform grid: pick a small set of cluster anchors,
66
+ * then scatter trees around them with a soft falloff. Deterministic when a
67
+ * seed is provided so the same catalog produces the same forest.
68
+ */
69
+ type Vec3 = [number, number, number];
70
+ interface DistributeForestOptions {
71
+ /** Outer radius the forest occupies, in world units. */
72
+ radius?: number;
73
+ /** Number of clumps. Capped at `count`. */
74
+ clumpCount?: number;
75
+ /** Spread of each clump (stddev-ish, in world units). */
76
+ clumpSpread?: number;
77
+ /** Deterministic seed; identical seeds produce identical layouts. */
78
+ seed?: number;
79
+ }
80
+ /**
81
+ * Cluster trees around a small number of anchor points, with each anchor
82
+ * placed in a disk of `radius`. Each tree is offset from its anchor by a
83
+ * 2D gaussian-ish jitter (`clumpSpread`). Y is always 0 — trees stand on
84
+ * the ground plane.
85
+ */
86
+ declare function distributeForest(count: number, options?: DistributeForestOptions): Vec3[];
87
+
88
+ export { type DistributeForestOptions, ForestBackdrop, type ForestBackdropProps, Tree, type TreeProps, type Vec3, WildsSandbox, type WildsSandboxProps, WildsWorld, type WildsWorldProps, distributeForest };
@@ -0,0 +1,359 @@
1
+ import { useRef, useMemo } from 'react';
2
+ import { useFrame, Canvas } from '@react-three/fiber';
3
+ import { jsxs, jsx } from 'react/jsx-runtime';
4
+ import { Stars, OrbitControls } from '@react-three/drei';
5
+ import { Object3D, Color } from 'three';
6
+
7
+ // src/realms/wilds/components/Tree.tsx
8
+ function Tree({ visual, onSelect }) {
9
+ const ref = useRef(null);
10
+ useFrame(({ clock }) => {
11
+ if (!ref.current) return;
12
+ const phase = visual.position[0] * 0.4 + visual.position[2] * 0.6;
13
+ const sway = Math.sin(clock.elapsedTime * 0.6 + phase) * 0.04;
14
+ ref.current.rotation.z = sway;
15
+ });
16
+ const trunkHeight = 1.6 + visual.scale * 2.4;
17
+ const trunkRadius = 0.09 + visual.scale * 0.055;
18
+ const lightness = 52 + visual.intensity * 18;
19
+ const canopyColor = `hsl(${visual.hue}, 82%, ${lightness}%)`;
20
+ const trunkColor = "#6b5a45";
21
+ const seed = Math.abs(Math.round(visual.position[0] * 127 + visual.position[2] * 311));
22
+ const foliage = useMemo(() => {
23
+ const clusters = [];
24
+ const crownY = trunkHeight * 0.78;
25
+ const baseR = 0.65 + visual.scale * 0.5 + visual.intensity * 0.3;
26
+ clusters.push({ x: 0, y: crownY + baseR * 0.35, z: 0, r: baseR });
27
+ clusters.push({ x: 0, y: crownY + baseR * 0.95, z: 0, r: baseR * 0.55 });
28
+ const mainCount = 5 + Math.floor(visual.scale * 3);
29
+ for (let i = 0; i < mainCount; i++) {
30
+ const a = i / mainCount * Math.PI * 2 + seed % 100 * 0.063;
31
+ const spread = baseR * (0.6 + (seed + i * 7) % 5 * 0.09);
32
+ const yOff = ((seed + i * 13) % 7 - 3) * 0.1;
33
+ const sz = baseR * (0.38 + (seed + i * 11) % 5 * 0.07);
34
+ clusters.push({
35
+ x: Math.cos(a) * spread,
36
+ y: crownY + yOff,
37
+ z: Math.sin(a) * spread,
38
+ r: sz
39
+ });
40
+ }
41
+ const lowerCount = 3 + Math.floor(visual.scale * 2);
42
+ for (let i = 0; i < lowerCount; i++) {
43
+ const a = i / lowerCount * Math.PI * 2 + seed % 50 * 0.126;
44
+ const spread = baseR * 0.85 + 0.25;
45
+ clusters.push({
46
+ x: Math.cos(a) * spread,
47
+ y: crownY - 0.35 - (seed + i * 17) % 4 * 0.12,
48
+ z: Math.sin(a) * spread,
49
+ r: baseR * (0.28 + (seed + i * 7) % 3 * 0.06)
50
+ });
51
+ }
52
+ return clusters;
53
+ }, [trunkHeight, visual.scale, visual.intensity, seed]);
54
+ function handleClick(e) {
55
+ if (!onSelect) return;
56
+ e.stopPropagation();
57
+ onSelect();
58
+ }
59
+ const onClick = onSelect ? handleClick : void 0;
60
+ return /* @__PURE__ */ jsxs("group", { ref, position: visual.position, children: [
61
+ /* @__PURE__ */ jsxs("mesh", { position: [0, trunkHeight / 2, 0], onClick, children: [
62
+ /* @__PURE__ */ jsx("cylinderGeometry", { args: [trunkRadius * 0.65, trunkRadius, trunkHeight, 8] }),
63
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: trunkColor, roughness: 0.9 })
64
+ ] }),
65
+ foliage.map((c, i) => /* @__PURE__ */ jsxs("mesh", { position: [c.x, c.y, c.z], onClick, children: [
66
+ /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [c.r, 1] }),
67
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: canopyColor, roughness: 0.7 })
68
+ ] }, i))
69
+ ] });
70
+ }
71
+ function ForestBackdrop({
72
+ count = 320,
73
+ innerRadius = 36,
74
+ outerRadius = 78,
75
+ seed = 7
76
+ }) {
77
+ const trunkRef = useRef(null);
78
+ const canopy1Ref = useRef(null);
79
+ const canopy2Ref = useRef(null);
80
+ const canopy3Ref = useRef(null);
81
+ const placements = useMemo(
82
+ () => buildPlacements(count, innerRadius, outerRadius, seed),
83
+ [count, innerRadius, outerRadius, seed]
84
+ );
85
+ useFrame(() => {
86
+ const trunk = trunkRef.current;
87
+ const c1 = canopy1Ref.current;
88
+ const c2 = canopy2Ref.current;
89
+ const c3 = canopy3Ref.current;
90
+ if (!trunk || !c1 || !c2 || !c3) return;
91
+ if (trunk.userData.placed) return;
92
+ const dummy = new Object3D();
93
+ const color = new Color();
94
+ placements.forEach((p, i) => {
95
+ const trunkH = p.trunkHeight;
96
+ const canopyH = p.canopyHeight;
97
+ const canopyR = p.canopyRadius;
98
+ dummy.position.set(p.x, trunkH / 2, p.z);
99
+ dummy.rotation.set(0, p.rot, 0);
100
+ dummy.scale.set(p.trunkRadius * 2, trunkH, p.trunkRadius * 2);
101
+ dummy.updateMatrix();
102
+ trunk.setMatrixAt(i, dummy.matrix);
103
+ dummy.position.set(p.x, trunkH + canopyH * 0.3, p.z);
104
+ dummy.rotation.set(0, p.rot, 0);
105
+ dummy.scale.set(canopyR * 0.9, canopyH * 0.5, canopyR * 0.9);
106
+ dummy.updateMatrix();
107
+ c1.setMatrixAt(i, dummy.matrix);
108
+ const off2x = Math.cos(p.rot + 0.8) * canopyR * 0.35;
109
+ const off2z = Math.sin(p.rot + 0.8) * canopyR * 0.35;
110
+ dummy.position.set(p.x + off2x, trunkH + canopyH * 0.5, p.z + off2z);
111
+ dummy.rotation.set(0, p.rot * 1.3, 0);
112
+ dummy.scale.set(canopyR * 0.7, canopyH * 0.4, canopyR * 0.7);
113
+ dummy.updateMatrix();
114
+ c2.setMatrixAt(i, dummy.matrix);
115
+ const off3x = Math.cos(p.rot + 3.2) * canopyR * 0.4;
116
+ const off3z = Math.sin(p.rot + 3.2) * canopyR * 0.4;
117
+ dummy.position.set(p.x + off3x, trunkH + canopyH * 0.1, p.z + off3z);
118
+ dummy.rotation.set(0, p.rot * 0.7, 0);
119
+ dummy.scale.set(canopyR * 0.55, canopyH * 0.35, canopyR * 0.55);
120
+ dummy.updateMatrix();
121
+ c3.setMatrixAt(i, dummy.matrix);
122
+ const hue = BACKDROP_HUES[i % BACKDROP_HUES.length];
123
+ color.setHSL(hue / 360, 0.5, 0.36);
124
+ c1.setColorAt(i, color);
125
+ color.setHSL(hue / 360, 0.5, 0.4);
126
+ c2.setColorAt(i, color);
127
+ color.setHSL(hue / 360, 0.5, 0.32);
128
+ c3.setColorAt(i, color);
129
+ });
130
+ trunk.instanceMatrix.needsUpdate = true;
131
+ c1.instanceMatrix.needsUpdate = true;
132
+ c2.instanceMatrix.needsUpdate = true;
133
+ c3.instanceMatrix.needsUpdate = true;
134
+ if (c1.instanceColor) c1.instanceColor.needsUpdate = true;
135
+ if (c2.instanceColor) c2.instanceColor.needsUpdate = true;
136
+ if (c3.instanceColor) c3.instanceColor.needsUpdate = true;
137
+ trunk.userData.placed = true;
138
+ });
139
+ return /* @__PURE__ */ jsxs("group", { children: [
140
+ /* @__PURE__ */ jsxs("instancedMesh", { ref: trunkRef, args: [void 0, void 0, count], children: [
141
+ /* @__PURE__ */ jsx("cylinderGeometry", { args: [1, 1, 1, 6] }),
142
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: "#5a4a38", roughness: 0.95 })
143
+ ] }),
144
+ /* @__PURE__ */ jsxs("instancedMesh", { ref: canopy1Ref, args: [void 0, void 0, count], children: [
145
+ /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [1, 1] }),
146
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: "#ffffff", roughness: 0.8 })
147
+ ] }),
148
+ /* @__PURE__ */ jsxs("instancedMesh", { ref: canopy2Ref, args: [void 0, void 0, count], children: [
149
+ /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [1, 1] }),
150
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: "#ffffff", roughness: 0.8 })
151
+ ] }),
152
+ /* @__PURE__ */ jsxs("instancedMesh", { ref: canopy3Ref, args: [void 0, void 0, count], children: [
153
+ /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [1, 1] }),
154
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: "#ffffff", roughness: 0.8 })
155
+ ] })
156
+ ] });
157
+ }
158
+ var BACKDROP_HUES = [120, 140, 100, 160, 80, 150, 95, 130, 110, 170];
159
+ function buildPlacements(count, innerRadius, outerRadius, seed) {
160
+ const random = mulberry32(seed);
161
+ const out = [];
162
+ for (let i = 0; i < count; i++) {
163
+ const t = Math.sqrt(random());
164
+ const r = innerRadius + t * (outerRadius - innerRadius);
165
+ const theta = random() * Math.PI * 2;
166
+ const x = Math.cos(theta) * r;
167
+ const z = Math.sin(theta) * r;
168
+ const sizeJitter = 0.7 + random() * 0.9;
169
+ const trunkHeight = 1.6 * sizeJitter;
170
+ const trunkRadius = 0.18 * sizeJitter;
171
+ const canopyHeight = 2.4 * sizeJitter;
172
+ const canopyRadius = 1.1 * sizeJitter;
173
+ out.push({
174
+ x,
175
+ z,
176
+ rot: random() * Math.PI * 2,
177
+ trunkHeight,
178
+ trunkRadius,
179
+ canopyHeight,
180
+ canopyRadius
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+ function mulberry32(seed) {
186
+ let s = seed >>> 0;
187
+ return () => {
188
+ s = s + 1831565813 >>> 0;
189
+ let t = s;
190
+ t = Math.imul(t ^ t >>> 15, t | 1);
191
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
192
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
193
+ };
194
+ }
195
+ var SKY = "#0e1030";
196
+ var GROUND_COLOR = "#2a5a35";
197
+ function WildsWorld({ children, dpr = [1, 2] }) {
198
+ return /* @__PURE__ */ jsxs(
199
+ Canvas,
200
+ {
201
+ camera: { position: [0, 8, 36], fov: 60, near: 0.1, far: 500 },
202
+ dpr,
203
+ gl: { antialias: true },
204
+ children: [
205
+ /* @__PURE__ */ jsx("color", { attach: "background", args: [SKY] }),
206
+ /* @__PURE__ */ jsx("fog", { attach: "fog", args: [SKY, 70, 180] }),
207
+ /* @__PURE__ */ jsx(
208
+ Stars,
209
+ {
210
+ radius: 180,
211
+ depth: 80,
212
+ count: 2500,
213
+ factor: 4,
214
+ saturation: 0.5,
215
+ fade: true,
216
+ speed: 0.8
217
+ }
218
+ ),
219
+ /* @__PURE__ */ jsx("ambientLight", { intensity: 0.65 }),
220
+ /* @__PURE__ */ jsx("hemisphereLight", { args: ["#f0f8ff", "#3a5a38", 1.2] }),
221
+ /* @__PURE__ */ jsx("directionalLight", { position: [10, 30, 8], intensity: 1.5, color: "#fff5e0" }),
222
+ /* @__PURE__ */ jsx(Ground, {}),
223
+ /* @__PURE__ */ jsx(ForestBackdrop, {}),
224
+ /* @__PURE__ */ jsx(
225
+ OrbitControls,
226
+ {
227
+ enablePan: false,
228
+ maxDistance: 80,
229
+ minDistance: 6,
230
+ maxPolarAngle: Math.PI / 2.05
231
+ }
232
+ ),
233
+ children
234
+ ]
235
+ }
236
+ );
237
+ }
238
+ function Ground() {
239
+ return /* @__PURE__ */ jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, -0.01, 0], receiveShadow: true, children: [
240
+ /* @__PURE__ */ jsx("circleGeometry", { args: [120, 64] }),
241
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: GROUND_COLOR, roughness: 0.9 })
242
+ ] });
243
+ }
244
+
245
+ // src/realms/wilds/index.ts
246
+ var GENRE_HUE = {
247
+ drama: 70,
248
+ // oak — warm green-gold
249
+ thriller: 150,
250
+ // pine — cool green
251
+ horror: 25,
252
+ // dead/gnarled — burnt umber
253
+ comedy: 95,
254
+ // birch — bright spring
255
+ romance: 330,
256
+ // cherry blossom — pink
257
+ scifi: 180,
258
+ // alien luminescent
259
+ documentary: 110,
260
+ // generic forest green
261
+ animation: 280
262
+ // fantasy violet
263
+ };
264
+ function clamp01(n) {
265
+ return Math.max(0, Math.min(1, n));
266
+ }
267
+ function popularityToIntensity(film) {
268
+ const watches = Math.max(0, film.watch_starts_count);
269
+ const log = Math.log10(1 + watches);
270
+ return clamp01(log / 6);
271
+ }
272
+ function deriveScale(film) {
273
+ const scenes = film.scenes_count ?? 1;
274
+ return clamp01(0.3 + Math.log10(1 + scenes) / 3);
275
+ }
276
+ function deriveHue(film) {
277
+ const genreKey = (film.genre ?? "").toLowerCase().replace(/[^a-z]/g, "");
278
+ const hue = GENRE_HUE[genreKey];
279
+ return hue !== void 0 ? hue : 110;
280
+ }
281
+ function deriveState(film) {
282
+ return film.published ? "default" : "new";
283
+ }
284
+ var wildsMapping = (film) => ({
285
+ position: [0, 0, 0],
286
+ // assigned by the realm's spatial layouter
287
+ scale: deriveScale(film),
288
+ hue: deriveHue(film),
289
+ intensity: popularityToIntensity(film),
290
+ state: deriveState(film),
291
+ metadata: {
292
+ title: film.title,
293
+ popularity: film.watch_starts_count,
294
+ genre: film.genre
295
+ }
296
+ });
297
+
298
+ // src/realms/wilds/components/layout.ts
299
+ function distributeForest(count, options = {}) {
300
+ const radius = options.radius ?? 28;
301
+ const clumpSpread = options.clumpSpread ?? 4;
302
+ const random = options.seed !== void 0 ? mulberry322(options.seed) : Math.random;
303
+ const desiredClumps = options.clumpCount ?? Math.max(2, Math.round(count / 4));
304
+ const clumpCount = Math.max(1, Math.min(count, desiredClumps));
305
+ const anchors = [];
306
+ for (let i = 0; i < clumpCount; i++) {
307
+ const r = Math.sqrt(random()) * radius;
308
+ const theta = random() * Math.PI * 2;
309
+ anchors.push([Math.cos(theta) * r, 0, Math.sin(theta) * r]);
310
+ }
311
+ const positions = [];
312
+ for (let i = 0; i < count; i++) {
313
+ const anchor = anchors[i % clumpCount];
314
+ const dx = gaussian(random) * clumpSpread;
315
+ const dz = gaussian(random) * clumpSpread;
316
+ positions.push([anchor[0] + dx, 0, anchor[2] + dz]);
317
+ }
318
+ return positions;
319
+ }
320
+ function gaussian(random) {
321
+ const u1 = Math.max(1e-9, random());
322
+ const u2 = random();
323
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
324
+ }
325
+ function mulberry322(seed) {
326
+ let s = seed >>> 0;
327
+ return () => {
328
+ s = s + 1831565813 >>> 0;
329
+ let t = s;
330
+ t = Math.imul(t ^ t >>> 15, t | 1);
331
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
332
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
333
+ };
334
+ }
335
+ function WildsSandbox({
336
+ films,
337
+ onFilmSelect,
338
+ seed = 1
339
+ }) {
340
+ const placed = useMemo(() => {
341
+ const positions = distributeForest(films.length, { seed });
342
+ return films.map((film, i) => ({
343
+ film,
344
+ visual: { ...wildsMapping(film), position: positions[i] }
345
+ }));
346
+ }, [films, seed]);
347
+ return /* @__PURE__ */ jsx(WildsWorld, { children: placed.map(({ film, visual }) => /* @__PURE__ */ jsx(
348
+ Tree,
349
+ {
350
+ visual,
351
+ onSelect: onFilmSelect ? () => onFilmSelect(film) : void 0
352
+ },
353
+ film.id
354
+ )) });
355
+ }
356
+
357
+ export { ForestBackdrop, Tree, WildsSandbox, WildsWorld, distributeForest };
358
+ //# sourceMappingURL=index.js.map
359
+ //# sourceMappingURL=index.js.map