diff --git a/README.md b/README.md index c8816f3..6fd4ef5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Roomy Worlds is a tool for creating and sharing small 3D worlds as a community. Made with [roomy-sdk](https://github.com/muni-town/roomy-sdk) and [leaf](https://github.com/muni-town/leaf). -Work in progress: You can create new worlds locally, but they might disappear/change unexpectedly with new updates. +Work in progress: You can create new worlds, but they might disappear/change unexpectedly with new updates. Also lots of bugs. [Demo](https://flo-bit.dev/roomy-worlds) @@ -41,9 +41,16 @@ as well as see all models made in any window. ## todo -### general - -- [x] add at proto login +- save world settings with roomy world +- add default gradients +- export and import world (incl world settings) +- rotate and scale models +- add advanced settings to world (noise settings) +- add sound effects (placing, walking) +- add selection between world/local/global models +- add model name and category +- download model +- add gradient for lower part of island ### worlds @@ -84,9 +91,7 @@ as well as see all models made in any window. ### bugs - fix click (for placement and selection on floor + cubes) to only work when mouse stays still (down-up) -- not showing world items on fresh load (until one item is placed) - first cube cant be removed in model editor -- new model in model picker not showing without refreshing (in world) - updates to models not showing without refreshing (in world) ## Credits diff --git a/src/lib/editor/Floor.svelte b/src/lib/editor/Floor.svelte index c2a2a4b..256a454 100644 --- a/src/lib/editor/Floor.svelte +++ b/src/lib/editor/Floor.svelte @@ -16,9 +16,12 @@ { + console.log(e.point, editorState.tool); e.stopPropagation(); if (editorState.tool !== 'place') return; + console.log(e.point); + addVoxel( [e.point.x, e.point.y + 0.5, e.point.z], [editorState.color.r, editorState.color.g, editorState.color.b] diff --git a/src/lib/editor/Scene.svelte b/src/lib/editor/Scene.svelte index f53aa45..680cdaf 100644 --- a/src/lib/editor/Scene.svelte +++ b/src/lib/editor/Scene.svelte @@ -9,7 +9,6 @@ interactivity({ filter: (hits, state) => { - console.log(hits, state); return hits.slice(0, 1); } }); diff --git a/src/lib/world/Terrain.svelte b/src/lib/world/Terrain.svelte index e636648..42fc91a 100644 --- a/src/lib/world/Terrain.svelte +++ b/src/lib/world/Terrain.svelte @@ -6,8 +6,10 @@ import { UberNoise } from 'uber-noise'; import type { IntersectionEvent } from '@threlte/extras'; import { shuffle } from '$lib'; + import { ColorGradient } from './colorgradient'; + import { editingState } from '../../routes/world/state.svelte'; - const size = 100; + const size = editingState.worldSettings.size; const voxelSize = 1; @@ -29,7 +31,7 @@ const heightfield: number[] = []; const upperNoise = new UberNoise({ - seed: 1, + seed: editingState.worldSettings.seed, octaves: 6, scale: 0.03, min: 0, @@ -37,7 +39,7 @@ warp: 1 }); const lowerNoise = new UberNoise({ - seed: 10, + seed: editingState.worldSettings.seed + 10, octaves: 6, scale: 0.05, min: 0, @@ -47,6 +49,14 @@ const lowerIndices = new Set(); + + const gradient = new ColorGradient({ + stops: editingState.worldSettings.terrainGradient.map(({ rgb, position }) => ({ + position: (position - 0.5) * 2, + value: new THREE.Color(rgb.r, rgb.g, rgb.b) + })) + }); + for (let x = -size / 2; x < size / 2; x += voxelSize) { for (let z = -size / 2; z < size / 2; z += voxelSize) { // get distance from center @@ -64,7 +74,9 @@ dummy.position.set(x, height, z); dummy.scale.set(1, 3, 1); dummy.updateMatrix(); - color.setRGB(0, 0.15 * noise + 0.2, 0); + //color.setRGB(0, 0.15 * noise + 0.2, 0); + + const color = gradient.get(noise); let i = indices.shift() ?? 0; diff --git a/src/lib/world/Water.svelte b/src/lib/world/Water.svelte index 772d8c7..a0fd8ad 100644 --- a/src/lib/world/Water.svelte +++ b/src/lib/world/Water.svelte @@ -3,8 +3,10 @@ import * as THREE from 'three'; import { UberNoise } from 'uber-noise'; import { shuffle } from '$lib'; + import { ColorGradient } from './colorgradient'; + import { editingState } from '../../routes/world/state.svelte'; - const size = 100; + const size = editingState.worldSettings.size; const voxelSize = 1; @@ -13,7 +15,7 @@ ); const instancedGeometry = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize); - const instancedMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 0, transparent: true, opacity: 0.8, roughness: 0 }); + const instancedMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 0, transparent: true, opacity: 0.95, roughness: 0 }); const instancedMesh = new THREE.InstancedMesh(instancedGeometry, instancedMaterial, totalVoxels); const indices = shuffle(Array.from({ length: totalVoxels }, (_, i) => i)); @@ -21,10 +23,9 @@ instancedMesh.receiveShadow = true; const dummy = new THREE.Object3D(); - const color = new THREE.Color(0x00ff00); const waterNoise = new UberNoise({ - seed: 1, + seed: editingState.worldSettings.seed + 10, octaves: 3, scale: 0.01, min: 0, @@ -34,7 +35,7 @@ const terrainNoise = new UberNoise({ - seed: 1, + seed: editingState.worldSettings.seed, octaves: 6, scale: 0.03, min: 0, @@ -42,6 +43,13 @@ warp: 1 }); + const waterGradient = new ColorGradient({ + stops: editingState.worldSettings.waterGradient.map(({ rgb, position }) => ({ + position: (position - 1), + value: new THREE.Color(rgb.r, rgb.g, rgb.b) + })) + }); + let positions: THREE.Vector3[] = new Array(totalVoxels); let edge: boolean[] = new Array(totalVoxels); @@ -70,15 +78,14 @@ const height = noiseHeight + Math.pow(distance / (size / 2), 2) * -5; - const noise = waterNoise.get(x, z); - if(water > -0.3) continue; + if(water > (editingState.worldSettings.waterPercentage / 100 * 2 - 1)) continue; dummy.position.set(x, height, z); dummy.scale.set(1.2, isEdge ? 20 : 3, 1.2); dummy.updateMatrix(); - color.setRGB(0.1 * noise, 0.1 * noise, 2 * noise ); + const color = waterGradient.get(water); //if(isEdge) color.setRGB(1, 0, 0); diff --git a/src/lib/world/colorgradient.ts b/src/lib/world/colorgradient.ts new file mode 100644 index 0000000..6ca3601 --- /dev/null +++ b/src/lib/world/colorgradient.ts @@ -0,0 +1,207 @@ +import { Color } from 'three'; + +const noColor = new Color(0xff00cc); + +export type ColorGradientOptions = + | { + between?: ColorGradientBetweenOptions; + min?: number; + max?: number; + stops?: ColorGradientStopOptions[]; + hsl?: boolean; + mod?: ModifierFunction; + } + | ColorGradientStopOptions[]; + +type ModifierFunction = ((number: number) => number) | undefined; + +type ColorGradientStop = { + position: number; + value: Color | ColorGradient; + mod?: ModifierFunction; +}; + +type ColorGradientBetweenOptions = (number | Color | ColorGradient | ColorGradientOptions)[]; + +type ColorGradientStopOptions = + | ColorGradientStop + | [number, number | Color | ColorGradient | ColorGradientOptions] + | [number, number | Color | ColorGradient | ColorGradientOptions, ModifierFunction]; + +export class ColorGradient { + stops: ColorGradientStop[]; + hsl: boolean = false; + + isColorGradient: boolean = true; + + defaultMod: ModifierFunction = undefined; + + constructor(opts: ColorGradientOptions = {}) { + this.stops = []; + + if (opts instanceof Array) opts = { stops: opts }; + + this.hsl = opts.hsl ?? false; + + this.defaultMod = opts.mod; + + if (opts.between) this.addBetween(opts.between, opts.min, opts.max); + if (opts.stops) this.addStops(opts.stops); + } + + addStops(arr: ColorGradientStopOptions[]) { + for (const s of arr) { + this.addStop(s); + } + } + + // add stops in even spacing between min and max + addBetween(arr: ColorGradientBetweenOptions, min: number = -1, max: number = 1) { + if (arr.length < 2) console.warn('ColorGradient: addBetween requires at least 2 stops'); + + const step = (max - min) / (arr.length - 1); + for (let i = 0; i < arr.length; i++) { + this.addStop([i * step + min, arr[i]]); + } + } + + addStop(stop: ColorGradientStopOptions) { + let pos, mixObject, mod; + + if (stop instanceof Array) { + pos = stop[0]; + mixObject = stop[1]; + if (stop.length > 2) mod = stop[2]; + } else { + pos = stop.position; + mixObject = stop.value; + mod = stop.mod; + } + + if (typeof mixObject == 'number' || (mixObject as Color).isColor) { + mixObject = new Color(mixObject as Color); + } else if (!(mixObject instanceof Color) && !(mixObject instanceof ColorGradient)) { + mixObject = new ColorGradient(mixObject); + } + + let i = 0; + while (i < this.stops.length && pos > this.stops[i].position) i++; + + this.stops.splice(i, 0, { + position: pos, + value: mixObject, + mod: mod + }); + } + + /** + * get the color at a specific index + * + * @param i - index of the color + * @param y + * @param z + * @param w + * @returns + */ + colorAtIndex( + i: number, + y: number | undefined = undefined, + z: number | undefined = undefined, + w: number | undefined = undefined + ): Color | undefined { + if (i < 0 || i >= this.stops.length) return; + + let c = this.stops[i].value; + if (c instanceof ColorGradient) { + c = c.get(y, z, w); + } else { + c = c.clone(); + } + return c; + } + + dimensions() { + let maxD = 0; + for (const s of this.stops) { + if (s.value instanceof ColorGradient) { + maxD = Math.max(s.value.dimensions(), maxD); + } + } + return maxD + 1; + } + + /** + * mix between two colors at a specific index + * + * @param i - index of the first color + * @param j - index of the second color + * @param amt - amount to mix between the two colors (0-1) + * @param y + * @param z + * @param w + * @returns the mixed color + */ + mix( + i: number, + j: number, + amt: number, + y: number | undefined = undefined, + z: number | undefined = undefined, + w: number | undefined = undefined + ): Color | undefined { + if (i < 0 || i >= this.stops.length || j < 0 || j >= this.stops.length) return; + + amt = Math.min(Math.max(0, amt), 1); + + const firstColor = this.colorAtIndex(i, y, z, w); + if (!firstColor) return; + + if (i == j) return firstColor; + + const stop = this.stops[j]; + if (stop.mod) { + amt = stop.mod(amt); + } + if (this.defaultMod) { + amt = this.defaultMod(amt); + } + + const secondColor = this.colorAtIndex(j, y, z, w); + if (!secondColor) return firstColor; + + if (this.hsl) firstColor.lerpHSL(secondColor, amt); + else firstColor.lerp(secondColor, amt); + + return firstColor; + } + + get( + x: number = 0, + y: number | undefined = undefined, + z: number | undefined = undefined, + w: number | undefined = undefined + ): Color { + if (this.stops.length < 1) return noColor; + + if (x <= this.stops[0].position || this.stops.length == 1) + return this.mix(0, 0, 0, y, z, w) ?? noColor; + + for (let i = 0; i < this.stops.length - 1; i++) { + const s1 = this.stops[i].position, + s2 = this.stops[i + 1].position; + if (s1 <= x && x <= s2) { + const amt = (x - s1) / (s2 - s1); + return this.mix(i, i + 1, amt, y, z, w) ?? noColor; + } + } + return this.mix(this.stops.length - 1, this.stops.length - 1, 0, y, z, w) ?? noColor; + } + + static between(arr: ColorGradientBetweenOptions, min: number, max: number) { + return new ColorGradient({ + between: arr, + min: min, + max: max + }); + } +} diff --git a/src/routes/editor/+page.svelte b/src/routes/editor/+page.svelte index 2b40e45..ae1d42f 100644 --- a/src/routes/editor/+page.svelte +++ b/src/routes/editor/+page.svelte @@ -1,221 +1,5 @@ - - -
- +
+ +
+ +
+ + + +
+ {#if !isPublished.value} + + {:else} + + {/if} +
+ +
+ + + +
+ +
+ +
\ No newline at end of file diff --git a/src/routes/world/+page.svelte b/src/routes/world/+page.svelte index a11e4c3..52e74dc 100644 --- a/src/routes/world/+page.svelte +++ b/src/routes/world/+page.svelte @@ -3,51 +3,26 @@ import Scene from './Scene.svelte'; import { PerfMonitor } from '@threlte/extras'; import { World } from '@threlte/rapier'; - import { onMount } from 'svelte'; + import { onMount, untrack } from 'svelte'; import { getVoxelObject } from '$lib'; - import { TransformedGroup, type Voxel, type VoxelGroup } from '$lib/shared/components'; + import { Models, TransformedGroup, Voxel, VoxelGroup } from '$lib/shared/components'; import { derivePromise } from '$lib/shared/utils.svelte'; import { g, initRoomy } from '$lib/shared/roomy.svelte'; - import { UndoManager, type EntityIdStr } from '@muni-town/leaf'; + import { type EntityIdStr } from '@muni-town/leaf'; import { ACESFilmicToneMapping, Quaternion, Vector3 } from 'three'; import ModelSelection from './ModelSelection.svelte'; import { applyTransform, editingState } from './state.svelte'; - import { dev } from '$app/environment'; + import ModelScene from '$lib/editor/Scene.svelte'; + import { editorState } from '$lib/editor/state.svelte'; + import { Button, ColorGradientPicker, Label, SliderNumber } from 'fuchs'; let voxelObject: VoxelGroup | null = $state(null); - let voxels: Voxel[] = $state([]); - let instances = derivePromise([], async () => (g.world ? await g.world.instances.items() : [])); let locations = derivePromise([], async () => (g.world ? await g.world.locations.items() : [])); - let undoManager: UndoManager | null = $state(null); - - $inspect(instances); - onMount(async () => { voxelObject = await getVoxelObject('leaf:vw7a1c7xhdcwnt915t953amrq8dfpcffj2ksc74pymqchv9ap48g'); - - voxels = await voxelObject.voxels.items(); - }); - - $effect(() => { - if (g.world?.entity.doc && !undoManager) { - undoManager = new UndoManager(g.world.entity.doc, { - maxUndoSteps: 100 - }); - - window.addEventListener('keyup', (e) => { - if (e.key.toLowerCase() === 'z' && e.shiftKey) { - console.log('undo', e); - undoManager?.undo(); - } else if (e.key.toLowerCase() === 'y' && e.shiftKey) { - undoManager?.redo(); - } else if (e.key.toLowerCase() === 'p') { - showPerfMonitor = !showPerfMonitor; - } - }); - } }); async function addInstance(id: EntityIdStr, position: Vector3) { @@ -68,22 +43,162 @@ g.world?.commit(); } - let showPerfMonitor = $state(dev); + let showPerfMonitor = $state(false); + + let selectedTool: 'place' | 'delete' | 'move' | 'rotate' | 'scale' = $state('place'); + let voxels = derivePromise([], async () => + g.voxelObject ? await g.voxelObject.voxels.items() : [] + ); + + const id = 'leaf:6hv4pxwp66xa5g9jqqz2q4jr39ryahrceafe71e2vwys1fstjmw0'; + + let isPublished = derivePromise(false, async () => { + if (!g.voxelObject || !g.roomy) return; + const models = await g.roomy.open(Models, id as EntityIdStr); + return models.models.ids().some((m) => m === g.voxelObject?.id); + }); + + export async function addVoxel( + position: [number, number, number], + color: [number, number, number], + scale?: [number, number, number], + quaternion?: [number, number, number, number] + ) { + if (!g.roomy) { + await initRoomy(); + + if (!g.roomy) return; + } + + const voxel = await g.roomy.create(Voxel); + voxel.x = position[0]; + voxel.y = position[1]; + voxel.z = position[2]; + voxel.r = color[0]; + voxel.g = color[1]; + voxel.b = color[2]; + + if (scale) { + voxel.sx = scale[0]; + voxel.sy = scale[1]; + voxel.sz = scale[2]; + } else { + voxel.sx = 1; + voxel.sy = 1; + voxel.sz = 1; + } + + if (quaternion) { + voxel.qx = quaternion[0]; + voxel.qy = quaternion[1]; + voxel.qz = quaternion[2]; + voxel.qw = quaternion[3]; + } else { + voxel.qx = 0; + voxel.qy = 0; + voxel.qz = 0; + voxel.qw = 1; + } + + voxel.visible = true; + voxel.collider = false; + + voxel.commit(); + g.voxelObject?.voxels.push(voxel); + g.voxelObject?.commit(); + } + + export async function deleteVoxel(id: string) { + // find voxel by id + const voxel = voxels.value.findIndex((v) => v.id === id); + if (!voxel) return; + + g.voxelObject?.voxels.remove(voxel); + g.voxelObject?.commit(); + } + + $effect(() => { + applyTransform(); + + editorState.tool = selectedTool; + + if (editorState.selectedVoxel !== null) { + editorState.selectedVoxel.r = editorState.color.r; + editorState.selectedVoxel.g = editorState.color.g; + editorState.selectedVoxel.b = editorState.color.b; + + editorState.selectedVoxel.commit(); + } + }); + -
+
{#if showPerfMonitor} {/if} - + {#if editingState.showModelEditor} + + {:else} + + {/if}
+
+ + + + + + { + editingState.worldSettings.version += 1; + }} + /> + + + + { + editingState.worldSettings.version += 1; + }} + /> + + + + + +
+ +
+ +
+
{#if editingState.selectedInstance} -
+
moving
{:else if editingState.selectedModelId} -
+
placing