mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 04:48:17 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3e229d99 |
@@ -20,12 +20,7 @@
|
|||||||
"@json-render/image",
|
"@json-render/image",
|
||||||
"@json-render/mcp",
|
"@json-render/mcp",
|
||||||
"@json-render/svelte",
|
"@json-render/svelte",
|
||||||
"@json-render/solid",
|
"@json-render/solid"
|
||||||
"@json-render/react-three-fiber",
|
|
||||||
"@json-render/yaml",
|
|
||||||
"@json-render/shadcn-svelte",
|
|
||||||
"@json-render/ink",
|
|
||||||
"@json-render/next"
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
|
|||||||
+25
-34
@@ -13,43 +13,34 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
name: Lint
|
name: Lint, Type Check & Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm lint
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout repository
|
||||||
- uses: pnpm/action-setup@v4
|
uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build packages
|
|
||||||
run: pnpm turbo run build --filter='./packages/*'
|
|
||||||
- run: pnpm test
|
|
||||||
|
|
||||||
typecheck:
|
- name: Install pnpm
|
||||||
name: Type Check
|
uses: pnpm/action-setup@v4
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
- name: Setup Node.js
|
||||||
- uses: actions/checkout@v4
|
uses: actions/setup-node@v4
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: pnpm
|
cache: "pnpm"
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm type-check
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm type-check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ npm install @json-render/core @json-render/vue
|
|||||||
npm install @json-render/core @json-render/svelte
|
npm install @json-render/core @json-render/svelte
|
||||||
# or for SolidJS
|
# or for SolidJS
|
||||||
npm install @json-render/core @json-render/solid
|
npm install @json-render/core @json-render/solid
|
||||||
# or for terminal UIs
|
|
||||||
npm install @json-render/core @json-render/ink ink react
|
|
||||||
# or for full Next.js apps (routes, layouts, SSR, metadata)
|
|
||||||
npm install @json-render/core @json-render/react @json-render/next
|
|
||||||
# or for 3D scenes
|
|
||||||
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why json-render?
|
## Why json-render?
|
||||||
@@ -127,14 +121,10 @@ function Dashboard({ spec }) {
|
|||||||
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
|
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
|
||||||
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
|
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
|
||||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||||
| `@json-render/shadcn-svelte`| 36 pre-built shadcn-svelte components (Svelte 5 + Tailwind CSS) |
|
|
||||||
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
|
|
||||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||||
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
|
|
||||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||||
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
||||||
| `@json-render/ink` | Ink terminal renderer with built-in components for interactive TUIs. |
|
|
||||||
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
|
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
|
||||||
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
|
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
|
||||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||||
@@ -142,7 +132,6 @@ function Dashboard({ spec }) {
|
|||||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||||
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||||
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
|
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
|
||||||
| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform |
|
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
@@ -445,162 +434,6 @@ import { renderToSvg } from "@json-render/image/render";
|
|||||||
const svg = await renderToSvg(spec, { fonts });
|
const svg = await renderToSvg(spec, { fonts });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Three.js (3D)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import { schema, defineRegistry } from "@json-render/react";
|
|
||||||
import {
|
|
||||||
threeComponentDefinitions,
|
|
||||||
threeComponents,
|
|
||||||
ThreeCanvas,
|
|
||||||
} from "@json-render/react-three-fiber";
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: {
|
|
||||||
Box: threeComponentDefinitions.Box,
|
|
||||||
Sphere: threeComponentDefinitions.Sphere,
|
|
||||||
AmbientLight: threeComponentDefinitions.AmbientLight,
|
|
||||||
DirectionalLight: threeComponentDefinitions.DirectionalLight,
|
|
||||||
OrbitControls: threeComponentDefinitions.OrbitControls,
|
|
||||||
},
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { registry } = defineRegistry(catalog, {
|
|
||||||
components: {
|
|
||||||
Box: threeComponents.Box,
|
|
||||||
Sphere: threeComponents.Sphere,
|
|
||||||
AmbientLight: threeComponents.AmbientLight,
|
|
||||||
DirectionalLight: threeComponents.DirectionalLight,
|
|
||||||
OrbitControls: threeComponents.OrbitControls,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
<ThreeCanvas
|
|
||||||
spec={spec}
|
|
||||||
registry={registry}
|
|
||||||
shadows
|
|
||||||
camera={{ position: [5, 5, 5], fov: 50 }}
|
|
||||||
style={{ width: "100%", height: "100vh" }}
|
|
||||||
/>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Next.js (Full Apps)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { NextAppSpec } from "@json-render/next";
|
|
||||||
import { createNextApp } from "@json-render/next/server";
|
|
||||||
import { NextAppProvider } from "@json-render/next";
|
|
||||||
|
|
||||||
const spec: NextAppSpec = {
|
|
||||||
metadata: { title: { default: "My App", template: "%s | My App" } },
|
|
||||||
layouts: {
|
|
||||||
main: {
|
|
||||||
root: "shell",
|
|
||||||
elements: {
|
|
||||||
shell: { type: "Container", props: {}, children: ["nav", "slot"] },
|
|
||||||
nav: { type: "NavBar", props: {}, children: [] },
|
|
||||||
slot: { type: "Slot", props: {}, children: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
routes: {
|
|
||||||
"/": {
|
|
||||||
layout: "main",
|
|
||||||
metadata: { title: "Home" },
|
|
||||||
page: {
|
|
||||||
root: "hero",
|
|
||||||
elements: {
|
|
||||||
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Server: creates Page, generateMetadata, generateStaticParams
|
|
||||||
const app = createNextApp({ spec });
|
|
||||||
|
|
||||||
// Client: wrap your layout with NextAppProvider
|
|
||||||
// <NextAppProvider registry={registry} handlers={handlers}>
|
|
||||||
// {children}
|
|
||||||
// </NextAppProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### shadcn-svelte (Svelte)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import { schema } from "@json-render/svelte/schema";
|
|
||||||
import { defineRegistry, Renderer } from "@json-render/svelte";
|
|
||||||
import { shadcnComponentDefinitions } from "@json-render/shadcn-svelte/catalog";
|
|
||||||
import { shadcnComponents } from "@json-render/shadcn-svelte";
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: {
|
|
||||||
Card: shadcnComponentDefinitions.Card,
|
|
||||||
Stack: shadcnComponentDefinitions.Stack,
|
|
||||||
Heading: shadcnComponentDefinitions.Heading,
|
|
||||||
Button: shadcnComponentDefinitions.Button,
|
|
||||||
},
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { registry } = defineRegistry(catalog, {
|
|
||||||
components: {
|
|
||||||
Card: shadcnComponents.Card,
|
|
||||||
Stack: shadcnComponents.Stack,
|
|
||||||
Heading: shadcnComponents.Heading,
|
|
||||||
Button: shadcnComponents.Button,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// In your Svelte component:
|
|
||||||
// <Renderer spec={spec} registry={registry} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ink (Terminal)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import {
|
|
||||||
schema,
|
|
||||||
standardComponentDefinitions,
|
|
||||||
standardActionDefinitions,
|
|
||||||
defineRegistry,
|
|
||||||
Renderer,
|
|
||||||
JSONUIProvider,
|
|
||||||
} from "@json-render/ink";
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: { ...standardComponentDefinitions },
|
|
||||||
actions: standardActionDefinitions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { registry } = defineRegistry(catalog, { components: {} });
|
|
||||||
|
|
||||||
const spec = {
|
|
||||||
root: "card-1",
|
|
||||||
elements: {
|
|
||||||
"card-1": {
|
|
||||||
type: "Card",
|
|
||||||
props: { title: "Status" },
|
|
||||||
children: ["status-1"],
|
|
||||||
},
|
|
||||||
"status-1": {
|
|
||||||
type: "StatusLine",
|
|
||||||
props: { label: "Build", status: "success" },
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
<JSONUIProvider initialState={{}}>
|
|
||||||
<Renderer spec={spec} registry={registry} />
|
|
||||||
</JSONUIProvider>;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Streaming (SpecStream)
|
### Streaming (SpecStream)
|
||||||
|
|||||||
@@ -1,54 +1,5 @@
|
|||||||
# web
|
# web
|
||||||
|
|
||||||
## 0.1.11
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [519a538]
|
|
||||||
- @json-render/core@0.16.0
|
|
||||||
- @json-render/codegen@0.16.0
|
|
||||||
- @json-render/react@0.16.0
|
|
||||||
- @json-render/yaml@0.16.0
|
|
||||||
|
|
||||||
## 0.1.10
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [bf3a7ec]
|
|
||||||
- @json-render/core@0.15.0
|
|
||||||
- @json-render/codegen@0.15.0
|
|
||||||
- @json-render/react@0.15.0
|
|
||||||
- @json-render/yaml@0.15.0
|
|
||||||
|
|
||||||
## 0.1.9
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [43b7515]
|
|
||||||
- @json-render/core@0.14.1
|
|
||||||
- @json-render/codegen@0.14.1
|
|
||||||
- @json-render/react@0.14.1
|
|
||||||
- @json-render/yaml@0.14.1
|
|
||||||
|
|
||||||
## 0.1.8
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [a8afd8b]
|
|
||||||
- @json-render/core@0.14.0
|
|
||||||
- @json-render/yaml@0.14.0
|
|
||||||
- @json-render/codegen@0.14.0
|
|
||||||
- @json-render/react@0.14.0
|
|
||||||
|
|
||||||
## 0.1.7
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [5b32de8]
|
|
||||||
- @json-render/core@0.13.0
|
|
||||||
- @json-render/codegen@0.13.0
|
|
||||||
- @json-render/react@0.13.0
|
|
||||||
|
|
||||||
## 0.1.6
|
## 0.1.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ interface PromptOptions {
|
|||||||
system?: string; // Custom system message intro
|
system?: string; // Custom system message intro
|
||||||
customRules?: string[]; // Additional rules to append
|
customRules?: string[]; // Additional rules to append
|
||||||
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
|
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
|
||||||
editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpecValidationResult<T> {
|
interface SpecValidationResult<T> {
|
||||||
@@ -481,10 +480,9 @@ function buildUserPrompt(options: UserPromptOptions): string
|
|||||||
|
|
||||||
interface UserPromptOptions {
|
interface UserPromptOptions {
|
||||||
prompt: string; // The user's text prompt
|
prompt: string; // The user's text prompt
|
||||||
currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode)
|
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
|
||||||
state?: Record<string, unknown> | null; // Runtime state context to include
|
state?: Record<string, unknown> | null; // Runtime state context to include
|
||||||
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
||||||
editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"])
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -494,15 +492,14 @@ interface UserPromptOptions {
|
|||||||
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Refinement (edit modes)
|
### Refinement (patch-only mode)
|
||||||
|
|
||||||
When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff).
|
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
prompt: "add a dark mode toggle",
|
prompt: "add a dark mode toggle",
|
||||||
currentSpec: existingSpec,
|
currentSpec: existingSpec,
|
||||||
editModes: ["patch", "merge"],
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -517,93 +514,6 @@ const userPrompt = buildUserPrompt({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Edit Modes
|
|
||||||
|
|
||||||
Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
buildEditInstructions,
|
|
||||||
buildEditUserPrompt,
|
|
||||||
isNonEmptySpec,
|
|
||||||
type EditMode,
|
|
||||||
type EditConfig,
|
|
||||||
} from '@json-render/core';
|
|
||||||
|
|
||||||
type EditMode = "patch" | "merge" | "diff";
|
|
||||||
```
|
|
||||||
|
|
||||||
### buildEditInstructions
|
|
||||||
|
|
||||||
Generate the prompt section describing available edit modes. Supports both JSON and YAML formats.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string
|
|
||||||
|
|
||||||
const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json");
|
|
||||||
```
|
|
||||||
|
|
||||||
### buildEditUserPrompt
|
|
||||||
|
|
||||||
Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function buildEditUserPrompt(options: BuildEditUserPromptOptions): string
|
|
||||||
|
|
||||||
interface BuildEditUserPromptOptions {
|
|
||||||
prompt: string;
|
|
||||||
currentSpec?: Spec | null;
|
|
||||||
config?: EditConfig;
|
|
||||||
format: "json" | "yaml";
|
|
||||||
maxPromptLength?: number;
|
|
||||||
serializer?: (spec: Spec) => string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### isNonEmptySpec
|
|
||||||
|
|
||||||
Check whether a value is a non-empty spec (has a root string and at least one element).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function isNonEmptySpec(spec: unknown): spec is Spec
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deep Merge and Diff
|
|
||||||
|
|
||||||
Format-agnostic utilities for merging and diffing spec objects.
|
|
||||||
|
|
||||||
### deepMergeSpec
|
|
||||||
|
|
||||||
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { deepMergeSpec } from '@json-render/core';
|
|
||||||
|
|
||||||
function deepMergeSpec(
|
|
||||||
base: Record<string, unknown>,
|
|
||||||
patch: Record<string, unknown>
|
|
||||||
): Record<string, unknown>
|
|
||||||
|
|
||||||
const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } });
|
|
||||||
```
|
|
||||||
|
|
||||||
### diffToPatches
|
|
||||||
|
|
||||||
Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { diffToPatches } from '@json-render/core';
|
|
||||||
|
|
||||||
function diffToPatches(
|
|
||||||
oldObj: Record<string, unknown>,
|
|
||||||
newObj: Record<string, unknown>,
|
|
||||||
basePath?: string
|
|
||||||
): JsonPatch[]
|
|
||||||
|
|
||||||
const patches = diffToPatches(oldSpec, newSpec);
|
|
||||||
// [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
## evaluateVisibility
|
## evaluateVisibility
|
||||||
|
|
||||||
Evaluates a visibility condition against the state model.
|
Evaluates a visibility condition against the state model.
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
import { pageMetadata } from "@/lib/page-metadata"
|
|
||||||
export const metadata = pageMetadata("docs/api/ink")
|
|
||||||
|
|
||||||
# @json-render/ink
|
|
||||||
|
|
||||||
Terminal renderer for [Ink](https://github.com/vadimdemedes/ink) with multiple standard components, providers, hooks, and streaming support.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
<PackageInstall packages="@json-render/core @json-render/ink" />
|
|
||||||
|
|
||||||
Peer dependencies: `react ^18.0.0 || ^19.0.0`, `ink ^6.0.0`, and `zod ^4.0.0`.
|
|
||||||
|
|
||||||
<PackageInstall packages="react ink zod" />
|
|
||||||
|
|
||||||
## Standard Components
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>Box</code></td><td><code>flexDirection</code>, <code>alignItems</code>, <code>justifyContent</code>, <code>gap</code>, <code>padding</code>, <code>margin</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>width</code>, <code>height</code>, <code>display</code>, <code>overflow</code></td><td>Flexbox layout container (like a terminal div)</td></tr>
|
|
||||||
<tr><td><code>Spacer</code></td><td>(none)</td><td>Flexible empty space that expands to fill available room</td></tr>
|
|
||||||
<tr><td><code>Newline</code></td><td><code>count</code></td><td>Insert blank lines</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Content
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>Text</code></td><td><code>text</code>, <code>color</code>, <code>bold</code>, <code>italic</code>, <code>underline</code>, <code>strikethrough</code>, <code>dimColor</code>, <code>inverse</code>, <code>wrap</code></td><td>Text output with styling</td></tr>
|
|
||||||
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code> (h1-h4), <code>color</code></td><td>Section heading</td></tr>
|
|
||||||
<tr><td><code>Divider</code></td><td><code>character</code>, <code>color</code>, <code>dimColor</code>, <code>title</code>, <code>width</code></td><td>Horizontal separator line with optional title</td></tr>
|
|
||||||
<tr><td><code>Badge</code></td><td><code>label</code>, <code>variant</code></td><td>Colored inline label (default, info, success, warning, error)</td></tr>
|
|
||||||
<tr><td><code>Spinner</code></td><td><code>label</code>, <code>color</code></td><td>Animated loading spinner</td></tr>
|
|
||||||
<tr><td><code>ProgressBar</code></td><td><code>progress</code> (0-1), <code>width</code>, <code>color</code>, <code>label</code></td><td>Horizontal progress bar</td></tr>
|
|
||||||
<tr><td><code>StatusLine</code></td><td><code>text</code>, <code>status</code>, <code>icon</code></td><td>Status message with colored icon</td></tr>
|
|
||||||
<tr><td><code>KeyValue</code></td><td><code>label</code>, <code>value</code>, <code>labelColor</code>, <code>separator</code></td><td>Key-value pair display</td></tr>
|
|
||||||
<tr><td><code>Link</code></td><td><code>url</code>, <code>label</code>, <code>color</code></td><td>Renders a URL as underlined text. Shows "label (url)" when label is provided.</td></tr>
|
|
||||||
<tr><td><code>Markdown</code></td><td><code>text</code></td><td>Renders markdown with terminal styling (headings, bold, italic, code, lists, blockquotes, horizontal rules)</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Data
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>Table</code></td><td><code>columns</code>, <code>rows</code>, <code>borderStyle</code>, <code>headerColor</code></td><td>Tabular data with headers</td></tr>
|
|
||||||
<tr><td><code>List</code></td><td><code>items</code>, <code>ordered</code>, <code>bulletChar</code>, <code>spacing</code></td><td>Bulleted or numbered list</td></tr>
|
|
||||||
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code></td><td>Structured list row</td></tr>
|
|
||||||
<tr><td><code>Card</code></td><td><code>title</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>padding</code></td><td>Bordered container with optional title</td></tr>
|
|
||||||
<tr><td><code>Sparkline</code></td><td><code>data</code>, <code>width</code>, <code>color</code>, <code>label</code>, <code>min</code>, <code>max</code></td><td>Inline sparkline chart using Unicode blocks (▁▂▃▄▅▆▇█)</td></tr>
|
|
||||||
<tr><td><code>BarChart</code></td><td><code>data</code> (label/value/color), <code>width</code>, <code>showValues</code>, <code>showPercentage</code></td><td>Horizontal bar chart for comparing values</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Interactive
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>mask</code></td><td>Text input field. Press Enter to submit.</td></tr>
|
|
||||||
<tr><td><code>Select</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code></td><td>Arrow-key selection menu</td></tr>
|
|
||||||
<tr><td><code>MultiSelect</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>min</code>, <code>max</code></td><td>Multi-selection menu. Space to toggle, Enter to confirm.</td></tr>
|
|
||||||
<tr><td><code>ConfirmInput</code></td><td><code>message</code>, <code>defaultValue</code>, <code>yesLabel</code>, <code>noLabel</code></td><td>Yes/No confirmation prompt. Press Y or N.</td></tr>
|
|
||||||
<tr><td><code>Tabs</code></td><td><code>tabs</code>, <code>value</code> (use <code>$bindState</code>), <code>color</code></td><td>Tab bar navigation with left/right arrow keys. Place child content inside with visible conditions.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Providers
|
|
||||||
|
|
||||||
### JSONUIProvider
|
|
||||||
|
|
||||||
Convenience wrapper around all providers: `StateProvider` → `VisibilityProvider` → `ValidationProvider` → `ActionProvider` → `FocusProvider`.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { JSONUIProvider, Renderer } from "@json-render/ink";
|
|
||||||
|
|
||||||
<JSONUIProvider initialState={{}} handlers={handlers}>
|
|
||||||
<Renderer spec={spec} registry={registry} />
|
|
||||||
</JSONUIProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### StateProvider
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<StateProvider initialState={object} onStateChange={fn}>
|
|
||||||
{children}
|
|
||||||
</StateProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
|
|
||||||
<tr><td><code>initialState</code></td><td><code>Record<string, unknown></code></td><td>Initial state model (uncontrolled mode).</td></tr>
|
|
||||||
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode).</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
#### External Store (Controlled Mode)
|
|
||||||
|
|
||||||
Pass a `StateStore` to bypass internal state and wire json-render to any state management:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createStateStore } from "@json-render/ink";
|
|
||||||
|
|
||||||
const store = createStateStore({ count: 0 });
|
|
||||||
|
|
||||||
<StateProvider store={store}>
|
|
||||||
{children}
|
|
||||||
</StateProvider>
|
|
||||||
|
|
||||||
// Mutate from anywhere — components re-render automatically:
|
|
||||||
store.set("/count", 1);
|
|
||||||
```
|
|
||||||
|
|
||||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
|
||||||
|
|
||||||
### ActionProvider
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ActionProvider handlers={Record<string, ActionHandler>} navigate={fn}>
|
|
||||||
{children}
|
|
||||||
</ActionProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
Built-in actions: `setState`, `pushState`, `removeState`, `log`, `exit`. Custom handlers override built-ins. Includes a terminal confirmation dialog (press Y/N) for actions with `confirm`.
|
|
||||||
|
|
||||||
### VisibilityProvider
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<VisibilityProvider>
|
|
||||||
{children}
|
|
||||||
</VisibilityProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### ValidationProvider
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ValidationProvider>
|
|
||||||
{children}
|
|
||||||
</ValidationProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### FocusProvider
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<FocusProvider>
|
|
||||||
{children}
|
|
||||||
</FocusProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
Manages Tab-cycling focus between interactive components (TextInput, Select). Supports `useFocusDisable` to suppress cycling during modal dialogs.
|
|
||||||
|
|
||||||
## defineRegistry
|
|
||||||
|
|
||||||
Create a type-safe component registry. Standard components are built-in; only register custom components.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { defineRegistry, type Components } from "@json-render/ink";
|
|
||||||
|
|
||||||
const { registry, handlers, executeAction } = defineRegistry(catalog, {
|
|
||||||
components: {
|
|
||||||
MyWidget: ({ props }) => <Text>{props.label}</Text>,
|
|
||||||
} as Components<typeof catalog>,
|
|
||||||
actions: {
|
|
||||||
submit: async (params, setState, state) => {
|
|
||||||
// custom action logic
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
`handlers` is designed for `JSONUIProvider`/`ActionProvider`. `executeAction` is an imperative helper.
|
|
||||||
|
|
||||||
## createRenderer
|
|
||||||
|
|
||||||
Higher-level helper that wraps `Renderer` + all providers into a single component.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createRenderer } from "@json-render/ink";
|
|
||||||
|
|
||||||
const UIRenderer = createRenderer(catalog, components);
|
|
||||||
|
|
||||||
<UIRenderer spec={spec} state={initialState} />;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hooks
|
|
||||||
|
|
||||||
### useUIStream
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const {
|
|
||||||
spec, // Spec | null - current UI state
|
|
||||||
isStreaming, // boolean - true while streaming
|
|
||||||
error, // Error | null
|
|
||||||
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
|
|
||||||
stop, // () => void - abort the current stream
|
|
||||||
clear, // () => void - reset spec and error
|
|
||||||
} = useUIStream({
|
|
||||||
api: string,
|
|
||||||
onComplete?: (spec: Spec) => void,
|
|
||||||
onError?: (error: Error) => void,
|
|
||||||
fetch?: (url: string, init?: RequestInit) => Promise<Response>,
|
|
||||||
validate?: boolean,
|
|
||||||
maxRetries?: number,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### useStateStore
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { state, get, set, update } = useStateStore();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useStateValue
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const value = useStateValue(path: string);
|
|
||||||
```
|
|
||||||
|
|
||||||
### useBoundProp
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [value, setValue] = useBoundProp(resolvedValue, bindingPath);
|
|
||||||
```
|
|
||||||
|
|
||||||
### useActions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { execute } = useActions();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useIsVisible
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
|
||||||
```
|
|
||||||
|
|
||||||
### useFocus
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { isActive, id } = useFocus();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useFocusDisable
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useFocusDisable(disabled: boolean);
|
|
||||||
```
|
|
||||||
|
|
||||||
Suppresses Tab-cycling while `disabled` is true (e.g., during a modal dialog).
|
|
||||||
|
|
||||||
## Catalog Exports
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/catalog";
|
|
||||||
import { schema } from "@json-render/ink/schema";
|
|
||||||
```
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Export</th><th>Purpose</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 19 standard components</td></tr>
|
|
||||||
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, pushState, removeState, log, exit)</td></tr>
|
|
||||||
<tr><td><code>schema</code></td><td>Ink element tree schema</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Server Export
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { schema, standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/server";
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-exports the schema and catalog definitions for server-side usage (e.g., building system prompts).
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { pageMetadata } from "@/lib/page-metadata"
|
|
||||||
export const metadata = pageMetadata("docs/api/next")
|
|
||||||
|
|
||||||
# @json-render/next
|
|
||||||
|
|
||||||
Next.js renderer. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @json-render/core @json-render/react @json-render/next
|
|
||||||
```
|
|
||||||
|
|
||||||
## schema
|
|
||||||
|
|
||||||
The Next.js app schema for multi-page specs. Use with `defineCatalog` from core.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineCatalog } from '@json-render/core';
|
|
||||||
import { schema } from '@json-render/next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: {
|
|
||||||
Card: {
|
|
||||||
props: z.object({ title: z.string() }),
|
|
||||||
description: 'Card container',
|
|
||||||
},
|
|
||||||
NavBar: {
|
|
||||||
props: z.object({ links: z.array(z.object({ href: z.string(), label: z.string() })) }),
|
|
||||||
description: 'Navigation bar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## createNextApp
|
|
||||||
|
|
||||||
Create all exports needed for a Next.js `[[...slug]]` catch-all route.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createNextApp } from '@json-render/next/server';
|
|
||||||
|
|
||||||
const { Page, generateMetadata, generateStaticParams } = createNextApp({
|
|
||||||
spec: myAppSpec,
|
|
||||||
loaders: {
|
|
||||||
loadPost: async ({ slug }) => {
|
|
||||||
const post = await db.post.findUnique({ where: { slug } });
|
|
||||||
return { post };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Option</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>spec</code></td>
|
|
||||||
<td><code>{'NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>)'}</code></td>
|
|
||||||
<td>The application spec (static or dynamic)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>loaders</code></td>
|
|
||||||
<td><code>{'Record<string, LoaderFn>'}</code></td>
|
|
||||||
<td>Server-side data loaders keyed by name</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Returns
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Export</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Page</code></td>
|
|
||||||
<td>Async Server Component for <code>page.tsx</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>generateMetadata</code></td>
|
|
||||||
<td>Metadata generator for Next.js SEO</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>generateStaticParams</code></td>
|
|
||||||
<td>Static params for pre-rendering at build time</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## NextAppSpec
|
|
||||||
|
|
||||||
The top-level spec defining an entire Next.js application.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface NextAppSpec {
|
|
||||||
metadata?: NextMetadata;
|
|
||||||
routes: Record<string, NextRouteSpec>;
|
|
||||||
layouts?: Record<string, Spec>;
|
|
||||||
state?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Route Patterns
|
|
||||||
|
|
||||||
Routes use Next.js URL conventions:
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Pattern</th>
|
|
||||||
<th>Example Match</th>
|
|
||||||
<th>Params</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>/</code></td>
|
|
||||||
<td><code>/</code></td>
|
|
||||||
<td><code>{'{}'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>/about</code></td>
|
|
||||||
<td><code>/about</code></td>
|
|
||||||
<td><code>{'{}'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>/blog/[slug]</code></td>
|
|
||||||
<td><code>/blog/hello</code></td>
|
|
||||||
<td><code>{'{ slug: "hello" }'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>/docs/[...path]</code></td>
|
|
||||||
<td><code>/docs/a/b/c</code></td>
|
|
||||||
<td><code>{'{ path: ["a","b","c"] }'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>/app/[[...path]]</code></td>
|
|
||||||
<td><code>/app</code> or <code>/app/x/y</code></td>
|
|
||||||
<td><code>{'{ path: [] }'}</code> or <code>{'{ path: ["x","y"] }'}</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## NextAppProvider
|
|
||||||
|
|
||||||
Client component that provides the component registry and action handlers to all pages.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { NextAppProvider } from '@json-render/next';
|
|
||||||
|
|
||||||
export default function Layout({ children }) {
|
|
||||||
return (
|
|
||||||
<NextAppProvider registry={registry} handlers={handlers}>
|
|
||||||
{children}
|
|
||||||
</NextAppProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Built-in Components
|
|
||||||
|
|
||||||
### Slot
|
|
||||||
|
|
||||||
Placeholder in layouts where page content is rendered. Every layout MUST include a Slot.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Slot", "props": {}, "children": [] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Link
|
|
||||||
|
|
||||||
Client-side navigation wrapping `next/link`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Link", "props": { "href": "/about" }, "children": ["link-text"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Built-in Actions
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Params</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>setState</code></td>
|
|
||||||
<td><code>{'{ statePath, value }'}</code></td>
|
|
||||||
<td>Update a value in state</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>pushState</code></td>
|
|
||||||
<td><code>{'{ statePath, value, clearStatePath? }'}</code></td>
|
|
||||||
<td>Append to array in state</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>removeState</code></td>
|
|
||||||
<td><code>{'{ statePath, index }'}</code></td>
|
|
||||||
<td>Remove from array by index</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>navigate</code></td>
|
|
||||||
<td><code>{'{ href }'}</code></td>
|
|
||||||
<td>Client-side navigation</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Server Utilities
|
|
||||||
|
|
||||||
### matchRoute
|
|
||||||
|
|
||||||
Match a pathname against a spec's routes.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { matchRoute } from '@json-render/next/server';
|
|
||||||
|
|
||||||
const matched = matchRoute(spec, '/blog/hello-world');
|
|
||||||
// { route: NextRouteSpec, pattern: '/blog/[slug]', params: { slug: 'hello-world' } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### resolveMetadata
|
|
||||||
|
|
||||||
Resolve merged metadata for a route.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { resolveMetadata } from '@json-render/next/server';
|
|
||||||
|
|
||||||
const metadata = resolveMetadata(spec, matchedRoute?.route);
|
|
||||||
```
|
|
||||||
|
|
||||||
### slugToPath
|
|
||||||
|
|
||||||
Convert catch-all slug array to pathname.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { slugToPath } from '@json-render/next/server';
|
|
||||||
|
|
||||||
slugToPath(undefined); // "/"
|
|
||||||
slugToPath(['blog', 'hello']); // "/blog/hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entry Points
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Import</th>
|
|
||||||
<th>Contents</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/next</code></td>
|
|
||||||
<td>Client components (NextAppProvider, PageRenderer, Link)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/next/server</code></td>
|
|
||||||
<td>Server utilities (createNextApp, matchRoute, schema)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { pageMetadata } from "@/lib/page-metadata"
|
|
||||||
export const metadata = pageMetadata("docs/api/react-three-fiber")
|
|
||||||
|
|
||||||
# @json-render/react-three-fiber
|
|
||||||
|
|
||||||
React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @json-render/react-three-fiber @json-render/core @json-render/react @react-three/fiber @react-three/drei three zod
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entry Points
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Entry Point</th>
|
|
||||||
<th>Exports</th>
|
|
||||||
<th>Use For</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/react-three-fiber</code></td>
|
|
||||||
<td><code>threeComponents</code>, <code>ThreeRenderer</code>, <code>ThreeCanvas</code>, schemas</td>
|
|
||||||
<td>React Three Fiber implementations and renderer</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/react-three-fiber/catalog</code></td>
|
|
||||||
<td><code>threeComponentDefinitions</code></td>
|
|
||||||
<td>Catalog schemas (no R3F dependency, safe for server)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {'{ defineCatalog }'} from "@json-render/core";
|
|
||||||
import {'{ schema, defineRegistry }'} from "@json-render/react";
|
|
||||||
import {'{'}
|
|
||||||
threeComponentDefinitions,
|
|
||||||
threeComponents,
|
|
||||||
ThreeCanvas,
|
|
||||||
{'}'} from "@json-render/react-three-fiber";
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {'{'}
|
|
||||||
components: {'{'}
|
|
||||||
Box: threeComponentDefinitions.Box,
|
|
||||||
Sphere: threeComponentDefinitions.Sphere,
|
|
||||||
AmbientLight: threeComponentDefinitions.AmbientLight,
|
|
||||||
DirectionalLight: threeComponentDefinitions.DirectionalLight,
|
|
||||||
OrbitControls: threeComponentDefinitions.OrbitControls,
|
|
||||||
{'}'},
|
|
||||||
actions: {'{}'},
|
|
||||||
{'}'});
|
|
||||||
|
|
||||||
const {'{ registry }'} = defineRegistry(catalog, {'{'}
|
|
||||||
components: {'{'}
|
|
||||||
Box: threeComponents.Box,
|
|
||||||
Sphere: threeComponents.Sphere,
|
|
||||||
AmbientLight: threeComponents.AmbientLight,
|
|
||||||
DirectionalLight: threeComponents.DirectionalLight,
|
|
||||||
OrbitControls: threeComponents.OrbitControls,
|
|
||||||
{'}'},
|
|
||||||
{'}'});
|
|
||||||
```
|
|
||||||
|
|
||||||
### ThreeCanvas (convenience)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ThreeCanvas
|
|
||||||
spec={'{spec}'}
|
|
||||||
registry={'{registry}'}
|
|
||||||
shadows
|
|
||||||
camera={'{'}{'{ position: [5, 5, 5], fov: 50 }'}{'}'}
|
|
||||||
style={'{'}{'{ width: "100%", height: "100vh" }'}{'}'}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Canvas Setup
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {'{ Canvas }'} from "@react-three/fiber";
|
|
||||||
import {'{ ThreeRenderer }'} from "@json-render/react-three-fiber";
|
|
||||||
|
|
||||||
<Canvas shadows>
|
|
||||||
<ThreeRenderer spec={'{spec}'} registry={'{registry}'} />
|
|
||||||
</Canvas>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Primitives
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Key Props</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Box</code></td>
|
|
||||||
<td>Box mesh (default 1x1x1)</td>
|
|
||||||
<td><code>width</code>, <code>height</code>, <code>depth</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Sphere</code></td>
|
|
||||||
<td>Sphere mesh</td>
|
|
||||||
<td><code>radius</code>, <code>widthSegments</code>, <code>heightSegments</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Cylinder</code></td>
|
|
||||||
<td>Cylinder mesh</td>
|
|
||||||
<td><code>radiusTop</code>, <code>radiusBottom</code>, <code>height</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Cone</code></td>
|
|
||||||
<td>Cone mesh</td>
|
|
||||||
<td><code>radius</code>, <code>height</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Torus</code></td>
|
|
||||||
<td>Torus (donut) mesh</td>
|
|
||||||
<td><code>radius</code>, <code>tube</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Plane</code></td>
|
|
||||||
<td>Flat plane mesh</td>
|
|
||||||
<td><code>width</code>, <code>height</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Capsule</code></td>
|
|
||||||
<td>Capsule mesh</td>
|
|
||||||
<td><code>radius</code>, <code>length</code>, <code>material</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
All primitives share: <code>position</code>, <code>rotation</code>, <code>scale</code>, <code>castShadow</code>, <code>receiveShadow</code>, <code>material</code>.
|
|
||||||
|
|
||||||
### Material Schema
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Property</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Default</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>color</code></td>
|
|
||||||
<td><code>string</code></td>
|
|
||||||
<td><code>"#ffffff"</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>metalness</code></td>
|
|
||||||
<td><code>number</code></td>
|
|
||||||
<td><code>0</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>roughness</code></td>
|
|
||||||
<td><code>number</code></td>
|
|
||||||
<td><code>1</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>emissive</code></td>
|
|
||||||
<td><code>string</code></td>
|
|
||||||
<td><code>"#000000"</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>emissiveIntensity</code></td>
|
|
||||||
<td><code>number</code></td>
|
|
||||||
<td><code>1</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>opacity</code></td>
|
|
||||||
<td><code>number</code></td>
|
|
||||||
<td><code>1</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>transparent</code></td>
|
|
||||||
<td><code>boolean</code></td>
|
|
||||||
<td><code>false</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>wireframe</code></td>
|
|
||||||
<td><code>boolean</code></td>
|
|
||||||
<td><code>false</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Lights
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Key Props</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>AmbientLight</code></td>
|
|
||||||
<td>Uniform illumination</td>
|
|
||||||
<td><code>color</code>, <code>intensity</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>DirectionalLight</code></td>
|
|
||||||
<td>Sunlight-style</td>
|
|
||||||
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>castShadow</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>PointLight</code></td>
|
|
||||||
<td>Radiates from a point</td>
|
|
||||||
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>distance</code>, <code>decay</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>SpotLight</code></td>
|
|
||||||
<td>Cone of light</td>
|
|
||||||
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>angle</code>, <code>penumbra</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Other Components
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Key Props</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Group</code></td>
|
|
||||||
<td>Container for children</td>
|
|
||||||
<td><code>position</code>, <code>rotation</code>, <code>scale</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Model</code></td>
|
|
||||||
<td>GLTF/GLB model loader</td>
|
|
||||||
<td><code>url</code>, <code>position</code>, <code>rotation</code>, <code>scale</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Environment</code></td>
|
|
||||||
<td>HDRI environment map</td>
|
|
||||||
<td><code>preset</code>, <code>background</code>, <code>blur</code>, <code>intensity</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Fog</code></td>
|
|
||||||
<td>Linear fog effect</td>
|
|
||||||
<td><code>color</code>, <code>near</code>, <code>far</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>GridHelper</code></td>
|
|
||||||
<td>Reference grid</td>
|
|
||||||
<td><code>size</code>, <code>divisions</code>, <code>color</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Text3D</code></td>
|
|
||||||
<td>3D text (SDF)</td>
|
|
||||||
<td><code>text</code>, <code>fontSize</code>, <code>color</code>, <code>anchorX</code>, <code>anchorY</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>PerspectiveCamera</code></td>
|
|
||||||
<td>Camera</td>
|
|
||||||
<td><code>position</code>, <code>fov</code>, <code>near</code>, <code>far</code>, <code>makeDefault</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>OrbitControls</code></td>
|
|
||||||
<td>Camera controls</td>
|
|
||||||
<td><code>enableDamping</code>, <code>enableZoom</code>, <code>autoRotate</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Shared Schemas
|
|
||||||
|
|
||||||
Reusable Zod schemas for custom 3D components:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {'{ vector3Schema, materialSchema, transformProps, shadowProps }'} from "@json-render/react-three-fiber";
|
|
||||||
```
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Export</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>vector3Schema</code></td>
|
|
||||||
<td><code>z.tuple([z.number(), z.number(), z.number()])</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>materialSchema</code></td>
|
|
||||||
<td>Standard material props (color, metalness, roughness, etc.)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>transformProps</code></td>
|
|
||||||
<td><code>{'{ position, rotation, scale }'}</code> schema fields</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>shadowProps</code></td>
|
|
||||||
<td><code>{'{ castShadow, receiveShadow }'}</code> schema fields</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## ThreeRenderer
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Prop</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>spec</code></td>
|
|
||||||
<td><code>Spec | null</code></td>
|
|
||||||
<td>The spec to render as a 3D scene</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>registry</code></td>
|
|
||||||
<td><code>ComponentRegistry</code></td>
|
|
||||||
<td>Component registry from <code>defineRegistry</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>store</code></td>
|
|
||||||
<td><code>StateStore</code></td>
|
|
||||||
<td>External state store (controlled mode)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>initialState</code></td>
|
|
||||||
<td><code>Record<string, unknown></code></td>
|
|
||||||
<td>Initial state (uncontrolled mode)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>handlers</code></td>
|
|
||||||
<td><code>Record<string, Function></code></td>
|
|
||||||
<td>Action handlers</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>loading</code></td>
|
|
||||||
<td><code>boolean</code></td>
|
|
||||||
<td>Whether the spec is streaming</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>children</code></td>
|
|
||||||
<td><code>ReactNode</code></td>
|
|
||||||
<td>Additional R3F elements alongside the spec</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## ThreeCanvas
|
|
||||||
|
|
||||||
Extends <code>ThreeRendererProps</code> with Canvas options:
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Prop</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>shadows</code></td>
|
|
||||||
<td><code>boolean</code></td>
|
|
||||||
<td>Enable shadow maps</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>camera</code></td>
|
|
||||||
<td><code>object</code></td>
|
|
||||||
<td>Default camera config (position, fov, etc.)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>className</code></td>
|
|
||||||
<td><code>string</code></td>
|
|
||||||
<td>CSS class for the canvas container</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>style</code></td>
|
|
||||||
<td><code>CSSProperties</code></td>
|
|
||||||
<td>Inline styles for the canvas container</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Type Helpers
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import type {'{ ThreeProps }'} from "@json-render/react-three-fiber";
|
|
||||||
|
|
||||||
type BoxProps = ThreeProps<"Box">;
|
|
||||||
type SphereProps = ThreeProps<"Sphere">;
|
|
||||||
```
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import { pageMetadata } from "@/lib/page-metadata"
|
|
||||||
export const metadata = pageMetadata("docs/api/shadcn-svelte")
|
|
||||||
|
|
||||||
# @json-render/shadcn-svelte
|
|
||||||
|
|
||||||
Pre-built [shadcn-svelte](https://www.shadcn-svelte.com/) components for json-render. 36 components built on Svelte 5 + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @json-render/shadcn-svelte @json-render/core @json-render/svelte zod
|
|
||||||
```
|
|
||||||
|
|
||||||
Your app must have Tailwind CSS configured.
|
|
||||||
|
|
||||||
## Entry Points
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Entry Point</th>
|
|
||||||
<th>Exports</th>
|
|
||||||
<th>Use For</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/shadcn-svelte</code></td>
|
|
||||||
<td><code>shadcnComponents</code>, <code>shadcnComponentDefinitions</code></td>
|
|
||||||
<td>Svelte implementations + catalog schemas</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>@json-render/shadcn-svelte/catalog</code></td>
|
|
||||||
<td><code>shadcnComponentDefinitions</code></td>
|
|
||||||
<td>Catalog schemas only (no Svelte dependency, safe for server)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Pick the components you need from the standard definitions:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import { schema } from "@json-render/svelte/schema";
|
|
||||||
import { shadcnComponentDefinitions } from "@json-render/shadcn-svelte/catalog";
|
|
||||||
import { defineRegistry } from "@json-render/svelte";
|
|
||||||
import { shadcnComponents } from "@json-render/shadcn-svelte";
|
|
||||||
|
|
||||||
// Catalog: pick definitions
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: {
|
|
||||||
Card: shadcnComponentDefinitions.Card,
|
|
||||||
Stack: shadcnComponentDefinitions.Stack,
|
|
||||||
Heading: shadcnComponentDefinitions.Heading,
|
|
||||||
Button: shadcnComponentDefinitions.Button,
|
|
||||||
Input: shadcnComponentDefinitions.Input,
|
|
||||||
},
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Registry: pick matching implementations
|
|
||||||
const { registry } = defineRegistry(catalog, {
|
|
||||||
components: {
|
|
||||||
Card: shadcnComponents.Card,
|
|
||||||
Stack: shadcnComponents.Stack,
|
|
||||||
Heading: shadcnComponents.Heading,
|
|
||||||
Button: shadcnComponents.Button,
|
|
||||||
Input: shadcnComponents.Input,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Then render in your Svelte component:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import { Renderer, JsonUIProvider } from "@json-render/svelte";
|
|
||||||
|
|
||||||
export let spec;
|
|
||||||
export let registry;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<JsonUIProvider initialState={spec?.state ?? {}}>
|
|
||||||
<Renderer {spec} {registry} />
|
|
||||||
</JsonUIProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Components
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Card</code></td>
|
|
||||||
<td>Container card with optional title, description, maxWidth, centered</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Stack</code></td>
|
|
||||||
<td>Flex container with direction, gap, align, justify</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Grid</code></td>
|
|
||||||
<td>Grid layout with columns (1-6) and gap</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Separator</code></td>
|
|
||||||
<td>Visual separator line with orientation</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Tabs</code></td>
|
|
||||||
<td>Tabbed navigation with tabs array, defaultValue, value</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Accordion</code></td>
|
|
||||||
<td>Collapsible sections with items array and type (single/multiple)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Collapsible</code></td>
|
|
||||||
<td>Single collapsible section with title and defaultOpen</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Pagination</code></td>
|
|
||||||
<td>Page navigation with totalPages and page</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Overlay
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Dialog</code></td>
|
|
||||||
<td>Modal dialog with title, description, openPath</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Drawer</code></td>
|
|
||||||
<td>Bottom drawer with title, description, openPath</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Tooltip</code></td>
|
|
||||||
<td>Hover tooltip with content and text</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Popover</code></td>
|
|
||||||
<td>Click-triggered popover with trigger and content</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>DropdownMenu</code></td>
|
|
||||||
<td>Dropdown menu with label and items array</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Content
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Heading</code></td>
|
|
||||||
<td>Heading text with level (h1-h4)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Text</code></td>
|
|
||||||
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Image</code></td>
|
|
||||||
<td>Image with alt, width, height</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Avatar</code></td>
|
|
||||||
<td>User avatar with src, name, size</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Badge</code></td>
|
|
||||||
<td>Status badge with text and variant</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Alert</code></td>
|
|
||||||
<td>Alert banner with title, message, type</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Carousel</code></td>
|
|
||||||
<td>Horizontally scrollable carousel with items</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Table</code></td>
|
|
||||||
<td>Data table with columns and rows</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Feedback
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Progress</code></td>
|
|
||||||
<td>Progress bar with value, max, label</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Skeleton</code></td>
|
|
||||||
<td>Loading placeholder with width, height, rounded</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Spinner</code></td>
|
|
||||||
<td>Loading spinner with size and label</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Input
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Component</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>Button</code></td>
|
|
||||||
<td>Clickable button with label, variant, disabled</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Link</code></td>
|
|
||||||
<td>Anchor link with label and href</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Input</code></td>
|
|
||||||
<td>Text input with label, name, type, placeholder, value, checks</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Textarea</code></td>
|
|
||||||
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Select</code></td>
|
|
||||||
<td>Dropdown select with label, name, options, value, checks</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Checkbox</code></td>
|
|
||||||
<td>Checkbox with label, name, checked</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Radio</code></td>
|
|
||||||
<td>Radio button group with label, name, options, value</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Switch</code></td>
|
|
||||||
<td>Toggle switch with label, name, checked</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Slider</code></td>
|
|
||||||
<td>Range slider with label, min, max, step, value</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>Toggle</code></td>
|
|
||||||
<td>Toggle button with label, pressed, variant</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>ToggleGroup</code></td>
|
|
||||||
<td>Group of toggle buttons with items, type, value</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>ButtonGroup</code></td>
|
|
||||||
<td>Group of buttons with buttons array and selected</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The `/catalog` entry point has no Svelte dependency -- use it for server-side prompt generation
|
|
||||||
- Components use Tailwind CSS classes -- your app must have Tailwind configured
|
|
||||||
- Component implementations use bundled shadcn-svelte primitives (not your app's `$lib/components/ui/`)
|
|
||||||
- Form inputs support `checks` for validation (type + message pairs) and `validateOn` for timing
|
|
||||||
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { pageMetadata } from "@/lib/page-metadata"
|
|
||||||
export const metadata = pageMetadata("docs/api/yaml")
|
|
||||||
|
|
||||||
# @json-render/yaml
|
|
||||||
|
|
||||||
YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
|
|
||||||
|
|
||||||
## Prompt Generation
|
|
||||||
|
|
||||||
### yamlPrompt
|
|
||||||
|
|
||||||
Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function yamlPrompt(
|
|
||||||
catalog: Catalog,
|
|
||||||
options?: YamlPromptOptions
|
|
||||||
): string
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { yamlPrompt } from "@json-render/yaml";
|
|
||||||
|
|
||||||
const systemPrompt = yamlPrompt(catalog, {
|
|
||||||
mode: "standalone",
|
|
||||||
customRules: ["Always use dark theme"],
|
|
||||||
editModes: ["merge"],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### YamlPromptOptions
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Option</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Default</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>system</code></td>
|
|
||||||
<td><code>string</code></td>
|
|
||||||
<td><code>{'\"You are a UI generator that outputs YAML.\"'}</code></td>
|
|
||||||
<td>Custom system message intro</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>mode</code></td>
|
|
||||||
<td><code>{'\"standalone\" | \"inline\"'}</code></td>
|
|
||||||
<td><code>{'\"standalone\"'}</code></td>
|
|
||||||
<td>Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>customRules</code></td>
|
|
||||||
<td><code>{'string[]'}</code></td>
|
|
||||||
<td><code>{'[]'}</code></td>
|
|
||||||
<td>Additional rules appended to the prompt</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>editModes</code></td>
|
|
||||||
<td><code>{'EditMode[]'}</code></td>
|
|
||||||
<td><code>{'[\"merge\"]'}</code></td>
|
|
||||||
<td>Edit modes to document in the prompt (patch, merge, diff)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## AI SDK Transform
|
|
||||||
|
|
||||||
### createYamlTransform
|
|
||||||
|
|
||||||
Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function createYamlTransform(
|
|
||||||
options?: YamlTransformOptions
|
|
||||||
): TransformStream<StreamChunk, StreamChunk>
|
|
||||||
```
|
|
||||||
|
|
||||||
Recognized fence types:
|
|
||||||
|
|
||||||
- <code>{'```yaml-spec'}</code> -- Full YAML spec, parsed progressively
|
|
||||||
- <code>{'```yaml-edit'}</code> -- Partial YAML, deep-merged with current spec
|
|
||||||
- <code>{'```yaml-patch'}</code> -- RFC 6902 JSON Patch lines
|
|
||||||
- <code>{'```diff'}</code> -- Unified diff against serialized spec
|
|
||||||
|
|
||||||
### pipeYamlRender
|
|
||||||
|
|
||||||
Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function pipeYamlRender<T>(
|
|
||||||
stream: ReadableStream<T>,
|
|
||||||
options?: YamlTransformOptions
|
|
||||||
): ReadableStream<T>
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { pipeYamlRender } from "@json-render/yaml";
|
|
||||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
|
||||||
execute: async ({ writer }) => {
|
|
||||||
writer.merge(pipeYamlRender(result.toUIMessageStream()));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return createUIMessageStreamResponse({ stream });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Streaming Parser
|
|
||||||
|
|
||||||
### createYamlStreamCompiler
|
|
||||||
|
|
||||||
Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function createYamlStreamCompiler<T>(
|
|
||||||
initial?: Partial<T>
|
|
||||||
): YamlStreamCompiler<T>
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createYamlStreamCompiler } from "@json-render/yaml";
|
|
||||||
|
|
||||||
const compiler = createYamlStreamCompiler<Spec>();
|
|
||||||
|
|
||||||
compiler.push("root: main\n");
|
|
||||||
compiler.push("elements:\n main:\n type: Card\n");
|
|
||||||
|
|
||||||
const { result, newPatches } = compiler.flush();
|
|
||||||
```
|
|
||||||
|
|
||||||
### YamlStreamCompiler
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Method</th>
|
|
||||||
<th>Returns</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>push(chunk)</code></td>
|
|
||||||
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
|
||||||
<td>Push a chunk of text, returns current result and new patches</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>flush()</code></td>
|
|
||||||
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
|
||||||
<td>Flush remaining buffer, return final result</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>getResult()</code></td>
|
|
||||||
<td><code>T</code></td>
|
|
||||||
<td>Get the current compiled result</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>getPatches()</code></td>
|
|
||||||
<td><code>{'JsonPatch[]'}</code></td>
|
|
||||||
<td>Get all patches applied so far</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>reset(initial?)</code></td>
|
|
||||||
<td><code>void</code></td>
|
|
||||||
<td>Reset to initial state</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Fence Constants
|
|
||||||
|
|
||||||
Exported string constants for fence detection in custom parsers:
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Constant</th>
|
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code>YAML_SPEC_FENCE</code></td>
|
|
||||||
<td><code>{'\"```yaml-spec\"'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>YAML_EDIT_FENCE</code></td>
|
|
||||||
<td><code>{'\"```yaml-edit\"'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>YAML_PATCH_FENCE</code></td>
|
|
||||||
<td><code>{'\"```yaml-patch\"'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>DIFF_FENCE</code></td>
|
|
||||||
<td><code>{'\"```diff\"'}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>FENCE_CLOSE</code></td>
|
|
||||||
<td><code>{'\"```\"'}</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Re-exports from @json-render/core
|
|
||||||
|
|
||||||
### diffToPatches
|
|
||||||
|
|
||||||
Generate RFC 6902 JSON Patch operations that transform one object into another.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function diffToPatches(
|
|
||||||
oldObj: Record<string, unknown>,
|
|
||||||
newObj: Record<string, unknown>,
|
|
||||||
basePath?: string
|
|
||||||
): JsonPatch[]
|
|
||||||
```
|
|
||||||
|
|
||||||
### deepMergeSpec
|
|
||||||
|
|
||||||
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function deepMergeSpec(
|
|
||||||
base: Record<string, unknown>,
|
|
||||||
patch: Record<string, unknown>
|
|
||||||
): Record<string, unknown>
|
|
||||||
```
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||||
import { CopyPageButton } from "@/components/copy-page-button";
|
import { CopyPageButton } from "@/components/copy-page-button";
|
||||||
import { TableOfContents } from "@/components/table-of-contents";
|
|
||||||
|
|
||||||
export default function DocsLayout({
|
export default function DocsLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,7 +10,7 @@ export default function DocsLayout({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocsMobileNav />
|
<DocsMobileNav />
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8 lg:py-12 flex gap-12">
|
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||||
<DocsSidebar />
|
<DocsSidebar />
|
||||||
@@ -24,11 +23,6 @@ export default function DocsLayout({
|
|||||||
</div>
|
</div>
|
||||||
<article>{children}</article>
|
<article>{children}</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* On this page */}
|
|
||||||
<aside className="w-44 shrink-0 hidden xl:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
|
||||||
<TableOfContents />
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,13 +83,6 @@ All renderers share the same workflow:
|
|||||||
</td>
|
</td>
|
||||||
<td>Video compositions</td>
|
<td>Video compositions</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Ink</td>
|
|
||||||
<td>
|
|
||||||
<code>@json-render/ink</code>
|
|
||||||
</td>
|
|
||||||
<td>Terminal UI (via Ink)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -270,30 +263,6 @@ Uses a timeline spec format with compositions, tracks, and clips. Includes stand
|
|||||||
|
|
||||||
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
|
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
|
||||||
|
|
||||||
## Ink (Terminal)
|
|
||||||
|
|
||||||
Render specs as terminal UIs using [Ink](https://github.com/vadimdemedes/ink). Multiple standard components including tables, progress bars, spinners, tabs, multi-select, and interactive inputs with Tab-cycling focus.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import { schema } from "@json-render/ink/schema";
|
|
||||||
import {
|
|
||||||
standardComponentDefinitions,
|
|
||||||
standardActionDefinitions,
|
|
||||||
} from "@json-render/ink/catalog";
|
|
||||||
import { defineRegistry, Renderer } from "@json-render/ink";
|
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
|
||||||
components: { ...standardComponentDefinitions },
|
|
||||||
actions: standardActionDefinitions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { registry } = defineRegistry(catalog, { components: {} });
|
|
||||||
<Renderer spec={spec} registry={registry} />;
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [@json-render/ink API reference](/docs/api/ink) for details.
|
|
||||||
|
|
||||||
## Custom Renderers
|
## Custom Renderers
|
||||||
|
|
||||||
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
|
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function ExampleCard({ example }: { example: Example }) {
|
|
||||||
return (
|
|
||||||
<div className="group flex flex-col rounded-xl border border-border bg-card text-card-foreground overflow-hidden transition-colors hover:border-foreground/25">
|
|
||||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
|
||||||
<h3 className="font-semibold leading-none">{example.title}</h3>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{example.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{example.tags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-[11px]">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center gap-3 pt-2">
|
|
||||||
{example.demoUrl && (
|
|
||||||
<a
|
|
||||||
href={example.demoUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M15 3h6v6" />
|
|
||||||
<path d="M10 14 21 3" />
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
||||||
</svg>
|
|
||||||
Live Demo
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={getGitHubUrl(example)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
Source
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExamplesPage() {
|
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const filtered = activeTag
|
|
||||||
? examples.filter((e) => e.tags.includes(activeTag))
|
|
||||||
: examples;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mx-auto max-w-6xl px-6 py-16">
|
|
||||||
<div className="mb-10">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
|
||||||
Examples
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-lg text-muted-foreground">
|
|
||||||
Explore json-render across frameworks, renderers, and use cases.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-8 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTag(null)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
activeTag === null
|
|
||||||
? "border-foreground bg-foreground text-background"
|
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{allTags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
activeTag === tag
|
|
||||||
? "border-foreground bg-foreground text-background"
|
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filtered.map((example) => (
|
|
||||||
<ExampleCard key={example.slug} example={example} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<p className="py-12 text-center text-muted-foreground">
|
|
||||||
No examples match the selected filter.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
|||||||
|
|
||||||
GitHub repository: https://github.com/vercel-labs/json-render
|
GitHub repository: https://github.com/vercel-labs/json-render
|
||||||
Documentation: https://json-render.dev/docs
|
Documentation: https://json-render.dev/docs
|
||||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
|
||||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
|
||||||
|
|
||||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import type { Spec, EditMode } from "@json-render/core";
|
import { buildUserPrompt } from "@json-render/core";
|
||||||
import {
|
|
||||||
buildUserPrompt,
|
|
||||||
buildEditUserPrompt,
|
|
||||||
isNonEmptySpec,
|
|
||||||
} from "@json-render/core";
|
|
||||||
import { yamlPrompt } from "@json-render/yaml";
|
|
||||||
import { stringify as yamlStringify } from "yaml";
|
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const PLAYGROUND_RULES = [
|
const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
||||||
|
customRules: [
|
||||||
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||||
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
||||||
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
||||||
@@ -21,53 +15,18 @@ const PLAYGROUND_RULES = [
|
|||||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||||
];
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const MAX_PROMPT_LENGTH = 500;
|
const MAX_PROMPT_LENGTH = 500;
|
||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
|
|
||||||
if (isYaml) {
|
|
||||||
return yamlPrompt(playgroundCatalog, {
|
|
||||||
mode: "standalone",
|
|
||||||
customRules: PLAYGROUND_RULES,
|
|
||||||
editModes: editModes ?? ["merge"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return playgroundCatalog.prompt({
|
|
||||||
customRules: PLAYGROUND_RULES,
|
|
||||||
editModes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildYamlUserPrompt(
|
|
||||||
prompt: string,
|
|
||||||
previousSpec?: Spec | null,
|
|
||||||
editModes?: EditMode[],
|
|
||||||
): string {
|
|
||||||
if (isNonEmptySpec(previousSpec)) {
|
|
||||||
return buildEditUserPrompt({
|
|
||||||
prompt,
|
|
||||||
currentSpec: previousSpec,
|
|
||||||
config: { modes: editModes ?? ["merge"] },
|
|
||||||
format: "yaml",
|
|
||||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
|
||||||
serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
|
|
||||||
return [
|
|
||||||
userText,
|
|
||||||
"",
|
|
||||||
"Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
// Get client IP for rate limiting
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
// Check rate limits (minute and daily)
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
minuteRateLimit.limit(ip),
|
minuteRateLimit.limit(ip),
|
||||||
dailyRateLimit.limit(ip),
|
dailyRateLimit.limit(ip),
|
||||||
@@ -89,34 +48,22 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prompt, context, format, editModes } = await req.json();
|
const { prompt, context } = await req.json();
|
||||||
const isYaml = format === "yaml";
|
|
||||||
|
|
||||||
const systemPrompt = getSystemPrompt(isYaml, editModes);
|
const userPrompt = buildUserPrompt({
|
||||||
const userPrompt = isYaml
|
|
||||||
? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
|
|
||||||
: buildUserPrompt({
|
|
||||||
prompt,
|
prompt,
|
||||||
currentSpec: context?.previousSpec,
|
currentSpec: context?.previousSpec,
|
||||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||||
editModes,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||||
system: [
|
system: SYSTEM_PROMPT,
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemPrompt,
|
|
||||||
providerOptions: {
|
|
||||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stream the text, then append token usage metadata at the end
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const textStream = result.textStream;
|
const textStream = result.textStream;
|
||||||
|
|
||||||
@@ -125,6 +72,7 @@ export async function POST(req: Request) {
|
|||||||
for await (const chunk of textStream) {
|
for await (const chunk of textStream) {
|
||||||
controller.enqueue(encoder.encode(chunk));
|
controller.enqueue(encoder.encode(chunk));
|
||||||
}
|
}
|
||||||
|
// Append usage metadata after stream completes
|
||||||
try {
|
try {
|
||||||
const usage = await result.usage;
|
const usage = await result.usage;
|
||||||
const meta = JSON.stringify({
|
const meta = JSON.stringify({
|
||||||
@@ -132,12 +80,10 @@ export async function POST(req: Request) {
|
|||||||
promptTokens: usage.inputTokens,
|
promptTokens: usage.inputTokens,
|
||||||
completionTokens: usage.outputTokens,
|
completionTokens: usage.outputTokens,
|
||||||
totalTokens: usage.totalTokens,
|
totalTokens: usage.totalTokens,
|
||||||
cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
|
|
||||||
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
|
|
||||||
});
|
});
|
||||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||||
} catch {
|
} catch {
|
||||||
// Usage not available
|
// Usage not available — skip silently
|
||||||
}
|
}
|
||||||
controller.close();
|
controller.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getSearchIndex } from "@/lib/search-index";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!q) {
|
|
||||||
return NextResponse.json({ results: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = await getSearchIndex();
|
|
||||||
const terms = q.split(/\s+/).filter(Boolean);
|
|
||||||
|
|
||||||
const results = index
|
|
||||||
.map((entry) => {
|
|
||||||
const titleLower = entry.title.toLowerCase();
|
|
||||||
const contentLower = entry.content.toLowerCase();
|
|
||||||
|
|
||||||
const titleMatch = terms.every((t) => titleLower.includes(t));
|
|
||||||
const contentMatch = terms.every((t) => contentLower.includes(t));
|
|
||||||
|
|
||||||
if (!titleMatch && !contentMatch) return null;
|
|
||||||
|
|
||||||
let snippet = "";
|
|
||||||
if (contentMatch) {
|
|
||||||
const firstTermIdx = Math.min(
|
|
||||||
...terms.map((t) => {
|
|
||||||
const idx = contentLower.indexOf(t);
|
|
||||||
return idx === -1 ? Infinity : idx;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (firstTermIdx !== Infinity) {
|
|
||||||
const start = Math.max(0, firstTermIdx - 40);
|
|
||||||
const end = Math.min(entry.content.length, firstTermIdx + 120);
|
|
||||||
snippet =
|
|
||||||
(start > 0 ? "..." : "") +
|
|
||||||
entry.content.slice(start, end).replace(/\n/g, " ") +
|
|
||||||
(end < entry.content.length ? "..." : "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: entry.title,
|
|
||||||
href: entry.href,
|
|
||||||
section: entry.section,
|
|
||||||
snippet,
|
|
||||||
score: titleMatch ? 2 : 1,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(
|
|
||||||
r,
|
|
||||||
): r is {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
section: string;
|
|
||||||
snippet: string;
|
|
||||||
score: number;
|
|
||||||
} => r !== null,
|
|
||||||
)
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map(({ title, href, section, snippet }) => ({
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
section,
|
|
||||||
snippet,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ results },
|
|
||||||
{ headers: { "Cache-Control": "public, max-age=60" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -145,7 +145,7 @@ function getHighlighter() {
|
|||||||
if (!highlighterPromise) {
|
if (!highlighterPromise) {
|
||||||
highlighterPromise = createHighlighter({
|
highlighterPromise = createHighlighter({
|
||||||
themes: [vercelLightTheme, vercelDarkTheme],
|
themes: [vercelLightTheme, vercelDarkTheme],
|
||||||
langs: ["json", "tsx", "typescript", "yaml"],
|
langs: ["json", "tsx", "typescript"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return highlighterPromise;
|
return highlighterPromise;
|
||||||
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang: "json" | "tsx" | "typescript" | "yaml";
|
lang: "json" | "tsx" | "typescript";
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
hideCopyButton?: boolean;
|
hideCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,10 +261,10 @@ export function DocsChat({
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
// Cmd+I to open sidebar and focus prompt, Escape to close
|
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => {
|
setOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -497,12 +497,12 @@ export function DocsChat({
|
|||||||
{!open && (
|
{!open && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
|
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
||||||
aria-label="Ask AI"
|
aria-label="Ask AI"
|
||||||
>
|
>
|
||||||
Ask AI
|
Ask AI
|
||||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||||
<span>⌘</span>I
|
<span>⌘</span>K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+29
-104
@@ -1,59 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import { Search } from "./search";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetContent,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ href: "/playground", label: "Playground" },
|
|
||||||
{ href: "/examples", label: "Examples" },
|
|
||||||
{ href: "/docs", label: "Docs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function GitHubLink({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href="https://github.com/vercel-labs/json-render"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
<span>12k</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === "/playground") {
|
if (href === "/playground") {
|
||||||
return pathname === "/playground";
|
return pathname === "/playground";
|
||||||
}
|
}
|
||||||
if (href === "/examples") {
|
|
||||||
return pathname.startsWith("/examples");
|
|
||||||
}
|
|
||||||
if (href === "/docs") {
|
if (href === "/docs") {
|
||||||
return pathname.startsWith("/docs");
|
return pathname.startsWith("/docs");
|
||||||
}
|
}
|
||||||
@@ -104,81 +62,48 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
{/* Desktop nav */}
|
|
||||||
<nav className="hidden sm:flex items-center gap-4">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
href="/playground"
|
||||||
href={link.href}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm transition-colors",
|
"text-sm transition-colors",
|
||||||
isActive(link.href)
|
isActive("/playground")
|
||||||
? "text-primary"
|
? "text-primary font-medium"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{link.label}
|
<span className="sm:hidden">Play</span>
|
||||||
|
<span className="hidden sm:inline">Playground</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
<Link
|
||||||
<Search />
|
href="/docs"
|
||||||
<GitHubLink />
|
className={cn(
|
||||||
<ThemeToggle />
|
"text-sm transition-colors",
|
||||||
</nav>
|
isActive("/docs")
|
||||||
|
? "text-primary font-medium"
|
||||||
{/* Mobile nav */}
|
: "text-muted-foreground hover:text-foreground",
|
||||||
<div className="flex sm:hidden items-center gap-3">
|
)}
|
||||||
<Search />
|
>
|
||||||
<GitHubLink />
|
Docs
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
</Link>
|
||||||
<SheetTrigger
|
<a
|
||||||
className="flex items-center justify-center"
|
href="https://github.com/vercel-labs/json-render"
|
||||||
aria-label="Open menu"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 16 16"
|
||||||
width="20"
|
className="h-4 w-4"
|
||||||
height="20"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24"
|
aria-hidden="true"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
<line x1="4" x2="20" y1="12" y2="12" />
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
<line x1="4" x2="20" y1="6" y2="6" />
|
|
||||||
<line x1="4" x2="20" y1="18" y2="18" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</SheetTrigger>
|
<span>11k</span>
|
||||||
<SheetContent side="right" className="overflow-y-auto p-6">
|
</a>
|
||||||
<SheetTitle className="mb-6">Menu</SheetTitle>
|
|
||||||
<nav className="flex flex-col gap-1">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<Link
|
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
"block py-2.5 text-sm transition-colors",
|
|
||||||
isActive(link.href)
|
|
||||||
? "text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<div className="my-3 border-t border-border" />
|
|
||||||
<div className="flex items-center justify-between py-2.5">
|
|
||||||
<span className="text-sm text-muted-foreground">Theme</span>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
import { useUIStream, type TokenUsage } from "@json-render/react";
|
||||||
import type { Spec } from "@json-render/core";
|
import type { Spec } from "@json-render/core";
|
||||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { stringify as yamlStringify } from "yaml";
|
|
||||||
import type { EditMode } from "@json-render/core";
|
|
||||||
import {
|
|
||||||
usePlaygroundStream,
|
|
||||||
type StreamFormat,
|
|
||||||
type TokenUsage,
|
|
||||||
} from "@/lib/use-playground-stream";
|
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -28,10 +22,10 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
|
|||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||||
|
|
||||||
type Tab = "spec" | "nested" | "stream" | "catalog" | "visual";
|
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
|
||||||
type RenderView = "preview" | "code";
|
type RenderView = "preview" | "code";
|
||||||
type MobileView =
|
type MobileView =
|
||||||
| "spec"
|
| "json"
|
||||||
| "nested"
|
| "nested"
|
||||||
| "stream"
|
| "stream"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
@@ -46,79 +40,6 @@ interface Version {
|
|||||||
status: "generating" | "complete" | "error";
|
status: "generating" | "complete" | "error";
|
||||||
usage: TokenUsage | null;
|
usage: TokenUsage | null;
|
||||||
rawLines: string[];
|
rawLines: string[];
|
||||||
format: StreamFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
|
||||||
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaygroundControls({
|
|
||||||
format,
|
|
||||||
setFormat,
|
|
||||||
editModes,
|
|
||||||
setEditModes,
|
|
||||||
showClear,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
format: StreamFormat;
|
|
||||||
setFormat: (f: StreamFormat) => void;
|
|
||||||
editModes: EditMode[];
|
|
||||||
setEditModes: React.Dispatch<React.SetStateAction<EditMode[]>>;
|
|
||||||
showClear: boolean;
|
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
|
||||||
{(["jsonl", "yaml"] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
onClick={() => setFormat(f)}
|
|
||||||
className={`px-1.5 py-0.5 transition-colors ${
|
|
||||||
format === f
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
|
||||||
{(["patch", "merge", "diff"] as const).map((m) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
onClick={() => {
|
|
||||||
setEditModes((prev) =>
|
|
||||||
prev.includes(m)
|
|
||||||
? prev.length > 1
|
|
||||||
? prev.filter((x) => x !== m)
|
|
||||||
: prev
|
|
||||||
: [...prev, m],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={`px-1.5 py-0.5 transition-colors ${
|
|
||||||
editModes.includes(m)
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showClear && (
|
|
||||||
<button
|
|
||||||
onClick={onClear}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,15 +100,13 @@ export function Playground() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("spec");
|
const [activeTab, setActiveTab] = useState<Tab>("json");
|
||||||
const [catalogSection, setCatalogSection] = useState<
|
const [catalogSection, setCatalogSection] = useState<
|
||||||
"components" | "actions"
|
"components" | "actions"
|
||||||
>("components");
|
>("components");
|
||||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||||
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
||||||
const [format, setFormat] = useState<StreamFormat>("jsonl");
|
|
||||||
const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -205,13 +124,12 @@ export function Playground() {
|
|||||||
rawLines: streamRawLines,
|
rawLines: streamRawLines,
|
||||||
send,
|
send,
|
||||||
clear,
|
clear,
|
||||||
} = usePlaygroundStream({
|
} = useUIStream({
|
||||||
api: "/api/generate",
|
api: "/api/generate",
|
||||||
format,
|
|
||||||
editModes,
|
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
console.error("Generation error:", err);
|
console.error("Generation error:", err);
|
||||||
toast.error(err.message || "Generation failed. Please try again.");
|
toast.error(err.message || "Generation failed. Please try again.");
|
||||||
|
// Mark the version as errored
|
||||||
if (generatingVersionIdRef.current) {
|
if (generatingVersionIdRef.current) {
|
||||||
const erroredVersionId = generatingVersionIdRef.current;
|
const erroredVersionId = generatingVersionIdRef.current;
|
||||||
setVersions((prev) =>
|
setVersions((prev) =>
|
||||||
@@ -222,7 +140,7 @@ export function Playground() {
|
|||||||
generatingVersionIdRef.current = null;
|
generatingVersionIdRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
} as Parameters<typeof useUIStream>[0]);
|
||||||
|
|
||||||
// Get the selected version
|
// Get the selected version
|
||||||
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
||||||
@@ -297,7 +215,6 @@ export function Playground() {
|
|||||||
status: "generating",
|
status: "generating",
|
||||||
usage: null,
|
usage: null,
|
||||||
rawLines: [],
|
rawLines: [],
|
||||||
format,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
generatingVersionIdRef.current = newVersionId;
|
generatingVersionIdRef.current = newVersionId;
|
||||||
@@ -307,7 +224,7 @@ export function Playground() {
|
|||||||
|
|
||||||
// Pass the current tree as context so the API can iterate on it
|
// Pass the current tree as context so the API can iterate on it
|
||||||
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
||||||
}, [inputValue, isStreaming, send, format]);
|
}, [inputValue, isStreaming, send]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -333,16 +250,9 @@ export function Playground() {
|
|||||||
[selectedVersionId, isStreaming],
|
[selectedVersionId, isStreaming],
|
||||||
);
|
);
|
||||||
|
|
||||||
const specCode = useMemo(() => {
|
const jsonCode = currentTree
|
||||||
if (!currentTree)
|
? JSON.stringify(currentTree, null, 2)
|
||||||
return format === "yaml" ? "# waiting..." : "// waiting...";
|
: "// waiting...";
|
||||||
if (format === "yaml") {
|
|
||||||
return yamlStringify(currentTree, { indent: 2 }).trimEnd();
|
|
||||||
}
|
|
||||||
return JSON.stringify(currentTree, null, 2);
|
|
||||||
}, [currentTree, format]);
|
|
||||||
|
|
||||||
const specLang = format === "yaml" ? "yaml" : "json";
|
|
||||||
|
|
||||||
const nestedCode = useMemo(() => {
|
const nestedCode = useMemo(() => {
|
||||||
if (!currentTree || !currentTree.root) return "// waiting...";
|
if (!currentTree || !currentTree.root) return "// waiting...";
|
||||||
@@ -365,7 +275,7 @@ export function Playground() {
|
|||||||
const componentName = element.type;
|
const componentName = element.type;
|
||||||
|
|
||||||
const propsObj: Record<string, unknown> = {};
|
const propsObj: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(element.props ?? {})) {
|
for (const [k, v] of Object.entries(element.props)) {
|
||||||
if (v !== null && v !== undefined) {
|
if (v !== null && v !== undefined) {
|
||||||
propsObj[k] = v;
|
propsObj[k] = v;
|
||||||
}
|
}
|
||||||
@@ -411,15 +321,6 @@ ${jsx}
|
|||||||
}`;
|
}`;
|
||||||
}, [currentTree]);
|
}, [currentTree]);
|
||||||
|
|
||||||
// Determine syntax lang for raw stream based on selected version's format
|
|
||||||
const streamLang = isSelectedVersionGenerating
|
|
||||||
? format === "yaml"
|
|
||||||
? "yaml"
|
|
||||||
: "json"
|
|
||||||
: selectedVersion?.format === "yaml"
|
|
||||||
? "yaml"
|
|
||||||
: "json";
|
|
||||||
|
|
||||||
// Chat pane content
|
// Chat pane content
|
||||||
const chatPane = (
|
const chatPane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
@@ -491,15 +392,9 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="mt-1 ml-6">
|
<div className="flex items-center gap-2 mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{formatTokens(
|
{version.usage.totalTokens.toLocaleString()} tokens
|
||||||
version.usage.promptTokens - version.usage.cachedTokens,
|
|
||||||
)}{" "}
|
|
||||||
in · {formatTokens(version.usage.completionTokens)} out
|
|
||||||
{version.usage.cachedTokens > 0
|
|
||||||
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
|
||||||
: ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -530,18 +425,20 @@ ${jsx}
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
<PlaygroundControls
|
{versions.length > 0 ? (
|
||||||
format={format}
|
<button
|
||||||
setFormat={setFormat}
|
onClick={() => {
|
||||||
editModes={editModes}
|
|
||||||
setEditModes={setEditModes}
|
|
||||||
showClear={versions.length > 0}
|
|
||||||
onClear={() => {
|
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
setSelectedVersionId(null);
|
setSelectedVersionId(null);
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
/>
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -595,18 +492,18 @@ ${jsx}
|
|||||||
const copyText =
|
const copyText =
|
||||||
activeTab === "stream"
|
activeTab === "stream"
|
||||||
? currentRawLines.join("\n")
|
? currentRawLines.join("\n")
|
||||||
: activeTab === "spec"
|
: activeTab === "json"
|
||||||
? specCode
|
? jsonCode
|
||||||
: activeTab === "nested"
|
: activeTab === "nested"
|
||||||
? nestedCode
|
? nestedCode
|
||||||
: activeTab === "visual"
|
: activeTab === "visual"
|
||||||
? specCode
|
? jsonCode
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const codePane = (
|
const codePane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
(tab) => (
|
(tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -617,7 +514,7 @@ ${jsx}
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -782,7 +679,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang={streamLang}
|
lang="json"
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -794,12 +691,7 @@ ${jsx}
|
|||||||
) : activeTab === "nested" ? (
|
) : activeTab === "nested" ? (
|
||||||
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
||||||
) : (
|
) : (
|
||||||
<CodeBlock
|
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||||
code={specCode}
|
|
||||||
lang={specLang}
|
|
||||||
fillHeight
|
|
||||||
hideCopyButton
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -898,7 +790,7 @@ ${jsx}
|
|||||||
: 0}
|
: 0}
|
||||||
</button>
|
</button>
|
||||||
{/* Code tabs */}
|
{/* Code tabs */}
|
||||||
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
(tab) => (
|
(tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -909,7 +801,7 @@ ${jsx}
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -1090,7 +982,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang={streamLang}
|
lang="json"
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -1106,13 +998,8 @@ ${jsx}
|
|||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
) : mobileView === "spec" ? (
|
) : mobileView === "json" ? (
|
||||||
<CodeBlock
|
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||||
code={specCode}
|
|
||||||
lang={specLang}
|
|
||||||
fillHeight
|
|
||||||
hideCopyButton
|
|
||||||
/>
|
|
||||||
) : mobileView === "preview" ? (
|
) : mobileView === "preview" ? (
|
||||||
currentTree && currentTree.root ? (
|
currentTree && currentTree.root ? (
|
||||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||||
@@ -1188,18 +1075,20 @@ ${jsx}
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
<PlaygroundControls
|
{versions.length > 0 ? (
|
||||||
format={format}
|
<button
|
||||||
setFormat={setFormat}
|
onClick={() => {
|
||||||
editModes={editModes}
|
|
||||||
setEditModes={setEditModes}
|
|
||||||
showClear={versions.length > 0}
|
|
||||||
onClear={() => {
|
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
setSelectedVersionId(null);
|
setSelectedVersionId(null);
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
/>
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -1276,16 +1165,9 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="mt-1 ml-6">
|
<div className="flex items-center gap-2 mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{formatTokens(
|
{version.usage.totalTokens.toLocaleString()} tokens
|
||||||
version.usage.promptTokens -
|
|
||||||
version.usage.cachedTokens,
|
|
||||||
)}{" "}
|
|
||||||
in · {formatTokens(version.usage.completionTokens)} out
|
|
||||||
{version.usage.cachedTokens > 0
|
|
||||||
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
|
||||||
: ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type SearchResult = {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
section: string;
|
|
||||||
snippet: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Search() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const navigate = useCallback(
|
|
||||||
(href: string) => {
|
|
||||||
setOpen(false);
|
|
||||||
setQuery("");
|
|
||||||
setResults([]);
|
|
||||||
router.push(href);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen((prev) => !prev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
|
||||||
} else {
|
|
||||||
setQuery("");
|
|
||||||
setResults([]);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q) {
|
|
||||||
setResults([]);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
|
|
||||||
const timeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setResults(data.results);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// aborted or network error
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveIndex(0);
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
||||||
} else if (e.key === "Enter" && results[activeIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
navigate(results[activeIndex].href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const active = listRef.current?.querySelector("[data-active='true']");
|
|
||||||
active?.scrollIntoView({ block: "nearest" });
|
|
||||||
}, [activeIndex]);
|
|
||||||
|
|
||||||
const hasQuery = query.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="hidden sm:flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
Search docs
|
|
||||||
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
||||||
<span>⌘</span>K
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Search docs"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent
|
|
||||||
showCloseButton={false}
|
|
||||||
className="gap-0 p-0 sm:max-w-lg"
|
|
||||||
>
|
|
||||||
<DialogTitle className="sr-only">Search documentation</DialogTitle>
|
|
||||||
<div className="flex items-center gap-2 border-b px-3">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="shrink-0 text-muted-foreground"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Search docs..."
|
|
||||||
className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
{query && (
|
|
||||||
<button
|
|
||||||
onClick={() => setQuery("")}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M18 6 6 18" />
|
|
||||||
<path d="m6 6 12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
|
|
||||||
>
|
|
||||||
{loading && hasQuery ? (
|
|
||||||
<div className="flex items-center justify-center py-6">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : hasQuery && results.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
No results found.
|
|
||||||
</p>
|
|
||||||
) : !hasQuery ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Type to search documentation...
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
results.map((item, i) => (
|
|
||||||
<button
|
|
||||||
key={item.href}
|
|
||||||
data-active={i === activeIndex}
|
|
||||||
onClick={() => navigate(item.href)}
|
|
||||||
onMouseEnter={() => setActiveIndex(i)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
|
|
||||||
i === activeIndex
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-medium">{item.title}</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{item.section}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{item.snippet && (
|
|
||||||
<span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{item.snippet}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Heading = {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
level: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getHeadings(): Heading[] {
|
|
||||||
const article = document.querySelector("article");
|
|
||||||
if (!article) return [];
|
|
||||||
const elements = article.querySelectorAll("h2[id], h3[id]");
|
|
||||||
return Array.from(elements).map((el) => ({
|
|
||||||
id: el.id,
|
|
||||||
text: el.textContent?.replace(/#$/, "").trim() ?? "",
|
|
||||||
level: el.tagName === "H3" ? 3 : 2,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableOfContents() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [headings, setHeadings] = useState<Heading[]>([]);
|
|
||||||
const [activeId, setActiveId] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setHeadings(getHeadings()), 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (headings.length === 0) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setActiveId(entry.target.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "0px 0px -75% 0px", threshold: 0.1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const h of headings) {
|
|
||||||
const el = document.getElementById(h.id);
|
|
||||||
if (el) observer.observe(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [headings]);
|
|
||||||
|
|
||||||
if (headings.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav aria-label="On this page">
|
|
||||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
|
||||||
On this page
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{headings.map((h) => (
|
|
||||||
<li key={h.id}>
|
|
||||||
<a
|
|
||||||
href={`#${h.id}`}
|
|
||||||
className={cn(
|
|
||||||
"block text-xs leading-relaxed py-0.5 transition-colors",
|
|
||||||
h.level === 3 && "pl-3",
|
|
||||||
activeId === h.id
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{h.text}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,63 @@ export const docsNavigation: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Examples",
|
title: "Examples",
|
||||||
items: [{ title: "Browse All Examples", href: "/examples" }],
|
items: [
|
||||||
|
{
|
||||||
|
title: "Chat",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "React Native",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "React PDF",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "React Email",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Remotion",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Svelte",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vue",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Renders with Vite (Vue / React / Svelte / Solid)",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "MCP App",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Guides",
|
title: "Guides",
|
||||||
@@ -69,29 +125,21 @@ export const docsNavigation: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||||
{ title: "@json-render/next", href: "/docs/api/next" },
|
|
||||||
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||||
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
|
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
|
||||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||||
{ title: "@json-render/shadcn-svelte", href: "/docs/api/shadcn-svelte" },
|
|
||||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||||
{ title: "@json-render/image", href: "/docs/api/image" },
|
{ title: "@json-render/image", href: "/docs/api/image" },
|
||||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||||
{ title: "@json-render/ink", href: "/docs/api/ink" },
|
|
||||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||||
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
|
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
|
||||||
{ title: "@json-render/solid", href: "/docs/api/solid" },
|
{ title: "@json-render/solid", href: "/docs/api/solid" },
|
||||||
{
|
|
||||||
title: "@json-render/react-three-fiber",
|
|
||||||
href: "/docs/api/react-three-fiber",
|
|
||||||
},
|
|
||||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||||
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
|
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
|
||||||
{ title: "@json-render/redux", href: "/docs/api/redux" },
|
{ title: "@json-render/redux", href: "/docs/api/redux" },
|
||||||
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
|
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
|
||||||
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
|
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
|
||||||
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
|
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
|
||||||
{ title: "@json-render/yaml", href: "/docs/api/yaml" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
export type Example = {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
githubPath: string;
|
|
||||||
demoUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GITHUB_BASE =
|
|
||||||
"https://github.com/vercel-labs/json-render/tree/main/examples";
|
|
||||||
|
|
||||||
export const examples: Example[] = [
|
|
||||||
{
|
|
||||||
slug: "chat",
|
|
||||||
title: "Chat",
|
|
||||||
description:
|
|
||||||
"AI chat app with tool calling, streaming UI, and rich components powered by the AI SDK.",
|
|
||||||
tags: ["React", "Next.js", "AI"],
|
|
||||||
githubPath: "examples/chat",
|
|
||||||
demoUrl: "https://chat-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "dashboard",
|
|
||||||
title: "Dashboard",
|
|
||||||
description:
|
|
||||||
"AI-generated dashboard with drag-and-drop, charts, and real-time data binding.",
|
|
||||||
tags: ["React", "Next.js", "AI"],
|
|
||||||
githubPath: "examples/dashboard",
|
|
||||||
demoUrl: "https://dashboard-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "no-ai",
|
|
||||||
title: "No AI",
|
|
||||||
description:
|
|
||||||
"Static specs rendered without any AI — forms, cards, tables, and more from hardcoded JSON.",
|
|
||||||
tags: ["React", "Next.js"],
|
|
||||||
githubPath: "examples/no-ai",
|
|
||||||
demoUrl: "https://no-ai-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "svelte",
|
|
||||||
title: "Svelte",
|
|
||||||
description:
|
|
||||||
"Svelte renderer demo with counter, todo list, and two-way data binding.",
|
|
||||||
tags: ["Svelte", "Vite"],
|
|
||||||
githubPath: "examples/svelte",
|
|
||||||
demoUrl: "https://svelte-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "svelte-chat",
|
|
||||||
title: "Svelte Chat",
|
|
||||||
description: "AI chat app built with SvelteKit and the Svelte renderer.",
|
|
||||||
tags: ["Svelte", "SvelteKit", "AI"],
|
|
||||||
githubPath: "examples/svelte-chat",
|
|
||||||
demoUrl: "https://json-render-svelte-chat-demo.labs.vercel.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "vue",
|
|
||||||
title: "Vue",
|
|
||||||
description:
|
|
||||||
"Vue renderer demo with counter, todo list, and two-way data binding.",
|
|
||||||
tags: ["Vue", "Vite"],
|
|
||||||
githubPath: "examples/vue",
|
|
||||||
demoUrl: "https://vue-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "solid",
|
|
||||||
title: "Solid",
|
|
||||||
description:
|
|
||||||
"Solid renderer demo with counter, todo list, and two-way data binding.",
|
|
||||||
tags: ["Solid", "Vite"],
|
|
||||||
githubPath: "examples/solid",
|
|
||||||
demoUrl: "https://solid-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "vite-renderers",
|
|
||||||
title: "Multi-Framework Renderers",
|
|
||||||
description:
|
|
||||||
"Same spec rendered with React, Vue, Svelte, and Solid side by side — hot-swappable at runtime.",
|
|
||||||
tags: ["React", "Vue", "Svelte", "Solid", "Vite"],
|
|
||||||
githubPath: "examples/vite-renderers",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "react-email",
|
|
||||||
title: "React Email",
|
|
||||||
description:
|
|
||||||
"Generate HTML and plain-text emails from json-render specs using React Email.",
|
|
||||||
tags: ["React", "Email"],
|
|
||||||
githubPath: "examples/react-email",
|
|
||||||
demoUrl: "https://react-email-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "react-pdf",
|
|
||||||
title: "React PDF",
|
|
||||||
description:
|
|
||||||
"Generate PDF documents from json-render specs with @react-pdf/renderer.",
|
|
||||||
tags: ["React", "PDF"],
|
|
||||||
githubPath: "examples/react-pdf",
|
|
||||||
demoUrl: "https://react-pdf-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "react-three-fiber",
|
|
||||||
title: "React Three Fiber",
|
|
||||||
description:
|
|
||||||
"3D scenes generated from json-render specs using Three.js and React Three Fiber.",
|
|
||||||
tags: ["React", "3D"],
|
|
||||||
githubPath: "examples/react-three-fiber",
|
|
||||||
demoUrl: "https://react-three-fiber-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "react-native",
|
|
||||||
title: "React Native",
|
|
||||||
description:
|
|
||||||
"Mobile app rendering json-render specs with Expo and React Native.",
|
|
||||||
tags: ["React Native", "Expo"],
|
|
||||||
githubPath: "examples/react-native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "remotion",
|
|
||||||
title: "Remotion",
|
|
||||||
description: "Generate videos from json-render specs using Remotion.",
|
|
||||||
tags: ["React", "Video"],
|
|
||||||
githubPath: "examples/remotion",
|
|
||||||
demoUrl: "https://remotion-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "image",
|
|
||||||
title: "Image",
|
|
||||||
description:
|
|
||||||
"Generate OG images and social cards from json-render specs using Satori.",
|
|
||||||
tags: ["React", "Image"],
|
|
||||||
githubPath: "examples/image",
|
|
||||||
demoUrl: "https://image-demo.json-render.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "ink-chat",
|
|
||||||
title: "Ink Chat",
|
|
||||||
description:
|
|
||||||
"Terminal chat agent that streams rich json-render UIs using Ink and the AI Gateway.",
|
|
||||||
tags: ["Ink", "Terminal", "AI"],
|
|
||||||
githubPath: "examples/ink-chat",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "mcp",
|
|
||||||
title: "MCP App",
|
|
||||||
description:
|
|
||||||
"MCP server that serves shadcn UIs to Claude, ChatGPT, Cursor, and VS Code.",
|
|
||||||
tags: ["React", "MCP", "Vite"],
|
|
||||||
githubPath: "examples/mcp",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const allTags = Array.from(
|
|
||||||
new Set(examples.flatMap((e) => e.tags)),
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
export function getGitHubUrl(example: Example): string {
|
|
||||||
return `${GITHUB_BASE}/${example.slug}`;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
|
|
||||||
// Top-level
|
// Top-level
|
||||||
playground: "Playground",
|
playground: "Playground",
|
||||||
examples: "Examples",
|
|
||||||
|
|
||||||
// Docs
|
// Docs
|
||||||
docs: "Introduction",
|
docs: "Introduction",
|
||||||
@@ -43,7 +42,6 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
// API references
|
// API references
|
||||||
"docs/api/core": "@json-render/core API",
|
"docs/api/core": "@json-render/core API",
|
||||||
"docs/api/react": "@json-render/react API",
|
"docs/api/react": "@json-render/react API",
|
||||||
"docs/api/next": "@json-render/next API",
|
|
||||||
"docs/api/vue": "@json-render/vue API",
|
"docs/api/vue": "@json-render/vue API",
|
||||||
"docs/api/solid": "@json-render/solid API",
|
"docs/api/solid": "@json-render/solid API",
|
||||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||||
@@ -54,15 +52,11 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
"docs/api/image": "@json-render/image API",
|
"docs/api/image": "@json-render/image API",
|
||||||
"docs/api/remotion": "@json-render/remotion API",
|
"docs/api/remotion": "@json-render/remotion API",
|
||||||
"docs/api/shadcn": "@json-render/shadcn API",
|
"docs/api/shadcn": "@json-render/shadcn API",
|
||||||
"docs/api/shadcn-svelte": "@json-render/shadcn-svelte API",
|
|
||||||
"docs/api/mcp": "@json-render/mcp API",
|
"docs/api/mcp": "@json-render/mcp API",
|
||||||
"docs/api/redux": "@json-render/redux API",
|
"docs/api/redux": "@json-render/redux API",
|
||||||
"docs/api/zustand": "@json-render/zustand API",
|
"docs/api/zustand": "@json-render/zustand API",
|
||||||
"docs/api/jotai": "@json-render/jotai API",
|
"docs/api/jotai": "@json-render/jotai API",
|
||||||
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
|
|
||||||
"docs/api/xstate": "@json-render/xstate API",
|
"docs/api/xstate": "@json-render/xstate API",
|
||||||
"docs/api/ink": "@json-render/ink API",
|
|
||||||
"docs/api/yaml": "@json-render/yaml API",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { readFile } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { docsNavigation } from "./docs-navigation";
|
|
||||||
import { mdxToCleanMarkdown } from "./mdx-to-markdown";
|
|
||||||
|
|
||||||
export type IndexEntry = {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
section: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cached: IndexEntry[] | null = null;
|
|
||||||
|
|
||||||
function stripMarkdown(md: string): string {
|
|
||||||
return (
|
|
||||||
md
|
|
||||||
// Remove fenced code blocks entirely
|
|
||||||
.replace(/```[\s\S]*?```/g, "")
|
|
||||||
// Remove inline code
|
|
||||||
.replace(/`[^`]+`/g, "")
|
|
||||||
// Remove markdown links, keep text
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
||||||
// Remove heading markers
|
|
||||||
.replace(/^#{1,6}\s+/gm, "")
|
|
||||||
// Remove bold/italic markers
|
|
||||||
.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
|
|
||||||
// Remove HTML tags
|
|
||||||
.replace(/<[^>]+>/g, "")
|
|
||||||
// Collapse whitespace
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mdxFileForSlug(slug: string): string {
|
|
||||||
const docsRoot = join(process.cwd(), "app", "(main)", "docs");
|
|
||||||
if (slug === "/docs") {
|
|
||||||
return join(docsRoot, "page.mdx");
|
|
||||||
}
|
|
||||||
const rest = slug.replace(/^\/docs\/?/, "");
|
|
||||||
return join(docsRoot, ...rest.split("/"), "page.mdx");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSearchIndex(): Promise<IndexEntry[]> {
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const entries: IndexEntry[] = [];
|
|
||||||
|
|
||||||
for (const section of docsNavigation) {
|
|
||||||
for (const item of section.items) {
|
|
||||||
if (item.external) continue;
|
|
||||||
try {
|
|
||||||
const raw = await readFile(mdxFileForSlug(item.href), "utf-8");
|
|
||||||
const md = mdxToCleanMarkdown(raw);
|
|
||||||
const content = stripMarkdown(md);
|
|
||||||
entries.push({
|
|
||||||
title: item.title,
|
|
||||||
href: item.href,
|
|
||||||
section: section.title,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
entries.push({
|
|
||||||
title: item.title,
|
|
||||||
href: item.href,
|
|
||||||
section: section.title,
|
|
||||||
content: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cached = entries;
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import type { Spec, JsonPatch } from "@json-render/core";
|
|
||||||
import { setByPath, getByPath, removeByPath } from "@json-render/core";
|
|
||||||
|
|
||||||
export function setSpecValue(
|
|
||||||
newSpec: Spec,
|
|
||||||
path: string,
|
|
||||||
value: unknown,
|
|
||||||
): void {
|
|
||||||
if (path === "/root") {
|
|
||||||
newSpec.root = value as string;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path === "/state") {
|
|
||||||
newSpec.state = value as Record<string, unknown>;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path.startsWith("/state/")) {
|
|
||||||
if (!newSpec.state) newSpec.state = {};
|
|
||||||
setByPath(
|
|
||||||
newSpec.state as Record<string, unknown>,
|
|
||||||
path.slice("/state".length),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path.startsWith("/elements/")) {
|
|
||||||
const pathParts = path.slice("/elements/".length).split("/");
|
|
||||||
const elementKey = pathParts[0];
|
|
||||||
if (!elementKey) return;
|
|
||||||
if (pathParts.length === 1) {
|
|
||||||
if (value == null || typeof value !== "object") return;
|
|
||||||
const el = value as Record<string, unknown>;
|
|
||||||
newSpec.elements[elementKey] = {
|
|
||||||
...el,
|
|
||||||
type: typeof el.type === "string" ? el.type : "",
|
|
||||||
props: el.props != null && typeof el.props === "object" ? el.props : {},
|
|
||||||
children: Array.isArray(el.children) ? el.children : [],
|
|
||||||
} as Spec["elements"][string];
|
|
||||||
} else {
|
|
||||||
const element = newSpec.elements[elementKey];
|
|
||||||
if (element) {
|
|
||||||
const newElement = { ...element };
|
|
||||||
setByPath(
|
|
||||||
newElement as unknown as Record<string, unknown>,
|
|
||||||
"/" + pathParts.slice(1).join("/"),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
newSpec.elements[elementKey] = newElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeSpecValue(newSpec: Spec, path: string): void {
|
|
||||||
if (path === "/state") {
|
|
||||||
delete newSpec.state;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path.startsWith("/state/") && newSpec.state) {
|
|
||||||
removeByPath(
|
|
||||||
newSpec.state as Record<string, unknown>,
|
|
||||||
path.slice("/state".length),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path.startsWith("/elements/")) {
|
|
||||||
const pathParts = path.slice("/elements/".length).split("/");
|
|
||||||
const elementKey = pathParts[0];
|
|
||||||
if (!elementKey) return;
|
|
||||||
if (pathParts.length === 1) {
|
|
||||||
delete newSpec.elements[elementKey];
|
|
||||||
} else {
|
|
||||||
const element = newSpec.elements[elementKey];
|
|
||||||
if (element) {
|
|
||||||
const newElement = { ...element };
|
|
||||||
removeByPath(
|
|
||||||
newElement as unknown as Record<string, unknown>,
|
|
||||||
"/" + pathParts.slice(1).join("/"),
|
|
||||||
);
|
|
||||||
newSpec.elements[elementKey] = newElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSpecValue(spec: Spec, path: string): unknown {
|
|
||||||
if (path === "/root") return spec.root;
|
|
||||||
if (path === "/state") return spec.state;
|
|
||||||
if (path.startsWith("/state/") && spec.state) {
|
|
||||||
return getByPath(
|
|
||||||
spec.state as Record<string, unknown>,
|
|
||||||
path.slice("/state".length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return getByPath(spec as unknown as Record<string, unknown>, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSpec(spec: Spec): void {
|
|
||||||
if (
|
|
||||||
spec.state === null ||
|
|
||||||
(spec.state !== undefined && typeof spec.state !== "object")
|
|
||||||
) {
|
|
||||||
spec.state = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(spec.elements)) {
|
|
||||||
const el = spec.elements[key];
|
|
||||||
if (!el || typeof el !== "object") {
|
|
||||||
delete spec.elements[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (el.props == null || typeof el.props !== "object") {
|
|
||||||
spec.elements[key] = { ...el, props: {} } as Spec["elements"][string];
|
|
||||||
}
|
|
||||||
if (!Array.isArray(spec.elements[key]!.children)) {
|
|
||||||
spec.elements[key] = {
|
|
||||||
...spec.elements[key]!,
|
|
||||||
children: [],
|
|
||||||
} as Spec["elements"][string];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySpecPatch(spec: Spec, patch: JsonPatch): Spec {
|
|
||||||
const newSpec = {
|
|
||||||
...spec,
|
|
||||||
elements: { ...spec.elements },
|
|
||||||
...(spec.state ? { state: { ...spec.state } } : {}),
|
|
||||||
};
|
|
||||||
switch (patch.op) {
|
|
||||||
case "add":
|
|
||||||
case "replace":
|
|
||||||
setSpecValue(newSpec, patch.path, patch.value);
|
|
||||||
break;
|
|
||||||
case "remove":
|
|
||||||
removeSpecValue(newSpec, patch.path);
|
|
||||||
break;
|
|
||||||
case "move":
|
|
||||||
if (patch.from) {
|
|
||||||
const moveValue = getSpecValue(newSpec, patch.from);
|
|
||||||
removeSpecValue(newSpec, patch.from);
|
|
||||||
setSpecValue(newSpec, patch.path, moveValue);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "copy":
|
|
||||||
if (patch.from) {
|
|
||||||
setSpecValue(newSpec, patch.path, getSpecValue(newSpec, patch.from));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "test":
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
normalizeSpec(newSpec);
|
|
||||||
return newSpec;
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
|
||||||
import type { Spec, JsonPatch, EditMode } from "@json-render/core";
|
|
||||||
import { deepMergeSpec, diffToPatches } from "@json-render/core";
|
|
||||||
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
||||||
import { applyPatch as applyUnifiedDiff } from "diff";
|
|
||||||
import {
|
|
||||||
createYamlStreamCompiler,
|
|
||||||
YAML_SPEC_FENCE,
|
|
||||||
YAML_EDIT_FENCE,
|
|
||||||
YAML_PATCH_FENCE,
|
|
||||||
DIFF_FENCE,
|
|
||||||
FENCE_CLOSE,
|
|
||||||
} from "@json-render/yaml";
|
|
||||||
import { applySpecPatch } from "./spec-patch";
|
|
||||||
|
|
||||||
export type StreamFormat = "jsonl" | "yaml";
|
|
||||||
|
|
||||||
export interface TokenUsage {
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
cachedTokens: number;
|
|
||||||
cacheWriteTokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsePlaygroundStreamOptions {
|
|
||||||
api: string;
|
|
||||||
format: StreamFormat;
|
|
||||||
editModes?: EditMode[];
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
onComplete?: (spec: Spec) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsePlaygroundStreamReturn {
|
|
||||||
spec: Spec | null;
|
|
||||||
isStreaming: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
usage: TokenUsage | null;
|
|
||||||
rawLines: string[];
|
|
||||||
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
|
||||||
clear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JSONL helpers ──
|
|
||||||
|
|
||||||
type ParsedLine =
|
|
||||||
| { type: "patch"; patch: JsonPatch }
|
|
||||||
| { type: "usage"; usage: TokenUsage }
|
|
||||||
| { type: "json-edit"; mergeObj: Record<string, unknown> }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
function parseLine(line: string): ParsedLine {
|
|
||||||
try {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("//")) return null;
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (parsed.__meta === "usage") {
|
|
||||||
return {
|
|
||||||
type: "usage",
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsed.promptTokens ?? 0,
|
|
||||||
completionTokens: parsed.completionTokens ?? 0,
|
|
||||||
totalTokens: parsed.totalTokens ?? 0,
|
|
||||||
cachedTokens: parsed.cachedTokens ?? 0,
|
|
||||||
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (parsed.__json_edit === true) {
|
|
||||||
const mergeObj = { ...parsed };
|
|
||||||
delete mergeObj.__json_edit;
|
|
||||||
return { type: "json-edit", mergeObj };
|
|
||||||
}
|
|
||||||
return { type: "patch", patch: parsed as JsonPatch };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
|
|
||||||
|
|
||||||
// ── Hook ──
|
|
||||||
|
|
||||||
export function usePlaygroundStream({
|
|
||||||
api,
|
|
||||||
format,
|
|
||||||
editModes,
|
|
||||||
onError,
|
|
||||||
onComplete,
|
|
||||||
}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
|
|
||||||
const [spec, setSpec] = useState<Spec | null>(null);
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [usage, setUsage] = useState<TokenUsage | null>(null);
|
|
||||||
const [rawLines, setRawLines] = useState<string[]>([]);
|
|
||||||
const rawLinesRef = useRef<string[]>([]);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const onCompleteRef = useRef(onComplete);
|
|
||||||
onCompleteRef.current = onComplete;
|
|
||||||
const onErrorRef = useRef(onError);
|
|
||||||
onErrorRef.current = onError;
|
|
||||||
const formatRef = useRef(format);
|
|
||||||
formatRef.current = format;
|
|
||||||
const editModesRef = useRef(editModes);
|
|
||||||
editModesRef.current = editModes;
|
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
|
||||||
setSpec(null);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const send = useCallback(
|
|
||||||
async (prompt: string, context?: Record<string, unknown>) => {
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
setIsStreaming(true);
|
|
||||||
setError(null);
|
|
||||||
setUsage(null);
|
|
||||||
rawLinesRef.current = [];
|
|
||||||
setRawLines([]);
|
|
||||||
|
|
||||||
const previousSpec = context?.previousSpec as Spec | undefined;
|
|
||||||
let currentSpec: Spec =
|
|
||||||
previousSpec && previousSpec.root
|
|
||||||
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
||||||
: { root: "", elements: {} };
|
|
||||||
setSpec(currentSpec);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(api, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
format: formatRef.current,
|
|
||||||
editModes: editModesRef.current,
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `HTTP error: ${response.status}`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.message) errorMessage = errorData.message;
|
|
||||||
else if (errorData.error) errorMessage = errorData.error;
|
|
||||||
} catch {
|
|
||||||
// use default
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) throw new Error("No response body");
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
if (formatRef.current === "yaml") {
|
|
||||||
// ── YAML streaming ──
|
|
||||||
let fenceState: FenceState = "outside";
|
|
||||||
const compiler = createYamlStreamCompiler<Record<string, unknown>>();
|
|
||||||
let yamlEditAccumulated = "";
|
|
||||||
let yamlPatchAccumulated = "";
|
|
||||||
let diffAccumulated = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
|
|
||||||
// Check for usage metadata (appended after stream)
|
|
||||||
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (parsed.__meta === "usage") {
|
|
||||||
setUsage({
|
|
||||||
promptTokens: parsed.promptTokens ?? 0,
|
|
||||||
completionTokens: parsed.completionTokens ?? 0,
|
|
||||||
totalTokens: parsed.totalTokens ?? 0,
|
|
||||||
cachedTokens: parsed.cachedTokens ?? 0,
|
|
||||||
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLinesRef.current.push(line);
|
|
||||||
|
|
||||||
if (fenceState === "outside") {
|
|
||||||
if (
|
|
||||||
trimmed === YAML_SPEC_FENCE ||
|
|
||||||
trimmed.startsWith(YAML_SPEC_FENCE + " ")
|
|
||||||
) {
|
|
||||||
fenceState = "yaml-spec";
|
|
||||||
compiler.reset();
|
|
||||||
} else if (
|
|
||||||
trimmed === YAML_EDIT_FENCE ||
|
|
||||||
trimmed.startsWith(YAML_EDIT_FENCE + " ")
|
|
||||||
) {
|
|
||||||
fenceState = "yaml-edit";
|
|
||||||
yamlEditAccumulated = "";
|
|
||||||
} else if (
|
|
||||||
trimmed === YAML_PATCH_FENCE ||
|
|
||||||
trimmed.startsWith(YAML_PATCH_FENCE + " ")
|
|
||||||
) {
|
|
||||||
fenceState = "yaml-patch";
|
|
||||||
yamlPatchAccumulated = "";
|
|
||||||
} else if (
|
|
||||||
trimmed === DIFF_FENCE ||
|
|
||||||
trimmed.startsWith(DIFF_FENCE + " ")
|
|
||||||
) {
|
|
||||||
fenceState = "diff";
|
|
||||||
diffAccumulated = "";
|
|
||||||
}
|
|
||||||
} else if (trimmed === FENCE_CLOSE || trimmed === "````") {
|
|
||||||
if (fenceState === "yaml-spec") {
|
|
||||||
const { result, newPatches } = compiler.flush();
|
|
||||||
if (result && typeof result === "object" && result.root) {
|
|
||||||
for (const patch of newPatches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
} else if (fenceState === "yaml-edit") {
|
|
||||||
try {
|
|
||||||
const editObj = yamlParse(yamlEditAccumulated);
|
|
||||||
if (editObj && typeof editObj === "object") {
|
|
||||||
const merged = deepMergeSpec(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
editObj as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const patches = diffToPatches(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
merged,
|
|
||||||
);
|
|
||||||
for (const patch of patches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid YAML edit
|
|
||||||
}
|
|
||||||
} else if (fenceState === "yaml-patch") {
|
|
||||||
for (const patchLine of yamlPatchAccumulated.split("\n")) {
|
|
||||||
const t = patchLine.trim();
|
|
||||||
if (!t) continue;
|
|
||||||
try {
|
|
||||||
const patch = JSON.parse(t) as JsonPatch;
|
|
||||||
if (patch.op) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip invalid JSON lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
} else if (fenceState === "diff") {
|
|
||||||
try {
|
|
||||||
const specYaml = yamlStringify(currentSpec, { indent: 2 });
|
|
||||||
const patched = applyUnifiedDiff(specYaml, diffAccumulated);
|
|
||||||
if (typeof patched === "string") {
|
|
||||||
const parsed = yamlParse(patched);
|
|
||||||
if (parsed && typeof parsed === "object") {
|
|
||||||
const patches = diffToPatches(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
parsed as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
for (const patch of patches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Diff apply or reparse failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fenceState = "outside";
|
|
||||||
} else if (fenceState === "yaml-spec") {
|
|
||||||
const { newPatches } = compiler.push(line + "\n");
|
|
||||||
if (newPatches.length > 0) {
|
|
||||||
for (const patch of newPatches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
} else if (fenceState === "yaml-edit") {
|
|
||||||
yamlEditAccumulated += line + "\n";
|
|
||||||
} else if (fenceState === "yaml-patch") {
|
|
||||||
yamlPatchAccumulated += line + "\n";
|
|
||||||
} else if (fenceState === "diff") {
|
|
||||||
diffAccumulated += line + "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRawLines([...rawLinesRef.current]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process remaining buffer
|
|
||||||
if (buffer.trim()) {
|
|
||||||
const trimmed = buffer.trim();
|
|
||||||
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (parsed.__meta === "usage") {
|
|
||||||
setUsage({
|
|
||||||
promptTokens: parsed.promptTokens ?? 0,
|
|
||||||
completionTokens: parsed.completionTokens ?? 0,
|
|
||||||
totalTokens: parsed.totalTokens ?? 0,
|
|
||||||
cachedTokens: parsed.cachedTokens ?? 0,
|
|
||||||
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON
|
|
||||||
}
|
|
||||||
} else if (fenceState === "yaml-spec") {
|
|
||||||
compiler.push(buffer);
|
|
||||||
const { result, newPatches } = compiler.flush();
|
|
||||||
if (result && typeof result === "object" && result.root) {
|
|
||||||
for (const patch of newPatches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ── JSONL streaming ──
|
|
||||||
let jsonlDiffState: "outside" | "diff" = "outside";
|
|
||||||
let jsonlDiffAccumulated = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
// Diff fence detection within JSONL mode
|
|
||||||
if (jsonlDiffState === "outside") {
|
|
||||||
if (
|
|
||||||
trimmed === DIFF_FENCE ||
|
|
||||||
trimmed.startsWith(DIFF_FENCE + " ")
|
|
||||||
) {
|
|
||||||
jsonlDiffState = "diff";
|
|
||||||
jsonlDiffAccumulated = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
jsonlDiffState === "diff" &&
|
|
||||||
(trimmed === FENCE_CLOSE || trimmed === "````")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const specJson = JSON.stringify(currentSpec, null, 2);
|
|
||||||
const patched = applyUnifiedDiff(
|
|
||||||
specJson,
|
|
||||||
jsonlDiffAccumulated,
|
|
||||||
);
|
|
||||||
if (typeof patched === "string") {
|
|
||||||
const parsed = JSON.parse(patched);
|
|
||||||
if (parsed && typeof parsed === "object") {
|
|
||||||
const patches = diffToPatches(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
parsed as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
for (const patch of patches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Diff apply failed
|
|
||||||
}
|
|
||||||
jsonlDiffState = "outside";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonlDiffState === "diff") {
|
|
||||||
jsonlDiffAccumulated += line + "\n";
|
|
||||||
rawLinesRef.current.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard JSONL line parsing
|
|
||||||
const result = parseLine(trimmed);
|
|
||||||
if (!result) continue;
|
|
||||||
if (result.type === "usage") {
|
|
||||||
setUsage(result.usage);
|
|
||||||
} else if (result.type === "json-edit") {
|
|
||||||
const merged = deepMergeSpec(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
result.mergeObj,
|
|
||||||
);
|
|
||||||
const patches = diffToPatches(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
merged,
|
|
||||||
);
|
|
||||||
for (const patch of patches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
rawLinesRef.current.push(trimmed);
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
} else {
|
|
||||||
rawLinesRef.current.push(trimmed);
|
|
||||||
currentSpec = applySpecPatch(currentSpec, result.patch);
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRawLines([...rawLinesRef.current]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer.trim()) {
|
|
||||||
const trimmed = buffer.trim();
|
|
||||||
const result = parseLine(trimmed);
|
|
||||||
if (result) {
|
|
||||||
if (result.type === "usage") {
|
|
||||||
setUsage(result.usage);
|
|
||||||
} else if (result.type === "json-edit") {
|
|
||||||
const merged = deepMergeSpec(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
result.mergeObj,
|
|
||||||
);
|
|
||||||
const patches = diffToPatches(
|
|
||||||
currentSpec as unknown as Record<string, unknown>,
|
|
||||||
merged,
|
|
||||||
);
|
|
||||||
for (const patch of patches) {
|
|
||||||
currentSpec = applySpecPatch(currentSpec, patch);
|
|
||||||
}
|
|
||||||
rawLinesRef.current.push(trimmed);
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
} else {
|
|
||||||
rawLinesRef.current.push(trimmed);
|
|
||||||
currentSpec = applySpecPatch(currentSpec, result.patch);
|
|
||||||
setSpec({ ...currentSpec });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCompleteRef.current?.(currentSpec);
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as Error).name === "AbortError") return;
|
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
|
||||||
setError(error);
|
|
||||||
onErrorRef.current?.(error);
|
|
||||||
} finally {
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { spec, isStreaming, error, usage, rawLines, send, clear };
|
|
||||||
}
|
|
||||||
@@ -35,26 +35,16 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
|||||||
h2: ({ children }: { children?: React.ReactNode }) => {
|
h2: ({ children }: { children?: React.ReactNode }) => {
|
||||||
const id = slugify(extractText(children));
|
const id = slugify(extractText(children));
|
||||||
return (
|
return (
|
||||||
<h2 id={id} className="group text-xl font-semibold mt-12 mb-4">
|
<h2 id={id} className="text-xl font-semibold mt-12 mb-4">
|
||||||
<a href={`#${id}`} className="no-underline hover:no-underline">
|
|
||||||
{children}
|
{children}
|
||||||
<span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
|
|
||||||
#
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
h3: ({ children }: { children?: React.ReactNode }) => {
|
h3: ({ children }: { children?: React.ReactNode }) => {
|
||||||
const id = slugify(extractText(children));
|
const id = slugify(extractText(children));
|
||||||
return (
|
return (
|
||||||
<h3 id={id} className="group text-lg font-medium mt-8 mb-3">
|
<h3 id={id} className="text-lg font-medium mt-8 mb-3">
|
||||||
<a href={`#${id}`} className="no-underline hover:no-underline">
|
|
||||||
{children}
|
{children}
|
||||||
<span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
|
|
||||||
#
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.11",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@json-render/codegen": "workspace:*",
|
"@json-render/codegen": "workspace:*",
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
"@json-render/react": "workspace:*",
|
"@json-render/react": "workspace:*",
|
||||||
"@json-render/yaml": "workspace:*",
|
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/mdx": "^3.1.1",
|
"@mdx-js/mdx": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -35,9 +34,9 @@
|
|||||||
"bash-tool": "1.3.14",
|
"bash-tool": "1.3.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"diff": "^8.0.3",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"geist": "1.7.0",
|
"geist": "1.7.0",
|
||||||
|
"just-bash": "2.9.6",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -52,7 +51,6 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unist-util-visit": "5.1.0",
|
"unist-util-visit": "5.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"yaml": "^2.8.2",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Vercel AI Gateway
|
|
||||||
# Automatically authenticated when deployed on Vercel
|
|
||||||
# For local development, get your key from https://vercel.com/ai-gateway
|
|
||||||
AI_GATEWAY_API_KEY=
|
|
||||||
|
|
||||||
# AI Model Configuration
|
|
||||||
# Default: anthropic/claude-haiku-4.5
|
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
|
||||||
|
|
||||||
# Rate Limiting (Upstash Redis)
|
|
||||||
# Optional - rate limiting is disabled when these are not set
|
|
||||||
KV_REST_API_URL=
|
|
||||||
KV_REST_API_TOKEN=
|
|
||||||
RATE_LIMIT_PER_MINUTE=10
|
|
||||||
RATE_LIMIT_PER_DAY=100
|
|
||||||
@@ -1,50 +1,5 @@
|
|||||||
# example-chat
|
# example-chat
|
||||||
|
|
||||||
## 0.1.11
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [519a538]
|
|
||||||
- @json-render/core@0.16.0
|
|
||||||
- @json-render/react@0.16.0
|
|
||||||
- @json-render/shadcn@0.16.0
|
|
||||||
|
|
||||||
## 0.1.10
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [bf3a7ec]
|
|
||||||
- @json-render/core@0.15.0
|
|
||||||
- @json-render/react@0.15.0
|
|
||||||
- @json-render/shadcn@0.15.0
|
|
||||||
|
|
||||||
## 0.1.9
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [43b7515]
|
|
||||||
- @json-render/core@0.14.1
|
|
||||||
- @json-render/react@0.14.1
|
|
||||||
- @json-render/shadcn@0.14.1
|
|
||||||
|
|
||||||
## 0.1.8
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [a8afd8b]
|
|
||||||
- @json-render/core@0.14.0
|
|
||||||
- @json-render/react@0.14.0
|
|
||||||
- @json-render/shadcn@0.14.0
|
|
||||||
|
|
||||||
## 0.1.7
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [5b32de8]
|
|
||||||
- @json-render/core@0.13.0
|
|
||||||
- @json-render/react@0.13.0
|
|
||||||
- @json-render/shadcn@0.13.0
|
|
||||||
|
|
||||||
## 0.1.6
|
## 0.1.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { agent } from "@/lib/agent";
|
import { agent } from "@/lib/agent";
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
@@ -7,35 +6,10 @@ import {
|
|||||||
type UIMessage,
|
type UIMessage,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
import { pipeJsonRender } from "@json-render/core";
|
import { pipeJsonRender } from "@json-render/core";
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const headersList = await headers();
|
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
|
||||||
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
|
||||||
minuteRateLimit.limit(ip),
|
|
||||||
dailyRateLimit.limit(ip),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!minuteResult.success || !dailyResult.success) {
|
|
||||||
const isMinuteLimit = !minuteResult.success;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Rate limit exceeded",
|
|
||||||
message: isMinuteLimit
|
|
||||||
? "Too many requests. Please wait a moment before trying again."
|
|
||||||
: "Daily limit reached. Please try again tomorrow.",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const uiMessages: UIMessage[] = body.messages;
|
const uiMessages: UIMessage[] = body.messages;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,57 +0,0 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
|
||||||
import { Redis } from "@upstash/redis";
|
|
||||||
|
|
||||||
// Lazy initialization to avoid errors when Redis env vars are not configured
|
|
||||||
let _minuteRateLimit: Ratelimit | null = null;
|
|
||||||
let _dailyRateLimit: Ratelimit | null = null;
|
|
||||||
|
|
||||||
function getRedis(): Redis | null {
|
|
||||||
const url = process.env.KV_REST_API_URL;
|
|
||||||
const token = process.env.KV_REST_API_TOKEN;
|
|
||||||
|
|
||||||
if (!url || !token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Redis({ url, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-op rate limiter for when Redis is not configured
|
|
||||||
const noopRateLimiter = {
|
|
||||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
|
||||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
|
||||||
|
|
||||||
// Requests per minute (sliding window)
|
|
||||||
export const minuteRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_minuteRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_minuteRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
|
||||||
prefix: "ratelimit:chat:minute",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _minuteRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Requests per day (fixed window)
|
|
||||||
export const dailyRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_dailyRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_dailyRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
|
||||||
prefix: "ratelimit:chat:daily",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _dailyRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-chat",
|
"name": "example-chat",
|
||||||
"version": "0.1.11",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,8 +34,6 @@
|
|||||||
"streamdown": "^2.2.0",
|
"streamdown": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
|
||||||
"@upstash/redis": "^1.37.0",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -4,10 +4,3 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard
|
|||||||
# AI (optional - for UI generation)
|
# AI (optional - for UI generation)
|
||||||
AI_GATEWAY_API_KEY=
|
AI_GATEWAY_API_KEY=
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
# Rate Limiting (Upstash Redis)
|
|
||||||
# Optional - rate limiting is disabled when these are not set
|
|
||||||
KV_REST_API_URL=
|
|
||||||
KV_REST_API_TOKEN=
|
|
||||||
RATE_LIMIT_PER_MINUTE=10
|
|
||||||
RATE_LIMIT_PER_DAY=100
|
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
# example-dashboard
|
# example-dashboard
|
||||||
|
|
||||||
## 0.1.11
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [519a538]
|
|
||||||
- @json-render/core@0.16.0
|
|
||||||
- @json-render/codegen@0.16.0
|
|
||||||
- @json-render/react@0.16.0
|
|
||||||
|
|
||||||
## 0.1.10
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [bf3a7ec]
|
|
||||||
- @json-render/core@0.15.0
|
|
||||||
- @json-render/codegen@0.15.0
|
|
||||||
- @json-render/react@0.15.0
|
|
||||||
|
|
||||||
## 0.1.9
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [43b7515]
|
|
||||||
- @json-render/core@0.14.1
|
|
||||||
- @json-render/codegen@0.14.1
|
|
||||||
- @json-render/react@0.14.1
|
|
||||||
|
|
||||||
## 0.1.8
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [a8afd8b]
|
|
||||||
- @json-render/core@0.14.0
|
|
||||||
- @json-render/codegen@0.14.0
|
|
||||||
- @json-render/react@0.14.0
|
|
||||||
|
|
||||||
## 0.1.7
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [5b32de8]
|
|
||||||
- @json-render/core@0.13.0
|
|
||||||
- @json-render/codegen@0.13.0
|
|
||||||
- @json-render/react@0.13.0
|
|
||||||
|
|
||||||
## 0.1.6
|
## 0.1.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { buildUserPrompt } from "@json-render/core";
|
import { buildUserPrompt } from "@json-render/core";
|
||||||
import { dashboardCatalog } from "@/lib/render/catalog";
|
import { dashboardCatalog } from "@/lib/render/catalog";
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
@@ -11,30 +9,6 @@ const SYSTEM_PROMPT = dashboardCatalog.prompt();
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const headersList = await headers();
|
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
|
||||||
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
|
||||||
minuteRateLimit.limit(ip),
|
|
||||||
dailyRateLimit.limit(ip),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!minuteResult.success || !dailyResult.success) {
|
|
||||||
const isMinuteLimit = !minuteResult.success;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Rate limit exceeded",
|
|
||||||
message: isMinuteLimit
|
|
||||||
? "Too many requests. Please wait a moment before trying again."
|
|
||||||
: "Daily limit reached. Please try again tomorrow.",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { prompt, context } = await req.json();
|
const { prompt, context } = await req.json();
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,57 +0,0 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
|
||||||
import { Redis } from "@upstash/redis";
|
|
||||||
|
|
||||||
// Lazy initialization to avoid errors when Redis env vars are not configured
|
|
||||||
let _minuteRateLimit: Ratelimit | null = null;
|
|
||||||
let _dailyRateLimit: Ratelimit | null = null;
|
|
||||||
|
|
||||||
function getRedis(): Redis | null {
|
|
||||||
const url = process.env.KV_REST_API_URL;
|
|
||||||
const token = process.env.KV_REST_API_TOKEN;
|
|
||||||
|
|
||||||
if (!url || !token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Redis({ url, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-op rate limiter for when Redis is not configured
|
|
||||||
const noopRateLimiter = {
|
|
||||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
|
||||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
|
||||||
|
|
||||||
// Requests per minute (sliding window)
|
|
||||||
export const minuteRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_minuteRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_minuteRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
|
||||||
prefix: "ratelimit:dashboard:minute",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _minuteRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Requests per day (fixed window)
|
|
||||||
export const dailyRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_dailyRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_dailyRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
|
||||||
prefix: "ratelimit:dashboard:daily",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _dailyRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-dashboard",
|
"name": "example-dashboard",
|
||||||
"version": "0.1.11",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,8 +40,6 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
|
||||||
"@upstash/redis": "^1.37.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# Vercel AI Gateway
|
|
||||||
# Automatically authenticated when deployed on Vercel
|
|
||||||
# For local development, get your key from https://vercel.com/ai-gateway
|
|
||||||
AI_GATEWAY_API_KEY=
|
|
||||||
|
|
||||||
# AI Model Configuration
|
|
||||||
# Default: anthropic/claude-sonnet-4-6
|
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-sonnet-4-6
|
|
||||||
|
|
||||||
# ElevenLabs Text-to-Speech (optional, for NPC dialogue)
|
|
||||||
ELEVENLABS_API_KEY=
|
|
||||||
|
|
||||||
# Upstash Redis for rate limiting (optional, no-op if not set)
|
|
||||||
KV_REST_API_URL=
|
|
||||||
KV_REST_API_TOKEN=
|
|
||||||
RATE_LIMIT_PER_MINUTE=10
|
|
||||||
RATE_LIMIT_PER_DAY=100
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# example-game-engine
|
|
||||||
|
|
||||||
## 0.1.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [519a538]
|
|
||||||
- @json-render/core@0.16.0
|
|
||||||
- @json-render/react@0.16.0
|
|
||||||
- @json-render/react-three-fiber@0.16.0
|
|
||||||
- @json-render/yaml@0.16.0
|
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [bf3a7ec]
|
|
||||||
- @json-render/core@0.15.0
|
|
||||||
- @json-render/react@0.15.0
|
|
||||||
- @json-render/react-three-fiber@0.15.0
|
|
||||||
- @json-render/yaml@0.15.0
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { streamText } from "ai";
|
|
||||||
import { gateway } from "@ai-sdk/gateway";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { generateGameAIPrompt } from "@/lib/ai-game-prompt";
|
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const headersList = await headers();
|
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
|
||||||
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
|
||||||
minuteRateLimit.limit(ip),
|
|
||||||
dailyRateLimit.limit(ip),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!minuteResult.success || !dailyResult.success) {
|
|
||||||
const isMinuteLimit = !minuteResult.success;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Rate limit exceeded",
|
|
||||||
message: isMinuteLimit
|
|
||||||
? "Too many requests. Please wait a moment before trying again."
|
|
||||||
: "Daily limit reached. Please try again tomorrow.",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { prompt, objects, previousPrompts } = await req.json();
|
|
||||||
|
|
||||||
if (!prompt) {
|
|
||||||
return Response.json({ error: "Prompt is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sceneObjects = Array.isArray(objects) ? objects : [];
|
|
||||||
|
|
||||||
const result = streamText({
|
|
||||||
model: gateway(
|
|
||||||
process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
|
|
||||||
),
|
|
||||||
prompt: generateGameAIPrompt(prompt, sceneObjects, previousPrompts || []),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.toTextStreamResponse();
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { streamText } from "ai";
|
|
||||||
import { gateway } from "@ai-sdk/gateway";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { generateSystemPrompt, generateUserPrompt } from "@/lib/ai-prompt";
|
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const headersList = await headers();
|
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
|
||||||
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
|
||||||
minuteRateLimit.limit(ip),
|
|
||||||
dailyRateLimit.limit(ip),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!minuteResult.success || !dailyResult.success) {
|
|
||||||
const isMinuteLimit = !minuteResult.success;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Rate limit exceeded",
|
|
||||||
message: isMinuteLimit
|
|
||||||
? "Too many requests. Please wait a moment before trying again."
|
|
||||||
: "Daily limit reached. Please try again tomorrow.",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { prompt, spec, previousPrompts } = await req.json();
|
|
||||||
|
|
||||||
const result = streamText({
|
|
||||||
model: gateway(
|
|
||||||
process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
|
|
||||||
),
|
|
||||||
system: generateSystemPrompt(),
|
|
||||||
prompt: generateUserPrompt(prompt, spec, previousPrompts),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.toTextStreamResponse();
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { streamText } from "ai";
|
|
||||||
import { gateway } from "@ai-sdk/gateway";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import speechQueue from "@/lib/speech-queue";
|
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
interface DialogMessage {
|
|
||||||
text: string;
|
|
||||||
audioUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VOICE_MAP: Record<string, string> = {
|
|
||||||
elder: "pNInz6obpgDQGcFmaJgB",
|
|
||||||
old: "pNInz6obpgDQGcFmaJgB",
|
|
||||||
wise: "pNInz6obpgDQGcFmaJgB",
|
|
||||||
warrior: "AZnzlk1XvdvUeBnXmlld",
|
|
||||||
soldier: "AZnzlk1XvdvUeBnXmlld",
|
|
||||||
guard: "AZnzlk1XvdvUeBnXmlld",
|
|
||||||
child: "MF3mGyEYCl7XYWbV9V6O",
|
|
||||||
young: "MF3mGyEYCl7XYWbV9V6O",
|
|
||||||
merchant: "jBpfuIE2acCO8z3wKNLl",
|
|
||||||
trader: "jBpfuIE2acCO8z3wKNLl",
|
|
||||||
wizard: "IKne3meq5aSn9XLyUdCD",
|
|
||||||
mage: "IKne3meq5aSn9XLyUdCD",
|
|
||||||
magic: "IKne3meq5aSn9XLyUdCD",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getVoiceIdForRole(role: string): string {
|
|
||||||
const lower = role.toLowerCase();
|
|
||||||
for (const [keyword, voiceId] of Object.entries(VOICE_MAP)) {
|
|
||||||
if (lower.includes(keyword)) return voiceId;
|
|
||||||
}
|
|
||||||
return "ThT5KcBeYPX3keUQqHPh";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const headersList = await headers();
|
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
|
||||||
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
|
||||||
minuteRateLimit.limit(ip),
|
|
||||||
dailyRateLimit.limit(ip),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!minuteResult.success || !dailyResult.success) {
|
|
||||||
const isMinuteLimit = !minuteResult.success;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Rate limit exceeded",
|
|
||||||
message: isMinuteLimit
|
|
||||||
? "Too many requests. Please wait a moment before trying again."
|
|
||||||
: "Daily limit reached. Please try again tomorrow.",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { role } = await req.json();
|
|
||||||
|
|
||||||
const prompt = `You are a character with the following role: "${role || "villager"}".
|
|
||||||
Generate 2-3 short messages that this character would say when interacted with.
|
|
||||||
Keep each message under 100 characters.
|
|
||||||
Return ONLY a JSON array of objects with a "text" field for each message.
|
|
||||||
Example: [{"text":"Hello traveler! Welcome to our village."}, {"text":"Can I help you with something?"}]`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await streamText({
|
|
||||||
model: gateway(
|
|
||||||
process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
|
|
||||||
),
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
let text = "";
|
|
||||||
for await (const chunk of result.textStream) {
|
|
||||||
text += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages: DialogMessage[] = [];
|
|
||||||
try {
|
|
||||||
const jsonMatch = text.match(/\[.*\]/s);
|
|
||||||
const jsonString = jsonMatch ? jsonMatch[0] : "[]";
|
|
||||||
messages = JSON.parse(jsonString);
|
|
||||||
} catch {
|
|
||||||
messages = [
|
|
||||||
{ text: "Hello there! How can I help you?" },
|
|
||||||
{ text: "It's a beautiful day, isn't it?" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
messages = [
|
|
||||||
{ text: "Hello there! How can I help you?" },
|
|
||||||
{ text: "It's a beautiful day, isn't it?" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTTS = !!process.env.ELEVENLABS_API_KEY;
|
|
||||||
if (hasTTS) {
|
|
||||||
const voiceId = getVoiceIdForRole(role || "");
|
|
||||||
const withAudio: DialogMessage[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
try {
|
|
||||||
const audioUrl = await speechQueue.add(msg.text, voiceId);
|
|
||||||
withAudio.push({ ...msg, audioUrl });
|
|
||||||
} catch {
|
|
||||||
withAudio.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Response.json({ messages: withAudio });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ messages });
|
|
||||||
} catch {
|
|
||||||
return Response.json({
|
|
||||||
messages: [
|
|
||||||
{ text: "Hello there! How can I help you?" },
|
|
||||||
{ text: "It's a beautiful day, isn't it?" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { list } from "@vercel/blob";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const { blobs } = await list({ prefix: "game-engine/environments/" });
|
|
||||||
const environments = blobs.map((blob) => ({
|
|
||||||
name: blob.pathname.split("/").pop() || "Unknown",
|
|
||||||
url: blob.url,
|
|
||||||
size: blob.size,
|
|
||||||
uploadedAt: blob.uploadedAt,
|
|
||||||
}));
|
|
||||||
return NextResponse.json({ environments });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to list environments:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to list environments" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { list } from "@vercel/blob";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const { blobs } = await list({ prefix: "game-engine/models/" });
|
|
||||||
const models = blobs.map((blob) => ({
|
|
||||||
name: blob.pathname.split("/").pop() || "Unknown",
|
|
||||||
url: blob.url,
|
|
||||||
size: blob.size,
|
|
||||||
uploadedAt: blob.uploadedAt,
|
|
||||||
}));
|
|
||||||
return NextResponse.json({ models });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to list models:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to list models" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export async function POST(req: Request) {
|
|
||||||
const { text, voiceId } = await req.json();
|
|
||||||
|
|
||||||
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "ELEVENLABS_API_KEY not set" }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const voice = voiceId || "21m00Tcm4TlvDq8ikWAM";
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.elevenlabs.io/v1/text-to-speech/${voice}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"xi-api-key": apiKey,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text,
|
|
||||||
model_id: "eleven_monolingual_v1",
|
|
||||||
voice_settings: {
|
|
||||||
stability: 0.5,
|
|
||||||
similarity_boost: 0.75,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return new Response(JSON.stringify({ error: "TTS generation failed" }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = await response.arrayBuffer();
|
|
||||||
return new Response(audioBuffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/mpeg",
|
|
||||||
"Cache-Control": "public, max-age=3600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { put } from "@vercel/blob";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filenameFromForm = formData.get("filename");
|
|
||||||
const filename = filenameFromForm ? String(filenameFromForm) : file.name;
|
|
||||||
const cleanFilename = filename.toLowerCase().replace(/[^a-z0-9.]/g, "-");
|
|
||||||
|
|
||||||
const blob = await put(`game-engine/environments/${cleanFilename}`, file, {
|
|
||||||
access: "public",
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ url: blob.url, name: cleanFilename });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload environment:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to upload file" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { put } from "@vercel/blob";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filenameFromForm = formData.get("filename");
|
|
||||||
const filename = filenameFromForm ? String(filenameFromForm) : file.name;
|
|
||||||
const cleanFilename = filename.toLowerCase().replace(/[^a-z0-9.]/g, "-");
|
|
||||||
|
|
||||||
const blob = await put(`game-engine/models/${cleanFilename}`, file, {
|
|
||||||
access: "public",
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ url: blob.url, name: cleanFilename });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload model:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to upload file" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,96 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: #0a0a0a;
|
|
||||||
--color-foreground: #ededed;
|
|
||||||
--color-muted: #1e1e1e;
|
|
||||||
--color-muted-foreground: #888;
|
|
||||||
--color-border: #2a2a2a;
|
|
||||||
--color-input: #1a1a1a;
|
|
||||||
--color-primary: #fff;
|
|
||||||
--color-primary-foreground: #0a0a0a;
|
|
||||||
--color-accent: #333;
|
|
||||||
--color-accent-foreground: #ededed;
|
|
||||||
--color-destructive: #e53935;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--color-background);
|
|
||||||
color: var(--color-foreground);
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
touch-action: none;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, [contenteditable] {
|
|
||||||
-webkit-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
-webkit-touch-callout: default;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
button, a, [role="button"] {
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
.hover\:bg-white\/5:hover { background-color: transparent !important; }
|
|
||||||
.hover\:bg-white\/10:hover { background-color: transparent !important; }
|
|
||||||
.hover\:bg-white\/20:hover { background-color: transparent !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--sai-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
--sai-top: env(safe-area-inset-top, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-bottom {
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-low {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.6; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse-low {
|
|
||||||
animation: pulse-low 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fade-in 0.5s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes screen-flash {
|
|
||||||
0% { opacity: 0.7; }
|
|
||||||
100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-screen-flash {
|
|
||||||
animation: screen-flash 0.4s ease-out forwards;
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Game Engine | json-render",
|
|
||||||
description:
|
|
||||||
"Build 3D worlds with AI. A scene editor and game runtime powered by json-render specs and React Three Fiber.",
|
|
||||||
icons: { icon: "/icon.svg" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,7 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { GameEngine } from "@/components/game-engine";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <GameEngine />;
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Box,
|
|
||||||
Circle,
|
|
||||||
Cylinder,
|
|
||||||
Triangle,
|
|
||||||
Sun,
|
|
||||||
User,
|
|
||||||
Volume2,
|
|
||||||
Image,
|
|
||||||
Video,
|
|
||||||
Package,
|
|
||||||
Users,
|
|
||||||
Hexagon,
|
|
||||||
Spline,
|
|
||||||
Pentagon,
|
|
||||||
Shapes,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import type { ObjectType } from "@/lib/types";
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
label: "Basic Shapes",
|
|
||||||
items: [
|
|
||||||
{ type: "box" as ObjectType, label: "Box", icon: Box },
|
|
||||||
{ type: "sphere" as ObjectType, label: "Sphere", icon: Circle },
|
|
||||||
{ type: "cylinder" as ObjectType, label: "Cylinder", icon: Cylinder },
|
|
||||||
{ type: "cone" as ObjectType, label: "Cone", icon: Triangle },
|
|
||||||
{ type: "torus" as ObjectType, label: "Torus", icon: Circle },
|
|
||||||
{ type: "plane" as ObjectType, label: "Plane", icon: Box },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Advanced Shapes",
|
|
||||||
items: [
|
|
||||||
{ type: "capsule" as ObjectType, label: "Capsule", icon: Cylinder },
|
|
||||||
{
|
|
||||||
type: "tetrahedron" as ObjectType,
|
|
||||||
label: "Tetrahedron",
|
|
||||||
icon: Triangle,
|
|
||||||
},
|
|
||||||
{ type: "octahedron" as ObjectType, label: "Octahedron", icon: Circle },
|
|
||||||
{
|
|
||||||
type: "dodecahedron" as ObjectType,
|
|
||||||
label: "Dodecahedron",
|
|
||||||
icon: Circle,
|
|
||||||
},
|
|
||||||
{ type: "icosahedron" as ObjectType, label: "Icosahedron", icon: Circle },
|
|
||||||
{ type: "knot" as ObjectType, label: "Knot", icon: Circle },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Custom Geometry",
|
|
||||||
items: [
|
|
||||||
{ type: "extrude" as ObjectType, label: "Extrude", icon: Hexagon },
|
|
||||||
{ type: "tube" as ObjectType, label: "Tube", icon: Spline },
|
|
||||||
{ type: "shape" as ObjectType, label: "Shape", icon: Pentagon },
|
|
||||||
{ type: "mesh" as ObjectType, label: "Mesh", icon: Shapes },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Environment",
|
|
||||||
items: [
|
|
||||||
{ type: "light" as ObjectType, label: "Light", icon: Sun },
|
|
||||||
{ type: "sound" as ObjectType, label: "Sound", icon: Volume2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Media",
|
|
||||||
items: [
|
|
||||||
{ type: "image" as ObjectType, label: "Image", icon: Image },
|
|
||||||
{ type: "video" as ObjectType, label: "Video", icon: Video },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Entities",
|
|
||||||
items: [
|
|
||||||
{ type: "player" as ObjectType, label: "Player", icon: User },
|
|
||||||
{ type: "character" as ObjectType, label: "Character", icon: Users },
|
|
||||||
{ type: "model" as ObjectType, label: "Model", icon: Package },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AddObjectButton() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const addObject = useEditorStore((s) => s.addObject);
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handleClickOutside = (e: PointerEvent) => {
|
|
||||||
if (
|
|
||||||
popoverRef.current &&
|
|
||||||
!popoverRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", handleClickOutside);
|
|
||||||
return () =>
|
|
||||||
document.removeEventListener("pointerdown", handleClickOutside);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-4 left-4 z-10" ref={popoverRef}>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-14 left-0 ${isMobile ? "w-64" : "w-56"} bg-[#141414] border border-[#2a2a2a] rounded-lg shadow-xl overflow-hidden`}
|
|
||||||
>
|
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<div key={section.label}>
|
|
||||||
<div className="px-3 py-1.5 text-[9px] font-semibold text-[#555] uppercase tracking-wider">
|
|
||||||
{section.label}
|
|
||||||
</div>
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.type}
|
|
||||||
onClick={() => {
|
|
||||||
addObject(item.type);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2.5 px-3 ${isMobile ? "py-2.5" : "py-1.5"} text-xs text-[#aaa] hover:text-white active:text-white hover:bg-white/5 active:bg-white/10 transition-colors`}
|
|
||||||
>
|
|
||||||
<item.icon size={isMobile ? 16 : 12} />
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className={`${isMobile ? "w-11 h-11" : "w-9 h-9"} flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white transition-colors backdrop-blur-sm`}
|
|
||||||
title="Add object"
|
|
||||||
>
|
|
||||||
<Plus size={isMobile ? 20 : 18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useMemo } from "react";
|
|
||||||
import { MessageSquare, X, ArrowUp, Loader2 } from "lucide-react";
|
|
||||||
import { deepMergeSpec } from "@json-render/core";
|
|
||||||
import type { Spec } from "@json-render/core";
|
|
||||||
import { parse } from "yaml";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import { sceneToSpec } from "@/lib/scene-to-spec";
|
|
||||||
import { specToSceneObjects } from "@/lib/spec-to-scene";
|
|
||||||
|
|
||||||
function stripFences(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/```yaml-edit\s*\n?/g, "")
|
|
||||||
.replace(/```yaml-spec\s*\n?/g, "")
|
|
||||||
.replace(/```\s*$/gm, "")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIPrompt() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const replaceSceneObjects = useEditorStore((s) => s.replaceSceneObjects);
|
|
||||||
const setIsPromptOpen = useEditorStore((s) => s.setIsPromptOpen);
|
|
||||||
|
|
||||||
const activeScene = useMemo(
|
|
||||||
() => scenes.find((s) => s.id === activeSceneId),
|
|
||||||
[scenes, activeSceneId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!prompt.trim() || isProcessing || !activeScene) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setMessage("Thinking...");
|
|
||||||
setIsPromptOpen(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentSpec = sceneToSpec(activeScene);
|
|
||||||
|
|
||||||
const res = await fetch("/api/ai", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt,
|
|
||||||
spec: currentSpec,
|
|
||||||
previousPrompts,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setMessage("Error: " + res.statusText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body?.getReader();
|
|
||||||
if (!reader) return;
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let accumulated = "";
|
|
||||||
let lastAppliedJson = "";
|
|
||||||
const baseSpec = JSON.parse(JSON.stringify(currentSpec));
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
accumulated += chunk;
|
|
||||||
|
|
||||||
if (!chunk.includes("\n")) continue;
|
|
||||||
|
|
||||||
const stripped = stripFences(accumulated);
|
|
||||||
if (!stripped) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const editObj = parse(stripped);
|
|
||||||
if (
|
|
||||||
editObj &&
|
|
||||||
typeof editObj === "object" &&
|
|
||||||
!Array.isArray(editObj)
|
|
||||||
) {
|
|
||||||
const editJson = JSON.stringify(editObj);
|
|
||||||
if (editJson === lastAppliedJson) continue;
|
|
||||||
lastAppliedJson = editJson;
|
|
||||||
|
|
||||||
const merged = deepMergeSpec(
|
|
||||||
JSON.parse(JSON.stringify(baseSpec)),
|
|
||||||
editObj as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const patchedSpec = merged as unknown as Spec;
|
|
||||||
const newObjects = specToSceneObjects(patchedSpec);
|
|
||||||
if (newObjects.length > 0) {
|
|
||||||
replaceSceneObjects(newObjects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Incomplete YAML — wait for more data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final parse to catch any remaining content
|
|
||||||
const stripped = stripFences(accumulated);
|
|
||||||
if (stripped) {
|
|
||||||
try {
|
|
||||||
const editObj = parse(stripped);
|
|
||||||
if (
|
|
||||||
editObj &&
|
|
||||||
typeof editObj === "object" &&
|
|
||||||
!Array.isArray(editObj)
|
|
||||||
) {
|
|
||||||
const merged = deepMergeSpec(
|
|
||||||
JSON.parse(JSON.stringify(baseSpec)),
|
|
||||||
editObj as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const patchedSpec = merged as unknown as Spec;
|
|
||||||
const newObjects = specToSceneObjects(patchedSpec);
|
|
||||||
if (newObjects.length > 0) {
|
|
||||||
replaceSceneObjects(newObjects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Parse failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviousPrompts((prev) => [...prev, prompt]);
|
|
||||||
setMessage("Done");
|
|
||||||
setPrompt("");
|
|
||||||
} catch {
|
|
||||||
setMessage("Error occurred");
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
setIsPromptOpen(false);
|
|
||||||
setTimeout(() => setMessage(""), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(true);
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
}}
|
|
||||||
className={`absolute bottom-4 right-4 z-10 ${isMobile ? "w-11 h-11" : "w-9 h-9"} flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white transition-colors backdrop-blur-sm`}
|
|
||||||
title="AI Scene Editor"
|
|
||||||
>
|
|
||||||
<MessageSquare size={isMobile ? 18 : 16} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-4 right-4 z-10 ${isMobile ? "left-4 right-4 w-auto" : "w-80"}`}
|
|
||||||
>
|
|
||||||
<div className="bg-[#141414] border border-[#2a2a2a] rounded-lg shadow-xl overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
|
|
||||||
<span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
|
|
||||||
AI Scene Editor
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
|
||||||
placeholder="Describe scene changes..."
|
|
||||||
disabled={isProcessing}
|
|
||||||
className={`flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2.5 ${isMobile ? "py-2.5 text-base" : "py-1.5 text-xs"} text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444] disabled:opacity-50`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isProcessing || !prompt.trim()}
|
|
||||||
className={`${isMobile ? "p-2.5" : "p-1.5"} rounded bg-white/10 hover:bg-white/20 active:bg-white/30 text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors`}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{message && (
|
|
||||||
<div className="mt-2 text-[10px] text-[#666] flex items-center gap-1.5">
|
|
||||||
{isProcessing && <Loader2 size={10} className="animate-spin" />}
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { Upload } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
|
|
||||||
export function DropZone() {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const dragCounter = useRef(0);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const createCustomObject = useEditorStore((s) => s.createCustomObject);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const hasGlbFiles = useCallback((e: DragEvent) => {
|
|
||||||
if (e.dataTransfer?.types.includes("Files")) return true;
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const processFiles = useCallback(
|
|
||||||
async (files: File[]) => {
|
|
||||||
const glbFiles = files.filter(
|
|
||||||
(f) =>
|
|
||||||
f.name.endsWith(".glb") ||
|
|
||||||
f.name.endsWith(".gltf") ||
|
|
||||||
f.type === "model/gltf-binary" ||
|
|
||||||
f.type === "model/gltf+json",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (glbFiles.length === 0) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
for (const file of glbFiles) {
|
|
||||||
try {
|
|
||||||
let modelUrl: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("filename", file.name);
|
|
||||||
|
|
||||||
const res = await fetch("/api/upload-model", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
modelUrl = data.url;
|
|
||||||
} else {
|
|
||||||
modelUrl = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
modelUrl = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = file.name.replace(/\.(glb|gltf)$/i, "");
|
|
||||||
createCustomObject("model", {
|
|
||||||
name: name.charAt(0).toUpperCase() + name.slice(1),
|
|
||||||
modelUrl,
|
|
||||||
position: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to add model:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(false);
|
|
||||||
},
|
|
||||||
[createCustomObject],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying) return;
|
|
||||||
|
|
||||||
const handleDragEnter = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
dragCounter.current++;
|
|
||||||
if (hasGlbFiles(e)) {
|
|
||||||
setIsDragging(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.dropEffect = "copy";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
dragCounter.current--;
|
|
||||||
if (dragCounter.current <= 0) {
|
|
||||||
dragCounter.current = 0;
|
|
||||||
setIsDragging(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
dragCounter.current = 0;
|
|
||||||
setIsDragging(false);
|
|
||||||
|
|
||||||
if (!e.dataTransfer) return;
|
|
||||||
await processFiles(Array.from(e.dataTransfer.files));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("dragenter", handleDragEnter);
|
|
||||||
window.addEventListener("dragover", handleDragOver);
|
|
||||||
window.addEventListener("dragleave", handleDragLeave);
|
|
||||||
window.addEventListener("drop", handleDrop);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("dragenter", handleDragEnter);
|
|
||||||
window.removeEventListener("dragover", handleDragOver);
|
|
||||||
window.removeEventListener("dragleave", handleDragLeave);
|
|
||||||
window.removeEventListener("drop", handleDrop);
|
|
||||||
};
|
|
||||||
}, [isPlaying, hasGlbFiles, processFiles]);
|
|
||||||
|
|
||||||
const handleFileInputChange = async (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
await processFiles(Array.from(e.target.files));
|
|
||||||
}
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isPlaying) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isDragging && (
|
|
||||||
<div className="absolute inset-0 bg-blue-500/10 border-2 border-dashed border-blue-500/50 rounded-lg flex items-center justify-center z-[6] pointer-events-none">
|
|
||||||
<div className="bg-black/70 backdrop-blur-sm rounded-lg px-6 py-4 flex flex-col items-center gap-2">
|
|
||||||
<Upload className="w-8 h-8 text-blue-400" />
|
|
||||||
<span className="text-sm text-blue-300 font-medium">
|
|
||||||
Drop .glb file to add model
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isUploading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[6]">
|
|
||||||
<div className="bg-black/70 backdrop-blur-sm rounded-lg px-6 py-4">
|
|
||||||
<span className="text-sm text-white">Adding model...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isMobile && (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".glb,.gltf"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="absolute bottom-4 left-[4.5rem] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
|
|
||||||
title="Import .glb model"
|
|
||||||
>
|
|
||||||
<Upload size={18} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, type ReactNode } from "react";
|
|
||||||
import { stringify } from "yaml";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { sceneToSpec } from "@/lib/scene-to-spec";
|
|
||||||
|
|
||||||
const YAML_TOKEN_RE =
|
|
||||||
/(^[ \t]*[\w][\w.-]*:(?=\s|$))|("(?:\\.|[^"\\])*"|'(?:\\'|[^'\\])*')|(true|false)|(null|~)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?(?=\s|$))/gm;
|
|
||||||
|
|
||||||
function highlightYaml(yaml: string): ReactNode[] {
|
|
||||||
const parts: ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = YAML_TOKEN_RE.exec(yaml)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(yaml.slice(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [full, key, str, bool, nil, num] = match;
|
|
||||||
let color: string;
|
|
||||||
if (key) color = "#c4a7e7";
|
|
||||||
else if (str) color = "#a8d4a2";
|
|
||||||
else if (bool) color = "#f6c177";
|
|
||||||
else if (nil) color = "#6e6a86";
|
|
||||||
else if (num) color = "#ebbcba";
|
|
||||||
else color = "#8a8a8a";
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
<span key={match.index} style={{ color }}>
|
|
||||||
{full}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
lastIndex = match.index + full.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < yaml.length) {
|
|
||||||
parts.push(yaml.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JsonPane() {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const activeScene = useMemo(
|
|
||||||
() => scenes.find((s) => s.id === activeSceneId) || scenes[0],
|
|
||||||
[scenes, activeSceneId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const spec = useMemo(() => {
|
|
||||||
if (!activeScene) return null;
|
|
||||||
return sceneToSpec(activeScene);
|
|
||||||
}, [activeScene]);
|
|
||||||
|
|
||||||
const yaml = useMemo(() => {
|
|
||||||
if (!spec) return "";
|
|
||||||
return stringify(spec, { indent: 2, lineWidth: 0 });
|
|
||||||
}, [spec]);
|
|
||||||
|
|
||||||
const highlighted = useMemo(() => {
|
|
||||||
if (!yaml) return null;
|
|
||||||
return highlightYaml(yaml);
|
|
||||||
}, [yaml]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="h-9 flex items-center px-3 border-b border-[#1e1e1e] flex-shrink-0">
|
|
||||||
<span className="text-[10px] font-semibold text-[#555] uppercase tracking-wider font-mono">
|
|
||||||
Spec
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<pre className="p-3 text-[11px] leading-[1.5] text-[#555] font-mono whitespace-pre select-all">
|
|
||||||
{mounted ? highlighted : ""}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import { ChevronDown, Plus, Copy, Trash2 } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
|
|
||||||
export function SceneDropdown() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
scenes,
|
|
||||||
activeSceneId,
|
|
||||||
setActiveScene,
|
|
||||||
createScene,
|
|
||||||
duplicateScene,
|
|
||||||
deleteScene,
|
|
||||||
} = useEditorStore();
|
|
||||||
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId) || scenes[0];
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: PointerEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", handleClickOutside);
|
|
||||||
return () =>
|
|
||||||
document.removeEventListener("pointerdown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[#ccc] hover:text-white hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="truncate max-w-[120px]">{activeScene?.name}</span>
|
|
||||||
<ChevronDown
|
|
||||||
size={12}
|
|
||||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute top-full left-0 mt-1 w-56 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md shadow-xl z-50 overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
createScene(`Scene ${scenes.length + 1}`);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 ${isMobile ? "py-2.5" : "py-2"} text-xs text-[#ccc] hover:bg-white/5 hover:text-white active:bg-white/10 transition-colors`}
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
New Scene
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="h-px bg-[#2a2a2a]" />
|
|
||||||
|
|
||||||
<div className="max-h-60 overflow-y-auto py-1">
|
|
||||||
{scenes.map((scene) => (
|
|
||||||
<div
|
|
||||||
key={scene.id}
|
|
||||||
className={`group flex items-center justify-between px-3 ${isMobile ? "py-2.5" : "py-1.5"} cursor-pointer text-xs transition-colors ${
|
|
||||||
scene.id === activeSceneId
|
|
||||||
? "bg-white/8 text-white"
|
|
||||||
: "text-[#888] hover:text-[#ccc] hover:bg-white/3"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveScene(scene.id);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="truncate">{scene.name}</span>
|
|
||||||
<div
|
|
||||||
className={`${isMobile ? "flex" : "hidden group-hover:flex"} items-center gap-0.5`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
duplicateScene(scene.id);
|
|
||||||
}}
|
|
||||||
className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-white active:text-white`}
|
|
||||||
title="Duplicate"
|
|
||||||
>
|
|
||||||
<Copy size={isMobile ? 14 : 10} />
|
|
||||||
</button>
|
|
||||||
{scenes.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteScene(scene.id);
|
|
||||||
}}
|
|
||||||
className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-red-400 active:text-red-400`}
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={isMobile ? 14 : 10} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import type { SceneObject, Physics, LightType } from "@/lib/types";
|
|
||||||
|
|
||||||
function Section({
|
|
||||||
label,
|
|
||||||
defaultOpen = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
return (
|
|
||||||
<div className="border-b border-[#1e1e1e]">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="w-full flex items-center gap-1.5 px-3 py-2.5 sm:py-2 text-[10px] font-semibold text-[#666] uppercase tracking-wider hover:text-[#999] active:text-[#999] min-h-[44px] sm:min-h-0"
|
|
||||||
>
|
|
||||||
{open ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="px-3 pb-3 space-y-2.5 sm:space-y-2">{children}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-[10px] text-[#666] w-16 flex-shrink-0">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<div className="flex-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
step = 0.1,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
step?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value != null ? Number(value.toFixed(3)) : 0}
|
|
||||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
|
||||||
step={step}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Vec3Input({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
labels = ["X", "Y", "Z"],
|
|
||||||
}: {
|
|
||||||
value: [number, number, number];
|
|
||||||
onChange: (v: [number, number, number]) => void;
|
|
||||||
labels?: [string, string, string];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div key={i} className="flex-1">
|
|
||||||
<div className="text-[8px] text-[#555] mb-0.5">{labels[i]}</div>
|
|
||||||
<NumberInput
|
|
||||||
value={value[i] ?? 0}
|
|
||||||
onChange={(v) => {
|
|
||||||
const next = [...value] as [number, number, number];
|
|
||||||
next[i] = v;
|
|
||||||
onChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-8 h-8 sm:w-6 sm:h-6 rounded border border-[#2a2a2a] cursor-pointer bg-transparent"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CheckboxInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
value: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center gap-2 text-xs sm:text-[11px] text-[#ccc] cursor-pointer py-0.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={value}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="rounded border-[#2a2a2a] w-4 h-4 sm:w-3.5 sm:h-3.5"
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ObjectListItem({ obj }: { obj: SceneObject }) {
|
|
||||||
const {
|
|
||||||
selectedObjectId,
|
|
||||||
selectObject,
|
|
||||||
removeObject,
|
|
||||||
toggleObjectVisibility,
|
|
||||||
} = useEditorStore();
|
|
||||||
const isSelected = selectedObjectId === obj.id;
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`group flex items-center justify-between px-2 ${isMobile ? "py-2" : "py-1"} rounded cursor-pointer text-[11px] ${
|
|
||||||
isSelected
|
|
||||||
? "bg-white/10 text-white"
|
|
||||||
: "text-[#888] hover:text-[#ccc] hover:bg-white/5"
|
|
||||||
}`}
|
|
||||||
onClick={() => selectObject(obj.id)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{obj.name}</span>
|
|
||||||
<div
|
|
||||||
className={`${isMobile || isSelected ? "flex" : "hidden group-hover:flex"} items-center gap-1`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleObjectVisibility(obj.id);
|
|
||||||
}}
|
|
||||||
className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-white active:text-white`}
|
|
||||||
>
|
|
||||||
{obj.visible ? (
|
|
||||||
<Eye size={isMobile ? 14 : 10} />
|
|
||||||
) : (
|
|
||||||
<EyeOff size={isMobile ? 14 : 10} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeObject(obj.id);
|
|
||||||
}}
|
|
||||||
className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-red-400 active:text-red-400`}
|
|
||||||
>
|
|
||||||
<Trash2 size={isMobile ? 14 : 10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransformEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateObjectTransform = useEditorStore((s) => s.updateObjectTransform);
|
|
||||||
const saveToHistory = useEditorStore((s) => s.saveToHistory);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Transform">
|
|
||||||
<Field label="Position">
|
|
||||||
<Vec3Input
|
|
||||||
value={obj.position}
|
|
||||||
onChange={(v) => {
|
|
||||||
updateObjectTransform(obj.id, { position: v });
|
|
||||||
saveToHistory();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Rotation">
|
|
||||||
<Vec3Input
|
|
||||||
value={obj.rotation}
|
|
||||||
onChange={(v) => {
|
|
||||||
updateObjectTransform(obj.id, { rotation: v });
|
|
||||||
saveToHistory();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Scale">
|
|
||||||
<Vec3Input
|
|
||||||
value={obj.scale}
|
|
||||||
onChange={(v) => {
|
|
||||||
updateObjectTransform(obj.id, { scale: v });
|
|
||||||
saveToHistory();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MaterialEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateObjectMaterial = useEditorStore((s) => s.updateObjectMaterial);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Material">
|
|
||||||
<Field label="Color">
|
|
||||||
<ColorInput
|
|
||||||
value={obj.material.color}
|
|
||||||
onChange={(v) => updateObjectMaterial(obj.id, { color: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Metalness">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.material.metalness}
|
|
||||||
onChange={(v) => updateObjectMaterial(obj.id, { metalness: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Roughness">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.material.roughness}
|
|
||||||
onChange={(v) => updateObjectMaterial(obj.id, { roughness: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Emissive">
|
|
||||||
<ColorInput
|
|
||||||
value={obj.material.emissive}
|
|
||||||
onChange={(v) => updateObjectMaterial(obj.id, { emissive: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Emit Int.">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.material.emissiveIntensity}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateObjectMaterial(obj.id, { emissiveIntensity: v })
|
|
||||||
}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PhysicsEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateObjectPhysics = useEditorStore((s) => s.updateObjectPhysics);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Physics" defaultOpen={false}>
|
|
||||||
<Field label="Collider">
|
|
||||||
<SelectInput
|
|
||||||
value={obj.physics.colliderType}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateObjectPhysics(obj.id, {
|
|
||||||
colliderType: v as Physics["colliderType"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: "none", label: "None" },
|
|
||||||
{ value: "cuboid", label: "Cuboid" },
|
|
||||||
{ value: "ball", label: "Ball" },
|
|
||||||
{ value: "capsule", label: "Capsule" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{obj.physics.colliderType !== "none" && (
|
|
||||||
<>
|
|
||||||
<Field label="Mass">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.physics.mass}
|
|
||||||
onChange={(v) => updateObjectPhysics(obj.id, { mass: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<CheckboxInput
|
|
||||||
value={obj.physics.isStatic}
|
|
||||||
onChange={(v) => updateObjectPhysics(obj.id, { isStatic: v })}
|
|
||||||
label="Static"
|
|
||||||
/>
|
|
||||||
<Field label="Bounce">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.physics.restitution}
|
|
||||||
onChange={(v) => updateObjectPhysics(obj.id, { restitution: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Friction">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.physics.friction}
|
|
||||||
onChange={(v) => updateObjectPhysics(obj.id, { friction: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DamageEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateDamage = useEditorStore((s) => s.updateDamage);
|
|
||||||
const dmg = obj.damage || { amount: 0, enabled: false };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Damage" defaultOpen={false}>
|
|
||||||
<CheckboxInput
|
|
||||||
value={dmg.enabled}
|
|
||||||
onChange={(v) => updateDamage(obj.id, { enabled: v })}
|
|
||||||
label="Enabled"
|
|
||||||
/>
|
|
||||||
{dmg.enabled && (
|
|
||||||
<Field label="Amount">
|
|
||||||
<NumberInput
|
|
||||||
value={dmg.amount}
|
|
||||||
onChange={(v) => updateDamage(obj.id, { amount: v })}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LightEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Light">
|
|
||||||
<Field label="Type">
|
|
||||||
<SelectInput
|
|
||||||
value={obj.lightType || "point"}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateObjectGeneral(obj.id, { lightType: v as LightType })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: "ambient", label: "Ambient" },
|
|
||||||
{ value: "directional", label: "Directional" },
|
|
||||||
{ value: "point", label: "Point" },
|
|
||||||
{ value: "spot", label: "Spot" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Intensity">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.intensity ?? 1}
|
|
||||||
onChange={(v) => updateObjectGeneral(obj.id, { intensity: v })}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{(obj.lightType === "point" || obj.lightType === "spot") && (
|
|
||||||
<Field label="Distance">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.distance ?? 0}
|
|
||||||
onChange={(v) => updateObjectGeneral(obj.id, { distance: v })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{obj.lightType === "spot" && (
|
|
||||||
<>
|
|
||||||
<Field label="Angle">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.angle ?? Math.PI / 3}
|
|
||||||
onChange={(v) => updateObjectGeneral(obj.id, { angle: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Penumbra">
|
|
||||||
<NumberInput
|
|
||||||
value={obj.penumbra ?? 0}
|
|
||||||
onChange={(v) => updateObjectGeneral(obj.id, { penumbra: v })}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444]"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelUrlEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Model">
|
|
||||||
<Field label="URL">
|
|
||||||
<TextInput
|
|
||||||
value={obj.modelUrl || ""}
|
|
||||||
onChange={(v) => updateObjectGeneral(obj.id, { modelUrl: v })}
|
|
||||||
placeholder="/models/example.glb"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<p className="text-[9px] text-[#555] leading-relaxed">
|
|
||||||
Enter a URL to a .glb or .gltf file. Place files in public/models/ to
|
|
||||||
use local paths like /models/file.glb
|
|
||||||
</p>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CharacterEditor({ obj }: { obj: SceneObject }) {
|
|
||||||
const updateCharacter = useEditorStore((s) => s.updateCharacter);
|
|
||||||
const role = obj.character?.role || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Character" defaultOpen={false}>
|
|
||||||
<Field label="Role">
|
|
||||||
<TextInput
|
|
||||||
value={role}
|
|
||||||
onChange={(v) => updateCharacter(obj.id, { role: v })}
|
|
||||||
placeholder="e.g. village guard, merchant"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnvironmentEditor() {
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const updateEnvironmentSettings = useEditorStore(
|
|
||||||
(s) => s.updateEnvironmentSettings,
|
|
||||||
);
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId);
|
|
||||||
if (!activeScene) return null;
|
|
||||||
const env = activeScene.environmentSettings;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Environment" defaultOpen={false}>
|
|
||||||
<Field label="Preset">
|
|
||||||
<SelectInput
|
|
||||||
value={env.preset}
|
|
||||||
onChange={(v) => updateEnvironmentSettings({ preset: v })}
|
|
||||||
options={[
|
|
||||||
{ value: "apartment", label: "Apartment" },
|
|
||||||
{ value: "city", label: "City" },
|
|
||||||
{ value: "dawn", label: "Dawn" },
|
|
||||||
{ value: "forest", label: "Forest" },
|
|
||||||
{ value: "lobby", label: "Lobby" },
|
|
||||||
{ value: "night", label: "Night" },
|
|
||||||
{ value: "park", label: "Park" },
|
|
||||||
{ value: "studio", label: "Studio" },
|
|
||||||
{ value: "sunset", label: "Sunset" },
|
|
||||||
{ value: "warehouse", label: "Warehouse" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Intensity">
|
|
||||||
<NumberInput
|
|
||||||
value={env.intensity}
|
|
||||||
onChange={(v) => updateEnvironmentSettings({ intensity: v })}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Blur">
|
|
||||||
<NumberInput
|
|
||||||
value={env.blur}
|
|
||||||
onChange={(v) => updateEnvironmentSettings({ blur: v })}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FogEditor() {
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId);
|
|
||||||
if (!activeScene) return null;
|
|
||||||
const fog = activeScene.sceneSettings.fog;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Fog" defaultOpen={false}>
|
|
||||||
<CheckboxInput
|
|
||||||
value={fog.enabled}
|
|
||||||
onChange={(v) => updateSceneSettings({ fog: { ...fog, enabled: v } })}
|
|
||||||
label="Enabled"
|
|
||||||
/>
|
|
||||||
{fog.enabled && (
|
|
||||||
<>
|
|
||||||
<Field label="Color">
|
|
||||||
<ColorInput
|
|
||||||
value={fog.color}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({ fog: { ...fog, color: v } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Near">
|
|
||||||
<NumberInput
|
|
||||||
value={fog.near}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({ fog: { ...fog, near: v } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Far">
|
|
||||||
<NumberInput
|
|
||||||
value={fog.far}
|
|
||||||
onChange={(v) => updateSceneSettings({ fog: { ...fog, far: v } })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GridEditor() {
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId);
|
|
||||||
if (!activeScene) return null;
|
|
||||||
const grid = activeScene.sceneSettings.grid;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section label="Grid" defaultOpen={false}>
|
|
||||||
<CheckboxInput
|
|
||||||
value={grid.visible}
|
|
||||||
onChange={(v) => updateSceneSettings({ grid: { ...grid, visible: v } })}
|
|
||||||
label="Visible"
|
|
||||||
/>
|
|
||||||
{grid.visible && (
|
|
||||||
<>
|
|
||||||
<Field label="Size">
|
|
||||||
<NumberInput
|
|
||||||
value={grid.size}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({ grid: { ...grid, size: v } })
|
|
||||||
}
|
|
||||||
step={0.5}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Divisions">
|
|
||||||
<NumberInput
|
|
||||||
value={grid.divisions}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({
|
|
||||||
grid: { ...grid, divisions: Math.max(1, Math.round(v)) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Fade Dist">
|
|
||||||
<NumberInput
|
|
||||||
value={grid.fadeDistance}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({ grid: { ...grid, fadeDistance: v } })
|
|
||||||
}
|
|
||||||
step={10}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Fade Str">
|
|
||||||
<NumberInput
|
|
||||||
value={grid.fadeStrength}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSceneSettings({ grid: { ...grid, fadeStrength: v } })
|
|
||||||
}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SceneInspector() {
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const selectedObjectId = useEditorStore((s) => s.selectedObjectId);
|
|
||||||
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId);
|
|
||||||
const objects = activeScene?.objects ?? [];
|
|
||||||
const selectedObj = selectedObjectId
|
|
||||||
? objects.find((o) => o.id === selectedObjectId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Object list */}
|
|
||||||
<Section label="Scene Objects">
|
|
||||||
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
|
||||||
{objects.map((obj) => (
|
|
||||||
<ObjectListItem key={obj.id} obj={obj} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Selected object editors */}
|
|
||||||
{selectedObj && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-1.5 border-b border-[#1e1e1e]">
|
|
||||||
<div className="text-[10px] text-[#555] uppercase tracking-wider">
|
|
||||||
{selectedObj.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TransformEditor obj={selectedObj} />
|
|
||||||
{selectedObj.type !== "light" &&
|
|
||||||
selectedObj.type !== "player" &&
|
|
||||||
selectedObj.type !== "sound" && (
|
|
||||||
<MaterialEditor obj={selectedObj} />
|
|
||||||
)}
|
|
||||||
{selectedObj.type !== "light" && selectedObj.type !== "player" && (
|
|
||||||
<PhysicsEditor obj={selectedObj} />
|
|
||||||
)}
|
|
||||||
{selectedObj.type !== "light" &&
|
|
||||||
selectedObj.type !== "player" &&
|
|
||||||
selectedObj.type !== "sound" && <DamageEditor obj={selectedObj} />}
|
|
||||||
{selectedObj.type === "light" && <LightEditor obj={selectedObj} />}
|
|
||||||
{(selectedObj.type === "model" ||
|
|
||||||
selectedObj.type === "character") && (
|
|
||||||
<ModelUrlEditor obj={selectedObj} />
|
|
||||||
)}
|
|
||||||
{selectedObj.type === "character" && (
|
|
||||||
<CharacterEditor obj={selectedObj} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scene-level editors */}
|
|
||||||
<EnvironmentEditor />
|
|
||||||
<FogEditor />
|
|
||||||
<GridEditor />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Copy, Trash2, Plus } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
export function SceneSelector() {
|
|
||||||
const {
|
|
||||||
scenes,
|
|
||||||
activeSceneId,
|
|
||||||
setActiveScene,
|
|
||||||
createScene,
|
|
||||||
duplicateScene,
|
|
||||||
deleteScene,
|
|
||||||
} = useEditorStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="h-9 flex items-center justify-between px-3 border-b border-[#1e1e1e] flex-shrink-0">
|
|
||||||
<span className="text-[10px] font-semibold text-[#555] uppercase tracking-wider font-mono">
|
|
||||||
Scenes
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => createScene(`Scene ${scenes.length + 1}`)}
|
|
||||||
className="p-1 text-[#666] hover:text-white transition-colors"
|
|
||||||
title="New scene"
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
|
||||||
{scenes.map((scene) => (
|
|
||||||
<div
|
|
||||||
key={scene.id}
|
|
||||||
className={`group flex items-center justify-between px-3 py-1.5 cursor-pointer text-xs ${
|
|
||||||
scene.id === activeSceneId
|
|
||||||
? "bg-white/8 text-white border-l-2 border-white"
|
|
||||||
: "text-[#888] hover:text-[#ccc] border-l-2 border-transparent"
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveScene(scene.id)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{scene.name}</span>
|
|
||||||
<div className="hidden group-hover:flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
duplicateScene(scene.id);
|
|
||||||
}}
|
|
||||||
className="p-0.5 text-[#666] hover:text-white"
|
|
||||||
title="Duplicate"
|
|
||||||
>
|
|
||||||
<Copy size={10} />
|
|
||||||
</button>
|
|
||||||
{scenes.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteScene(scene.id);
|
|
||||||
}}
|
|
||||||
className="p-0.5 text-[#666] hover:text-red-400"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={10} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Play,
|
|
||||||
Square,
|
|
||||||
Move,
|
|
||||||
RotateCcw,
|
|
||||||
Maximize,
|
|
||||||
MousePointer,
|
|
||||||
Undo2,
|
|
||||||
Redo2,
|
|
||||||
Eye,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { SceneDropdown } from "./scene-dropdown";
|
|
||||||
|
|
||||||
export function Toolbar() {
|
|
||||||
const {
|
|
||||||
transformMode,
|
|
||||||
setTransformMode,
|
|
||||||
isPlaying,
|
|
||||||
setIsPlaying,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
viewMode,
|
|
||||||
setViewMode,
|
|
||||||
} = useEditorStore();
|
|
||||||
|
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative z-20 flex items-center justify-between px-2 sm:px-3 border-b border-[#1e1e1e] bg-[#0f0f0f] shrink-0"
|
|
||||||
style={{ minHeight: 44, paddingTop: "env(safe-area-inset-top, 0px)" }}
|
|
||||||
>
|
|
||||||
{/* Left: Transform modes */}
|
|
||||||
<div className="flex items-center z-10">
|
|
||||||
{!isPlaying && (
|
|
||||||
<>
|
|
||||||
{/* Mobile: collapsed transform dropdown */}
|
|
||||||
<div className="relative flex items-center sm:hidden">
|
|
||||||
<ToolButton
|
|
||||||
icon={
|
|
||||||
transformMode === "select" ? (
|
|
||||||
<MousePointer size={16} />
|
|
||||||
) : transformMode === "translate" ? (
|
|
||||||
<Move size={16} />
|
|
||||||
) : transformMode === "rotate" ? (
|
|
||||||
<RotateCcw size={16} />
|
|
||||||
) : (
|
|
||||||
<Maximize size={16} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
active
|
|
||||||
onClick={() => setMoreOpen(!moreOpen)}
|
|
||||||
title="Transform mode"
|
|
||||||
size="mobile"
|
|
||||||
/>
|
|
||||||
{moreOpen && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40"
|
|
||||||
onClick={() => setMoreOpen(false)}
|
|
||||||
/>
|
|
||||||
<div className="absolute top-full left-0 mt-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-xl z-50 p-1 flex flex-col gap-0.5 min-w-[140px]">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
mode: "select" as const,
|
|
||||||
icon: MousePointer,
|
|
||||||
label: "Select",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "translate" as const,
|
|
||||||
icon: Move,
|
|
||||||
label: "Move",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "rotate" as const,
|
|
||||||
icon: RotateCcw,
|
|
||||||
label: "Rotate",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "scale" as const,
|
|
||||||
icon: Maximize,
|
|
||||||
label: "Scale",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
).map(({ mode, icon: Icon, label }) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
onClick={() => {
|
|
||||||
setTransformMode(mode);
|
|
||||||
setMoreOpen(false);
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded text-xs transition-colors ${
|
|
||||||
transformMode === mode
|
|
||||||
? "bg-white/10 text-white"
|
|
||||||
: "text-[#888] active:bg-white/5"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ToolButton
|
|
||||||
icon={<Undo2 size={16} />}
|
|
||||||
onClick={undo}
|
|
||||||
disabled={!canUndo}
|
|
||||||
title="Undo"
|
|
||||||
size="mobile"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<Redo2 size={16} />}
|
|
||||||
onClick={redo}
|
|
||||||
disabled={!canRedo}
|
|
||||||
title="Redo"
|
|
||||||
size="mobile"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: individual transform buttons */}
|
|
||||||
<div className="hidden sm:flex items-center gap-1">
|
|
||||||
<ToolButton
|
|
||||||
icon={<MousePointer size={14} />}
|
|
||||||
active={transformMode === "select"}
|
|
||||||
onClick={() => setTransformMode("select")}
|
|
||||||
title="Select (Q)"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<Move size={14} />}
|
|
||||||
active={transformMode === "translate"}
|
|
||||||
onClick={() => setTransformMode("translate")}
|
|
||||||
title="Translate (W)"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<RotateCcw size={14} />}
|
|
||||||
active={transformMode === "rotate"}
|
|
||||||
onClick={() => setTransformMode("rotate")}
|
|
||||||
title="Rotate (E)"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<Maximize size={14} />}
|
|
||||||
active={transformMode === "scale"}
|
|
||||||
onClick={() => setTransformMode("scale")}
|
|
||||||
title="Scale (R)"
|
|
||||||
/>
|
|
||||||
<div className="w-px h-5 bg-[#2a2a2a] mx-1" />
|
|
||||||
<ToolButton
|
|
||||||
icon={<Undo2 size={14} />}
|
|
||||||
onClick={undo}
|
|
||||||
disabled={!canUndo}
|
|
||||||
title="Undo (Ctrl+Z)"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<Redo2 size={14} />}
|
|
||||||
onClick={redo}
|
|
||||||
disabled={!canRedo}
|
|
||||||
title="Redo (Ctrl+Shift+Z)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: Scene selector (absolutely centered) */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
||||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
{!isPlaying && <SceneDropdown />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Play/View controls */}
|
|
||||||
<div className="flex items-center z-10">
|
|
||||||
{isPlaying && (
|
|
||||||
<>
|
|
||||||
{/* Mobile view mode buttons */}
|
|
||||||
<div className="flex items-center sm:hidden">
|
|
||||||
<ToolButton
|
|
||||||
icon={<Eye size={16} />}
|
|
||||||
active={viewMode === "first-person"}
|
|
||||||
onClick={() => setViewMode("first-person")}
|
|
||||||
title="First Person"
|
|
||||||
size="mobile"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<User size={16} />}
|
|
||||||
active={viewMode === "third-person"}
|
|
||||||
onClick={() => setViewMode("third-person")}
|
|
||||||
title="Third Person"
|
|
||||||
size="mobile"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Desktop view mode buttons */}
|
|
||||||
<div className="hidden sm:flex items-center">
|
|
||||||
<ToolButton
|
|
||||||
icon={<Eye size={14} />}
|
|
||||||
active={viewMode === "first-person"}
|
|
||||||
onClick={() => setViewMode("first-person")}
|
|
||||||
title="First Person"
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
icon={<User size={14} />}
|
|
||||||
active={viewMode === "third-person"}
|
|
||||||
onClick={() => setViewMode("third-person")}
|
|
||||||
title="Third Person"
|
|
||||||
/>
|
|
||||||
<div className="w-px h-5 bg-[#2a2a2a] mx-1" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsPlaying(!isPlaying)}
|
|
||||||
className="flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3 py-1.5 sm:py-1 rounded text-xs font-medium transition-colors bg-white/10 hover:bg-white/20 active:bg-white/20 text-white"
|
|
||||||
>
|
|
||||||
{isPlaying ? <Square size={12} /> : <Play size={12} />}
|
|
||||||
{isPlaying ? "Stop" : "Play"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolButton({
|
|
||||||
icon,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
size,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
active?: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
title?: string;
|
|
||||||
size?: "mobile" | "desktop";
|
|
||||||
}) {
|
|
||||||
const isMobileSize = size === "mobile";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
title={title}
|
|
||||||
className={`${isMobileSize ? "p-2.5 min-w-[44px] min-h-[44px]" : "p-1.5"} rounded transition-colors flex items-center justify-center ${
|
|
||||||
active
|
|
||||||
? isMobileSize
|
|
||||||
? "text-white"
|
|
||||||
: "bg-white/10 text-white"
|
|
||||||
: disabled
|
|
||||||
? "text-[#444] cursor-not-allowed"
|
|
||||||
: "text-[#888] hover:text-white hover:bg-white/5 active:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect, useCallback } from "react";
|
|
||||||
import { useThree } from "@react-three/fiber";
|
|
||||||
import { TransformControls, OrbitControls } from "@react-three/drei";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
export function EditorControls() {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
|
|
||||||
if (isPlaying) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EditorOrbitControls />
|
|
||||||
<TransformGizmo />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorOrbitControls() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const orbitRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const enable = () => {
|
|
||||||
if (orbitRef.current) orbitRef.current.enabled = false;
|
|
||||||
};
|
|
||||||
const disable = () => {
|
|
||||||
if (orbitRef.current) orbitRef.current.enabled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("transform-start", enable);
|
|
||||||
window.addEventListener("transform-end", disable);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("transform-start", enable);
|
|
||||||
window.removeEventListener("transform-end", disable);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OrbitControls
|
|
||||||
ref={orbitRef}
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={1}
|
|
||||||
maxDistance={200}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransformGizmo() {
|
|
||||||
const {
|
|
||||||
selectedObjectId,
|
|
||||||
transformMode,
|
|
||||||
isPlaying,
|
|
||||||
updateObjectTransform,
|
|
||||||
saveToHistory,
|
|
||||||
} = useEditorStore();
|
|
||||||
const { scene } = useThree();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const controlsRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const findObjectById = useCallback(
|
|
||||||
(id: string): THREE.Object3D | null => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let found: any = null;
|
|
||||||
scene.traverse((child) => {
|
|
||||||
if (child.userData?.id === id || child.userData?.objectId === id) {
|
|
||||||
found = child;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
},
|
|
||||||
[scene],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!controlsRef.current ||
|
|
||||||
!selectedObjectId ||
|
|
||||||
isPlaying ||
|
|
||||||
transformMode === "select"
|
|
||||||
) {
|
|
||||||
if (controlsRef.current) {
|
|
||||||
try {
|
|
||||||
controlsRef.current.detach();
|
|
||||||
} catch {
|
|
||||||
/* may not be attached */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const obj = findObjectById(selectedObjectId);
|
|
||||||
if (obj && controlsRef.current) {
|
|
||||||
try {
|
|
||||||
controlsRef.current.attach(obj);
|
|
||||||
} catch {
|
|
||||||
/* object may not be ready */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [selectedObjectId, transformMode, isPlaying, findObjectById]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controls = controlsRef.current;
|
|
||||||
if (!controls) return;
|
|
||||||
|
|
||||||
const onDragStart = () =>
|
|
||||||
window.dispatchEvent(new CustomEvent("transform-start"));
|
|
||||||
const onDragEnd = () =>
|
|
||||||
window.dispatchEvent(new CustomEvent("transform-end"));
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
controls.addEventListener("dragging-changed", (e: any) => {
|
|
||||||
if (e.value) onDragStart();
|
|
||||||
else onDragEnd();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
controls.removeEventListener("dragging-changed", onDragStart);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = useCallback(() => {
|
|
||||||
if (!controlsRef.current || !selectedObjectId) return;
|
|
||||||
const obj = controlsRef.current.object;
|
|
||||||
if (!obj) return;
|
|
||||||
|
|
||||||
updateObjectTransform(selectedObjectId, {
|
|
||||||
position: [obj.position.x, obj.position.y, obj.position.z],
|
|
||||||
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
|
|
||||||
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
|
|
||||||
});
|
|
||||||
}, [selectedObjectId, updateObjectTransform]);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
saveToHistory();
|
|
||||||
}, [saveToHistory]);
|
|
||||||
|
|
||||||
if (!selectedObjectId || isPlaying || transformMode === "select") return null;
|
|
||||||
|
|
||||||
const mode =
|
|
||||||
transformMode === "translate"
|
|
||||||
? "translate"
|
|
||||||
: transformMode === "rotate"
|
|
||||||
? "rotate"
|
|
||||||
: "scale";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TransformControls
|
|
||||||
ref={controlsRef}
|
|
||||||
mode={mode}
|
|
||||||
onObjectChange={handleChange}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useEffect, Suspense, useState } from "react";
|
|
||||||
import { Canvas } from "@react-three/fiber";
|
|
||||||
import { Physics } from "@react-three/rapier";
|
|
||||||
import { ThreeRenderer } from "@json-render/react-three-fiber";
|
|
||||||
import { Braces, SlidersHorizontal, X } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import { registry } from "@/lib/registry";
|
|
||||||
import { sceneToSpec } from "@/lib/scene-to-spec";
|
|
||||||
import { Toolbar } from "@/components/editor/toolbar";
|
|
||||||
import { SceneInspector } from "@/components/editor/scene-inspector";
|
|
||||||
import { AddObjectButton } from "@/components/editor/add-object-button";
|
|
||||||
import { EditorControls } from "@/components/editor/transform-controls";
|
|
||||||
import { JsonPane } from "@/components/editor/json-pane";
|
|
||||||
import { AIPrompt } from "@/components/editor/ai-prompt";
|
|
||||||
import { HealthBar } from "@/components/hud/health-bar";
|
|
||||||
import { DamageEffect } from "@/components/hud/damage-effect";
|
|
||||||
import { GameOverScreen } from "@/components/hud/game-over";
|
|
||||||
import { CharacterDialog } from "@/components/hud/character-dialog";
|
|
||||||
import { InteractionPrompt } from "@/components/hud/interaction-prompt";
|
|
||||||
import { InGamePrompt } from "@/components/hud/in-game-prompt";
|
|
||||||
import { DamageSound } from "@/components/hud/damage-sound";
|
|
||||||
import { DeathSound } from "@/components/hud/death-sound";
|
|
||||||
import { LoadingSpinner } from "@/components/hud/loading-spinner";
|
|
||||||
import { TouchControls } from "@/components/hud/touch-controls";
|
|
||||||
import { CharacterInteraction } from "@/components/game/character-interaction";
|
|
||||||
import { DropZone } from "@/components/editor/drop-zone";
|
|
||||||
|
|
||||||
export function GameEngine() {
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const setIsPlaying = useEditorStore((s) => s.setIsPlaying);
|
|
||||||
const selectedObjectId = useEditorStore((s) => s.selectedObjectId);
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const lastDamageTime = useEditorStore((s) => s.lastDamageTime);
|
|
||||||
const undo = useEditorStore((s) => s.undo);
|
|
||||||
const redo = useEditorStore((s) => s.redo);
|
|
||||||
const removeObject = useEditorStore((s) => s.removeObject);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const [leftDrawer, setLeftDrawer] = useState(false);
|
|
||||||
const [rightDrawer, setRightDrawer] = useState(false);
|
|
||||||
|
|
||||||
const activeScene = useMemo(
|
|
||||||
() => scenes.find((s) => s.id === activeSceneId) || scenes[0],
|
|
||||||
[scenes, activeSceneId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const spec = useMemo(() => {
|
|
||||||
if (!activeScene) return null;
|
|
||||||
return sceneToSpec(activeScene);
|
|
||||||
}, [activeScene]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isMod = e.metaKey || e.ctrlKey;
|
|
||||||
|
|
||||||
if (isMod && e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMod && (e.key === "Backspace" || e.key === "Delete")) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedObjectId && !isPlaying) {
|
|
||||||
removeObject(selectedObjectId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMod && e.key === "z" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
undo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMod && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
|
|
||||||
e.preventDefault();
|
|
||||||
redo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isPlaying, setIsPlaying, selectedObjectId, removeObject, undo, redo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying) {
|
|
||||||
setLeftDrawer(false);
|
|
||||||
setRightDrawer(false);
|
|
||||||
}
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
const showSidebars = !isPlaying;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-dvh w-screen flex flex-col bg-[#0a0a0a] overflow-hidden">
|
|
||||||
<Toolbar />
|
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0 relative">
|
|
||||||
{/* Left sidebar - JSON Pane (desktop only) */}
|
|
||||||
{showSidebars && (
|
|
||||||
<div className="hidden sm:block w-72 shrink-0 border-r border-[#1e1e1e] bg-[#0f0f0f] overflow-hidden">
|
|
||||||
<JsonPane />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile left drawer */}
|
|
||||||
{showSidebars && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 bg-black/50 z-30 transition-opacity duration-200 ${leftDrawer ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
|
||||||
onClick={() => setLeftDrawer(false)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`fixed left-0 top-0 bottom-0 w-72 max-w-[85vw] bg-[#0f0f0f] border-r border-[#1e1e1e] z-40 overflow-hidden transition-transform duration-200 ease-out ${leftDrawer ? "translate-x-0" : "-translate-x-full"}`}
|
|
||||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
|
|
||||||
<span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
|
|
||||||
JSON Spec
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setLeftDrawer(false)}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<JsonPane />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Canvas area */}
|
|
||||||
<div className="flex-1 relative min-w-0 safe-bottom">
|
|
||||||
<DropZone />
|
|
||||||
<Canvas
|
|
||||||
shadows
|
|
||||||
camera={{ position: [5, 5, 5], fov: 50 }}
|
|
||||||
style={{ width: "100%", height: "100%", touchAction: "none" }}
|
|
||||||
>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
{isPlaying ? (
|
|
||||||
<Physics gravity={[0, -9.81, 0]}>
|
|
||||||
<ThreeRenderer spec={spec} registry={registry} />
|
|
||||||
<CharacterInteraction />
|
|
||||||
</Physics>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ThreeRenderer spec={spec} registry={registry} />
|
|
||||||
<EditorControls />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
|
|
||||||
{/* Mobile drawer toggle buttons */}
|
|
||||||
{showSidebars && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setLeftDrawer(true)}
|
|
||||||
className="sm:hidden absolute top-2 left-2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<Braces size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setRightDrawer(true)}
|
|
||||||
className="sm:hidden absolute top-2 right-2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={18} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isPlaying && <AddObjectButton />}
|
|
||||||
{!isPlaying && <AIPrompt />}
|
|
||||||
|
|
||||||
<LoadingSpinner />
|
|
||||||
|
|
||||||
{isPlaying && <HealthBar />}
|
|
||||||
{isPlaying && <DamageEffect lastDamageTime={lastDamageTime} />}
|
|
||||||
{isPlaying && health <= 0 && <GameOverScreen />}
|
|
||||||
{isPlaying && <CharacterDialog />}
|
|
||||||
{isPlaying && <InteractionPrompt />}
|
|
||||||
{isPlaying && <InGamePrompt />}
|
|
||||||
{isPlaying && isMobile && <TouchControls />}
|
|
||||||
<DamageSound />
|
|
||||||
<DeathSound />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right sidebar - Inspector (desktop only) */}
|
|
||||||
{showSidebars && (
|
|
||||||
<div className="hidden sm:block w-72 shrink-0 border-l border-[#1e1e1e] bg-[#0f0f0f] overflow-y-auto">
|
|
||||||
<SceneInspector />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile right drawer */}
|
|
||||||
{showSidebars && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 bg-black/50 z-30 transition-opacity duration-200 ${rightDrawer ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
|
||||||
onClick={() => setRightDrawer(false)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`fixed right-0 top-0 bottom-0 w-72 max-w-[85vw] bg-[#0f0f0f] border-l border-[#1e1e1e] z-40 overflow-y-auto transition-transform duration-200 ease-out ${rightDrawer ? "translate-x-0" : "translate-x-full"}`}
|
|
||||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
|
|
||||||
<span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
|
|
||||||
Inspector
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setRightDrawer(false)}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<SceneInspector />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useThree } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
export function CharacterInteraction() {
|
|
||||||
const [nearbyCharacter, setNearbyCharacter] = useState<{
|
|
||||||
id: string;
|
|
||||||
role: string;
|
|
||||||
} | null>(null);
|
|
||||||
const { camera } = useThree();
|
|
||||||
const scenes = useEditorStore((s) => s.scenes);
|
|
||||||
const activeSceneId = useEditorStore((s) => s.activeSceneId);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
|
|
||||||
const activeScene = scenes.find((s) => s.id === activeSceneId) || scenes[0];
|
|
||||||
const objects = useMemo(
|
|
||||||
() => (activeScene ? activeScene.objects : []),
|
|
||||||
[activeScene],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying) {
|
|
||||||
setNearbyCharacter(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkProximity = () => {
|
|
||||||
if (!camera) return;
|
|
||||||
|
|
||||||
const cameraPos = new THREE.Vector3();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
camera.getWorldPosition(cameraPos as any);
|
|
||||||
|
|
||||||
const characters = objects.filter(
|
|
||||||
(obj) => obj.type === "character" && obj.visible,
|
|
||||||
);
|
|
||||||
|
|
||||||
let closest: { id: string; role: string } | null = null;
|
|
||||||
let closestDist = Infinity;
|
|
||||||
|
|
||||||
for (const character of characters) {
|
|
||||||
const charPos = new THREE.Vector3(...character.position);
|
|
||||||
const dist = cameraPos.distanceTo(charPos);
|
|
||||||
|
|
||||||
if (dist < 3 && dist < closestDist) {
|
|
||||||
closestDist = dist;
|
|
||||||
closest = {
|
|
||||||
id: character.id,
|
|
||||||
role: character.character?.role || "villager",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closest) {
|
|
||||||
if (!nearbyCharacter || nearbyCharacter.id !== closest.id) {
|
|
||||||
setNearbyCharacter(closest);
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("character-nearby", {
|
|
||||||
detail: { id: closest.id, role: closest.role },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (nearbyCharacter) {
|
|
||||||
setNearbyCharacter(null);
|
|
||||||
window.dispatchEvent(new CustomEvent("character-far"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalId = setInterval(checkProximity, 200);
|
|
||||||
|
|
||||||
const triggerInteract = () => {
|
|
||||||
if (nearbyCharacter) {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("character-interact", {
|
|
||||||
detail: {
|
|
||||||
id: nearbyCharacter.id,
|
|
||||||
role: nearbyCharacter.role,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "e" || e.key === "E") {
|
|
||||||
triggerInteract();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestInteract = () => {
|
|
||||||
triggerInteract();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener(
|
|
||||||
"request-character-interact",
|
|
||||||
handleRequestInteract,
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener(
|
|
||||||
"request-character-interact",
|
|
||||||
handleRequestInteract,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [camera, isPlaying, nearbyCharacter, objects]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
interface GameCharacterProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
castShadow?: boolean | null;
|
|
||||||
receiveShadow?: boolean | null;
|
|
||||||
modelUrl: string;
|
|
||||||
role?: string | null;
|
|
||||||
physics?: {
|
|
||||||
mass?: number | null;
|
|
||||||
isStatic?: boolean | null;
|
|
||||||
restitution?: number | null;
|
|
||||||
friction?: number | null;
|
|
||||||
colliderType?: string | null;
|
|
||||||
} | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameCharacter({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
modelUrl,
|
|
||||||
}: GameCharacterProps) {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
|
||||||
const { camera } = useThree();
|
|
||||||
const [isNear, setIsNear] = useState(false);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (!isPlaying || !groupRef.current) return;
|
|
||||||
const dist = groupRef.current.position.distanceTo(camera.position);
|
|
||||||
setIsNear(dist < 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!modelUrl) {
|
|
||||||
return (
|
|
||||||
<group position={pos} rotation={rot} scale={scl} ref={groupRef}>
|
|
||||||
<mesh castShadow>
|
|
||||||
<capsuleGeometry args={[0.3, 0.8, 8, 16]} />
|
|
||||||
<meshStandardMaterial color="#e74c3c" />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={groupRef} position={pos} rotation={rot} scale={scl}>
|
|
||||||
<CharacterModel url={modelUrl} />
|
|
||||||
{isPlaying && isNear && (
|
|
||||||
<sprite position={[0, 2.2, 0]} scale={[0.5, 0.5, 0.5]}>
|
|
||||||
<spriteMaterial color="#ffffff" opacity={0.8} transparent />
|
|
||||||
</sprite>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CharacterModel({ url }: { url: string }) {
|
|
||||||
const { scene } = useGLTF(url);
|
|
||||||
return <primitive object={scene.clone()} />;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface GameLightProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
lightType: "ambient" | "directional" | "point" | "spot";
|
|
||||||
color?: string | null;
|
|
||||||
intensity?: number | null;
|
|
||||||
distance?: number | null;
|
|
||||||
decay?: number | null;
|
|
||||||
angle?: number | null;
|
|
||||||
penumbra?: number | null;
|
|
||||||
castShadow?: boolean | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameLight({
|
|
||||||
position,
|
|
||||||
lightType,
|
|
||||||
color,
|
|
||||||
intensity,
|
|
||||||
distance,
|
|
||||||
decay,
|
|
||||||
angle,
|
|
||||||
penumbra,
|
|
||||||
castShadow,
|
|
||||||
}: GameLightProps) {
|
|
||||||
const c = color ?? "#ffffff";
|
|
||||||
const i = intensity ?? 1;
|
|
||||||
|
|
||||||
switch (lightType) {
|
|
||||||
case "ambient":
|
|
||||||
return <ambientLight color={c} intensity={i} />;
|
|
||||||
case "directional":
|
|
||||||
return (
|
|
||||||
<directionalLight
|
|
||||||
position={position ?? [5, 10, 5]}
|
|
||||||
color={c}
|
|
||||||
intensity={i}
|
|
||||||
castShadow={castShadow ?? true}
|
|
||||||
shadow-mapSize-width={2048}
|
|
||||||
shadow-mapSize-height={2048}
|
|
||||||
shadow-camera-far={50}
|
|
||||||
shadow-camera-left={-20}
|
|
||||||
shadow-camera-right={20}
|
|
||||||
shadow-camera-top={20}
|
|
||||||
shadow-camera-bottom={-20}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "spot":
|
|
||||||
return (
|
|
||||||
<spotLight
|
|
||||||
position={position ?? [0, 5, 0]}
|
|
||||||
color={c}
|
|
||||||
intensity={i}
|
|
||||||
distance={distance ?? 0}
|
|
||||||
decay={decay ?? 2}
|
|
||||||
angle={angle ?? Math.PI / 3}
|
|
||||||
penumbra={penumbra ?? 0}
|
|
||||||
castShadow={castShadow ?? true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "point":
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<pointLight
|
|
||||||
position={position ?? [0, 3, 0]}
|
|
||||||
color={c}
|
|
||||||
intensity={i}
|
|
||||||
distance={distance ?? 0}
|
|
||||||
decay={decay ?? 2}
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,799 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { RigidBody } from "@react-three/rapier";
|
|
||||||
import type { RapierRigidBody } from "@react-three/rapier";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
interface PhysicsProps {
|
|
||||||
mass?: number | null;
|
|
||||||
isStatic?: boolean | null;
|
|
||||||
restitution?: number | null;
|
|
||||||
friction?: number | null;
|
|
||||||
colliderType?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DamageProps {
|
|
||||||
amount?: number | null;
|
|
||||||
enabled?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MaterialProps {
|
|
||||||
color?: string | null;
|
|
||||||
metalness?: number | null;
|
|
||||||
roughness?: number | null;
|
|
||||||
emissive?: string | null;
|
|
||||||
emissiveIntensity?: number | null;
|
|
||||||
opacity?: number | null;
|
|
||||||
transparent?: boolean | null;
|
|
||||||
wireframe?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GamePrimitiveProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
castShadow?: boolean | null;
|
|
||||||
receiveShadow?: boolean | null;
|
|
||||||
material?: MaterialProps | null;
|
|
||||||
physics?: PhysicsProps | null;
|
|
||||||
damage?: DamageProps | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMaterialProps(mat?: MaterialProps | null) {
|
|
||||||
if (!mat) return { color: "#888888" };
|
|
||||||
return {
|
|
||||||
color: mat.color ?? "#888888",
|
|
||||||
metalness: mat.metalness ?? 0,
|
|
||||||
roughness: mat.roughness ?? 0.5,
|
|
||||||
...(mat.emissive ? { emissive: mat.emissive } : {}),
|
|
||||||
...(mat.emissiveIntensity
|
|
||||||
? { emissiveIntensity: mat.emissiveIntensity }
|
|
||||||
: {}),
|
|
||||||
...(mat.opacity != null ? { opacity: mat.opacity, transparent: true } : {}),
|
|
||||||
...(mat.wireframe ? { wireframe: true } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function PhysicsWrapper({
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
physics?: PhysicsProps | null;
|
|
||||||
damage?: DamageProps | null;
|
|
||||||
position: [number, number, number];
|
|
||||||
rotation: [number, number, number];
|
|
||||||
scale: [number, number, number];
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const bodyRef = useRef<RapierRigidBody>(null);
|
|
||||||
const takeDamage = useEditorStore((s) => s.takeDamage);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
|
|
||||||
const hasPhysics =
|
|
||||||
physics && physics.colliderType && physics.colliderType !== "none";
|
|
||||||
const hasDamage = damage?.enabled && (damage.amount ?? 0) > 0;
|
|
||||||
|
|
||||||
if (!hasPhysics || !isPlaying) {
|
|
||||||
return (
|
|
||||||
<group position={position} rotation={rotation} scale={scale}>
|
|
||||||
{children}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RigidBody
|
|
||||||
ref={bodyRef}
|
|
||||||
type={physics.isStatic ? "fixed" : "dynamic"}
|
|
||||||
position={position}
|
|
||||||
rotation={rotation}
|
|
||||||
mass={physics.mass ?? 1}
|
|
||||||
restitution={physics.restitution ?? 0.2}
|
|
||||||
friction={physics.friction ?? 0.5}
|
|
||||||
onCollisionEnter={
|
|
||||||
hasDamage
|
|
||||||
? () => {
|
|
||||||
takeDamage(damage!.amount!);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<group scale={scale}>{children}</group>
|
|
||||||
</RigidBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameBox({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
depth?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<boxGeometry args={[width ?? 1, height ?? 1, depth ?? 1]} />
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameSphere({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
widthSegments,
|
|
||||||
heightSegments,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
widthSegments?: number | null;
|
|
||||||
heightSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<sphereGeometry
|
|
||||||
args={[radius ?? 1, widthSegments ?? 32, heightSegments ?? 16]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameCylinder({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radiusTop,
|
|
||||||
radiusBottom,
|
|
||||||
height,
|
|
||||||
radialSegments,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radiusTop?: number | null;
|
|
||||||
radiusBottom?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<cylinderGeometry
|
|
||||||
args={[
|
|
||||||
radiusTop ?? 1,
|
|
||||||
radiusBottom ?? 1,
|
|
||||||
height ?? 1,
|
|
||||||
radialSegments ?? 32,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameCone({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
radialSegments,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<coneGeometry args={[radius ?? 1, height ?? 1, radialSegments ?? 32]} />
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameTorus({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
tube,
|
|
||||||
radialSegments,
|
|
||||||
tubularSegments,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
tube?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
tubularSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<torusGeometry
|
|
||||||
args={[
|
|
||||||
radius ?? 1,
|
|
||||||
tube ?? 0.4,
|
|
||||||
radialSegments ?? 16,
|
|
||||||
tubularSegments ?? 48,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GamePlane({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: GamePrimitiveProps & { width?: number | null; height?: number | null }) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? true}
|
|
||||||
>
|
|
||||||
<planeGeometry args={[width ?? 1, height ?? 1]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
{...buildMaterialProps(material)}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameCapsule({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
length,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
length?: number | null;
|
|
||||||
capSegments?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<capsuleGeometry args={[radius ?? 0.5, length ?? 1, 16, 32]} />
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameKnot({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
tube,
|
|
||||||
tubularSegments,
|
|
||||||
radialSegments,
|
|
||||||
p,
|
|
||||||
q,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
tube?: number | null;
|
|
||||||
tubularSegments?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
p?: number | null;
|
|
||||||
q?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<torusKnotGeometry
|
|
||||||
args={[
|
|
||||||
radius ?? 1,
|
|
||||||
tube ?? 0.3,
|
|
||||||
tubularSegments ?? 64,
|
|
||||||
radialSegments ?? 8,
|
|
||||||
p ?? 2,
|
|
||||||
q ?? 3,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PolyhedronPrimitive({
|
|
||||||
Geometry,
|
|
||||||
...props
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
Geometry: "tetrahedron" | "octahedron" | "dodecahedron" | "icosahedron";
|
|
||||||
radius?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = props.position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = props.rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = props.scale ?? [1, 1, 1];
|
|
||||||
const r = props.radius ?? 1;
|
|
||||||
|
|
||||||
const geoElement = {
|
|
||||||
tetrahedron: <tetrahedronGeometry args={[r]} />,
|
|
||||||
octahedron: <octahedronGeometry args={[r]} />,
|
|
||||||
dodecahedron: <dodecahedronGeometry args={[r]} />,
|
|
||||||
icosahedron: <icosahedronGeometry args={[r]} />,
|
|
||||||
}[Geometry];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={props.physics}
|
|
||||||
damage={props.damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={props.castShadow ?? false}
|
|
||||||
receiveShadow={props.receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
{geoElement}
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(props.material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameTetrahedron(
|
|
||||||
props: GamePrimitiveProps & { radius?: number | null },
|
|
||||||
) {
|
|
||||||
return <PolyhedronPrimitive {...props} Geometry="tetrahedron" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameOctahedron(
|
|
||||||
props: GamePrimitiveProps & { radius?: number | null },
|
|
||||||
) {
|
|
||||||
return <PolyhedronPrimitive {...props} Geometry="octahedron" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameDodecahedron(
|
|
||||||
props: GamePrimitiveProps & { radius?: number | null },
|
|
||||||
) {
|
|
||||||
return <PolyhedronPrimitive {...props} Geometry="dodecahedron" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameIcosahedron(
|
|
||||||
props: GamePrimitiveProps & { radius?: number | null },
|
|
||||||
) {
|
|
||||||
return <PolyhedronPrimitive {...props} Geometry="icosahedron" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameExtrude({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
shapeData,
|
|
||||||
depth,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
shapeData?: {
|
|
||||||
points: [number, number][];
|
|
||||||
holes?: [number, number][][];
|
|
||||||
} | null;
|
|
||||||
depth?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
const shape = new THREE.Shape();
|
|
||||||
const pts = shapeData?.points ?? [
|
|
||||||
[-0.5, -0.5],
|
|
||||||
[0.5, -0.5],
|
|
||||||
[0.5, 0.5],
|
|
||||||
[-0.5, 0.5],
|
|
||||||
];
|
|
||||||
if (pts.length > 0) {
|
|
||||||
shape.moveTo(pts[0]![0], pts[0]![1]);
|
|
||||||
for (let i = 1; i < pts.length; i++) {
|
|
||||||
shape.lineTo(pts[i]![0], pts[i]![1]);
|
|
||||||
}
|
|
||||||
shape.closePath();
|
|
||||||
}
|
|
||||||
if (shapeData?.holes) {
|
|
||||||
for (const hole of shapeData.holes) {
|
|
||||||
const holePath = new THREE.Path();
|
|
||||||
if (hole.length > 0) {
|
|
||||||
holePath.moveTo(hole[0]![0], hole[0]![1]);
|
|
||||||
for (let i = 1; i < hole.length; i++) {
|
|
||||||
holePath.lineTo(hole[i]![0], hole[i]![1]);
|
|
||||||
}
|
|
||||||
holePath.closePath();
|
|
||||||
shape.holes.push(holePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<extrudeGeometry
|
|
||||||
args={[
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
shape as any,
|
|
||||||
{ depth: depth ?? 1, bevelEnabled: false },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameTube({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
radius,
|
|
||||||
tubularSegments,
|
|
||||||
radialSegments,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
radius?: number | null;
|
|
||||||
tubularSegments?: number | null;
|
|
||||||
radialSegments?: number | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
const curve = new THREE.CatmullRomCurve3([
|
|
||||||
new THREE.Vector3(-1, 0, 0),
|
|
||||||
new THREE.Vector3(-0.5, 0.5, 0),
|
|
||||||
new THREE.Vector3(0.5, -0.5, 0),
|
|
||||||
new THREE.Vector3(1, 0, 0),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<tubeGeometry
|
|
||||||
args={[
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
curve as any,
|
|
||||||
tubularSegments ?? 64,
|
|
||||||
radius ?? 0.1,
|
|
||||||
radialSegments ?? 8,
|
|
||||||
false,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameShape({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
shapeData,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
shapeData?: {
|
|
||||||
points: [number, number][];
|
|
||||||
holes?: [number, number][][];
|
|
||||||
} | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
const shape = new THREE.Shape();
|
|
||||||
const pts = shapeData?.points ?? [
|
|
||||||
[-0.5, -0.5],
|
|
||||||
[0.5, -0.5],
|
|
||||||
[0, 0.5],
|
|
||||||
];
|
|
||||||
if (pts.length > 0) {
|
|
||||||
shape.moveTo(pts[0]![0], pts[0]![1]);
|
|
||||||
for (let i = 1; i < pts.length; i++) {
|
|
||||||
shape.lineTo(pts[i]![0], pts[i]![1]);
|
|
||||||
}
|
|
||||||
shape.closePath();
|
|
||||||
}
|
|
||||||
if (shapeData?.holes) {
|
|
||||||
for (const hole of shapeData.holes) {
|
|
||||||
const holePath = new THREE.Path();
|
|
||||||
if (hole.length > 0) {
|
|
||||||
holePath.moveTo(hole[0]![0], hole[0]![1]);
|
|
||||||
for (let i = 1; i < hole.length; i++) {
|
|
||||||
holePath.lineTo(hole[i]![0], hole[i]![1]);
|
|
||||||
}
|
|
||||||
holePath.closePath();
|
|
||||||
shape.holes.push(holePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch */}
|
|
||||||
<shapeGeometry args={[shape as any]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
{...buildMaterialProps(material)}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameMesh({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
castShadow,
|
|
||||||
receiveShadow,
|
|
||||||
material,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
meshData,
|
|
||||||
}: GamePrimitiveProps & {
|
|
||||||
meshData?: {
|
|
||||||
vertices: number[];
|
|
||||||
indices: number[];
|
|
||||||
normals?: number[];
|
|
||||||
uvs?: number[];
|
|
||||||
} | null;
|
|
||||||
}) {
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!meshRef.current || !meshData) return;
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
geometry.setAttribute(
|
|
||||||
"position",
|
|
||||||
new THREE.Float32BufferAttribute(meshData.vertices, 3),
|
|
||||||
);
|
|
||||||
if (meshData.indices.length > 0) {
|
|
||||||
geometry.setIndex(meshData.indices);
|
|
||||||
}
|
|
||||||
if (meshData.normals) {
|
|
||||||
geometry.setAttribute(
|
|
||||||
"normal",
|
|
||||||
new THREE.Float32BufferAttribute(meshData.normals, 3),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
}
|
|
||||||
if (meshData.uvs) {
|
|
||||||
geometry.setAttribute(
|
|
||||||
"uv",
|
|
||||||
new THREE.Float32BufferAttribute(meshData.uvs, 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
meshRef.current.geometry = geometry;
|
|
||||||
}, [meshData]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PhysicsWrapper
|
|
||||||
physics={physics}
|
|
||||||
damage={damage}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
>
|
|
||||||
<mesh
|
|
||||||
ref={meshRef}
|
|
||||||
castShadow={castShadow ?? false}
|
|
||||||
receiveShadow={receiveShadow ?? false}
|
|
||||||
>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial {...buildMaterialProps(material)} />
|
|
||||||
</mesh>
|
|
||||||
</PhysicsWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { RigidBody, CuboidCollider } from "@react-three/rapier";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
interface GroundPlaneProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
material?: {
|
|
||||||
color?: string | null;
|
|
||||||
metalness?: number | null;
|
|
||||||
roughness?: number | null;
|
|
||||||
} | null;
|
|
||||||
size?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroundPlane({ position, material, size }: GroundPlaneProps) {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const s = size ?? 5000;
|
|
||||||
const pos: [number, number, number] = position ?? [0, -0.1, 0];
|
|
||||||
|
|
||||||
const plane = (
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={pos} receiveShadow>
|
|
||||||
<planeGeometry args={[s, s]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={material?.color ?? "#4CAF50"}
|
|
||||||
metalness={material?.metalness ?? 0}
|
|
||||||
roughness={material?.roughness ?? 0.9}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isPlaying) return plane;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RigidBody type="fixed" position={pos} colliders={false}>
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
|
||||||
<planeGeometry args={[s, s]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={material?.color ?? "#4CAF50"}
|
|
||||||
metalness={material?.metalness ?? 0}
|
|
||||||
roughness={material?.roughness ?? 0.9}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<CuboidCollider args={[s / 2, 0.01, s / 2]} position={[0, 0, 0]} />
|
|
||||||
</RigidBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
interface MediaPlaneProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
castShadow?: boolean | null;
|
|
||||||
receiveShadow?: boolean | null;
|
|
||||||
url: string;
|
|
||||||
mediaType: "image" | "video";
|
|
||||||
loop?: boolean | null;
|
|
||||||
autoplay?: boolean | null;
|
|
||||||
muted?: boolean | null;
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaPlane({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
url,
|
|
||||||
mediaType,
|
|
||||||
loop,
|
|
||||||
autoplay,
|
|
||||||
muted,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: MediaPlaneProps) {
|
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
|
||||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
if (mediaType === "video") {
|
|
||||||
const video = document.createElement("video");
|
|
||||||
video.src = url;
|
|
||||||
video.crossOrigin = "anonymous";
|
|
||||||
video.loop = loop ?? false;
|
|
||||||
video.muted = muted ?? true;
|
|
||||||
if (autoplay !== false) video.play();
|
|
||||||
|
|
||||||
const tex = new THREE.VideoTexture(video);
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
setTexture(tex);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
video.pause();
|
|
||||||
video.src = "";
|
|
||||||
tex.dispose();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const loader = new THREE.TextureLoader();
|
|
||||||
loader.load(url, (tex) => {
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
setTexture(tex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [url, mediaType, loop, autoplay, muted]);
|
|
||||||
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
const w = width ?? 2;
|
|
||||||
const h = height ?? w * 0.75;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh
|
|
||||||
ref={meshRef}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
scale={scl}
|
|
||||||
castShadow
|
|
||||||
receiveShadow
|
|
||||||
>
|
|
||||||
<planeGeometry args={[w, h]} />
|
|
||||||
{texture ? (
|
|
||||||
<meshBasicMaterial
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
map={texture as any}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<meshStandardMaterial color="#333" side={THREE.DoubleSide} />
|
|
||||||
)}
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect, Suspense } from "react";
|
|
||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import {
|
|
||||||
RigidBody,
|
|
||||||
CuboidCollider,
|
|
||||||
BallCollider,
|
|
||||||
CapsuleCollider,
|
|
||||||
} from "@react-three/rapier";
|
|
||||||
import type * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
interface PhysicsProps {
|
|
||||||
mass?: number | null;
|
|
||||||
isStatic?: boolean | null;
|
|
||||||
restitution?: number | null;
|
|
||||||
friction?: number | null;
|
|
||||||
colliderType?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DamageProps {
|
|
||||||
amount?: number | null;
|
|
||||||
enabled?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameModelProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
castShadow?: boolean | null;
|
|
||||||
receiveShadow?: boolean | null;
|
|
||||||
url: string;
|
|
||||||
physics?: PhysicsProps | null;
|
|
||||||
damage?: DamageProps | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelInner({
|
|
||||||
url,
|
|
||||||
userData,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
userData: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
|
||||||
const { scene } = useGLTF(url);
|
|
||||||
const setIsLoading = useEditorStore((s) => s.setIsLoading);
|
|
||||||
|
|
||||||
const model = scene.clone();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
model.traverse((node: any) => {
|
|
||||||
if (node.isMesh) {
|
|
||||||
node.castShadow = true;
|
|
||||||
node.receiveShadow = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (groupRef.current) {
|
|
||||||
Object.assign(groupRef.current.userData, userData);
|
|
||||||
}
|
|
||||||
}, [userData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setIsLoading(false), 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [setIsLoading]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={groupRef}>
|
|
||||||
<primitive object={model} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameModel({
|
|
||||||
position,
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
url,
|
|
||||||
physics,
|
|
||||||
damage,
|
|
||||||
objectId,
|
|
||||||
}: GameModelProps) {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const takeDamage = useEditorStore((s) => s.takeDamage);
|
|
||||||
const setIsLoading = useEditorStore((s) => s.setIsLoading);
|
|
||||||
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
const rot: [number, number, number] = rotation ?? [0, 0, 0];
|
|
||||||
const scl: [number, number, number] = scale ?? [1, 1, 1];
|
|
||||||
|
|
||||||
const hasPhysics =
|
|
||||||
isPlaying &&
|
|
||||||
physics &&
|
|
||||||
physics.colliderType &&
|
|
||||||
physics.colliderType !== "none";
|
|
||||||
const hasDamage = damage?.enabled && (damage.amount ?? 0) > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (url) setIsLoading(true);
|
|
||||||
}, [url, setIsLoading]);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return (
|
|
||||||
<group position={pos} rotation={rot} scale={scl}>
|
|
||||||
<mesh castShadow receiveShadow>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color="#888888" wireframe />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModelInner url={url} userData={{ id: objectId, objectId }} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasPhysics) {
|
|
||||||
return (
|
|
||||||
<group position={pos} rotation={rot} scale={scl}>
|
|
||||||
{inner}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const collider =
|
|
||||||
physics!.colliderType === "ball" ? (
|
|
||||||
<BallCollider args={[Math.max(scl[0], scl[1], scl[2]) * 0.5]} />
|
|
||||||
) : physics!.colliderType === "capsule" ? (
|
|
||||||
<CapsuleCollider args={[scl[1] * 0.25, Math.max(scl[0], scl[2]) * 0.5]} />
|
|
||||||
) : (
|
|
||||||
<CuboidCollider args={[scl[0] * 0.5, scl[1] * 0.5, scl[2] * 0.5]} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RigidBody
|
|
||||||
type={physics!.isStatic ? "fixed" : "dynamic"}
|
|
||||||
position={pos}
|
|
||||||
rotation={rot}
|
|
||||||
mass={physics!.mass ?? 1}
|
|
||||||
restitution={physics!.restitution ?? 0.2}
|
|
||||||
friction={physics!.friction ?? 0.5}
|
|
||||||
colliders={false}
|
|
||||||
onCollisionEnter={
|
|
||||||
hasDamage ? () => takeDamage(damage!.amount!) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<group scale={scl}>{inner}</group>
|
|
||||||
{collider}
|
|
||||||
</RigidBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, Suspense } from "react";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import { PointerLockControls, useGLTF } from "@react-three/drei";
|
|
||||||
import { RigidBody, CapsuleCollider, useRapier } from "@react-three/rapier";
|
|
||||||
import type { RapierRigidBody } from "@react-three/rapier";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import { touchMoveState } from "@/lib/touch-state";
|
|
||||||
import { resetBoneScales } from "@/lib/bone-utils";
|
|
||||||
|
|
||||||
interface PlayerProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
isPlayer?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Player({ position }: PlayerProps) {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const viewMode = useEditorStore((s) => s.viewMode);
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const isPromptOpen = useEditorStore((s) => s.isPromptOpen);
|
|
||||||
|
|
||||||
if (!isPlaying) {
|
|
||||||
const pos = position ?? [0, 0, 0];
|
|
||||||
return (
|
|
||||||
<mesh position={pos as [number, number, number]}>
|
|
||||||
<capsuleGeometry args={[0.3, 0.8, 8, 16]} />
|
|
||||||
<meshStandardMaterial color="#3498db" transparent opacity={0.5} />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewMode === "first-person") {
|
|
||||||
return (
|
|
||||||
<FirstPersonController
|
|
||||||
initialPosition={position ?? [0, 0, 0]}
|
|
||||||
health={health}
|
|
||||||
isPromptOpen={isPromptOpen}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThirdPersonController
|
|
||||||
initialPosition={position ?? [0, 0, 0]}
|
|
||||||
health={health}
|
|
||||||
isPromptOpen={isPromptOpen}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FirstPersonController({
|
|
||||||
initialPosition,
|
|
||||||
health,
|
|
||||||
isPromptOpen,
|
|
||||||
}: {
|
|
||||||
initialPosition: [number, number, number];
|
|
||||||
health: number;
|
|
||||||
isPromptOpen: boolean;
|
|
||||||
}) {
|
|
||||||
const { camera } = useThree();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const controlsRef = useRef<any>(null);
|
|
||||||
const playerRef = useRef<RapierRigidBody>(null);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const moveState = useRef({
|
|
||||||
forward: false,
|
|
||||||
backward: false,
|
|
||||||
left: false,
|
|
||||||
right: false,
|
|
||||||
jump: false,
|
|
||||||
sprint: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { rapier, world } = useRapier();
|
|
||||||
const playerDirection = useRef(new THREE.Vector3());
|
|
||||||
const isOnGround = useRef(true);
|
|
||||||
const jumpCooldown = useRef(0);
|
|
||||||
const touchYaw = useRef(0);
|
|
||||||
const touchPitch = useRef(0);
|
|
||||||
|
|
||||||
const speed = 5;
|
|
||||||
const jumpForce = 5;
|
|
||||||
const groundCastDist = 0.85;
|
|
||||||
const isGameOver = health <= 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
camera.rotation.order = "YXZ";
|
|
||||||
camera.rotation.set(0, 0, 0);
|
|
||||||
touchYaw.current = 0;
|
|
||||||
touchPitch.current = 0;
|
|
||||||
}
|
|
||||||
}, [isMobile, camera]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile) return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (isPromptOpen || isGameOver) return;
|
|
||||||
switch (e.code) {
|
|
||||||
case "KeyW":
|
|
||||||
case "ArrowUp":
|
|
||||||
moveState.current.forward = true;
|
|
||||||
break;
|
|
||||||
case "KeyS":
|
|
||||||
case "ArrowDown":
|
|
||||||
moveState.current.backward = true;
|
|
||||||
break;
|
|
||||||
case "KeyA":
|
|
||||||
case "ArrowLeft":
|
|
||||||
moveState.current.left = true;
|
|
||||||
break;
|
|
||||||
case "KeyD":
|
|
||||||
case "ArrowRight":
|
|
||||||
moveState.current.right = true;
|
|
||||||
break;
|
|
||||||
case "Space":
|
|
||||||
moveState.current.jump = true;
|
|
||||||
break;
|
|
||||||
case "ShiftLeft":
|
|
||||||
case "ShiftRight":
|
|
||||||
moveState.current.sprint = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
switch (e.code) {
|
|
||||||
case "KeyW":
|
|
||||||
case "ArrowUp":
|
|
||||||
moveState.current.forward = false;
|
|
||||||
break;
|
|
||||||
case "KeyS":
|
|
||||||
case "ArrowDown":
|
|
||||||
moveState.current.backward = false;
|
|
||||||
break;
|
|
||||||
case "KeyA":
|
|
||||||
case "ArrowLeft":
|
|
||||||
moveState.current.left = false;
|
|
||||||
break;
|
|
||||||
case "KeyD":
|
|
||||||
case "ArrowRight":
|
|
||||||
moveState.current.right = false;
|
|
||||||
break;
|
|
||||||
case "Space":
|
|
||||||
moveState.current.jump = false;
|
|
||||||
break;
|
|
||||||
case "ShiftLeft":
|
|
||||||
case "ShiftRight":
|
|
||||||
moveState.current.sprint = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
|
||||||
};
|
|
||||||
}, [isPromptOpen, isGameOver, isMobile]);
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
if (!playerRef.current || isGameOver) return;
|
|
||||||
|
|
||||||
const body = playerRef.current;
|
|
||||||
const vel = body.linvel();
|
|
||||||
|
|
||||||
let forward: boolean;
|
|
||||||
let backward: boolean;
|
|
||||||
let left: boolean;
|
|
||||||
let right: boolean;
|
|
||||||
let jump: boolean;
|
|
||||||
let sprint: boolean;
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
forward = touchMoveState.forward > 0.15;
|
|
||||||
backward = touchMoveState.forward < -0.15;
|
|
||||||
left = touchMoveState.right < -0.15;
|
|
||||||
right = touchMoveState.right > 0.15;
|
|
||||||
jump = touchMoveState.jump;
|
|
||||||
sprint = touchMoveState.sprint;
|
|
||||||
|
|
||||||
if (touchMoveState.lookDeltaX !== 0 || touchMoveState.lookDeltaY !== 0) {
|
|
||||||
touchYaw.current -= touchMoveState.lookDeltaX;
|
|
||||||
touchPitch.current -= touchMoveState.lookDeltaY;
|
|
||||||
touchPitch.current = Math.max(
|
|
||||||
-Math.PI / 2 + 0.01,
|
|
||||||
Math.min(Math.PI / 2 - 0.01, touchPitch.current),
|
|
||||||
);
|
|
||||||
camera.rotation.order = "YXZ";
|
|
||||||
camera.rotation.y = touchYaw.current;
|
|
||||||
camera.rotation.x = touchPitch.current;
|
|
||||||
touchMoveState.lookDeltaX = 0;
|
|
||||||
touchMoveState.lookDeltaY = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
({ forward, backward, left, right, jump, sprint } = moveState.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
playerDirection.current.set(0, 0, 0);
|
|
||||||
|
|
||||||
const frontVector = new THREE.Vector3(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
(backward ? 1 : 0) - (forward ? 1 : 0),
|
|
||||||
);
|
|
||||||
const sideVector = new THREE.Vector3(
|
|
||||||
(right ? 1 : 0) - (left ? 1 : 0),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
playerDirection.current
|
|
||||||
.addVectors(frontVector, sideVector)
|
|
||||||
.normalize()
|
|
||||||
.multiplyScalar(speed * (sprint ? 1.8 : 1))
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
.applyEuler(camera.rotation as any);
|
|
||||||
|
|
||||||
body.setLinvel(
|
|
||||||
{ x: playerDirection.current.x, y: vel.y, z: playerDirection.current.z },
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (jump && isOnGround.current && jumpCooldown.current <= 0) {
|
|
||||||
body.setLinvel({ x: vel.x, y: jumpForce, z: vel.z }, true);
|
|
||||||
isOnGround.current = false;
|
|
||||||
jumpCooldown.current = 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jumpCooldown.current > 0) jumpCooldown.current -= delta;
|
|
||||||
|
|
||||||
const pos = body.translation();
|
|
||||||
const ray = new rapier.Ray(
|
|
||||||
{ x: pos.x, y: pos.y, z: pos.z },
|
|
||||||
{ x: 0, y: -1, z: 0 },
|
|
||||||
);
|
|
||||||
const hit = world.castRay(ray, groundCastDist, false);
|
|
||||||
isOnGround.current = hit !== null;
|
|
||||||
|
|
||||||
camera.position.set(pos.x, pos.y + 0.85, pos.z);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isMobile && <PointerLockControls ref={controlsRef} />}
|
|
||||||
<RigidBody
|
|
||||||
ref={playerRef}
|
|
||||||
position={[
|
|
||||||
initialPosition[0],
|
|
||||||
initialPosition[1] + 2,
|
|
||||||
initialPosition[2],
|
|
||||||
]}
|
|
||||||
enabledRotations={[false, false, false]}
|
|
||||||
mass={1}
|
|
||||||
linearDamping={0.5}
|
|
||||||
type="dynamic"
|
|
||||||
colliders={false}
|
|
||||||
lockRotations
|
|
||||||
>
|
|
||||||
<CapsuleCollider args={[0.35, 0.3]} />
|
|
||||||
</RigidBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnimatedCharacterModelInner({
|
|
||||||
characterRef,
|
|
||||||
isMoving,
|
|
||||||
}: {
|
|
||||||
characterRef: React.RefObject<THREE.Group | null>;
|
|
||||||
isMoving: boolean;
|
|
||||||
}) {
|
|
||||||
const characterGltf = useGLTF("/models/bot.glb");
|
|
||||||
const walkGltf = useGLTF("/models/walking.glb");
|
|
||||||
const idleGltf = useGLTF("/models/idle.glb");
|
|
||||||
|
|
||||||
const mixerRef = useRef<THREE.AnimationMixer | null>(null);
|
|
||||||
const actionsRef = useRef<Record<string, THREE.AnimationAction>>({});
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!characterRef.current || !characterGltf || !walkGltf || !idleGltf)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
characterGltf.scene.scale.set(1, 1, 1);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
resetBoneScales(characterGltf.scene as any);
|
|
||||||
|
|
||||||
characterRef.current.clear();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch between drei and three
|
|
||||||
characterRef.current.add(characterGltf.scene.clone() as any);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
const mixer = new THREE.AnimationMixer(characterRef.current as any);
|
|
||||||
mixerRef.current = mixer;
|
|
||||||
|
|
||||||
if (idleGltf.animations[0] && walkGltf.animations[0]) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
const idleAction = mixer.clipAction(idleGltf.animations[0] as any);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
|
||||||
const walkAction = mixer.clipAction(walkGltf.animations[0] as any);
|
|
||||||
actionsRef.current = { idle: idleAction, walk: walkAction };
|
|
||||||
idleAction.play();
|
|
||||||
setReady(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Model loading failed
|
|
||||||
}
|
|
||||||
}, [characterRef, characterGltf, walkGltf, idleGltf]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ready) return;
|
|
||||||
const { idle, walk } = actionsRef.current;
|
|
||||||
if (!idle || !walk) return;
|
|
||||||
|
|
||||||
if (isMoving) {
|
|
||||||
idle.fadeOut(0.2);
|
|
||||||
walk.reset().fadeIn(0.2).play();
|
|
||||||
} else {
|
|
||||||
walk.fadeOut(0.2);
|
|
||||||
idle.reset().fadeIn(0.2).play();
|
|
||||||
}
|
|
||||||
}, [isMoving, ready]);
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
mixerRef.current?.update(delta);
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThirdPersonController({
|
|
||||||
initialPosition,
|
|
||||||
health,
|
|
||||||
isPromptOpen,
|
|
||||||
}: {
|
|
||||||
initialPosition: [number, number, number];
|
|
||||||
health: number;
|
|
||||||
isPromptOpen: boolean;
|
|
||||||
}) {
|
|
||||||
const { camera } = useThree();
|
|
||||||
const playerRef = useRef<RapierRigidBody>(null);
|
|
||||||
const characterRef = useRef<THREE.Group>(null);
|
|
||||||
const cameraAngle = useRef(0);
|
|
||||||
const [isMoving, setIsMoving] = useState(false);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const moveState = useRef({
|
|
||||||
forward: false,
|
|
||||||
backward: false,
|
|
||||||
left: false,
|
|
||||||
right: false,
|
|
||||||
sprint: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const speed = 5;
|
|
||||||
const isGameOver = health <= 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
cameraAngle.current = 0;
|
|
||||||
}
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile) return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (isPromptOpen || isGameOver) return;
|
|
||||||
switch (e.code) {
|
|
||||||
case "KeyW":
|
|
||||||
case "ArrowUp":
|
|
||||||
moveState.current.forward = true;
|
|
||||||
break;
|
|
||||||
case "KeyS":
|
|
||||||
case "ArrowDown":
|
|
||||||
moveState.current.backward = true;
|
|
||||||
break;
|
|
||||||
case "KeyA":
|
|
||||||
case "ArrowLeft":
|
|
||||||
moveState.current.left = true;
|
|
||||||
break;
|
|
||||||
case "KeyD":
|
|
||||||
case "ArrowRight":
|
|
||||||
moveState.current.right = true;
|
|
||||||
break;
|
|
||||||
case "ShiftLeft":
|
|
||||||
case "ShiftRight":
|
|
||||||
moveState.current.sprint = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
switch (e.code) {
|
|
||||||
case "KeyW":
|
|
||||||
case "ArrowUp":
|
|
||||||
moveState.current.forward = false;
|
|
||||||
break;
|
|
||||||
case "KeyS":
|
|
||||||
case "ArrowDown":
|
|
||||||
moveState.current.backward = false;
|
|
||||||
break;
|
|
||||||
case "KeyA":
|
|
||||||
case "ArrowLeft":
|
|
||||||
moveState.current.left = false;
|
|
||||||
break;
|
|
||||||
case "KeyD":
|
|
||||||
case "ArrowRight":
|
|
||||||
moveState.current.right = false;
|
|
||||||
break;
|
|
||||||
case "ShiftLeft":
|
|
||||||
case "ShiftRight":
|
|
||||||
moveState.current.sprint = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
cameraAngle.current -= e.movementX * 0.003;
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
};
|
|
||||||
}, [isPromptOpen, isGameOver, isMobile]);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (!playerRef.current || isGameOver) return;
|
|
||||||
|
|
||||||
const body = playerRef.current;
|
|
||||||
const vel = body.linvel();
|
|
||||||
|
|
||||||
let forward: boolean;
|
|
||||||
let backward: boolean;
|
|
||||||
let left: boolean;
|
|
||||||
let right: boolean;
|
|
||||||
let sprint: boolean;
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
forward = touchMoveState.forward > 0.15;
|
|
||||||
backward = touchMoveState.forward < -0.15;
|
|
||||||
left = touchMoveState.right < -0.15;
|
|
||||||
right = touchMoveState.right > 0.15;
|
|
||||||
sprint = touchMoveState.sprint;
|
|
||||||
|
|
||||||
if (touchMoveState.lookDeltaX !== 0) {
|
|
||||||
cameraAngle.current -= touchMoveState.lookDeltaX;
|
|
||||||
touchMoveState.lookDeltaX = 0;
|
|
||||||
touchMoveState.lookDeltaY = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
({ forward, backward, left, right, sprint } = moveState.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = new THREE.Vector3(0, 0, 0);
|
|
||||||
if (forward) dir.z -= 1;
|
|
||||||
if (backward) dir.z += 1;
|
|
||||||
if (left) dir.x -= 1;
|
|
||||||
if (right) dir.x += 1;
|
|
||||||
dir.normalize().multiplyScalar(speed * (sprint ? 1.8 : 1));
|
|
||||||
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), cameraAngle.current);
|
|
||||||
|
|
||||||
body.setLinvel({ x: dir.x, y: vel.y, z: dir.z }, true);
|
|
||||||
|
|
||||||
const moving = dir.length() > 0.1;
|
|
||||||
setIsMoving(moving);
|
|
||||||
|
|
||||||
const pos = body.translation();
|
|
||||||
|
|
||||||
if (characterRef.current) {
|
|
||||||
characterRef.current.position.set(pos.x, pos.y, pos.z);
|
|
||||||
if (moving) {
|
|
||||||
const targetAngle = Math.atan2(dir.x, dir.z);
|
|
||||||
characterRef.current.rotation.y = targetAngle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const camDist = 6;
|
|
||||||
const camHeight = 3;
|
|
||||||
camera.position.set(
|
|
||||||
pos.x + Math.sin(cameraAngle.current) * camDist,
|
|
||||||
pos.y + camHeight,
|
|
||||||
pos.z + Math.cos(cameraAngle.current) * camDist,
|
|
||||||
);
|
|
||||||
camera.lookAt(pos.x, pos.y + 1, pos.z);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RigidBody
|
|
||||||
ref={playerRef}
|
|
||||||
position={[
|
|
||||||
initialPosition[0],
|
|
||||||
initialPosition[1] + 2,
|
|
||||||
initialPosition[2],
|
|
||||||
]}
|
|
||||||
enabledRotations={[false, false, false]}
|
|
||||||
mass={1}
|
|
||||||
linearDamping={0.5}
|
|
||||||
type="dynamic"
|
|
||||||
colliders={false}
|
|
||||||
lockRotations
|
|
||||||
>
|
|
||||||
<CapsuleCollider args={[0.35, 0.3]} />
|
|
||||||
</RigidBody>
|
|
||||||
<group ref={characterRef} position={initialPosition}>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AnimatedCharacterModelInner
|
|
||||||
characterRef={characterRef}
|
|
||||||
isMoving={isMoving}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
{/* Fallback capsule mesh visible until models load */}
|
|
||||||
<mesh castShadow>
|
|
||||||
<capsuleGeometry args={[0.3, 0.8, 8, 16]} />
|
|
||||||
<meshStandardMaterial color="#3498db" />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import { useThree } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
interface SoundEmitterProps {
|
|
||||||
position?: [number, number, number] | null;
|
|
||||||
rotation?: [number, number, number] | null;
|
|
||||||
scale?: [number, number, number] | null;
|
|
||||||
url: string;
|
|
||||||
loop?: boolean | null;
|
|
||||||
volume?: number | null;
|
|
||||||
positional?: boolean | null;
|
|
||||||
distance?: number | null;
|
|
||||||
objectId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SoundEmitter({
|
|
||||||
position,
|
|
||||||
url,
|
|
||||||
loop,
|
|
||||||
volume,
|
|
||||||
positional,
|
|
||||||
distance,
|
|
||||||
}: SoundEmitterProps) {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const { camera } = useThree();
|
|
||||||
const listenerRef = useRef<THREE.AudioListener | null>(null);
|
|
||||||
const soundRef = useRef<THREE.PositionalAudio | THREE.Audio | null>(null);
|
|
||||||
const pos: [number, number, number] = position ?? [0, 0, 0];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || !url) return;
|
|
||||||
|
|
||||||
const listener = new THREE.AudioListener();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
camera.add(listener as any);
|
|
||||||
listenerRef.current = listener;
|
|
||||||
|
|
||||||
const loader = new THREE.AudioLoader();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let audio: any;
|
|
||||||
|
|
||||||
if (positional !== false) {
|
|
||||||
audio = new THREE.PositionalAudio(listener);
|
|
||||||
audio.setRefDistance(distance ?? 10);
|
|
||||||
} else {
|
|
||||||
audio = new THREE.Audio(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
loader.load(url, (buffer) => {
|
|
||||||
audio.setBuffer(buffer);
|
|
||||||
audio.setLoop(loop ?? false);
|
|
||||||
audio.setVolume(volume ?? 1);
|
|
||||||
audio.play();
|
|
||||||
});
|
|
||||||
|
|
||||||
soundRef.current = audio;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (soundRef.current?.isPlaying) soundRef.current.stop();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
camera.remove(listener as any);
|
|
||||||
};
|
|
||||||
}, [isPlaying, url, loop, volume, positional, distance, camera]);
|
|
||||||
|
|
||||||
// Edit mode indicator
|
|
||||||
if (!isPlaying) {
|
|
||||||
return (
|
|
||||||
<mesh position={pos}>
|
|
||||||
<sphereGeometry args={[0.2, 8, 8]} />
|
|
||||||
<meshStandardMaterial color="#9b59b6" wireframe />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <group position={pos} />;
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { X, Volume2, VolumeX } from "lucide-react";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
|
|
||||||
interface DialogMessage {
|
|
||||||
text: string;
|
|
||||||
audioUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CharacterDialog() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [messages, setMessages] = useState<DialogMessage[]>([]);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [characterRole, setCharacterRole] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = new Audio();
|
|
||||||
audioRef.current = audio;
|
|
||||||
return () => {
|
|
||||||
audio.pause();
|
|
||||||
audio.src = "";
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleInteract = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail as {
|
|
||||||
id?: string;
|
|
||||||
role?: string;
|
|
||||||
messages?: DialogMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
setCharacterRole(detail?.role || "NPC");
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setIsOpen(true);
|
|
||||||
window.dispatchEvent(new CustomEvent("dialog-open"));
|
|
||||||
|
|
||||||
if (detail?.messages && detail.messages.length > 0) {
|
|
||||||
setMessages(detail.messages);
|
|
||||||
setIsLoading(false);
|
|
||||||
} else {
|
|
||||||
setIsLoading(true);
|
|
||||||
fetchCharacterResponses(detail?.role || "villager").then(
|
|
||||||
(responses) => {
|
|
||||||
setMessages(responses);
|
|
||||||
setIsLoading(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("character-interact", handleInteract);
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("character-interact", handleInteract);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && messages.length > 0 && !isMuted && audioRef.current) {
|
|
||||||
const current = messages[currentIndex];
|
|
||||||
if (current?.audioUrl) {
|
|
||||||
audioRef.current.src = current.audioUrl;
|
|
||||||
audioRef.current.volume = 1.0;
|
|
||||||
audioRef.current.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentIndex, messages, isLoading, isMuted]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setMessages([]);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
if (audioRef.current) audioRef.current.pause();
|
|
||||||
window.dispatchEvent(new CustomEvent("dialog-close"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
|
||||||
if (audioRef.current) audioRef.current.pause();
|
|
||||||
if (currentIndex < messages.length - 1) {
|
|
||||||
setCurrentIndex((i) => i + 1);
|
|
||||||
} else {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
}, [currentIndex, messages.length, handleClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleNext();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKey);
|
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
|
||||||
}, [isOpen, handleNext, handleClose]);
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
setIsMuted(!isMuted);
|
|
||||||
if (audioRef.current) {
|
|
||||||
if (!isMuted) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
} else if (messages[currentIndex]?.audioUrl) {
|
|
||||||
audioRef.current.src = messages[currentIndex].audioUrl!;
|
|
||||||
audioRef.current.volume = 1.0;
|
|
||||||
audioRef.current.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
|
|
||||||
<div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<span className="text-[10px] text-[#666] uppercase tracking-wider">
|
|
||||||
{characterRole}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="animate-pulse h-4 bg-[#222] rounded w-3/4 mb-2" />
|
|
||||||
<div className="animate-pulse h-4 bg-[#222] rounded w-1/2" />
|
|
||||||
<p className="text-[9px] text-[#555] mt-3">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length === 0) return null;
|
|
||||||
|
|
||||||
const current = messages[currentIndex];
|
|
||||||
const isLast = currentIndex >= messages.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
|
|
||||||
<div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<span className="text-[10px] text-[#666] uppercase tracking-wider">
|
|
||||||
{characterRole || "NPC"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={toggleMute}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
title={isMuted ? "Unmute" : "Mute"}
|
|
||||||
>
|
|
||||||
{isMuted ? <VolumeX size={14} /> : <Volume2 size={14} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="p-1.5 text-[#666] hover:text-white active:text-white"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[#ddd] leading-relaxed">{current?.text}</p>
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
|
||||||
<span className="text-[9px] text-[#555]">
|
|
||||||
{currentIndex + 1} / {messages.length}
|
|
||||||
</span>
|
|
||||||
{isMobile ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="px-3 py-1.5 text-xs text-[#888] bg-white/5 rounded active:bg-white/15"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
{!isLast && (
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
className="px-3 py-1.5 text-xs text-white bg-white/10 rounded active:bg-white/25"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[9px] text-[#555]">
|
|
||||||
{isLast
|
|
||||||
? "Press Enter or Esc to close"
|
|
||||||
: "Press Enter to continue"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCharacterResponses(
|
|
||||||
role: string,
|
|
||||||
): Promise<{ text: string; audioUrl?: string }[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/character-responses", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ role }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch responses");
|
|
||||||
const data = await res.json();
|
|
||||||
return (
|
|
||||||
data.messages || [
|
|
||||||
{ text: "Hello there! How can I help you?" },
|
|
||||||
{ text: "It's a beautiful day, isn't it?" },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [
|
|
||||||
{ text: "Hello there! How can I help you?" },
|
|
||||||
{ text: "It's a beautiful day, isn't it?" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
interface DamageEffectProps {
|
|
||||||
lastDamageTime: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DamageEffect({ lastDamageTime }: DamageEffectProps) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastDamageTime) return;
|
|
||||||
setShow(true);
|
|
||||||
const timer = setTimeout(() => setShow(false), 400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [lastDamageTime]);
|
|
||||||
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 pointer-events-none z-20 animate-screen-flash bg-red-600/30 rounded-none" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
const DAMAGE_SOUND_URL =
|
|
||||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/hit-OaUo19QcLdyFfqy5SMfEWyAF3ZgyxR.mp3";
|
|
||||||
|
|
||||||
export function DamageSound() {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const lastDamageTime = useEditorStore((s) => s.lastDamageTime);
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const lastPlayedRef = useRef<number>(0);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = new Audio();
|
|
||||||
audio.src = DAMAGE_SOUND_URL;
|
|
||||||
audio.preload = "auto";
|
|
||||||
audio.addEventListener("canplaythrough", () => setLoaded(true));
|
|
||||||
audio.addEventListener("error", () => setLoaded(false));
|
|
||||||
audioRef.current = audio;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.pause();
|
|
||||||
audio.src = "";
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || !lastDamageTime || health <= 0 || !loaded) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastPlayedRef.current < 300) return;
|
|
||||||
lastPlayedRef.current = now;
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.volume = 0.5;
|
|
||||||
audioRef.current.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}, [isPlaying, lastDamageTime, health, loaded]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
const DEATH_SOUND_URL =
|
|
||||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/death-y5RAJQHhGgzagKglqKEYKzMFc84QcR.mp3";
|
|
||||||
|
|
||||||
export function DeathSound() {
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const [prevHealth, setPrevHealth] = useState(100);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = new Audio();
|
|
||||||
audio.src = DEATH_SOUND_URL;
|
|
||||||
audio.preload = "auto";
|
|
||||||
audio.addEventListener("canplaythrough", () => setLoaded(true));
|
|
||||||
audio.addEventListener("error", () => setLoaded(false));
|
|
||||||
audioRef.current = audio;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.pause();
|
|
||||||
audio.src = "";
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isPlaying &&
|
|
||||||
prevHealth > 0 &&
|
|
||||||
health <= 0 &&
|
|
||||||
audioRef.current &&
|
|
||||||
loaded
|
|
||||||
) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.volume = 0.7;
|
|
||||||
audioRef.current.play().catch(() => {});
|
|
||||||
}
|
|
||||||
setPrevHealth(health);
|
|
||||||
}, [health, isPlaying, prevHealth, loaded]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
export function GameOverScreen() {
|
|
||||||
const setIsPlaying = useEditorStore((s) => s.setIsPlaying);
|
|
||||||
const setHealth = useEditorStore((s) => s.setHealth);
|
|
||||||
const setShield = useEditorStore((s) => s.setShield);
|
|
||||||
const maxHealth = useEditorStore((s) => s.maxHealth);
|
|
||||||
const maxShield = useEditorStore((s) => s.maxShield);
|
|
||||||
|
|
||||||
const handleRestart = () => {
|
|
||||||
setHealth(maxHealth);
|
|
||||||
setShield(maxShield);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setTimeout(() => setIsPlaying(true), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExit = () => {
|
|
||||||
setHealth(maxHealth);
|
|
||||||
setShield(maxShield);
|
|
||||||
setIsPlaying(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/80 animate-fade-in">
|
|
||||||
<div className="text-center px-4">
|
|
||||||
<h1 className="text-3xl sm:text-5xl font-bold text-red-500 mb-2">
|
|
||||||
GAME OVER
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#888] mb-8 text-sm sm:text-base">
|
|
||||||
You have been defeated
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={handleRestart}
|
|
||||||
className="px-6 py-2.5 sm:py-2 bg-white/10 hover:bg-white/20 active:bg-white/30 text-white rounded-lg text-sm transition-colors min-w-[100px]"
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExit}
|
|
||||||
className="px-6 py-2.5 sm:py-2 bg-white/5 hover:bg-white/10 active:bg-white/20 text-[#888] rounded-lg text-sm transition-colors min-w-[100px]"
|
|
||||||
>
|
|
||||||
Exit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
|
|
||||||
export function HealthBar() {
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const shield = useEditorStore((s) => s.shield);
|
|
||||||
const maxHealth = useEditorStore((s) => s.maxHealth);
|
|
||||||
const maxShield = useEditorStore((s) => s.maxShield);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const healthPct = (health / maxHealth) * 100;
|
|
||||||
const shieldPct = (shield / maxShield) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute ${isMobile ? "top-14 left-3" : "bottom-4 left-4"} z-10 flex flex-col gap-1.5 pointer-events-none`}
|
|
||||||
>
|
|
||||||
{/* Shield bar */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-[10px] text-blue-400 w-5 text-right font-mono">
|
|
||||||
{Math.round(shield)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`${isMobile ? "w-28" : "w-40"} h-2 bg-[#1a1a1a] rounded-full overflow-hidden`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-blue-600 to-blue-400 transition-all duration-300"
|
|
||||||
style={{ width: `${shieldPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Health bar */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`text-[10px] w-5 text-right font-mono ${
|
|
||||||
health < 30 ? "text-red-400 animate-pulse-low" : "text-green-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{Math.round(health)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`${isMobile ? "w-28" : "w-40"} h-2 bg-[#1a1a1a] rounded-full overflow-hidden`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all duration-300 ${
|
|
||||||
health < 30
|
|
||||||
? "bg-gradient-to-r from-red-600 to-red-400 animate-pulse-low"
|
|
||||||
: "bg-gradient-to-r from-green-600 to-green-400"
|
|
||||||
}`}
|
|
||||||
style={{ width: `${healthPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { ArrowUp, Loader2, MessageSquare } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
import type {
|
|
||||||
ObjectType,
|
|
||||||
TransformMode,
|
|
||||||
SceneObject,
|
|
||||||
Material,
|
|
||||||
Physics,
|
|
||||||
Damage,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
export function InGamePrompt() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const {
|
|
||||||
addObject,
|
|
||||||
scenes,
|
|
||||||
activeSceneId,
|
|
||||||
updateObjectTransform,
|
|
||||||
updateObjectMaterial,
|
|
||||||
updateObjectPhysics,
|
|
||||||
updateDamage,
|
|
||||||
selectObject,
|
|
||||||
setTransformMode,
|
|
||||||
createCustomObject,
|
|
||||||
isPlaying,
|
|
||||||
setIsPromptOpen,
|
|
||||||
} = useEditorStore();
|
|
||||||
|
|
||||||
const activeScene =
|
|
||||||
scenes.find((scene) => scene.id === activeSceneId) || scenes[0];
|
|
||||||
const objects = activeScene ? activeScene.objects : [];
|
|
||||||
|
|
||||||
const processAction = (action: { function: string; args: unknown[] }) => {
|
|
||||||
try {
|
|
||||||
switch (action.function) {
|
|
||||||
case "addObject":
|
|
||||||
addObject(action.args[0] as ObjectType);
|
|
||||||
break;
|
|
||||||
case "createCustomObject":
|
|
||||||
createCustomObject(
|
|
||||||
action.args[0] as ObjectType,
|
|
||||||
action.args[1] as Partial<SceneObject>,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "updateObjectTransform":
|
|
||||||
updateObjectTransform(
|
|
||||||
action.args[0] as string,
|
|
||||||
action.args[1] as Partial<
|
|
||||||
Pick<SceneObject, "position" | "rotation" | "scale">
|
|
||||||
>,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "updateObjectMaterial":
|
|
||||||
updateObjectMaterial(
|
|
||||||
action.args[0] as string,
|
|
||||||
action.args[1] as Partial<Material>,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "updateObjectPhysics":
|
|
||||||
updateObjectPhysics(
|
|
||||||
action.args[0] as string,
|
|
||||||
action.args[1] as Partial<Physics>,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "updateDamage":
|
|
||||||
updateDamage(
|
|
||||||
action.args[0] as string,
|
|
||||||
action.args[1] as Partial<Damage>,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "selectObject":
|
|
||||||
selectObject(action.args[0] as string);
|
|
||||||
break;
|
|
||||||
case "setTransformMode":
|
|
||||||
setTransformMode(action.args[0] as TransformMode);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing action:", error, action);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
|
||||||
if (e) e.preventDefault();
|
|
||||||
if (!prompt.trim() || isProcessing) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
const currentPrompt = prompt;
|
|
||||||
setPreviousPrompts((prev) => [...prev.slice(-4), currentPrompt]);
|
|
||||||
setIsOpen(false);
|
|
||||||
setIsPromptOpen(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/ai-game", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt: currentPrompt,
|
|
||||||
objects,
|
|
||||||
previousPrompts,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error("Failed to get AI response");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
buffer += chunk;
|
|
||||||
|
|
||||||
let startIndex = 0;
|
|
||||||
while (true) {
|
|
||||||
const openBrace = buffer.indexOf("{", startIndex);
|
|
||||||
if (openBrace === -1) break;
|
|
||||||
|
|
||||||
let depth = 0;
|
|
||||||
let closeBrace = -1;
|
|
||||||
|
|
||||||
for (let i = openBrace; i < buffer.length; i++) {
|
|
||||||
if (buffer[i] === "{") depth++;
|
|
||||||
else if (buffer[i] === "}") {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) {
|
|
||||||
closeBrace = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closeBrace === -1) {
|
|
||||||
startIndex = openBrace + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonStr = buffer.substring(openBrace, closeBrace + 1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const jsonObj = JSON.parse(jsonStr);
|
|
||||||
if (
|
|
||||||
jsonObj &&
|
|
||||||
typeof jsonObj.function === "string" &&
|
|
||||||
Array.isArray(jsonObj.args)
|
|
||||||
) {
|
|
||||||
processAction(jsonObj);
|
|
||||||
}
|
|
||||||
buffer =
|
|
||||||
buffer.substring(0, openBrace) + buffer.substring(closeBrace + 1);
|
|
||||||
startIndex = 0;
|
|
||||||
} catch {
|
|
||||||
startIndex = openBrace + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing AI prompt:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
setPrompt("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying) {
|
|
||||||
setIsOpen(false);
|
|
||||||
setIsPromptOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "/") {
|
|
||||||
e.preventDefault();
|
|
||||||
const newIsOpen = !isOpen;
|
|
||||||
setIsOpen(newIsOpen);
|
|
||||||
setIsPromptOpen(newIsOpen);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isPlaying, isOpen, setIsPromptOpen, isMobile]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && textareaRef.current) {
|
|
||||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setPrompt(e.target.value);
|
|
||||||
e.target.style.height = "auto";
|
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isPlaying) return null;
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
setIsPromptOpen(true);
|
|
||||||
}}
|
|
||||||
className="absolute top-14 right-3 z-30 w-11 h-11 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-sm text-white/70 active:bg-white/20 border border-white/10"
|
|
||||||
>
|
|
||||||
<MessageSquare size={18} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute ${isMobile ? "top-14 left-2 right-2 bottom-auto" : "top-[50px] left-0 right-0 bottom-0 flex items-center justify-center"} z-40 animate-fade-in`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 bg-black/70 backdrop-blur-md border border-white/20 ${isMobile ? "rounded-xl w-full" : "rounded-full"} shadow-lg overflow-hidden ${isMobile ? "" : "max-w-[600px]"}`}
|
|
||||||
>
|
|
||||||
<div className={isMobile ? "pl-3" : "pl-5"}>
|
|
||||||
<MessageSquare className="h-5 w-5 text-white/70" />
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex items-center flex-1 min-w-0"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={prompt}
|
|
||||||
onChange={handleTextareaChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Describe what to add or change..."
|
|
||||||
className={`bg-transparent border-none px-3 py-4 ${isMobile ? "flex-1 min-w-0 text-base" : "w-[400px] text-sm"} text-white placeholder-gray-400 focus:outline-none resize-none overflow-hidden min-h-[44px] max-h-[200px]`}
|
|
||||||
rows={1}
|
|
||||||
style={{ height: "auto" }}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1 mr-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-white/15 text-white p-2 rounded-full hover:bg-white/25 active:bg-white/35 transition-colors disabled:opacity-30"
|
|
||||||
disabled={!prompt.trim() || isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
import { useIsMobile } from "@/lib/use-mobile";
|
|
||||||
|
|
||||||
export function InteractionPrompt() {
|
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying) {
|
|
||||||
setShowPrompt(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNearby = () => setShowPrompt(true);
|
|
||||||
const handleFar = () => setShowPrompt(false);
|
|
||||||
const handleDialogOpen = () => setShowPrompt(false);
|
|
||||||
|
|
||||||
window.addEventListener("character-nearby", handleNearby);
|
|
||||||
window.addEventListener("character-far", handleFar);
|
|
||||||
window.addEventListener("dialog-open", handleDialogOpen);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("character-nearby", handleNearby);
|
|
||||||
window.removeEventListener("character-far", handleFar);
|
|
||||||
window.removeEventListener("dialog-open", handleDialogOpen);
|
|
||||||
};
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
if (!isPlaying || !showPrompt) return null;
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
window.dispatchEvent(new CustomEvent("request-character-interact"));
|
|
||||||
}}
|
|
||||||
className="absolute bottom-28 left-1/2 -translate-x-1/2 z-20 bg-white/20 backdrop-blur-sm text-white px-6 py-3 rounded-full text-sm font-medium active:bg-white/40 border border-white/30"
|
|
||||||
>
|
|
||||||
Tap to interact
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10 bg-black/70 text-white px-4 py-2 rounded-md text-sm">
|
|
||||||
Press "E" to interact
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
export function LoadingSpinner() {
|
|
||||||
const isLoading = useEditorStore((s) => s.isLoading);
|
|
||||||
|
|
||||||
if (!isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 bg-black/60 rounded-full p-2 shadow-md z-10 backdrop-blur-sm">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { ArrowUp } from "lucide-react";
|
|
||||||
import { touchMoveState } from "@/lib/touch-state";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
|
||||||
|
|
||||||
const JOYSTICK_SIZE = 120;
|
|
||||||
const KNOB_SIZE = 48;
|
|
||||||
const MAX_DIST = (JOYSTICK_SIZE - KNOB_SIZE) / 2;
|
|
||||||
|
|
||||||
export function TouchControls() {
|
|
||||||
const isPlaying = useEditorStore((s) => s.isPlaying);
|
|
||||||
const health = useEditorStore((s) => s.health);
|
|
||||||
const joystickRef = useRef<HTMLDivElement>(null);
|
|
||||||
const knobRef = useRef<HTMLDivElement>(null);
|
|
||||||
const joystickTouchId = useRef<number | null>(null);
|
|
||||||
const joystickCenter = useRef<{ x: number; y: number } | null>(null);
|
|
||||||
const lookTouchRef = useRef<{ x: number; y: number } | null>(null);
|
|
||||||
|
|
||||||
const isGameOver = health <= 0;
|
|
||||||
|
|
||||||
const resetJoystick = useCallback(() => {
|
|
||||||
joystickTouchId.current = null;
|
|
||||||
joystickCenter.current = null;
|
|
||||||
touchMoveState.forward = 0;
|
|
||||||
touchMoveState.right = 0;
|
|
||||||
touchMoveState.sprint = false;
|
|
||||||
if (knobRef.current) {
|
|
||||||
knobRef.current.style.transform = "translate(-50%, -50%)";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || isGameOver) {
|
|
||||||
resetJoystick();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zone = joystickRef.current;
|
|
||||||
if (!zone) return;
|
|
||||||
|
|
||||||
const handleStart = (e: TouchEvent) => {
|
|
||||||
if (joystickTouchId.current !== null) return;
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const t = e.changedTouches[i]!;
|
|
||||||
const rect = zone.getBoundingClientRect();
|
|
||||||
if (
|
|
||||||
t.clientX >= rect.left &&
|
|
||||||
t.clientX <= rect.right &&
|
|
||||||
t.clientY >= rect.top &&
|
|
||||||
t.clientY <= rect.bottom
|
|
||||||
) {
|
|
||||||
joystickTouchId.current = t.identifier;
|
|
||||||
joystickCenter.current = { x: t.clientX, y: t.clientY };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMove = (e: TouchEvent) => {
|
|
||||||
if (joystickTouchId.current === null || !joystickCenter.current) return;
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const t = e.changedTouches[i]!;
|
|
||||||
if (t.identifier !== joystickTouchId.current) continue;
|
|
||||||
|
|
||||||
let dx = t.clientX - joystickCenter.current.x;
|
|
||||||
let dy = t.clientY - joystickCenter.current.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
const clamped = Math.min(dist, MAX_DIST);
|
|
||||||
if (dist > 0) {
|
|
||||||
dx = (dx / dist) * clamped;
|
|
||||||
dy = (dy / dist) * clamped;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = dist > 0 ? Math.min(dist / MAX_DIST, 1) : 0;
|
|
||||||
touchMoveState.right = (dx / MAX_DIST) * normalized;
|
|
||||||
touchMoveState.forward = -(dy / MAX_DIST) * normalized;
|
|
||||||
|
|
||||||
if (knobRef.current) {
|
|
||||||
knobRef.current.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnd = (e: TouchEvent) => {
|
|
||||||
if (joystickTouchId.current === null) return;
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
if (e.changedTouches[i]!.identifier === joystickTouchId.current) {
|
|
||||||
resetJoystick();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
zone.addEventListener("touchstart", handleStart, { passive: true });
|
|
||||||
window.addEventListener("touchmove", handleMove, { passive: true });
|
|
||||||
window.addEventListener("touchend", handleEnd, { passive: true });
|
|
||||||
window.addEventListener("touchcancel", handleEnd, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
zone.removeEventListener("touchstart", handleStart);
|
|
||||||
window.removeEventListener("touchmove", handleMove);
|
|
||||||
window.removeEventListener("touchend", handleEnd);
|
|
||||||
window.removeEventListener("touchcancel", handleEnd);
|
|
||||||
resetJoystick();
|
|
||||||
};
|
|
||||||
}, [isPlaying, isGameOver, resetJoystick]);
|
|
||||||
|
|
||||||
const handleLookStart = useCallback((e: React.TouchEvent) => {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
if (touch) {
|
|
||||||
lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLookMove = useCallback((e: React.TouchEvent) => {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
if (!touch || !lookTouchRef.current) return;
|
|
||||||
touchMoveState.lookDeltaX +=
|
|
||||||
(touch.clientX - lookTouchRef.current.x) * 0.004;
|
|
||||||
touchMoveState.lookDeltaY +=
|
|
||||||
(touch.clientY - lookTouchRef.current.y) * 0.004;
|
|
||||||
lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLookEnd = useCallback(() => {
|
|
||||||
lookTouchRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleJump = useCallback(() => {
|
|
||||||
touchMoveState.jump = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
touchMoveState.jump = false;
|
|
||||||
}, 150);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isPlaying || isGameOver) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Joystick zone (left half, bottom) */}
|
|
||||||
<div
|
|
||||||
ref={joystickRef}
|
|
||||||
className="fixed left-0 bottom-0 z-20 pointer-events-auto"
|
|
||||||
style={{ width: "45vw", height: "40vh" }}
|
|
||||||
>
|
|
||||||
{/* Joystick base */}
|
|
||||||
<div
|
|
||||||
className="absolute rounded-full border border-white/20 bg-white/5"
|
|
||||||
style={{
|
|
||||||
width: JOYSTICK_SIZE,
|
|
||||||
height: JOYSTICK_SIZE,
|
|
||||||
left: 60 - JOYSTICK_SIZE / 2,
|
|
||||||
bottom: 60 - JOYSTICK_SIZE / 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Knob */}
|
|
||||||
<div
|
|
||||||
ref={knobRef}
|
|
||||||
className="absolute left-1/2 top-1/2 rounded-full bg-white/25 border border-white/30"
|
|
||||||
style={{
|
|
||||||
width: KNOB_SIZE,
|
|
||||||
height: KNOB_SIZE,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Look zone (right half) */}
|
|
||||||
<div
|
|
||||||
className="fixed right-0 top-10 bottom-0 z-20 pointer-events-auto"
|
|
||||||
style={{ width: "55vw" }}
|
|
||||||
onTouchStart={handleLookStart}
|
|
||||||
onTouchMove={handleLookMove}
|
|
||||||
onTouchEnd={handleLookEnd}
|
|
||||||
onTouchCancel={handleLookEnd}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Jump button */}
|
|
||||||
<button
|
|
||||||
onTouchStart={handleJump}
|
|
||||||
className="fixed bottom-6 right-6 z-30 w-14 h-14 rounded-full bg-white/15 backdrop-blur-sm border border-white/20 flex items-center justify-center active:bg-white/30 pointer-events-auto"
|
|
||||||
>
|
|
||||||
<ArrowUp size={24} className="text-white/70" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
|
||||||
export default [
|
|
||||||
...nextJsConfig,
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"react/no-unknown-property": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { SceneObject } from "./types";
|
|
||||||
|
|
||||||
export function generateGameAIPrompt(
|
|
||||||
userPrompt: string,
|
|
||||||
objects: SceneObject[] = [],
|
|
||||||
previousPrompts: string[] = [],
|
|
||||||
): string {
|
|
||||||
const simplifiedObjects = objects.map((obj) => ({
|
|
||||||
id: obj.id,
|
|
||||||
name: obj.name,
|
|
||||||
type: obj.type,
|
|
||||||
position: obj.position,
|
|
||||||
rotation: obj.rotation,
|
|
||||||
scale: obj.scale,
|
|
||||||
material: obj.material,
|
|
||||||
physics: obj.physics,
|
|
||||||
lightType: obj.lightType,
|
|
||||||
intensity: obj.intensity,
|
|
||||||
damage: obj.damage,
|
|
||||||
shapeData: obj.shapeData,
|
|
||||||
meshData: obj.meshData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return `You are an AI assistant that helps users create and modify 3D scenes.
|
|
||||||
The user has given you this instruction: "${userPrompt}".
|
|
||||||
|
|
||||||
You have access to these functions:
|
|
||||||
- addObject(type): Creates a new object of type "box", "sphere", "cylinder", "cone", "torus", "plane", "light", "capsule", "extrude", "tetrahedron", "octahedron", "dodecahedron", "icosahedron", "knot", "tube", "shape", "mesh"
|
|
||||||
- createCustomObject(type, properties): Creates a new object with custom properties. Properties can include:
|
|
||||||
* name: string
|
|
||||||
* position: [x, y, z]
|
|
||||||
* rotation: [x, y, z] (For plane objects, default rotation is [-1.5708, 0, 0])
|
|
||||||
* scale: [x, y, z] (For plane objects, Z scale is always 1)
|
|
||||||
* material: { color, metalness, roughness, emissive, emissiveIntensity }
|
|
||||||
* physics: { mass, isStatic, restitution, friction, colliderType }
|
|
||||||
* damage: { amount, enabled }
|
|
||||||
* lightType: "ambient", "directional", "point", or "spot"
|
|
||||||
* intensity: number
|
|
||||||
* distance: number
|
|
||||||
* decay: number
|
|
||||||
* angle: number
|
|
||||||
* penumbra: number
|
|
||||||
* shapeData: { points: [x,y][], holes?: [x,y][][] }
|
|
||||||
* meshData: { vertices: number[], indices: number[], normals?: number[], uvs?: number[] }
|
|
||||||
- updateObjectTransform(id, {position, rotation, scale}): Updates an object's transform
|
|
||||||
- updateObjectMaterial(id, {color, metalness, roughness, emissive, emissiveIntensity}): Updates material
|
|
||||||
- updateObjectPhysics(id, {mass, isStatic, restitution, friction, colliderType}): Updates physics
|
|
||||||
- updateDamage(id, {amount, enabled}): Updates damage properties
|
|
||||||
- selectObject(id): Selects an object
|
|
||||||
- setTransformMode(mode): Sets transform mode to "select", "translate", "rotate", or "scale"
|
|
||||||
|
|
||||||
Current objects in the scene:
|
|
||||||
${JSON.stringify(simplifiedObjects, null, 2)}
|
|
||||||
|
|
||||||
Previous user instructions:
|
|
||||||
${previousPrompts.map((p) => `- "${p}"`).join("\n")}
|
|
||||||
|
|
||||||
Respond with a series of JSON objects, one per line (JSONL format). Each JSON object should have a "function" property and "args" property.
|
|
||||||
Example:
|
|
||||||
{"function": "createCustomObject", "args": ["box", {"name": "Large Red Box", "position": [0, 1, 0], "scale": [2, 2, 2], "material": {"color": "#ff0000"}, "physics": {"mass": 5, "isStatic": false, "restitution": 0.5}}]}
|
|
||||||
{"function": "updateObjectTransform", "args": ["object-id", {"position": [0, -1, 0], "scale": [10, 1, 10]}]}
|
|
||||||
|
|
||||||
IMPORTANT CONSTRAINTS:
|
|
||||||
1. For plane objects, the Z scale must always be 1.
|
|
||||||
2. For plane objects, the default rotation is [-1.5708, 0, 0] to make them horizontal.
|
|
||||||
3. Only use the functions listed above.
|
|
||||||
4. Your response must be valid JSONL format with one JSON object per line. Do not include any explanations.
|
|
||||||
5. Do not wrap your response in an array. Each line should be a separate, complete JSON object.
|
|
||||||
6. Each JSON object MUST have exactly this format: {"function": "functionName", "args": [arg1, arg2, ...]}
|
|
||||||
7. Do not use JavaScript expressions like Math.PI in your JSON. Use the actual numeric values instead.`;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { yamlPrompt } from "@json-render/yaml";
|
|
||||||
import { buildEditUserPrompt } from "@json-render/core";
|
|
||||||
import type { Spec } from "@json-render/core";
|
|
||||||
import { stringify } from "yaml";
|
|
||||||
import { catalog } from "./catalog";
|
|
||||||
|
|
||||||
const GAME_RULES = [
|
|
||||||
"Use numeric values only (no Math.PI, use 3.14159)",
|
|
||||||
"For GamePlane as ground: rotation [-1.5708, 0, 0], scale [size, size, 1]",
|
|
||||||
"Use descriptive keys for new objects (e.g. 'obj-tree-1-trunk', 'obj-tree-1-canopy')",
|
|
||||||
"Object element keys start with 'obj-'",
|
|
||||||
"The root element 'scene' is a Group whose children lists all top-level element keys",
|
|
||||||
"When adding an object: add the element AND append the key to the scene children",
|
|
||||||
"COMPOSE objects into recognizable structures. TREE RECIPE: trunk = GameCylinder with radiusTop 0.12-0.2, radiusBottom 0.15-0.25, height 3-6, color '#8B4513' or '#5C3317', position Y = height/2. Canopy = GameSphere with radius 1.5-3, color '#228B22' or '#2E8B57', position Y = trunk height + canopy radius * 0.7. The trunk must be TALL and THIN (radius << height). HOUSE: GameBox walls (width 3, height 2.5, depth 3) + GameCone roof on top. LAMP: GameCylinder radiusTop 0.05, radiusBottom 0.05, height 3 + GameSphere radius 0.2 with emissive material at top. NEVER scatter disconnected primitives randomly.",
|
|
||||||
"SPATIAL COHERENCE: related parts share the same X/Z base. Stack vertically with exact Y math: if trunk height=4, trunk Y=2 (half height). Canopy radius=2, canopy Y = 4 + 2*0.7 = 5.4. Objects that belong together must visually touch or overlap.",
|
|
||||||
"Be AMBITIOUS and THOROUGH: when the user asks for something, go all-in — build 10-20+ composed structures with varied sizes, not just a handful of loose shapes",
|
|
||||||
"REPEATING DETAILS: when placing repeating elements (road dashes, fence posts, windows, floor tiles, pillars, streetlights along a road), space them evenly and use ENOUGH to cover the full length/area. A 100-unit road needs 20+ dashes, not 5. A building face 10 units wide needs 4-5 windows per floor, not 1. Do NOT be lazy with repetition — density sells the scene.",
|
|
||||||
"Always set castShadow on lights and objects, receiveShadow on ground/floors, and use materials with varied colors, metalness, roughness for visual richness",
|
|
||||||
"When building environments, include atmosphere (Sky, Fog, ambient light), ground plane with receiveShadow, and decorative elements — make it feel like a complete, coherent scene",
|
|
||||||
];
|
|
||||||
|
|
||||||
function serializeSpec(spec: Spec): string {
|
|
||||||
return stringify(spec, { indent: 2, lineWidth: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSystemPrompt(): string {
|
|
||||||
return yamlPrompt(catalog, {
|
|
||||||
system:
|
|
||||||
"You are an expert 3D level designer AI for a json-render game engine. You build rich, detailed, immersive scenes using YAML patch operations. When the user describes a scene or asks for changes, go big — create dense, visually interesting environments with many objects, proper lighting, shadows, physics, varied materials, and atmospheric effects. Think like a AAA game level designer, not a minimal prototype builder.",
|
|
||||||
mode: "standalone",
|
|
||||||
editModes: ["merge"],
|
|
||||||
customRules: GAME_RULES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateUserPrompt(
|
|
||||||
userPrompt: string,
|
|
||||||
currentSpec: Spec,
|
|
||||||
previousPrompts: string[] = [],
|
|
||||||
): string {
|
|
||||||
const prevContext =
|
|
||||||
previousPrompts.length > 0
|
|
||||||
? `Previous instructions:\n${previousPrompts.join("\n")}\n\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
prevContext +
|
|
||||||
buildEditUserPrompt({
|
|
||||||
prompt: userPrompt,
|
|
||||||
currentSpec,
|
|
||||||
config: { modes: ["merge"] },
|
|
||||||
format: "yaml",
|
|
||||||
serializer: serializeSpec,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type * as THREE from "three";
|
|
||||||
|
|
||||||
export function resetBoneScales(object: THREE.Object3D) {
|
|
||||||
object.traverse((node) => {
|
|
||||||
if ((node as THREE.Bone).isBone) {
|
|
||||||
node.scale.set(1, 1, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { defineCatalog } from "@json-render/core";
|
|
||||||
import { schema } from "@json-render/react/schema";
|
|
||||||
import { threeComponentDefinitions } from "@json-render/react-three-fiber/catalog";
|
|
||||||
|
|
||||||
const vector3Schema = z.tuple([z.number(), z.number(), z.number()]);
|
|
||||||
|
|
||||||
const materialSchema = z.object({
|
|
||||||
color: z.string().nullable(),
|
|
||||||
metalness: z.number().nullable(),
|
|
||||||
roughness: z.number().nullable(),
|
|
||||||
emissive: z.string().nullable(),
|
|
||||||
emissiveIntensity: z.number().nullable(),
|
|
||||||
opacity: z.number().nullable(),
|
|
||||||
transparent: z.boolean().nullable(),
|
|
||||||
wireframe: z.boolean().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const transformProps = {
|
|
||||||
position: vector3Schema.nullable(),
|
|
||||||
rotation: vector3Schema.nullable(),
|
|
||||||
scale: vector3Schema.nullable(),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const shadowProps = {
|
|
||||||
castShadow: z.boolean().nullable(),
|
|
||||||
receiveShadow: z.boolean().nullable(),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const physicsSchema = z.object({
|
|
||||||
mass: z.number().nullable(),
|
|
||||||
isStatic: z.boolean().nullable(),
|
|
||||||
restitution: z.number().nullable(),
|
|
||||||
friction: z.number().nullable(),
|
|
||||||
colliderType: z.enum(["cuboid", "ball", "capsule", "none"]).nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const damageSchema = z.object({
|
|
||||||
amount: z.number().nullable(),
|
|
||||||
enabled: z.boolean().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const gameTransformShadowPhysicsDamage = {
|
|
||||||
...transformProps,
|
|
||||||
...shadowProps,
|
|
||||||
material: materialSchema.nullable(),
|
|
||||||
physics: physicsSchema.nullable(),
|
|
||||||
damage: damageSchema.nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const gameComponentDefinitions = {
|
|
||||||
// Keep all non-primitive R3F components as-is
|
|
||||||
AmbientLight: threeComponentDefinitions.AmbientLight,
|
|
||||||
DirectionalLight: threeComponentDefinitions.DirectionalLight,
|
|
||||||
PointLight: threeComponentDefinitions.PointLight,
|
|
||||||
SpotLight: threeComponentDefinitions.SpotLight,
|
|
||||||
Group: threeComponentDefinitions.Group,
|
|
||||||
Model: threeComponentDefinitions.Model,
|
|
||||||
Environment: threeComponentDefinitions.Environment,
|
|
||||||
Fog: threeComponentDefinitions.Fog,
|
|
||||||
GridHelper: threeComponentDefinitions.GridHelper,
|
|
||||||
Text3D: threeComponentDefinitions.Text3D,
|
|
||||||
Sparkles: threeComponentDefinitions.Sparkles,
|
|
||||||
Stars: threeComponentDefinitions.Stars,
|
|
||||||
Sky: threeComponentDefinitions.Sky,
|
|
||||||
Cloud: threeComponentDefinitions.Cloud,
|
|
||||||
ContactShadows: threeComponentDefinitions.ContactShadows,
|
|
||||||
Float: threeComponentDefinitions.Float,
|
|
||||||
EffectComposer: threeComponentDefinitions.EffectComposer,
|
|
||||||
Bloom: threeComponentDefinitions.Bloom,
|
|
||||||
Vignette: threeComponentDefinitions.Vignette,
|
|
||||||
PerspectiveCamera: threeComponentDefinitions.PerspectiveCamera,
|
|
||||||
OrbitControls: threeComponentDefinitions.OrbitControls,
|
|
||||||
|
|
||||||
// Game-specific primitives with physics & damage
|
|
||||||
GameBox: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
width: z.number().nullable(),
|
|
||||||
height: z.number().nullable(),
|
|
||||||
depth: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Box with optional physics and damage. ALWAYS set width, height, depth to create varied shapes — wide flat platforms (4, 0.2, 4), tall walls (0.3, 3, 5), long beams (6, 0.3, 0.3). Avoid 1x1x1 cubes.",
|
|
||||||
example: {
|
|
||||||
position: [0, 1.5, 0],
|
|
||||||
width: 2,
|
|
||||||
height: 3,
|
|
||||||
depth: 0.3,
|
|
||||||
material: { color: "#4488ff" },
|
|
||||||
physics: { mass: 1, isStatic: true, colliderType: "cuboid" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameSphere: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
widthSegments: z.number().nullable(),
|
|
||||||
heightSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Sphere with optional physics and damage. Set radius explicitly (0.2 for small details, 1-3 for tree canopies, 0.5 for default).",
|
|
||||||
example: {
|
|
||||||
position: [0, 1.5, 0],
|
|
||||||
radius: 1.5,
|
|
||||||
material: { color: "#228B22" },
|
|
||||||
castShadow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameCylinder: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radiusTop: z.number().nullable(),
|
|
||||||
radiusBottom: z.number().nullable(),
|
|
||||||
height: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Cylinder with optional physics and damage. Set radiusTop/radiusBottom to control thickness and height for tall/thin shapes (e.g. tree trunk: radiusTop 0.15, radiusBottom 0.2, height 4).",
|
|
||||||
example: {
|
|
||||||
position: [0, 2, 0],
|
|
||||||
radiusTop: 0.15,
|
|
||||||
radiusBottom: 0.2,
|
|
||||||
height: 4,
|
|
||||||
material: { color: "#8B4513" },
|
|
||||||
castShadow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameCone: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
height: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Cone with optional physics and damage. Set radius and height for varied shapes (e.g. roof: radius 2.5, height 1.5).",
|
|
||||||
example: {
|
|
||||||
position: [0, 3.5, 0],
|
|
||||||
radius: 2.5,
|
|
||||||
height: 1.5,
|
|
||||||
material: { color: "#8B0000" },
|
|
||||||
castShadow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameTorus: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
tube: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
tubularSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Torus (ring) with optional physics and damage. Set radius and tube to control ring size and thickness.",
|
|
||||||
example: {
|
|
||||||
position: [0, 1, 0],
|
|
||||||
radius: 1,
|
|
||||||
tube: 0.15,
|
|
||||||
material: { color: "#ff44ff" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GamePlane: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
width: z.number().nullable(),
|
|
||||||
height: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Plane with optional physics. Use for floors and walls. Rotate [-PI/2, 0, 0] for ground.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0, 0],
|
|
||||||
rotation: [-1.5708, 0, 0],
|
|
||||||
scale: [10, 10, 1],
|
|
||||||
material: { color: "#888888" },
|
|
||||||
physics: { isStatic: true, colliderType: "cuboid" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameCapsule: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
length: z.number().nullable(),
|
|
||||||
capSegments: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Capsule with optional physics and damage.",
|
|
||||||
example: {
|
|
||||||
position: [0, 1, 0],
|
|
||||||
material: { color: "#00cccc" },
|
|
||||||
physics: { mass: 1, colliderType: "capsule" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameKnot: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
tube: z.number().nullable(),
|
|
||||||
tubularSegments: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
p: z.number().nullable(),
|
|
||||||
q: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Torus knot with optional physics and damage.",
|
|
||||||
example: {
|
|
||||||
position: [0, 1, 0],
|
|
||||||
radius: 1,
|
|
||||||
tube: 0.3,
|
|
||||||
material: { color: "#ff44ff" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameTetrahedron: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Tetrahedron with optional physics and damage.",
|
|
||||||
},
|
|
||||||
|
|
||||||
GameOctahedron: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Octahedron with optional physics and damage.",
|
|
||||||
},
|
|
||||||
|
|
||||||
GameDodecahedron: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Dodecahedron with optional physics and damage.",
|
|
||||||
},
|
|
||||||
|
|
||||||
GameIcosahedron: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Icosahedron with optional physics and damage.",
|
|
||||||
},
|
|
||||||
|
|
||||||
GameExtrude: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
shapeData: z
|
|
||||||
.object({
|
|
||||||
points: z.array(z.tuple([z.number(), z.number()])),
|
|
||||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).nullable(),
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
depth: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Extruded 2D shape into 3D with optional physics and damage.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0.5, 0],
|
|
||||||
shapeData: {
|
|
||||||
points: [
|
|
||||||
[-0.5, -0.5],
|
|
||||||
[0.5, -0.5],
|
|
||||||
[0.5, 0.5],
|
|
||||||
[-0.5, 0.5],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
depth: 1,
|
|
||||||
material: { color: "#ff8800" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameTube: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
radius: z.number().nullable(),
|
|
||||||
tubularSegments: z.number().nullable(),
|
|
||||||
radialSegments: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Tube geometry following a curve with optional physics.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0.5, 0],
|
|
||||||
radius: 0.1,
|
|
||||||
material: { color: "#00ccff" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameShape: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
shapeData: z
|
|
||||||
.object({
|
|
||||||
points: z.array(z.tuple([z.number(), z.number()])),
|
|
||||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).nullable(),
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"2D shape geometry (flat polygon) with optional physics and damage.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0.5, 0],
|
|
||||||
shapeData: {
|
|
||||||
points: [
|
|
||||||
[-0.5, -0.5],
|
|
||||||
[0.5, -0.5],
|
|
||||||
[0, 0.5],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
material: { color: "#ff00ff" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
GameMesh: {
|
|
||||||
props: z.object({
|
|
||||||
...gameTransformShadowPhysicsDamage,
|
|
||||||
meshData: z
|
|
||||||
.object({
|
|
||||||
vertices: z.array(z.number()),
|
|
||||||
indices: z.array(z.number()),
|
|
||||||
normals: z.array(z.number()).nullable(),
|
|
||||||
uvs: z.array(z.number()).nullable(),
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Custom mesh with raw vertex data. Specify vertices, indices, normals, and UVs.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0.5, 0],
|
|
||||||
meshData: {
|
|
||||||
vertices: [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
|
|
||||||
indices: [0, 1, 2],
|
|
||||||
},
|
|
||||||
material: { color: "#88ff00" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Unified light component
|
|
||||||
GameLight: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
lightType: z.enum(["ambient", "directional", "point", "spot"]),
|
|
||||||
color: z.string().nullable(),
|
|
||||||
intensity: z.number().nullable(),
|
|
||||||
distance: z.number().nullable(),
|
|
||||||
decay: z.number().nullable(),
|
|
||||||
angle: z.number().nullable(),
|
|
||||||
penumbra: z.number().nullable(),
|
|
||||||
castShadow: z.boolean().nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Unified light component. Set lightType to ambient, directional, point, or spot.",
|
|
||||||
example: {
|
|
||||||
lightType: "directional",
|
|
||||||
position: [5, 10, 5],
|
|
||||||
intensity: 1,
|
|
||||||
castShadow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Player controller
|
|
||||||
Player: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
isPlayer: z.boolean().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"Player spawn point. Enables first/third-person controls when playing.",
|
|
||||||
example: {
|
|
||||||
position: [0, 0, 0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Character NPC
|
|
||||||
GameCharacter: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
...shadowProps,
|
|
||||||
modelUrl: z.string(),
|
|
||||||
role: z.string().nullable(),
|
|
||||||
physics: physicsSchema.nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
"NPC character with GLTF model, proximity interaction, and AI dialogue.",
|
|
||||||
example: {
|
|
||||||
position: [3, 0, 0],
|
|
||||||
modelUrl: "/models/character.glb",
|
|
||||||
role: "village guard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Game model with physics
|
|
||||||
GameModel: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
...shadowProps,
|
|
||||||
url: z.string(),
|
|
||||||
physics: physicsSchema.nullable(),
|
|
||||||
damage: damageSchema.nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
description: "GLTF/GLB 3D model with optional physics and damage.",
|
|
||||||
example: {
|
|
||||||
url: "/models/crate.glb",
|
|
||||||
position: [0, 0, 0],
|
|
||||||
physics: { isStatic: true, colliderType: "cuboid" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Sound emitter
|
|
||||||
SoundEmitter: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
url: z.string(),
|
|
||||||
loop: z.boolean().nullable(),
|
|
||||||
volume: z.number().nullable(),
|
|
||||||
positional: z.boolean().nullable(),
|
|
||||||
distance: z.number().nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Positional or global audio emitter.",
|
|
||||||
example: {
|
|
||||||
position: [0, 1, 0],
|
|
||||||
url: "/sounds/ambient.mp3",
|
|
||||||
loop: true,
|
|
||||||
positional: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Media plane
|
|
||||||
MediaPlane: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
...shadowProps,
|
|
||||||
url: z.string(),
|
|
||||||
mediaType: z.enum(["image", "video"]),
|
|
||||||
loop: z.boolean().nullable(),
|
|
||||||
autoplay: z.boolean().nullable(),
|
|
||||||
muted: z.boolean().nullable(),
|
|
||||||
width: z.number().nullable(),
|
|
||||||
height: z.number().nullable(),
|
|
||||||
objectId: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Image or video displayed on a 3D plane.",
|
|
||||||
example: {
|
|
||||||
position: [0, 2, -3],
|
|
||||||
url: "/images/poster.jpg",
|
|
||||||
mediaType: "image",
|
|
||||||
width: 3,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Ground plane (physics-enabled floor)
|
|
||||||
GroundPlane: {
|
|
||||||
props: z.object({
|
|
||||||
...transformProps,
|
|
||||||
material: materialSchema.nullable(),
|
|
||||||
size: z.number().nullable(),
|
|
||||||
}),
|
|
||||||
description: "Physics-enabled ground plane.",
|
|
||||||
example: {
|
|
||||||
position: [0, -0.1, 0],
|
|
||||||
size: 5000,
|
|
||||||
material: { color: "#4CAF50" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameComponentDefinitions = typeof gameComponentDefinitions;
|
|
||||||
|
|
||||||
export const catalog = defineCatalog(schema, {
|
|
||||||
components: gameComponentDefinitions,
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import type {
|
|
||||||
SceneObject,
|
|
||||||
Scene,
|
|
||||||
SceneSettings,
|
|
||||||
EnvironmentSettings,
|
|
||||||
ObjectType,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export function createDefaultGrass(): SceneObject {
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: "Grass",
|
|
||||||
description: "Large grass plane",
|
|
||||||
type: "plane",
|
|
||||||
position: [0, -0.1, 0],
|
|
||||||
rotation: [-Math.PI / 2, 0, 0],
|
|
||||||
scale: [5000, 5000, 1],
|
|
||||||
material: {
|
|
||||||
color: "#4CAF50",
|
|
||||||
metalness: 0.0,
|
|
||||||
roughness: 0.9,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
},
|
|
||||||
physics: {
|
|
||||||
mass: 0,
|
|
||||||
isStatic: true,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.8,
|
|
||||||
colliderType: "cuboid",
|
|
||||||
},
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultAmbientLight(): SceneObject {
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: "Ambient Light",
|
|
||||||
description: "Default ambient light",
|
|
||||||
type: "light",
|
|
||||||
position: [0, 3, 0],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
material: {
|
|
||||||
color: "#ffffff",
|
|
||||||
metalness: 0,
|
|
||||||
roughness: 1,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
},
|
|
||||||
physics: {
|
|
||||||
mass: 1,
|
|
||||||
isStatic: false,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.5,
|
|
||||||
colliderType: "none",
|
|
||||||
},
|
|
||||||
visible: true,
|
|
||||||
intensity: 0.5,
|
|
||||||
lightType: "ambient",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultDirectionalLight(): SceneObject {
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: "Directional Light",
|
|
||||||
description: "Default directional light",
|
|
||||||
type: "light",
|
|
||||||
position: [10, 10, 10],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
material: {
|
|
||||||
color: "#ffffff",
|
|
||||||
metalness: 0,
|
|
||||||
roughness: 1,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
},
|
|
||||||
physics: {
|
|
||||||
mass: 1,
|
|
||||||
isStatic: false,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.5,
|
|
||||||
colliderType: "none",
|
|
||||||
},
|
|
||||||
visible: true,
|
|
||||||
intensity: 1,
|
|
||||||
lightType: "directional",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultPlayer(): SceneObject {
|
|
||||||
return {
|
|
||||||
id: "player-" + uuidv4(),
|
|
||||||
name: "Player",
|
|
||||||
description: "Default player",
|
|
||||||
type: "player",
|
|
||||||
position: [0, 0, 0],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
material: {
|
|
||||||
color: "#3498db",
|
|
||||||
metalness: 0.1,
|
|
||||||
roughness: 0.5,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
},
|
|
||||||
physics: {
|
|
||||||
mass: 1,
|
|
||||||
isStatic: false,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.5,
|
|
||||||
colliderType: "none",
|
|
||||||
},
|
|
||||||
visible: true,
|
|
||||||
isPlayer: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultSceneSettings(): SceneSettings {
|
|
||||||
return {
|
|
||||||
grid: {
|
|
||||||
visible: true,
|
|
||||||
size: 1,
|
|
||||||
divisions: 100,
|
|
||||||
color: "#444444",
|
|
||||||
secondaryColor: "#888888",
|
|
||||||
fadeDistance: 100,
|
|
||||||
fadeStrength: 1,
|
|
||||||
},
|
|
||||||
fog: {
|
|
||||||
enabled: false,
|
|
||||||
color: "#cccccc",
|
|
||||||
near: 1,
|
|
||||||
far: 50,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultEnvironmentSettings(): EnvironmentSettings {
|
|
||||||
return {
|
|
||||||
preset: "city",
|
|
||||||
customHdri: null,
|
|
||||||
intensity: 1,
|
|
||||||
blur: 0.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultScene(name = "Scene 1"): Scene {
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
name,
|
|
||||||
description: "",
|
|
||||||
isDefault: true,
|
|
||||||
objects: [
|
|
||||||
createDefaultGrass(),
|
|
||||||
createDefaultAmbientLight(),
|
|
||||||
createDefaultDirectionalLight(),
|
|
||||||
createDefaultPlayer(),
|
|
||||||
],
|
|
||||||
sceneSettings: createDefaultSceneSettings(),
|
|
||||||
environmentSettings: createDefaultEnvironmentSettings(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultObject(
|
|
||||||
type: ObjectType,
|
|
||||||
id: string,
|
|
||||||
objectCount: number,
|
|
||||||
): SceneObject {
|
|
||||||
const obj: SceneObject = {
|
|
||||||
id,
|
|
||||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} ${objectCount + 1}`,
|
|
||||||
description: "",
|
|
||||||
type,
|
|
||||||
position: [
|
|
||||||
0,
|
|
||||||
type === "box" ||
|
|
||||||
type === "cylinder" ||
|
|
||||||
type === "cone" ||
|
|
||||||
type === "torus"
|
|
||||||
? 0.5
|
|
||||||
: 0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
material: {
|
|
||||||
color:
|
|
||||||
type === "light"
|
|
||||||
? "#ffffff"
|
|
||||||
: type === "player"
|
|
||||||
? "#3498db"
|
|
||||||
: `#${Math.floor(Math.random() * 16777215)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(6, "0")}`,
|
|
||||||
metalness: 0.1,
|
|
||||||
roughness: 0.5,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
},
|
|
||||||
physics: {
|
|
||||||
mass: 1,
|
|
||||||
isStatic: false,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.5,
|
|
||||||
colliderType:
|
|
||||||
type === "sphere" ? "ball" : type === "capsule" ? "capsule" : "cuboid",
|
|
||||||
},
|
|
||||||
damage: {
|
|
||||||
amount: 0,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === "capsule" ||
|
|
||||||
type === "extrude" ||
|
|
||||||
type === "tetrahedron" ||
|
|
||||||
type === "octahedron" ||
|
|
||||||
type === "dodecahedron" ||
|
|
||||||
type === "icosahedron" ||
|
|
||||||
type === "knot" ||
|
|
||||||
type === "tube" ||
|
|
||||||
type === "shape" ||
|
|
||||||
type === "mesh"
|
|
||||||
) {
|
|
||||||
obj.position = [0, 0.5, 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "light") {
|
|
||||||
obj.intensity = 1;
|
|
||||||
obj.distance = 0;
|
|
||||||
obj.decay = 2;
|
|
||||||
obj.lightType = "point";
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "plane") {
|
|
||||||
obj.rotation = [-Math.PI / 2, 0, 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "player") {
|
|
||||||
obj.name = "Player";
|
|
||||||
obj.position = [0, 0, 0];
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
obj.isPlayer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "image" || type === "video") {
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
obj.physics.isStatic = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "shape") {
|
|
||||||
obj.shapeData = {
|
|
||||||
points: [
|
|
||||||
[-0.5, -0.5],
|
|
||||||
[0.5, -0.5],
|
|
||||||
[0.5, 0.5],
|
|
||||||
[-0.5, 0.5],
|
|
||||||
],
|
|
||||||
holes: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "mesh") {
|
|
||||||
obj.meshData = {
|
|
||||||
vertices: [
|
|
||||||
-0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5, -0.5,
|
|
||||||
-0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5,
|
|
||||||
-0.5, -0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5,
|
|
||||||
0.5, -0.5, -0.5, 0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5,
|
|
||||||
0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, -0.5, -0.5, -0.5,
|
|
||||||
0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5,
|
|
||||||
],
|
|
||||||
indices: [
|
|
||||||
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12,
|
|
||||||
14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
|
||||||
import { Redis } from "@upstash/redis";
|
|
||||||
|
|
||||||
let _minuteRateLimit: Ratelimit | null = null;
|
|
||||||
let _dailyRateLimit: Ratelimit | null = null;
|
|
||||||
|
|
||||||
function getRedis(): Redis | null {
|
|
||||||
const url = process.env.KV_REST_API_URL;
|
|
||||||
const token = process.env.KV_REST_API_TOKEN;
|
|
||||||
|
|
||||||
if (!url || !token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Redis({ url, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
const noopRateLimiter = {
|
|
||||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
|
||||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
|
||||||
|
|
||||||
export const minuteRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_minuteRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_minuteRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
|
||||||
prefix: "ratelimit:game-engine:minute",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _minuteRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dailyRateLimit = {
|
|
||||||
limit: async (identifier: string) => {
|
|
||||||
if (!_dailyRateLimit) {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return noopRateLimiter.limit();
|
|
||||||
_dailyRateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
|
||||||
prefix: "ratelimit:game-engine:daily",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return _dailyRateLimit.limit(identifier);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
||||||
// @ts-nocheck
|
|
||||||
import { defineRegistry } from "@json-render/react";
|
|
||||||
import { threeComponents } from "@json-render/react-three-fiber";
|
|
||||||
import { catalog } from "./catalog";
|
|
||||||
import {
|
|
||||||
GameBox,
|
|
||||||
GameSphere,
|
|
||||||
GameCylinder,
|
|
||||||
GameCone,
|
|
||||||
GameTorus,
|
|
||||||
GamePlane,
|
|
||||||
GameCapsule,
|
|
||||||
GameKnot,
|
|
||||||
GameTetrahedron,
|
|
||||||
GameOctahedron,
|
|
||||||
GameDodecahedron,
|
|
||||||
GameIcosahedron,
|
|
||||||
GameExtrude,
|
|
||||||
GameTube,
|
|
||||||
GameShape,
|
|
||||||
GameMesh,
|
|
||||||
} from "@/components/game/game-primitives";
|
|
||||||
import { GameLight } from "@/components/game/game-light";
|
|
||||||
import { Player } from "@/components/game/player";
|
|
||||||
import { GameCharacter } from "@/components/game/character";
|
|
||||||
import { SoundEmitter } from "@/components/game/sound-emitter";
|
|
||||||
import { MediaPlane } from "@/components/game/media-plane";
|
|
||||||
import { GroundPlane } from "@/components/game/ground-plane";
|
|
||||||
import { GameModel as GameModelComponent } from "@/components/game/model-wrapper";
|
|
||||||
|
|
||||||
const gameComponents = {
|
|
||||||
GameBox: ({ props, children }) => <GameBox {...props}>{children}</GameBox>,
|
|
||||||
GameSphere: ({ props, children }) => (
|
|
||||||
<GameSphere {...props}>{children}</GameSphere>
|
|
||||||
),
|
|
||||||
GameCylinder: ({ props, children }) => (
|
|
||||||
<GameCylinder {...props}>{children}</GameCylinder>
|
|
||||||
),
|
|
||||||
GameCone: ({ props, children }) => <GameCone {...props}>{children}</GameCone>,
|
|
||||||
GameTorus: ({ props, children }) => (
|
|
||||||
<GameTorus {...props}>{children}</GameTorus>
|
|
||||||
),
|
|
||||||
GamePlane: ({ props, children }) => (
|
|
||||||
<GamePlane {...props}>{children}</GamePlane>
|
|
||||||
),
|
|
||||||
GameCapsule: ({ props, children }) => (
|
|
||||||
<GameCapsule {...props}>{children}</GameCapsule>
|
|
||||||
),
|
|
||||||
GameKnot: ({ props, children }) => <GameKnot {...props}>{children}</GameKnot>,
|
|
||||||
GameTetrahedron: ({ props, children }) => (
|
|
||||||
<GameTetrahedron {...props}>{children}</GameTetrahedron>
|
|
||||||
),
|
|
||||||
GameOctahedron: ({ props, children }) => (
|
|
||||||
<GameOctahedron {...props}>{children}</GameOctahedron>
|
|
||||||
),
|
|
||||||
GameDodecahedron: ({ props, children }) => (
|
|
||||||
<GameDodecahedron {...props}>{children}</GameDodecahedron>
|
|
||||||
),
|
|
||||||
GameIcosahedron: ({ props, children }) => (
|
|
||||||
<GameIcosahedron {...props}>{children}</GameIcosahedron>
|
|
||||||
),
|
|
||||||
GameExtrude: ({ props, children }) => (
|
|
||||||
<GameExtrude {...props}>{children}</GameExtrude>
|
|
||||||
),
|
|
||||||
GameTube: ({ props, children }) => <GameTube {...props}>{children}</GameTube>,
|
|
||||||
GameShape: ({ props, children }) => (
|
|
||||||
<GameShape {...props}>{children}</GameShape>
|
|
||||||
),
|
|
||||||
GameMesh: ({ props, children }) => <GameMesh {...props}>{children}</GameMesh>,
|
|
||||||
GameLight: ({ props }) => <GameLight {...props} />,
|
|
||||||
Player: ({ props }) => <Player {...props} />,
|
|
||||||
GameCharacter: ({ props }) => <GameCharacter {...props} />,
|
|
||||||
GameModel: ({ props }) => <GameModelComponent {...props} />,
|
|
||||||
SoundEmitter: ({ props }) => <SoundEmitter {...props} />,
|
|
||||||
MediaPlane: ({ props }) => <MediaPlane {...props} />,
|
|
||||||
GroundPlane: ({ props }) => <GroundPlane {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allComponents = {
|
|
||||||
...threeComponents,
|
|
||||||
...gameComponents,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const { registry } = defineRegistry(catalog, {
|
|
||||||
components: allComponents,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { catalog };
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import type { Scene, SceneObject } from "./types";
|
|
||||||
|
|
||||||
interface SpecElement {
|
|
||||||
type: string;
|
|
||||||
props: Record<string, unknown>;
|
|
||||||
children: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Spec {
|
|
||||||
root: string;
|
|
||||||
elements: Record<string, SpecElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const primitiveTypeMap: Record<string, string> = {
|
|
||||||
box: "GameBox",
|
|
||||||
sphere: "GameSphere",
|
|
||||||
cylinder: "GameCylinder",
|
|
||||||
cone: "GameCone",
|
|
||||||
torus: "GameTorus",
|
|
||||||
plane: "GamePlane",
|
|
||||||
capsule: "GameCapsule",
|
|
||||||
knot: "GameKnot",
|
|
||||||
tetrahedron: "GameTetrahedron",
|
|
||||||
octahedron: "GameOctahedron",
|
|
||||||
dodecahedron: "GameDodecahedron",
|
|
||||||
icosahedron: "GameIcosahedron",
|
|
||||||
extrude: "GameExtrude",
|
|
||||||
tube: "GameTube",
|
|
||||||
shape: "GameShape",
|
|
||||||
mesh: "GameMesh",
|
|
||||||
};
|
|
||||||
|
|
||||||
function objectToSpecType(obj: SceneObject): string {
|
|
||||||
if (obj.type === "light") return "GameLight";
|
|
||||||
if (obj.type === "player") return "Player";
|
|
||||||
if (obj.type === "character") return "GameCharacter";
|
|
||||||
if (obj.type === "model") return "GameModel";
|
|
||||||
if (obj.type === "sound") return "SoundEmitter";
|
|
||||||
if (obj.type === "image" || obj.type === "video") return "MediaPlane";
|
|
||||||
return primitiveTypeMap[obj.type] || "GameBox";
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectToProps(obj: SceneObject): Record<string, unknown> {
|
|
||||||
const props: Record<string, unknown> = {
|
|
||||||
position: obj.position,
|
|
||||||
rotation: obj.rotation,
|
|
||||||
scale: obj.scale,
|
|
||||||
objectId: obj.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const specType = objectToSpecType(obj);
|
|
||||||
|
|
||||||
if (specType === "GameLight") {
|
|
||||||
props.lightType = obj.lightType || "point";
|
|
||||||
props.color = obj.material.color;
|
|
||||||
props.intensity = obj.intensity ?? 1;
|
|
||||||
if (obj.distance) props.distance = obj.distance;
|
|
||||||
if (obj.decay) props.decay = obj.decay;
|
|
||||||
if (obj.angle) props.angle = obj.angle;
|
|
||||||
if (obj.penumbra) props.penumbra = obj.penumbra;
|
|
||||||
props.castShadow =
|
|
||||||
obj.lightType === "directional" || obj.lightType === "spot";
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specType === "Player") {
|
|
||||||
props.isPlayer = true;
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specType === "GameCharacter") {
|
|
||||||
props.modelUrl = obj.modelUrl || "";
|
|
||||||
props.role = obj.character?.role || "villager";
|
|
||||||
if (obj.physics.colliderType !== "none") {
|
|
||||||
props.physics = obj.physics;
|
|
||||||
}
|
|
||||||
props.castShadow = true;
|
|
||||||
props.receiveShadow = true;
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specType === "GameModel") {
|
|
||||||
props.url = obj.modelUrl || "";
|
|
||||||
if (obj.physics.colliderType !== "none") {
|
|
||||||
props.physics = obj.physics;
|
|
||||||
}
|
|
||||||
if (obj.damage?.enabled) {
|
|
||||||
props.damage = obj.damage;
|
|
||||||
}
|
|
||||||
props.castShadow = true;
|
|
||||||
props.receiveShadow = true;
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specType === "SoundEmitter") {
|
|
||||||
props.url = obj.sound?.url || "";
|
|
||||||
props.loop = obj.sound?.loop ?? false;
|
|
||||||
props.volume = obj.sound?.volume ?? 1;
|
|
||||||
props.positional = obj.sound?.positional ?? true;
|
|
||||||
props.distance = obj.sound?.distance ?? 10;
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specType === "MediaPlane") {
|
|
||||||
props.url = obj.media?.url || "";
|
|
||||||
props.mediaType = obj.type === "video" ? "video" : "image";
|
|
||||||
props.loop = obj.media?.loop;
|
|
||||||
props.autoplay = obj.media?.autoplay;
|
|
||||||
props.muted = obj.media?.muted;
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game primitives
|
|
||||||
props.material = obj.material;
|
|
||||||
if (obj.physics.colliderType !== "none") {
|
|
||||||
props.physics = obj.physics;
|
|
||||||
}
|
|
||||||
if (obj.damage?.enabled) {
|
|
||||||
props.damage = obj.damage;
|
|
||||||
}
|
|
||||||
if (obj.shapeData) {
|
|
||||||
props.shapeData = obj.shapeData;
|
|
||||||
}
|
|
||||||
if (obj.meshData) {
|
|
||||||
props.meshData = obj.meshData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geometry props
|
|
||||||
if (obj.width != null) props.width = obj.width;
|
|
||||||
if (obj.height != null) props.height = obj.height;
|
|
||||||
if (obj.depth != null) props.depth = obj.depth;
|
|
||||||
if (obj.radius != null) props.radius = obj.radius;
|
|
||||||
if (obj.radiusTop != null) props.radiusTop = obj.radiusTop;
|
|
||||||
if (obj.radiusBottom != null) props.radiusBottom = obj.radiusBottom;
|
|
||||||
if (obj.radialSegments != null) props.radialSegments = obj.radialSegments;
|
|
||||||
if (obj.tube != null) props.tube = obj.tube;
|
|
||||||
if (obj.tubularSegments != null) props.tubularSegments = obj.tubularSegments;
|
|
||||||
if (obj.widthSegments != null) props.widthSegments = obj.widthSegments;
|
|
||||||
if (obj.heightSegments != null) props.heightSegments = obj.heightSegments;
|
|
||||||
if (obj.length != null) props.length = obj.length;
|
|
||||||
if (obj.p != null) props.p = obj.p;
|
|
||||||
if (obj.q != null) props.q = obj.q;
|
|
||||||
|
|
||||||
props.castShadow = true;
|
|
||||||
props.receiveShadow = true;
|
|
||||||
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sceneToSpec(scene: Scene): Spec {
|
|
||||||
const elements: Record<string, SpecElement> = {};
|
|
||||||
const childIds: string[] = [];
|
|
||||||
|
|
||||||
// Add environment
|
|
||||||
elements["env"] = {
|
|
||||||
type: "Environment",
|
|
||||||
props: {
|
|
||||||
preset: scene.environmentSettings.preset,
|
|
||||||
background: true,
|
|
||||||
blur: scene.environmentSettings.blur,
|
|
||||||
intensity: scene.environmentSettings.intensity,
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
childIds.push("env");
|
|
||||||
|
|
||||||
// Add fog if enabled
|
|
||||||
if (scene.sceneSettings.fog.enabled) {
|
|
||||||
elements["fog"] = {
|
|
||||||
type: "Fog",
|
|
||||||
props: {
|
|
||||||
color: scene.sceneSettings.fog.color,
|
|
||||||
near: scene.sceneSettings.fog.near,
|
|
||||||
far: scene.sceneSettings.fog.far,
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
childIds.push("fog");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add grid if visible
|
|
||||||
if (scene.sceneSettings.grid.visible) {
|
|
||||||
elements["grid"] = {
|
|
||||||
type: "GridHelper",
|
|
||||||
props: {
|
|
||||||
position: [0, 0.01, 0],
|
|
||||||
size:
|
|
||||||
scene.sceneSettings.grid.size * scene.sceneSettings.grid.divisions,
|
|
||||||
divisions: scene.sceneSettings.grid.divisions,
|
|
||||||
color: scene.sceneSettings.grid.color,
|
|
||||||
secondaryColor: scene.sceneSettings.grid.secondaryColor,
|
|
||||||
fadeDistance: scene.sceneSettings.grid.fadeDistance,
|
|
||||||
fadeStrength: scene.sceneSettings.grid.fadeStrength,
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
childIds.push("grid");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add scene objects
|
|
||||||
for (const obj of scene.objects) {
|
|
||||||
if (!obj.visible) continue;
|
|
||||||
|
|
||||||
const key = `obj-${obj.id}`;
|
|
||||||
elements[key] = {
|
|
||||||
type: objectToSpecType(obj),
|
|
||||||
props: objectToProps(obj),
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
childIds.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root scene group
|
|
||||||
elements["scene"] = {
|
|
||||||
type: "Group",
|
|
||||||
props: { position: null, rotation: null, scale: null },
|
|
||||||
children: childIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
root: "scene",
|
|
||||||
elements,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import type { Spec } from "@json-render/core";
|
|
||||||
import type {
|
|
||||||
SceneObject,
|
|
||||||
ObjectType,
|
|
||||||
LightType,
|
|
||||||
Material,
|
|
||||||
Physics,
|
|
||||||
ColliderType,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const specTypeToObjectType: Record<string, ObjectType> = {
|
|
||||||
GameBox: "box",
|
|
||||||
GameSphere: "sphere",
|
|
||||||
GameCylinder: "cylinder",
|
|
||||||
GameCone: "cone",
|
|
||||||
GameTorus: "torus",
|
|
||||||
GamePlane: "plane",
|
|
||||||
GameCapsule: "capsule",
|
|
||||||
GameKnot: "knot",
|
|
||||||
GameTetrahedron: "tetrahedron",
|
|
||||||
GameOctahedron: "octahedron",
|
|
||||||
GameDodecahedron: "dodecahedron",
|
|
||||||
GameIcosahedron: "icosahedron",
|
|
||||||
GameExtrude: "extrude",
|
|
||||||
GameTube: "tube",
|
|
||||||
GameShape: "shape",
|
|
||||||
GameMesh: "mesh",
|
|
||||||
GameLight: "light",
|
|
||||||
Player: "player",
|
|
||||||
GameCharacter: "character",
|
|
||||||
GameModel: "model",
|
|
||||||
SoundEmitter: "sound",
|
|
||||||
MediaPlane: "image",
|
|
||||||
GroundPlane: "plane",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_MATERIAL: Material = {
|
|
||||||
color: "#888888",
|
|
||||||
metalness: 0,
|
|
||||||
roughness: 0.5,
|
|
||||||
emissive: "#000000",
|
|
||||||
emissiveIntensity: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PHYSICS: Physics = {
|
|
||||||
mass: 1,
|
|
||||||
isStatic: false,
|
|
||||||
restitution: 0.3,
|
|
||||||
friction: 0.5,
|
|
||||||
colliderType: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseMaterial(raw: unknown): Material {
|
|
||||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_MATERIAL };
|
|
||||||
const m = raw as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
color: (m.color as string) || DEFAULT_MATERIAL.color,
|
|
||||||
metalness: (m.metalness as number) ?? DEFAULT_MATERIAL.metalness,
|
|
||||||
roughness: (m.roughness as number) ?? DEFAULT_MATERIAL.roughness,
|
|
||||||
emissive: (m.emissive as string) || DEFAULT_MATERIAL.emissive,
|
|
||||||
emissiveIntensity:
|
|
||||||
(m.emissiveIntensity as number) ?? DEFAULT_MATERIAL.emissiveIntensity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePhysics(raw: unknown): Physics {
|
|
||||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_PHYSICS };
|
|
||||||
const p = raw as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
mass: (p.mass as number) ?? DEFAULT_PHYSICS.mass,
|
|
||||||
isStatic: (p.isStatic as boolean) ?? DEFAULT_PHYSICS.isStatic,
|
|
||||||
restitution: (p.restitution as number) ?? DEFAULT_PHYSICS.restitution,
|
|
||||||
friction: (p.friction as number) ?? DEFAULT_PHYSICS.friction,
|
|
||||||
colliderType:
|
|
||||||
(p.colliderType as ColliderType) || DEFAULT_PHYSICS.colliderType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function specElementToSceneObject(
|
|
||||||
key: string,
|
|
||||||
element: {
|
|
||||||
type: string;
|
|
||||||
props: Record<string, unknown>;
|
|
||||||
children?: string[];
|
|
||||||
},
|
|
||||||
): SceneObject | null {
|
|
||||||
const objectType = specTypeToObjectType[element.type];
|
|
||||||
if (!objectType) return null;
|
|
||||||
|
|
||||||
const props = element.props;
|
|
||||||
|
|
||||||
const id = (props.objectId as string) || key.replace(/^obj-/, "") || uuidv4();
|
|
||||||
|
|
||||||
const obj: SceneObject = {
|
|
||||||
id,
|
|
||||||
name: (props.name as string) || element.type.replace(/^Game/, ""),
|
|
||||||
type: objectType,
|
|
||||||
position: (props.position as [number, number, number]) || [0, 0, 0],
|
|
||||||
rotation: (props.rotation as [number, number, number]) || [0, 0, 0],
|
|
||||||
scale: (props.scale as [number, number, number]) || [1, 1, 1],
|
|
||||||
material: parseMaterial(props.material),
|
|
||||||
physics: parsePhysics(props.physics),
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (props.damage && typeof props.damage === "object") {
|
|
||||||
const d = props.damage as Record<string, unknown>;
|
|
||||||
obj.damage = {
|
|
||||||
amount: (d.amount as number) ?? 0,
|
|
||||||
enabled: (d.enabled as boolean) ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "GameLight") {
|
|
||||||
obj.lightType = (props.lightType as LightType) || "point";
|
|
||||||
obj.intensity = (props.intensity as number) ?? 1;
|
|
||||||
if (props.color) obj.material.color = props.color as string;
|
|
||||||
if (props.distance != null) obj.distance = props.distance as number;
|
|
||||||
if (props.decay != null) obj.decay = props.decay as number;
|
|
||||||
if (props.angle != null) obj.angle = props.angle as number;
|
|
||||||
if (props.penumbra != null) obj.penumbra = props.penumbra as number;
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "Player") {
|
|
||||||
obj.isPlayer = true;
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "GameCharacter") {
|
|
||||||
obj.modelUrl = (props.modelUrl as string) || "";
|
|
||||||
obj.character = { role: (props.role as string) || "villager" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "GameModel") {
|
|
||||||
obj.modelUrl = (props.url as string) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "SoundEmitter") {
|
|
||||||
obj.sound = {
|
|
||||||
url: (props.url as string) || "",
|
|
||||||
loop: (props.loop as boolean) ?? false,
|
|
||||||
volume: (props.volume as number) ?? 1,
|
|
||||||
positional: (props.positional as boolean) ?? true,
|
|
||||||
distance: (props.distance as number) ?? 10,
|
|
||||||
};
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "MediaPlane") {
|
|
||||||
const mediaType = (props.mediaType as string) || "image";
|
|
||||||
obj.type = mediaType === "video" ? "video" : "image";
|
|
||||||
obj.media = {
|
|
||||||
url: (props.url as string) || "",
|
|
||||||
type: mediaType,
|
|
||||||
loop: props.loop as boolean | undefined,
|
|
||||||
autoplay: props.autoplay as boolean | undefined,
|
|
||||||
muted: props.muted as boolean | undefined,
|
|
||||||
};
|
|
||||||
obj.physics.colliderType = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geometry props
|
|
||||||
if (props.width != null) obj.width = props.width as number;
|
|
||||||
if (props.height != null) obj.height = props.height as number;
|
|
||||||
if (props.depth != null) obj.depth = props.depth as number;
|
|
||||||
if (props.radius != null) obj.radius = props.radius as number;
|
|
||||||
if (props.radiusTop != null) obj.radiusTop = props.radiusTop as number;
|
|
||||||
if (props.radiusBottom != null)
|
|
||||||
obj.radiusBottom = props.radiusBottom as number;
|
|
||||||
if (props.radialSegments != null)
|
|
||||||
obj.radialSegments = props.radialSegments as number;
|
|
||||||
if (props.tube != null) obj.tube = props.tube as number;
|
|
||||||
if (props.tubularSegments != null)
|
|
||||||
obj.tubularSegments = props.tubularSegments as number;
|
|
||||||
if (props.widthSegments != null)
|
|
||||||
obj.widthSegments = props.widthSegments as number;
|
|
||||||
if (props.heightSegments != null)
|
|
||||||
obj.heightSegments = props.heightSegments as number;
|
|
||||||
if (props.length != null) obj.length = props.length as number;
|
|
||||||
if (props.p != null) obj.p = props.p as number;
|
|
||||||
if (props.q != null) obj.q = props.q as number;
|
|
||||||
|
|
||||||
if (props.shapeData && typeof props.shapeData === "object") {
|
|
||||||
obj.shapeData = props.shapeData as SceneObject["shapeData"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.meshData && typeof props.meshData === "object") {
|
|
||||||
obj.meshData = props.meshData as SceneObject["meshData"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "GroundPlane") {
|
|
||||||
obj.material = parseMaterial(props.material);
|
|
||||||
obj.rotation = [-Math.PI / 2, 0, 0];
|
|
||||||
const size = (props.size as number) ?? 5000;
|
|
||||||
obj.scale = [size, size, 1];
|
|
||||||
obj.physics = {
|
|
||||||
mass: 0,
|
|
||||||
isStatic: true,
|
|
||||||
restitution: 0.2,
|
|
||||||
friction: 0.8,
|
|
||||||
colliderType: "cuboid",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a json-render spec back into SceneObject[].
|
|
||||||
* Extracts all elements whose keys start with "obj-".
|
|
||||||
* Uses the scene root's children for ordering, then appends any
|
|
||||||
* obj-* elements not yet in children (handles streaming where
|
|
||||||
* elements arrive before the scene children array is updated).
|
|
||||||
*/
|
|
||||||
export function specToSceneObjects(spec: Spec): SceneObject[] {
|
|
||||||
const sceneElement = spec.elements["scene"] || spec.elements[spec.root];
|
|
||||||
const objects: SceneObject[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
const childKeys = sceneElement?.children || [];
|
|
||||||
for (const key of childKeys) {
|
|
||||||
if (!key.startsWith("obj-")) continue;
|
|
||||||
const element = spec.elements[key];
|
|
||||||
if (!element) continue;
|
|
||||||
seen.add(key);
|
|
||||||
|
|
||||||
const obj = specElementToSceneObject(key, element);
|
|
||||||
if (obj) objects.push(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(spec.elements)) {
|
|
||||||
if (!key.startsWith("obj-") || seen.has(key)) continue;
|
|
||||||
const element = spec.elements[key];
|
|
||||||
if (!element) continue;
|
|
||||||
|
|
||||||
const obj = specElementToSceneObject(key, element);
|
|
||||||
if (obj) objects.push(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
return objects;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
interface SpeechRequest {
|
|
||||||
text: string;
|
|
||||||
voiceId: string;
|
|
||||||
resolve: (url: string) => void;
|
|
||||||
reject: (error: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpeechQueue {
|
|
||||||
private queue: SpeechRequest[] = [];
|
|
||||||
private processing = 0;
|
|
||||||
private maxConcurrent = 1;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.processQueue = this.processQueue.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async add(text: string, voiceId: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.queue.push({ text, voiceId, resolve, reject });
|
|
||||||
this.processQueue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processQueue() {
|
|
||||||
if (this.processing >= this.maxConcurrent || this.queue.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = this.queue.shift();
|
|
||||||
if (!request) return;
|
|
||||||
|
|
||||||
this.processing++;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/text-to-speech", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: request.text,
|
|
||||||
voiceId: request.voiceId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || "Failed to generate speech");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
request.resolve(data.audioUrl);
|
|
||||||
} catch (error) {
|
|
||||||
request.reject(error);
|
|
||||||
} finally {
|
|
||||||
this.processing--;
|
|
||||||
this.processQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const speechQueue = new SpeechQueue();
|
|
||||||
export default speechQueue;
|
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import type {
|
|
||||||
EditorState,
|
|
||||||
Scene,
|
|
||||||
SceneObject,
|
|
||||||
ObjectType,
|
|
||||||
TransformMode,
|
|
||||||
ViewMode,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
|
||||||
createDefaultScene,
|
|
||||||
createDefaultObject,
|
|
||||||
createDefaultGrass,
|
|
||||||
createDefaultAmbientLight,
|
|
||||||
createDefaultPlayer,
|
|
||||||
createDefaultSceneSettings,
|
|
||||||
createDefaultEnvironmentSettings,
|
|
||||||
} from "./defaults";
|
|
||||||
|
|
||||||
function saveHistory(state: EditorState): Partial<EditorState> {
|
|
||||||
return {
|
|
||||||
history: {
|
|
||||||
past: [...state.history.past.slice(-49), state.scenes],
|
|
||||||
future: [],
|
|
||||||
},
|
|
||||||
canUndo: true,
|
|
||||||
canRedo: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialScene = createDefaultScene();
|
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
|
||||||
scenes: [initialScene],
|
|
||||||
activeSceneId: initialScene.id,
|
|
||||||
selectedObjectId: null,
|
|
||||||
transformMode: "translate" as TransformMode,
|
|
||||||
isPlaying: false,
|
|
||||||
isLoading: false,
|
|
||||||
viewMode: "first-person" as ViewMode,
|
|
||||||
isPromptOpen: false,
|
|
||||||
|
|
||||||
health: 100,
|
|
||||||
shield: 50,
|
|
||||||
maxHealth: 100,
|
|
||||||
maxShield: 100,
|
|
||||||
lastDamageTime: null,
|
|
||||||
damageCooldown: 0.5,
|
|
||||||
|
|
||||||
history: { past: [], future: [] },
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
|
|
||||||
get activeScene() {
|
|
||||||
const state = get();
|
|
||||||
return (
|
|
||||||
state.scenes.find((s) => s.id === state.activeSceneId) || state.scenes[0]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
get activeSceneObjects() {
|
|
||||||
const state = get();
|
|
||||||
const scene =
|
|
||||||
state.scenes.find((s) => s.id === state.activeSceneId) || state.scenes[0];
|
|
||||||
return scene ? scene.objects : [];
|
|
||||||
},
|
|
||||||
|
|
||||||
// Scene actions
|
|
||||||
createScene: (name = "New Scene") =>
|
|
||||||
set((state) => {
|
|
||||||
const newSceneId = uuidv4();
|
|
||||||
const newScene: Scene = {
|
|
||||||
id: newSceneId,
|
|
||||||
name,
|
|
||||||
description: "",
|
|
||||||
isDefault: state.scenes.length === 0,
|
|
||||||
objects: [
|
|
||||||
createDefaultGrass(),
|
|
||||||
createDefaultAmbientLight(),
|
|
||||||
createDefaultPlayer(),
|
|
||||||
],
|
|
||||||
sceneSettings: createDefaultSceneSettings(),
|
|
||||||
environmentSettings: createDefaultEnvironmentSettings(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
scenes: [...state.scenes, newScene],
|
|
||||||
activeSceneId: newSceneId,
|
|
||||||
selectedObjectId: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteScene: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
if (state.scenes.length <= 1) return state;
|
|
||||||
const updated = state.scenes.filter((s) => s.id !== id);
|
|
||||||
const deletedWasDefault = state.scenes.find(
|
|
||||||
(s) => s.id === id,
|
|
||||||
)?.isDefault;
|
|
||||||
if (deletedWasDefault && updated[0]) updated[0].isDefault = true;
|
|
||||||
return {
|
|
||||||
scenes: updated,
|
|
||||||
activeSceneId:
|
|
||||||
state.activeSceneId === id ? updated[0]!.id : state.activeSceneId,
|
|
||||||
selectedObjectId: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
duplicateScene: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
const src = state.scenes.find((s) => s.id === id);
|
|
||||||
if (!src) return state;
|
|
||||||
const newId = uuidv4();
|
|
||||||
const clone: Scene = {
|
|
||||||
...JSON.parse(JSON.stringify(src)),
|
|
||||||
id: newId,
|
|
||||||
name: `${src.name} (Copy)`,
|
|
||||||
isDefault: false,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
scenes: [...state.scenes, clone],
|
|
||||||
activeSceneId: newId,
|
|
||||||
selectedObjectId: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
setActiveScene: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
if (!state.scenes.some((s) => s.id === id)) return state;
|
|
||||||
return { activeSceneId: id, selectedObjectId: null };
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateSceneName: (id, name) =>
|
|
||||||
set((state) => ({
|
|
||||||
scenes: state.scenes.map((s) => (s.id === id ? { ...s, name } : s)),
|
|
||||||
})),
|
|
||||||
|
|
||||||
updateSceneDescription: (id, description) =>
|
|
||||||
set((state) => ({
|
|
||||||
scenes: state.scenes.map((s) =>
|
|
||||||
s.id === id ? { ...s, description } : s,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
|
|
||||||
// Object actions
|
|
||||||
addObject: (type: ObjectType) =>
|
|
||||||
set((state) => {
|
|
||||||
const scene = state.scenes.find((s) => s.id === state.activeSceneId);
|
|
||||||
if (!scene) return state;
|
|
||||||
if (type === "player" && scene.objects.some((o) => o.type === "player"))
|
|
||||||
return state;
|
|
||||||
|
|
||||||
const id = uuidv4();
|
|
||||||
const newObj = createDefaultObject(type, id, scene.objects.length);
|
|
||||||
|
|
||||||
if (type === "light") {
|
|
||||||
const lightCount = scene.objects.filter(
|
|
||||||
(o) => o.type === "light",
|
|
||||||
).length;
|
|
||||||
if (lightCount === 0) {
|
|
||||||
newObj.name = "Ambient Light";
|
|
||||||
newObj.intensity = 0.5;
|
|
||||||
newObj.position = [0, 3, 0];
|
|
||||||
newObj.lightType = "ambient";
|
|
||||||
} else if (lightCount === 1) {
|
|
||||||
newObj.name = "Directional Light";
|
|
||||||
newObj.intensity = 1;
|
|
||||||
newObj.position = [10, 10, 10];
|
|
||||||
newObj.lightType = "directional";
|
|
||||||
} else {
|
|
||||||
newObj.name = "Point Light";
|
|
||||||
newObj.intensity = 1;
|
|
||||||
newObj.distance = 10;
|
|
||||||
newObj.lightType = "point";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedScenes = state.scenes.map((s) =>
|
|
||||||
s.id === state.activeSceneId
|
|
||||||
? { ...s, objects: [...s.objects, newObj] }
|
|
||||||
: s,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
selectedObjectId: id,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
createCustomObject: (type: ObjectType, properties: Partial<SceneObject>) =>
|
|
||||||
set((state) => {
|
|
||||||
const scene = state.scenes.find((s) => s.id === state.activeSceneId);
|
|
||||||
if (!scene) return state;
|
|
||||||
if (type === "player" && scene.objects.some((o) => o.type === "player"))
|
|
||||||
return state;
|
|
||||||
|
|
||||||
const id = uuidv4();
|
|
||||||
const base = createDefaultObject(type, id, scene.objects.length);
|
|
||||||
const newObj: SceneObject = {
|
|
||||||
...base,
|
|
||||||
...properties,
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
material: { ...base.material, ...(properties.material || {}) },
|
|
||||||
physics: { ...base.physics, ...(properties.physics || {}) },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === "light" && properties.lightType) {
|
|
||||||
newObj.lightType = properties.lightType;
|
|
||||||
if (
|
|
||||||
properties.lightType === "ambient" &&
|
|
||||||
properties.intensity === undefined
|
|
||||||
)
|
|
||||||
newObj.intensity = 0.5;
|
|
||||||
if (properties.lightType === "spot" && properties.angle === undefined)
|
|
||||||
newObj.angle = Math.PI / 3;
|
|
||||||
if (
|
|
||||||
properties.lightType === "spot" &&
|
|
||||||
properties.penumbra === undefined
|
|
||||||
)
|
|
||||||
newObj.penumbra = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "plane") {
|
|
||||||
newObj.scale[2] = 1;
|
|
||||||
if (!properties.rotation) newObj.rotation = [-Math.PI / 2, 0, 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedScenes = state.scenes.map((s) =>
|
|
||||||
s.id === state.activeSceneId
|
|
||||||
? { ...s, objects: [...s.objects, newObj] }
|
|
||||||
: s,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
selectedObjectId: id,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
removeObject: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) =>
|
|
||||||
s.id === state.activeSceneId
|
|
||||||
? { ...s, objects: s.objects.filter((o) => o.id !== id) }
|
|
||||||
: s,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
selectedObjectId:
|
|
||||||
state.selectedObjectId === id ? null : state.selectedObjectId,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
replaceSceneObjects: (objects: SceneObject[]) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) =>
|
|
||||||
s.id === state.activeSceneId ? { ...s, objects } : s,
|
|
||||||
);
|
|
||||||
const selectionStillExists =
|
|
||||||
state.selectedObjectId != null &&
|
|
||||||
objects.some((o) => o.id === state.selectedObjectId);
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
selectedObjectId: selectionStillExists ? state.selectedObjectId : null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
initializePlayer: () =>
|
|
||||||
set((state) => {
|
|
||||||
const scene = state.scenes.find((s) => s.id === state.activeSceneId);
|
|
||||||
if (!scene || scene.objects.some((o) => o.type === "player"))
|
|
||||||
return state;
|
|
||||||
const player = createDefaultPlayer();
|
|
||||||
const updatedScenes = state.scenes.map((s) =>
|
|
||||||
s.id === state.activeSceneId
|
|
||||||
? { ...s, objects: [...s.objects, player] }
|
|
||||||
: s,
|
|
||||||
);
|
|
||||||
return { scenes: updatedScenes };
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Updates
|
|
||||||
selectObject: (id) => set({ selectedObjectId: id }),
|
|
||||||
|
|
||||||
updateObjectTransform: (id, transform) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) => {
|
|
||||||
if (o.id !== id) return o;
|
|
||||||
const t = { ...transform };
|
|
||||||
if (o.type === "plane" && t.scale)
|
|
||||||
(t.scale as [number, number, number])[2] = 1;
|
|
||||||
return { ...o, ...t };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { scenes: updatedScenes };
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateObjectMaterial: (id, material) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) =>
|
|
||||||
o.id === id
|
|
||||||
? { ...o, material: { ...o.material, ...material } }
|
|
||||||
: o,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateObjectPhysics: (id, physics) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) =>
|
|
||||||
o.id === id ? { ...o, physics: { ...o.physics, ...physics } } : o,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateCharacter: (id, character) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) =>
|
|
||||||
o.id === id
|
|
||||||
? {
|
|
||||||
...o,
|
|
||||||
character: { ...(o.character || { role: "" }), ...character },
|
|
||||||
}
|
|
||||||
: o,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateSound: (id, soundUpdates) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) => {
|
|
||||||
if (o.id !== id) return o;
|
|
||||||
const current = o.sound || {
|
|
||||||
url: "",
|
|
||||||
loop: false,
|
|
||||||
volume: 1,
|
|
||||||
positional: true,
|
|
||||||
distance: 10,
|
|
||||||
};
|
|
||||||
return { ...o, sound: { ...current, ...soundUpdates } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateMedia: (id, mediaUpdates) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) => {
|
|
||||||
if (o.id !== id) return o;
|
|
||||||
const current = o.media || {
|
|
||||||
url: "",
|
|
||||||
type: "image",
|
|
||||||
loop: false,
|
|
||||||
autoplay: false,
|
|
||||||
muted: true,
|
|
||||||
};
|
|
||||||
return { ...o, media: { ...current, ...mediaUpdates } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateDamage: (id, damageUpdates) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) => {
|
|
||||||
if (o.id !== id) return o;
|
|
||||||
const current = o.damage || { amount: 0, enabled: false };
|
|
||||||
return { ...o, damage: { ...current, ...damageUpdates } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateSceneSettings: (settings) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
sceneSettings: {
|
|
||||||
...s.sceneSettings,
|
|
||||||
...settings,
|
|
||||||
grid: { ...s.sceneSettings.grid, ...(settings.grid || {}) },
|
|
||||||
fog: { ...s.sceneSettings.fog, ...(settings.fog || {}) },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateEnvironmentSettings: (settings) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
environmentSettings: { ...s.environmentSettings, ...settings },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateObjectGeneral: (id, updates) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) => {
|
|
||||||
if (o.id !== id) return o;
|
|
||||||
if (o.type === "plane" && updates.scale) updates.scale[2] = 1;
|
|
||||||
return { ...o, ...updates };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
toggleObjectVisibility: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
const updatedScenes = state.scenes.map((s) => {
|
|
||||||
if (s.id !== state.activeSceneId) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
objects: s.objects.map((o) =>
|
|
||||||
o.id === id ? { ...o, visible: !o.visible } : o,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...saveHistory({ ...state, scenes: updatedScenes }),
|
|
||||||
scenes: updatedScenes,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
setTransformMode: (mode) => set({ transformMode: mode }),
|
|
||||||
setIsPlaying: (playing) =>
|
|
||||||
set((state) => ({
|
|
||||||
isPlaying: playing,
|
|
||||||
selectedObjectId: playing ? null : state.selectedObjectId,
|
|
||||||
health: playing ? state.maxHealth : state.health,
|
|
||||||
shield: playing ? state.maxShield : state.shield,
|
|
||||||
isPromptOpen: playing ? false : state.isPromptOpen,
|
|
||||||
})),
|
|
||||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
|
||||||
setIsPromptOpen: (open) => set({ isPromptOpen: open }),
|
|
||||||
|
|
||||||
// History
|
|
||||||
saveToHistory: () => set((state) => saveHistory(state)),
|
|
||||||
|
|
||||||
undo: () =>
|
|
||||||
set((state) => {
|
|
||||||
if (state.history.past.length === 0) return state;
|
|
||||||
const prev = state.history.past[state.history.past.length - 1]!;
|
|
||||||
return {
|
|
||||||
scenes: prev,
|
|
||||||
history: {
|
|
||||||
past: state.history.past.slice(0, -1),
|
|
||||||
future: [state.scenes, ...state.history.future],
|
|
||||||
},
|
|
||||||
canUndo: state.history.past.length > 1,
|
|
||||||
canRedo: true,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
redo: () =>
|
|
||||||
set((state) => {
|
|
||||||
if (state.history.future.length === 0) return state;
|
|
||||||
const next = state.history.future[0]!;
|
|
||||||
return {
|
|
||||||
scenes: next,
|
|
||||||
history: {
|
|
||||||
past: [...state.history.past, state.scenes],
|
|
||||||
future: state.history.future.slice(1),
|
|
||||||
},
|
|
||||||
canUndo: true,
|
|
||||||
canRedo: state.history.future.length > 1,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Health
|
|
||||||
setHealth: (health) =>
|
|
||||||
set((state) => ({
|
|
||||||
health: Math.max(0, Math.min(health, state.maxHealth)),
|
|
||||||
})),
|
|
||||||
setShield: (shield) =>
|
|
||||||
set((state) => ({
|
|
||||||
shield: Math.max(0, Math.min(shield, state.maxShield)),
|
|
||||||
})),
|
|
||||||
takeDamage: (amount) =>
|
|
||||||
set((state) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (
|
|
||||||
state.lastDamageTime &&
|
|
||||||
now - state.lastDamageTime < state.damageCooldown * 1000
|
|
||||||
)
|
|
||||||
return state;
|
|
||||||
|
|
||||||
let remainingShield = state.shield;
|
|
||||||
let remainingHealth = state.health;
|
|
||||||
let remaining = amount;
|
|
||||||
|
|
||||||
if (remainingShield > 0) {
|
|
||||||
if (remainingShield >= remaining) {
|
|
||||||
remainingShield -= remaining;
|
|
||||||
remaining = 0;
|
|
||||||
} else {
|
|
||||||
remaining -= remainingShield;
|
|
||||||
remainingShield = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (remaining > 0)
|
|
||||||
remainingHealth = Math.max(0, remainingHealth - remaining);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shield: remainingShield,
|
|
||||||
health: remainingHealth,
|
|
||||||
lastDamageTime: now,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const touchMoveState = {
|
|
||||||
forward: 0,
|
|
||||||
right: 0,
|
|
||||||
jump: false,
|
|
||||||
sprint: false,
|
|
||||||
lookDeltaX: 0,
|
|
||||||
lookDeltaY: 0,
|
|
||||||
};
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
export type ObjectType =
|
|
||||||
| "box"
|
|
||||||
| "sphere"
|
|
||||||
| "cylinder"
|
|
||||||
| "cone"
|
|
||||||
| "torus"
|
|
||||||
| "plane"
|
|
||||||
| "capsule"
|
|
||||||
| "tetrahedron"
|
|
||||||
| "octahedron"
|
|
||||||
| "dodecahedron"
|
|
||||||
| "icosahedron"
|
|
||||||
| "knot"
|
|
||||||
| "tube"
|
|
||||||
| "extrude"
|
|
||||||
| "shape"
|
|
||||||
| "mesh"
|
|
||||||
| "light"
|
|
||||||
| "model"
|
|
||||||
| "character"
|
|
||||||
| "sound"
|
|
||||||
| "player"
|
|
||||||
| "image"
|
|
||||||
| "video";
|
|
||||||
|
|
||||||
export type TransformMode = "select" | "translate" | "rotate" | "scale";
|
|
||||||
|
|
||||||
export type ColliderType = "cuboid" | "ball" | "capsule" | "none";
|
|
||||||
|
|
||||||
export type LightType = "ambient" | "directional" | "point" | "spot";
|
|
||||||
|
|
||||||
export type ViewMode = "first-person" | "third-person";
|
|
||||||
|
|
||||||
export interface Material {
|
|
||||||
color: string;
|
|
||||||
metalness: number;
|
|
||||||
roughness: number;
|
|
||||||
emissive: string;
|
|
||||||
emissiveIntensity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Physics {
|
|
||||||
mass: number;
|
|
||||||
isStatic: boolean;
|
|
||||||
restitution: number;
|
|
||||||
friction: number;
|
|
||||||
colliderType: ColliderType | "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Character {
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Sound {
|
|
||||||
url: string;
|
|
||||||
loop: boolean;
|
|
||||||
volume: number;
|
|
||||||
positional: boolean;
|
|
||||||
distance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Media {
|
|
||||||
url: string;
|
|
||||||
type: string;
|
|
||||||
loop?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Damage {
|
|
||||||
amount: number;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShapeData {
|
|
||||||
points: [number, number][];
|
|
||||||
holes: [number, number][][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeshData {
|
|
||||||
vertices: number[];
|
|
||||||
indices: number[];
|
|
||||||
normals?: number[];
|
|
||||||
uvs?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SceneSettings {
|
|
||||||
grid: {
|
|
||||||
visible: boolean;
|
|
||||||
size: number;
|
|
||||||
divisions: number;
|
|
||||||
color: string;
|
|
||||||
secondaryColor: string;
|
|
||||||
fadeDistance: number;
|
|
||||||
fadeStrength: number;
|
|
||||||
};
|
|
||||||
fog: {
|
|
||||||
enabled: boolean;
|
|
||||||
color: string;
|
|
||||||
near: number;
|
|
||||||
far: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvironmentSettings {
|
|
||||||
preset: string;
|
|
||||||
customHdri: string | null;
|
|
||||||
intensity: number;
|
|
||||||
blur: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SceneObject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
type: ObjectType;
|
|
||||||
position: [number, number, number];
|
|
||||||
rotation: [number, number, number];
|
|
||||||
scale: [number, number, number];
|
|
||||||
material: Material;
|
|
||||||
physics: Physics;
|
|
||||||
visible: boolean;
|
|
||||||
intensity?: number;
|
|
||||||
distance?: number;
|
|
||||||
decay?: number;
|
|
||||||
angle?: number;
|
|
||||||
penumbra?: number;
|
|
||||||
modelUrl?: string;
|
|
||||||
character?: Character;
|
|
||||||
sound?: Sound;
|
|
||||||
media?: Media;
|
|
||||||
damage?: Damage;
|
|
||||||
lightType?: LightType;
|
|
||||||
isPlayer?: boolean;
|
|
||||||
shapeData?: ShapeData;
|
|
||||||
meshData?: MeshData;
|
|
||||||
|
|
||||||
// Geometry props (preserved through spec round-trip)
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
depth?: number;
|
|
||||||
radius?: number;
|
|
||||||
radiusTop?: number;
|
|
||||||
radiusBottom?: number;
|
|
||||||
radialSegments?: number;
|
|
||||||
tube?: number;
|
|
||||||
tubularSegments?: number;
|
|
||||||
widthSegments?: number;
|
|
||||||
heightSegments?: number;
|
|
||||||
length?: number;
|
|
||||||
p?: number;
|
|
||||||
q?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Scene {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
objects: SceneObject[];
|
|
||||||
sceneSettings: SceneSettings;
|
|
||||||
environmentSettings: EnvironmentSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryState {
|
|
||||||
past: Scene[][];
|
|
||||||
future: Scene[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditorState {
|
|
||||||
scenes: Scene[];
|
|
||||||
activeSceneId: string;
|
|
||||||
selectedObjectId: string | null;
|
|
||||||
transformMode: TransformMode;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
viewMode: ViewMode;
|
|
||||||
isPromptOpen: boolean;
|
|
||||||
|
|
||||||
health: number;
|
|
||||||
shield: number;
|
|
||||||
maxHealth: number;
|
|
||||||
maxShield: number;
|
|
||||||
lastDamageTime: number | null;
|
|
||||||
damageCooldown: number;
|
|
||||||
|
|
||||||
history: HistoryState;
|
|
||||||
canUndo: boolean;
|
|
||||||
canRedo: boolean;
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
activeScene: Scene | undefined;
|
|
||||||
activeSceneObjects: SceneObject[];
|
|
||||||
|
|
||||||
// Scene actions
|
|
||||||
createScene: (name?: string) => void;
|
|
||||||
deleteScene: (id: string) => void;
|
|
||||||
duplicateScene: (id: string) => void;
|
|
||||||
setActiveScene: (id: string) => void;
|
|
||||||
updateSceneName: (id: string, name: string) => void;
|
|
||||||
updateSceneDescription: (id: string, description: string) => void;
|
|
||||||
|
|
||||||
// Object actions
|
|
||||||
addObject: (type: ObjectType) => void;
|
|
||||||
createCustomObject: (
|
|
||||||
type: ObjectType,
|
|
||||||
properties: Partial<SceneObject>,
|
|
||||||
) => void;
|
|
||||||
removeObject: (id: string) => void;
|
|
||||||
replaceSceneObjects: (objects: SceneObject[]) => void;
|
|
||||||
initializePlayer: () => void;
|
|
||||||
|
|
||||||
// Updates
|
|
||||||
selectObject: (id: string | null) => void;
|
|
||||||
updateObjectTransform: (
|
|
||||||
id: string,
|
|
||||||
transform: Partial<Pick<SceneObject, "position" | "rotation" | "scale">>,
|
|
||||||
) => void;
|
|
||||||
updateObjectMaterial: (id: string, material: Partial<Material>) => void;
|
|
||||||
updateObjectPhysics: (id: string, physics: Partial<Physics>) => void;
|
|
||||||
updateCharacter: (id: string, character: Partial<Character>) => void;
|
|
||||||
updateSound: (id: string, sound: Partial<Sound>) => void;
|
|
||||||
updateMedia: (id: string, media: Partial<Media>) => void;
|
|
||||||
updateDamage: (id: string, damage: Partial<Damage>) => void;
|
|
||||||
updateSceneSettings: (settings: Partial<SceneSettings>) => void;
|
|
||||||
updateEnvironmentSettings: (settings: Partial<EnvironmentSettings>) => void;
|
|
||||||
updateObjectGeneral: (id: string, updates: Partial<SceneObject>) => void;
|
|
||||||
toggleObjectVisibility: (id: string) => void;
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
setTransformMode: (mode: TransformMode) => void;
|
|
||||||
setIsPlaying: (playing: boolean) => void;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
setViewMode: (mode: ViewMode) => void;
|
|
||||||
setIsPromptOpen: (open: boolean) => void;
|
|
||||||
|
|
||||||
// History
|
|
||||||
saveToHistory: () => void;
|
|
||||||
undo: () => void;
|
|
||||||
redo: () => void;
|
|
||||||
|
|
||||||
// Health
|
|
||||||
setHealth: (health: number) => void;
|
|
||||||
setShield: (shield: number) => void;
|
|
||||||
takeDamage: (amount: number) => void;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user