@runek/core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +148 -0
- package/dist/index.js +499 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nullorder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, ComponentType } from 'react';
|
|
3
|
+
import { KeyboardControlsEntry } from '@react-three/drei';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Named color slots shared by the whole world. Components default their colors
|
|
7
|
+
* to these slots, so swapping the palette re-themes every component at once —
|
|
8
|
+
* explicit color props still win.
|
|
9
|
+
*/
|
|
10
|
+
interface WorldPalette {
|
|
11
|
+
/** Furniture frames, table tops, doors. */
|
|
12
|
+
wood: string;
|
|
13
|
+
/** Shelf backs and other shaded wood. */
|
|
14
|
+
woodDark: string;
|
|
15
|
+
/** Walls and rooms. */
|
|
16
|
+
wall: string;
|
|
17
|
+
/** Interior floors. */
|
|
18
|
+
floor: string;
|
|
19
|
+
roof: string;
|
|
20
|
+
/** Stairs and masonry. */
|
|
21
|
+
stone: string;
|
|
22
|
+
/** Terrain ground cover. */
|
|
23
|
+
ground: string;
|
|
24
|
+
/** Leaves and grass. */
|
|
25
|
+
foliage: string;
|
|
26
|
+
/** Tree trunks and branches. */
|
|
27
|
+
bark: string;
|
|
28
|
+
/** Shores and beaches. */
|
|
29
|
+
sand: string;
|
|
30
|
+
/** Rugs and upholstery. */
|
|
31
|
+
fabric: string;
|
|
32
|
+
/** Trim, knobs, rug borders. */
|
|
33
|
+
accent: string;
|
|
34
|
+
/** Lamp poles and hardware. */
|
|
35
|
+
metal: string;
|
|
36
|
+
waterDeep: string;
|
|
37
|
+
waterShallow: string;
|
|
38
|
+
}
|
|
39
|
+
declare const DEFAULT_PALETTE: WorldPalette;
|
|
40
|
+
|
|
41
|
+
type Vec3 = [number, number, number];
|
|
42
|
+
/** The contract every Runek component implements. */
|
|
43
|
+
interface WorldComponentProps {
|
|
44
|
+
position?: Vec3;
|
|
45
|
+
rotation?: Vec3;
|
|
46
|
+
seed?: number;
|
|
47
|
+
}
|
|
48
|
+
/** Linear distance fog over the whole world. */
|
|
49
|
+
interface WorldFog {
|
|
50
|
+
color: string;
|
|
51
|
+
/** Distance where the fog starts, in units. */
|
|
52
|
+
near: number;
|
|
53
|
+
/** Distance where the fog fully obscures, in units. */
|
|
54
|
+
far: number;
|
|
55
|
+
}
|
|
56
|
+
interface WorldContextValue {
|
|
57
|
+
/** Meters per unit. Components scale their geometry by this. */
|
|
58
|
+
unit: number;
|
|
59
|
+
gravity: Vec3;
|
|
60
|
+
/** Resolved color slots components default their materials to. */
|
|
61
|
+
palette: WorldPalette;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
declare const WorldContext: react.Context<WorldContextValue>;
|
|
65
|
+
|
|
66
|
+
/** Default movement bindings, matching the action names ecctrl reads. */
|
|
67
|
+
declare const keyboardMap: KeyboardControlsEntry[];
|
|
68
|
+
|
|
69
|
+
type Rng = () => number;
|
|
70
|
+
/** Deterministic seeded RNG: a given seed always yields the same sequence in [0, 1). */
|
|
71
|
+
declare function rng(seed: number): Rng;
|
|
72
|
+
declare const range: (r: Rng, min: number, max: number) => number;
|
|
73
|
+
declare const int: (r: Rng, min: number, max: number) => number;
|
|
74
|
+
declare const pick: <T>(r: Rng, items: readonly T[]) => T;
|
|
75
|
+
/** Derive a stable child seed, so nested components stay deterministic. */
|
|
76
|
+
declare const sub: (seed: number, n: number) => number;
|
|
77
|
+
|
|
78
|
+
declare const useWorld: () => WorldContextValue;
|
|
79
|
+
|
|
80
|
+
interface WorldProps {
|
|
81
|
+
unit?: number;
|
|
82
|
+
gravity?: Vec3;
|
|
83
|
+
keyboardMap?: KeyboardControlsEntry[];
|
|
84
|
+
/** Render the default light rig. Set false to supply your own (e.g. <LightRig>). */
|
|
85
|
+
lights?: boolean;
|
|
86
|
+
/** Override color slots; unset slots keep their defaults. Components read these via `useWorld()`. */
|
|
87
|
+
palette?: Partial<WorldPalette>;
|
|
88
|
+
/** Linear distance fog. Pair the color with your sky's horizon. */
|
|
89
|
+
fog?: WorldFog;
|
|
90
|
+
/** Fired when a pointer click misses every object (used to deselect in the editor). */
|
|
91
|
+
onPointerMissed?: () => void;
|
|
92
|
+
debug?: boolean;
|
|
93
|
+
children?: ReactNode;
|
|
94
|
+
}
|
|
95
|
+
declare function World({ unit, gravity, keyboardMap, lights, palette, fog, onPointerMissed, debug, children, }: WorldProps): react.JSX.Element;
|
|
96
|
+
|
|
97
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
98
|
+
[key: string]: JsonValue;
|
|
99
|
+
};
|
|
100
|
+
/** One placed component: a registry key, its props, and optional nested children. */
|
|
101
|
+
interface WorldNode {
|
|
102
|
+
/** Registry key — the component's name, e.g. "Bookshelf". */
|
|
103
|
+
type: string;
|
|
104
|
+
props?: Record<string, JsonValue>;
|
|
105
|
+
children?: WorldNode[];
|
|
106
|
+
}
|
|
107
|
+
/** A whole world as plain data — diffable, forkable, version-controlled like any file. */
|
|
108
|
+
interface WorldData {
|
|
109
|
+
version: 1;
|
|
110
|
+
unit?: number;
|
|
111
|
+
gravity?: Vec3;
|
|
112
|
+
/** Color-slot overrides applied to every component in the world. */
|
|
113
|
+
palette?: Partial<WorldPalette>;
|
|
114
|
+
fog?: WorldFog;
|
|
115
|
+
nodes: WorldNode[];
|
|
116
|
+
}
|
|
117
|
+
type ComponentRegistry = Record<string, ComponentType<any>>;
|
|
118
|
+
/** Serialize a world to pretty JSON text. */
|
|
119
|
+
declare function serializeWorld(data: WorldData): string;
|
|
120
|
+
/** Parse and lightly validate world JSON text. Throws on an unsupported shape. */
|
|
121
|
+
declare function parseWorld(json: string): WorldData;
|
|
122
|
+
|
|
123
|
+
interface WorldEditorProps extends Omit<WorldProps, 'children' | 'unit' | 'gravity' | 'palette' | 'fog'> {
|
|
124
|
+
data: WorldData;
|
|
125
|
+
registry: ComponentRegistry;
|
|
126
|
+
onChange: (next: WorldData) => void;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Edit a world data-first: orbit camera, click-select, gizmo move/rotate, leva props,
|
|
130
|
+
* add/duplicate/delete nodes, undo — all writing back to `WorldData`.
|
|
131
|
+
*/
|
|
132
|
+
declare function WorldEditor({ data, registry, onChange, ...worldProps }: WorldEditorProps): react.JSX.Element;
|
|
133
|
+
|
|
134
|
+
interface WorldNodesProps {
|
|
135
|
+
nodes: WorldNode[];
|
|
136
|
+
registry: ComponentRegistry;
|
|
137
|
+
}
|
|
138
|
+
/** Render a list of world nodes by looking each `type` up in the registry. Recurses into children. */
|
|
139
|
+
declare function WorldNodes({ nodes, registry }: WorldNodesProps): react.JSX.Element;
|
|
140
|
+
|
|
141
|
+
interface WorldRendererProps extends Omit<WorldProps, 'children' | 'unit' | 'gravity' | 'palette' | 'fog'> {
|
|
142
|
+
data: WorldData;
|
|
143
|
+
registry: ComponentRegistry;
|
|
144
|
+
}
|
|
145
|
+
/** Render a `WorldData` object inside a `<World>`, resolving each node via the registry. */
|
|
146
|
+
declare function WorldRenderer({ data, registry, ...worldProps }: WorldRendererProps): react.JSX.Element;
|
|
147
|
+
|
|
148
|
+
export { type ComponentRegistry, DEFAULT_PALETTE, type JsonValue, type Rng, type Vec3, World, type WorldComponentProps, WorldContext, type WorldContextValue, type WorldData, WorldEditor, type WorldEditorProps, type WorldFog, type WorldNode, WorldNodes, type WorldNodesProps, type WorldPalette, type WorldProps, WorldRenderer, type WorldRendererProps, int, keyboardMap, parseWorld, pick, range, rng, serializeWorld, sub, useWorld };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// src/context.ts
|
|
2
|
+
import { createContext } from "react";
|
|
3
|
+
|
|
4
|
+
// src/palette.ts
|
|
5
|
+
var DEFAULT_PALETTE = {
|
|
6
|
+
wood: "#6b4f3a",
|
|
7
|
+
woodDark: "#5c4330",
|
|
8
|
+
wall: "#cfc7ba",
|
|
9
|
+
floor: "#b8a98f",
|
|
10
|
+
roof: "#8a5a44",
|
|
11
|
+
stone: "#9a8c78",
|
|
12
|
+
ground: "#3a4a3f",
|
|
13
|
+
foliage: "#4f7a3a",
|
|
14
|
+
bark: "#5b4636",
|
|
15
|
+
sand: "#d8c79a",
|
|
16
|
+
fabric: "#7a3b3b",
|
|
17
|
+
accent: "#caa24a",
|
|
18
|
+
metal: "#39383a",
|
|
19
|
+
waterDeep: "#13415c",
|
|
20
|
+
waterShallow: "#3f86a8"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/context.ts
|
|
24
|
+
var WorldContext = createContext({
|
|
25
|
+
unit: 1,
|
|
26
|
+
gravity: [0, -9.81, 0],
|
|
27
|
+
palette: DEFAULT_PALETTE
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// src/keyboard.ts
|
|
31
|
+
var keyboardMap = [
|
|
32
|
+
{ name: "forward", keys: ["ArrowUp", "KeyW"] },
|
|
33
|
+
{ name: "backward", keys: ["ArrowDown", "KeyS"] },
|
|
34
|
+
{ name: "leftward", keys: ["ArrowLeft", "KeyA"] },
|
|
35
|
+
{ name: "rightward", keys: ["ArrowRight", "KeyD"] },
|
|
36
|
+
{ name: "jump", keys: ["Space"] },
|
|
37
|
+
{ name: "run", keys: ["ShiftLeft", "ShiftRight"] }
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// src/rng.ts
|
|
41
|
+
function rng(seed) {
|
|
42
|
+
let s = seed >>> 0;
|
|
43
|
+
return () => {
|
|
44
|
+
s = Math.imul(s ^ s >>> 15, 1 | s);
|
|
45
|
+
s ^= s + Math.imul(s ^ s >>> 7, 61 | s);
|
|
46
|
+
return ((s ^ s >>> 14) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
var range = (r, min, max) => min + r() * (max - min);
|
|
50
|
+
var int = (r, min, max) => Math.floor(range(r, min, max + 1));
|
|
51
|
+
var pick = (r, items) => items[Math.floor(r() * items.length)];
|
|
52
|
+
var sub = (seed, n) => Math.imul(seed >>> 0, 2654435761) + n >>> 0;
|
|
53
|
+
|
|
54
|
+
// src/useWorld.ts
|
|
55
|
+
import { useContext } from "react";
|
|
56
|
+
var useWorld = () => useContext(WorldContext);
|
|
57
|
+
|
|
58
|
+
// src/World.tsx
|
|
59
|
+
import { KeyboardControls } from "@react-three/drei";
|
|
60
|
+
import { Canvas } from "@react-three/fiber";
|
|
61
|
+
import { Physics } from "@react-three/rapier";
|
|
62
|
+
import { useMemo } from "react";
|
|
63
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
64
|
+
function World({
|
|
65
|
+
unit = 1,
|
|
66
|
+
gravity = [0, -9.81, 0],
|
|
67
|
+
keyboardMap: keyboardMap2 = keyboardMap,
|
|
68
|
+
lights = true,
|
|
69
|
+
palette,
|
|
70
|
+
fog,
|
|
71
|
+
onPointerMissed,
|
|
72
|
+
debug = false,
|
|
73
|
+
children
|
|
74
|
+
}) {
|
|
75
|
+
const context = useMemo(
|
|
76
|
+
() => ({ unit, gravity, palette: { ...DEFAULT_PALETTE, ...palette } }),
|
|
77
|
+
[unit, gravity, palette]
|
|
78
|
+
);
|
|
79
|
+
return /* @__PURE__ */ jsx(KeyboardControls, { map: keyboardMap2, children: /* @__PURE__ */ jsx(Canvas, { shadows: true, camera: { position: [6, 4, 6], fov: 60 }, onPointerMissed, children: /* @__PURE__ */ jsxs(WorldContext.Provider, { value: context, children: [
|
|
80
|
+
fog && /* @__PURE__ */ jsx("fog", { attach: "fog", args: [fog.color, fog.near * unit, fog.far * unit] }),
|
|
81
|
+
lights && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
82
|
+
/* @__PURE__ */ jsx("ambientLight", { intensity: 0.6 }),
|
|
83
|
+
/* @__PURE__ */ jsx(
|
|
84
|
+
"directionalLight",
|
|
85
|
+
{
|
|
86
|
+
position: [12, 18, 8],
|
|
87
|
+
intensity: 1.6,
|
|
88
|
+
castShadow: true,
|
|
89
|
+
"shadow-mapSize": [2048, 2048],
|
|
90
|
+
"shadow-camera-near": 1,
|
|
91
|
+
"shadow-camera-far": 60,
|
|
92
|
+
"shadow-camera-left": -25,
|
|
93
|
+
"shadow-camera-right": 25,
|
|
94
|
+
"shadow-camera-top": 25,
|
|
95
|
+
"shadow-camera-bottom": -25
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
] }),
|
|
99
|
+
/* @__PURE__ */ jsx(Physics, { gravity, debug, children })
|
|
100
|
+
] }) }) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/WorldEditor.tsx
|
|
104
|
+
import { OrbitControls, TransformControls } from "@react-three/drei";
|
|
105
|
+
import { Leva, useControls } from "leva";
|
|
106
|
+
import { useEffect, useRef, useState } from "react";
|
|
107
|
+
|
|
108
|
+
// src/world-data.ts
|
|
109
|
+
function serializeWorld(data) {
|
|
110
|
+
return `${JSON.stringify(data, null, 2)}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
function parseWorld(json) {
|
|
114
|
+
const data = JSON.parse(json);
|
|
115
|
+
if (data.version !== 1) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Unsupported world version: ${JSON.stringify(data.version)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(data.nodes)) {
|
|
121
|
+
throw new Error('World data must have a "nodes" array');
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/WorldEditor.tsx
|
|
127
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
128
|
+
var NON_SELECTABLE = /* @__PURE__ */ new Set(["Sky", "LightRig"]);
|
|
129
|
+
var SKIPPED = /* @__PURE__ */ new Set(["Player"]);
|
|
130
|
+
var HISTORY_LIMIT = 100;
|
|
131
|
+
var asVec3 = (value) => Array.isArray(value) && value.length === 3 ? value : void 0;
|
|
132
|
+
var isTyping = (target) => {
|
|
133
|
+
const el = target;
|
|
134
|
+
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
|
135
|
+
};
|
|
136
|
+
function WorldEditor({ data, registry, onChange, ...worldProps }) {
|
|
137
|
+
const [selected, setSelected] = useState(null);
|
|
138
|
+
const [mode, setMode] = useState("translate");
|
|
139
|
+
const history = useRef([]);
|
|
140
|
+
const apply = (next) => {
|
|
141
|
+
history.current.push(data);
|
|
142
|
+
if (history.current.length > HISTORY_LIMIT) history.current.shift();
|
|
143
|
+
onChange(next);
|
|
144
|
+
};
|
|
145
|
+
const undo = () => {
|
|
146
|
+
const prev = history.current.pop();
|
|
147
|
+
if (!prev) return;
|
|
148
|
+
setSelected(null);
|
|
149
|
+
onChange(prev);
|
|
150
|
+
};
|
|
151
|
+
const patchNode = (index, patch) => {
|
|
152
|
+
const nodes = data.nodes.map(
|
|
153
|
+
(node, i) => i === index ? { ...node, props: { ...node.props, ...patch } } : node
|
|
154
|
+
);
|
|
155
|
+
apply({ ...data, nodes });
|
|
156
|
+
};
|
|
157
|
+
const addNode = (type) => {
|
|
158
|
+
apply({ ...data, nodes: [...data.nodes, { type, props: { position: [0, 0, 0] } }] });
|
|
159
|
+
setSelected(null);
|
|
160
|
+
};
|
|
161
|
+
const duplicateSelected = () => {
|
|
162
|
+
if (!selected) return;
|
|
163
|
+
const source = data.nodes[selected.index];
|
|
164
|
+
const copy = JSON.parse(JSON.stringify(source));
|
|
165
|
+
const at = asVec3(copy.props?.position) ?? [0, 0, 0];
|
|
166
|
+
copy.props = { ...copy.props, position: [at[0] + 0.5, at[1], at[2] + 0.5] };
|
|
167
|
+
apply({ ...data, nodes: [...data.nodes, copy] });
|
|
168
|
+
setSelected(null);
|
|
169
|
+
};
|
|
170
|
+
const deleteSelected = () => {
|
|
171
|
+
if (!selected) return;
|
|
172
|
+
apply({ ...data, nodes: data.nodes.filter((_, i) => i !== selected.index) });
|
|
173
|
+
setSelected(null);
|
|
174
|
+
};
|
|
175
|
+
const commitTransform = () => {
|
|
176
|
+
if (!selected) return;
|
|
177
|
+
const { position, rotation } = selected.object;
|
|
178
|
+
const r = (n) => Math.round(n * 1e3) / 1e3;
|
|
179
|
+
patchNode(selected.index, {
|
|
180
|
+
position: [r(position.x), r(position.y), r(position.z)],
|
|
181
|
+
rotation: [r(rotation.x), r(rotation.y), r(rotation.z)]
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const onKey = (event) => {
|
|
186
|
+
if (isTyping(event.target)) return;
|
|
187
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "z") {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
undo();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (event.key === "Escape") setSelected(null);
|
|
193
|
+
else if (event.key === "g") setMode("translate");
|
|
194
|
+
else if (event.key === "r") setMode("rotate");
|
|
195
|
+
else if (event.key === "d") duplicateSelected();
|
|
196
|
+
else if (event.key === "Delete" || event.key === "Backspace") deleteSelected();
|
|
197
|
+
};
|
|
198
|
+
window.addEventListener("keydown", onKey);
|
|
199
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
200
|
+
}, [data, selected, onChange]);
|
|
201
|
+
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
202
|
+
/* @__PURE__ */ jsxs2(
|
|
203
|
+
World,
|
|
204
|
+
{
|
|
205
|
+
...worldProps,
|
|
206
|
+
unit: data.unit,
|
|
207
|
+
gravity: data.gravity,
|
|
208
|
+
palette: data.palette,
|
|
209
|
+
fog: data.fog,
|
|
210
|
+
onPointerMissed: () => setSelected(null),
|
|
211
|
+
children: [
|
|
212
|
+
/* @__PURE__ */ jsx2(OrbitControls, { makeDefault: true }),
|
|
213
|
+
/* @__PURE__ */ jsx2(
|
|
214
|
+
EditableNodes,
|
|
215
|
+
{
|
|
216
|
+
nodes: data.nodes,
|
|
217
|
+
registry,
|
|
218
|
+
onSelect: (index, object) => setSelected({ index, object })
|
|
219
|
+
}
|
|
220
|
+
),
|
|
221
|
+
selected && /* @__PURE__ */ jsx2(TransformControls, { object: selected.object, mode, onMouseUp: commitTransform })
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
/* @__PURE__ */ jsx2(Leva, { hidden: selected === null }),
|
|
226
|
+
/* @__PURE__ */ jsx2(
|
|
227
|
+
EditorToolbar,
|
|
228
|
+
{
|
|
229
|
+
mode,
|
|
230
|
+
onMode: setMode,
|
|
231
|
+
data,
|
|
232
|
+
registry,
|
|
233
|
+
selected: selected?.index ?? null,
|
|
234
|
+
canUndo: history.current.length > 0,
|
|
235
|
+
onAdd: addNode,
|
|
236
|
+
onDuplicate: duplicateSelected,
|
|
237
|
+
onDelete: deleteSelected,
|
|
238
|
+
onUndo: undo
|
|
239
|
+
}
|
|
240
|
+
),
|
|
241
|
+
selected !== null && /* @__PURE__ */ jsx2(
|
|
242
|
+
NodeControls,
|
|
243
|
+
{
|
|
244
|
+
index: selected.index,
|
|
245
|
+
node: data.nodes[selected.index],
|
|
246
|
+
onPatch: patchNode
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
] });
|
|
250
|
+
}
|
|
251
|
+
function EditableNodes({ nodes, registry, onSelect }) {
|
|
252
|
+
return /* @__PURE__ */ jsx2(Fragment2, { children: nodes.map((node, index) => {
|
|
253
|
+
if (SKIPPED.has(node.type)) return null;
|
|
254
|
+
const Component = registry[node.type];
|
|
255
|
+
if (!Component) return null;
|
|
256
|
+
if (NON_SELECTABLE.has(node.type)) {
|
|
257
|
+
return /* @__PURE__ */ jsx2(Component, { ...node.props }, index);
|
|
258
|
+
}
|
|
259
|
+
const { position, rotation, ...rest } = node.props ?? {};
|
|
260
|
+
return (
|
|
261
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: <group> is a three.js object, not a DOM element
|
|
262
|
+
/* @__PURE__ */ jsx2(
|
|
263
|
+
"group",
|
|
264
|
+
{
|
|
265
|
+
position: asVec3(position) ?? [0, 0, 0],
|
|
266
|
+
rotation: asVec3(rotation) ?? [0, 0, 0],
|
|
267
|
+
onClick: (event) => {
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
onSelect(index, event.eventObject);
|
|
270
|
+
},
|
|
271
|
+
children: /* @__PURE__ */ jsx2(Component, { ...rest })
|
|
272
|
+
},
|
|
273
|
+
index
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
}) });
|
|
277
|
+
}
|
|
278
|
+
function NodeControls({ index, node, onPatch }) {
|
|
279
|
+
const props = node.props ?? {};
|
|
280
|
+
const position = asVec3(props.position) ?? [0, 0, 0];
|
|
281
|
+
const rotation = asVec3(props.rotation) ?? [0, 0, 0];
|
|
282
|
+
const seed = typeof props.seed === "number" ? props.seed : void 0;
|
|
283
|
+
useControls(
|
|
284
|
+
`${node.type} #${index}`,
|
|
285
|
+
() => ({
|
|
286
|
+
position: {
|
|
287
|
+
value: { x: position[0], y: position[1], z: position[2] },
|
|
288
|
+
step: 0.1,
|
|
289
|
+
onChange: (v, _path, ctx) => {
|
|
290
|
+
if (!ctx.initial) onPatch(index, { position: [v.x, v.y, v.z] });
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
rotation: {
|
|
294
|
+
value: { x: rotation[0], y: rotation[1], z: rotation[2] },
|
|
295
|
+
step: 0.05,
|
|
296
|
+
onChange: (v, _path, ctx) => {
|
|
297
|
+
if (!ctx.initial) onPatch(index, { rotation: [v.x, v.y, v.z] });
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
...seed !== void 0 ? {
|
|
301
|
+
seed: {
|
|
302
|
+
value: seed,
|
|
303
|
+
step: 1,
|
|
304
|
+
onChange: (v, _path, ctx) => {
|
|
305
|
+
if (!ctx.initial) onPatch(index, { seed: Math.round(v) });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} : {}
|
|
309
|
+
}),
|
|
310
|
+
[index]
|
|
311
|
+
);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
var TOOLBAR = {
|
|
315
|
+
position: "fixed",
|
|
316
|
+
top: "1rem",
|
|
317
|
+
left: "1rem",
|
|
318
|
+
display: "flex",
|
|
319
|
+
gap: "0.45rem",
|
|
320
|
+
alignItems: "center",
|
|
321
|
+
zIndex: 10,
|
|
322
|
+
padding: "0.45rem 0.6rem",
|
|
323
|
+
borderRadius: 10,
|
|
324
|
+
background: "rgba(7, 11, 17, 0.82)",
|
|
325
|
+
border: "1px solid #15202a",
|
|
326
|
+
backdropFilter: "blur(8px)",
|
|
327
|
+
fontFamily: "ui-monospace, SF Mono, Menlo, monospace"
|
|
328
|
+
};
|
|
329
|
+
var button = (active, disabled = false) => ({
|
|
330
|
+
padding: "0.35rem 0.65rem",
|
|
331
|
+
borderRadius: 6,
|
|
332
|
+
border: `1px solid ${active ? "rgba(61, 245, 138, 0.5)" : "#15202a"}`,
|
|
333
|
+
cursor: disabled ? "default" : "pointer",
|
|
334
|
+
background: active ? "rgba(61, 245, 138, 0.14)" : "rgba(255, 255, 255, 0.04)",
|
|
335
|
+
color: disabled ? "#5f7d75" : active ? "#3df58a" : "#cfe6db",
|
|
336
|
+
opacity: disabled ? 0.5 : 1,
|
|
337
|
+
fontSize: "0.78rem",
|
|
338
|
+
fontFamily: "inherit"
|
|
339
|
+
});
|
|
340
|
+
var SELECT = {
|
|
341
|
+
...button(false),
|
|
342
|
+
appearance: "none",
|
|
343
|
+
paddingRight: "0.9rem"
|
|
344
|
+
};
|
|
345
|
+
var DIVIDER = {
|
|
346
|
+
width: 1,
|
|
347
|
+
alignSelf: "stretch",
|
|
348
|
+
background: "#15202a"
|
|
349
|
+
};
|
|
350
|
+
var HINT = {
|
|
351
|
+
color: "#5f7d75",
|
|
352
|
+
fontSize: "0.72rem",
|
|
353
|
+
paddingLeft: "0.2rem"
|
|
354
|
+
};
|
|
355
|
+
function EditorToolbar({
|
|
356
|
+
mode,
|
|
357
|
+
onMode,
|
|
358
|
+
data,
|
|
359
|
+
registry,
|
|
360
|
+
selected,
|
|
361
|
+
canUndo,
|
|
362
|
+
onAdd,
|
|
363
|
+
onDuplicate,
|
|
364
|
+
onDelete,
|
|
365
|
+
onUndo
|
|
366
|
+
}) {
|
|
367
|
+
const hasSelection = selected !== null;
|
|
368
|
+
const exportWorld = () => {
|
|
369
|
+
const text = serializeWorld(data);
|
|
370
|
+
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
371
|
+
const a = document.createElement("a");
|
|
372
|
+
a.href = url;
|
|
373
|
+
a.download = "world.json";
|
|
374
|
+
a.click();
|
|
375
|
+
URL.revokeObjectURL(url);
|
|
376
|
+
};
|
|
377
|
+
return /* @__PURE__ */ jsxs2("div", { style: TOOLBAR, children: [
|
|
378
|
+
/* @__PURE__ */ jsx2(
|
|
379
|
+
"button",
|
|
380
|
+
{
|
|
381
|
+
type: "button",
|
|
382
|
+
style: button(mode === "translate"),
|
|
383
|
+
onClick: () => onMode("translate"),
|
|
384
|
+
title: "Move (g)",
|
|
385
|
+
children: "Move"
|
|
386
|
+
}
|
|
387
|
+
),
|
|
388
|
+
/* @__PURE__ */ jsx2(
|
|
389
|
+
"button",
|
|
390
|
+
{
|
|
391
|
+
type: "button",
|
|
392
|
+
style: button(mode === "rotate"),
|
|
393
|
+
onClick: () => onMode("rotate"),
|
|
394
|
+
title: "Rotate (r)",
|
|
395
|
+
children: "Rotate"
|
|
396
|
+
}
|
|
397
|
+
),
|
|
398
|
+
/* @__PURE__ */ jsx2("span", { style: DIVIDER }),
|
|
399
|
+
/* @__PURE__ */ jsxs2(
|
|
400
|
+
"select",
|
|
401
|
+
{
|
|
402
|
+
style: SELECT,
|
|
403
|
+
value: "",
|
|
404
|
+
onChange: (event) => {
|
|
405
|
+
if (event.target.value) onAdd(event.target.value);
|
|
406
|
+
event.target.value = "";
|
|
407
|
+
},
|
|
408
|
+
title: "Insert a component at the origin",
|
|
409
|
+
children: [
|
|
410
|
+
/* @__PURE__ */ jsx2("option", { value: "", children: "+ Add\u2026" }),
|
|
411
|
+
Object.keys(registry).sort().map((name) => /* @__PURE__ */ jsx2("option", { value: name, children: name }, name))
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
),
|
|
415
|
+
/* @__PURE__ */ jsx2(
|
|
416
|
+
"button",
|
|
417
|
+
{
|
|
418
|
+
type: "button",
|
|
419
|
+
style: button(false, !hasSelection),
|
|
420
|
+
disabled: !hasSelection,
|
|
421
|
+
onClick: onDuplicate,
|
|
422
|
+
title: "Duplicate selection (d)",
|
|
423
|
+
children: "Duplicate"
|
|
424
|
+
}
|
|
425
|
+
),
|
|
426
|
+
/* @__PURE__ */ jsx2(
|
|
427
|
+
"button",
|
|
428
|
+
{
|
|
429
|
+
type: "button",
|
|
430
|
+
style: button(false, !hasSelection),
|
|
431
|
+
disabled: !hasSelection,
|
|
432
|
+
onClick: onDelete,
|
|
433
|
+
title: "Delete selection (\u232B)",
|
|
434
|
+
children: "Delete"
|
|
435
|
+
}
|
|
436
|
+
),
|
|
437
|
+
/* @__PURE__ */ jsx2(
|
|
438
|
+
"button",
|
|
439
|
+
{
|
|
440
|
+
type: "button",
|
|
441
|
+
style: button(false, !canUndo),
|
|
442
|
+
disabled: !canUndo,
|
|
443
|
+
onClick: onUndo,
|
|
444
|
+
title: "Undo (\u2318Z)",
|
|
445
|
+
children: "Undo"
|
|
446
|
+
}
|
|
447
|
+
),
|
|
448
|
+
/* @__PURE__ */ jsx2("span", { style: DIVIDER }),
|
|
449
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: button(false), onClick: exportWorld, children: "Download JSON" }),
|
|
450
|
+
/* @__PURE__ */ jsx2("span", { style: HINT, children: selected === null ? "click a component \xB7 Esc deselects" : `selected #${selected}` })
|
|
451
|
+
] });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/WorldNodes.tsx
|
|
455
|
+
import { Fragment as Fragment3, jsx as jsx3 } from "react/jsx-runtime";
|
|
456
|
+
function WorldNodes({ nodes, registry }) {
|
|
457
|
+
return /* @__PURE__ */ jsx3(Fragment3, { children: nodes.map((node, index) => {
|
|
458
|
+
const Component = registry[node.type];
|
|
459
|
+
if (!Component) {
|
|
460
|
+
console.warn(`[runek] Unknown component "${node.type}" \u2014 skipped.`);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
const children = node.children?.length ? /* @__PURE__ */ jsx3(WorldNodes, { nodes: node.children, registry }) : null;
|
|
464
|
+
return /* @__PURE__ */ jsx3(Component, { ...node.props, children }, index);
|
|
465
|
+
}) });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/WorldRenderer.tsx
|
|
469
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
470
|
+
function WorldRenderer({ data, registry, ...worldProps }) {
|
|
471
|
+
return /* @__PURE__ */ jsx4(
|
|
472
|
+
World,
|
|
473
|
+
{
|
|
474
|
+
unit: data.unit,
|
|
475
|
+
gravity: data.gravity,
|
|
476
|
+
palette: data.palette,
|
|
477
|
+
fog: data.fog,
|
|
478
|
+
...worldProps,
|
|
479
|
+
children: /* @__PURE__ */ jsx4(WorldNodes, { nodes: data.nodes, registry })
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
export {
|
|
484
|
+
DEFAULT_PALETTE,
|
|
485
|
+
World,
|
|
486
|
+
WorldContext,
|
|
487
|
+
WorldEditor,
|
|
488
|
+
WorldNodes,
|
|
489
|
+
WorldRenderer,
|
|
490
|
+
int,
|
|
491
|
+
keyboardMap,
|
|
492
|
+
parseWorld,
|
|
493
|
+
pick,
|
|
494
|
+
range,
|
|
495
|
+
rng,
|
|
496
|
+
serializeWorld,
|
|
497
|
+
sub,
|
|
498
|
+
useWorld
|
|
499
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runek/core",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Core runtime for Runek: the World provider, useWorld, seeded rng, and the component contract.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/nullorder/runek.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://runek.nullorder.org",
|
|
13
|
+
"bugs": "https://github.com/nullorder/runek/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"react-three-fiber",
|
|
16
|
+
"threejs",
|
|
17
|
+
"3d",
|
|
18
|
+
"procedural-generation",
|
|
19
|
+
"runek"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@react-three/drei": "^10.7.7",
|
|
35
|
+
"@react-three/fiber": "^9.6.1",
|
|
36
|
+
"@react-three/rapier": "^2.2.0",
|
|
37
|
+
"leva": "^0.10.1",
|
|
38
|
+
"react": "^19.2.0",
|
|
39
|
+
"three": "^0.184.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@react-three/drei": "^10.7.7",
|
|
43
|
+
"@react-three/fiber": "^9.6.1",
|
|
44
|
+
"@react-three/rapier": "^2.2.0",
|
|
45
|
+
"@types/react": "^19.2.16",
|
|
46
|
+
"@types/three": "^0.184.1",
|
|
47
|
+
"leva": "^0.10.1",
|
|
48
|
+
"react": "^19.2.0",
|
|
49
|
+
"three": "^0.184.0",
|
|
50
|
+
"tsup": "^8.0.0",
|
|
51
|
+
"vitest": "^4.1.8"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsup",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
}
|
|
58
|
+
}
|