Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SpatialJS Spatial Clock App Example

Create an interactive spatial clock app


Built with ❤️ by Deamoner | Personal Site | YouTube | Medium | Discord

Live Demo

You can try out this project live on CodeSandbox:

Edit on CodeSandbox

This live demo allows you to explore the interactive spatial room with the wall-jumping a wall Clock and timer.

Base Libraries

This project is built using the following:

These libraries provide the foundation for creating interactive 3D spatial environments with React and Three.js.

Getting Started

  1. Clone this repository
  2. Install dependencies:
npm install
  1. Run the development server:
npm run dev

Now you should be able to run the completed project without issue. Let's look at how we did it.

Project Build

This project uses Vite as the build tool. Here's how to set up the basic structure:

  1. Initialize a new Vite project with React and TypeScript:
npm create vite@latest spatialjs-room-example -- --template react-ts
  1. Install Dependencies:
npm install @react-three/fiber @react-three/drei @react-three/xr @react-three/uikit @spatialjs/core three
  1. Setup your Basic App with a Canvas, OrbitControls, and XR:
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Preload, useGLTF } from '@react-three/drei';
import { XR } from '@react-three/xr';
import { WindowManager, createWindow, useWindowStore } from '@spatialjs/core';

const store = createXRStore({
  emulate: true,
  frameRate: 'high',
  foveation: 100,
});

const App: React.FC = () => {
  return (
    <Canvas>
      <XR store={store}>
        <OrbitControls />
      </XR>
    </Canvas>
  );
};
  1. Setup your Basic Room with an environment and room mesh:

import { useThree } from '@react-three/fiber'
import { useGLTF } from '@react-three/drei'
import { useEffect, useState, useCallback } from 'react'
import * as THREE from 'three'
import { useWindowStore } from '@spatialjs/core';

export function Room() {
    const { nodes } = useGLTF('/level-react-draco.glb');
    const [position, setPosition] = useState([0, -0.45, 0])
     return (
    <mesh
      key="room"
      scale={4.5}
      geometry={nodes.Level.geometry}
      material={nodes.Level.material}
      position={[position[0] - 1.75, -position[1], -position[2]]}
      rotation={[Math.PI / 2, -Math.PI / 9, 0]}
      onClick={handleClick}
      pointerEventsType={{ deny: ["grab", "touch"] }}
    />
  )
}
  1. Create the Different Clock Types and UI:
import React, { useState, useEffect } from "react";
import { Text } from "@react-three/uikit";

interface ClockProps {
  disableSeconds?: boolean;
}

const Clock: React.FC<ClockProps> = ({ disableSeconds = false }) => {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => {
      clearInterval(timer);
    };
  }, []);

  const formatTime = (date: Date) => {
    return date.toLocaleTimeString([], {
      hour: "2-digit",
      minute: "2-digit",
      second: disableSeconds ? undefined : "2-digit",
    });
  };

  return <Text fontSize={45}>{formatTime(time)}</Text>;
};

export default Clock;
import React, { useState, useEffect } from "react";
import { Text, Container } from "@react-three/uikit";
import { Input } from "./apfel/input";
import { Button } from "./default/button";
import { Timer, TimerOff } from "@react-three/uikit-lucide";

const StopWatch: React.FC = () => {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    let intervalId: any;
    if (isRunning) {
      intervalId = setInterval(() => {
        setTime((prevTime) => prevTime + 10);
      }, 10);
    }
    return () => clearInterval(intervalId);
  }, [isRunning]);

  const startStop = (e: any) => {
    e.stopPropagation();
    setIsRunning(!isRunning);
  };

  const reset = (e: any) => {
    e.stopPropagation();
    setTime(0);
    setIsRunning(false);
  };

  const formatTime = (ms: number) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return `${minutes.toString().padStart(2, "0")}:${seconds
      .toString()
      .padStart(2, "0")}.${centiseconds.toString().padStart(2, "0")}`;
  };

  return (
    <Container spacing={2} width={200} flexDirection="column">
      <Container gap={12} flexgrow={1} justifyContent="center">
        <Text fontSize={45}>{formatTime(time)}</Text>
      </Container>
      <Container spacing={2} gap={12} flex-grow={1} justifyContent="center">
        <Button type="button" onClick={startStop}>
          {isRunning ? <TimerOff /> : <Timer />}
        </Button>
        <Button type="button" onClick={reset}>
          <Text>Reset</Text>
        </Button>
      </Container>
    </Container>
  );
};

export default StopWatch;
import React, { useState, useEffect } from "react";
import { Text, Container } from "@react-three/uikit";
import { Button } from "@react-three/uikit-apfel";
import { Timer, TimerOff } from "@react-three/uikit-lucide";

const Pomondoro: React.FC = () => {
  const [time, setTime] = useState(30 * 60 * 1000); // 30 minutes in milliseconds
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    let intervalId: any;
    if (isRunning && time > 0) {
      intervalId = setInterval(() => {
        setTime((prevTime) => prevTime - 10);
      }, 10);
    } else if (time === 0) {
      setIsRunning(false);
    }
    return () => clearInterval(intervalId);
  }, [isRunning, time]);

  const startStop = (e: any) => {
    e.stopPropagation();
    setIsRunning(!isRunning);
  };

  const reset = (e: any) => {
    e.stopPropagation();
    setTime(30 * 60 * 1000);
    setIsRunning(false);
  };

  const formatTime = (ms: number) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    return `${minutes.toString().padStart(2, "0")}:${seconds
      .toString()
      .padStart(2, "0")}`;
  };

  return (
    <Container spacing={2} width={200} flexDirection="column">
      <Container gap={12} flex-grow={1} justifyContent="center">
        <Text fontSize={45}>{formatTime(time)}</Text>
      </Container>
      <Container spacing={2} gap={12} flex-grow={1} justifyContent="center">
        <Button type="button" onClick={startStop}>
          {isRunning ? <TimerOff /> : <Timer />}
        </Button>
        <Button type="button" onClick={reset}>
          <Text>Reset</Text>
        </Button>
      </Container>
    </Container>
  );
};

export default Pomondoro;
  1. Now add the windows when the app starts, add the following to the App.tsx file:
useEffect(() => {
    createWindow(Clock, {
      id: "clock",
      title: "Clock",
      props: { disableSeconds: true },
      disableBackground: false,
      followCamera: true,
      disableTiling: true,
      position: new THREE.Vector3(
        0.7054231986607751,
        5.388817243461132,
        -6.841060185432433
      ),
    });

    createWindow(StopWatch, {
      id: "StopWatch",
      title: "Stop Watch",
      props: { disableSeconds: true },
      disableBackground: false,
      followCamera: true,
      disableTiling: true,
      position: new THREE.Vector3(
        -0.7054231986607751,
        3.288817243461132,
        -6.841060185432433
      ),
    });

    createWindow(Pomodoro, {
      id: "Pomondoro",
      title: "Pomondoro Timer",
      props: { disableSeconds: true },
      disableBackground: false,
      followCamera: true,
      disableTiling: true,
      position: new THREE.Vector3(
        1.9054231986607751,
        3.288817243461132,
        -6.841060185432433
      ),
    });
  }, []);
  1. For interactions to allow the players to move around the room add the following to the Room Mesh:
const [position, setPosition] = useState([0, -0.45, 0])
  const { setPosition: setWindowPosition, setRotation: setWindowRotation, lastWindow } = useWindowStore();

  const handleClick = useCallback((event: any) => {
    event.stopPropagation()
    console.log(event);
    // Calculate surface normal at the click point
    const face = event.face
    const normal = face.normal.clone()
    normal.transformDirection(event.object.matrixWorld)

    // Calculate a point slightly in front of the clicked point along the surface normal
    const offsetDistance = 0.1 // Adjust this value as needed
    const offsetPoint = event.point.clone().add(normal.multiplyScalar(offsetDistance))

    // Calculate rotation to face outward from the surface
    const rotationMatrix = new THREE.Matrix4().lookAt(normal, new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 1, 0))
    const rotation = new THREE.Euler().setFromRotationMatrix(rotationMatrix)

    console.log('Room clicked!', offsetPoint)
    if (lastWindow) {
      setWindowPosition(lastWindow, offsetPoint)
      setWindowRotation(lastWindow, rotation)
      useWindowStore.getState().updateWindow(lastWindow, {
        disableTiling: true,
        position: offsetPoint,
        rotation: rotation
      });
    }
  }, [setWindowPosition, setWindowRotation, lastWindow])

Support

For questions, bug reports, or feature requests, please open an issue on our GitHub repository.


Built with ❤️ by Deamoner | Personal Site | YouTube | Medium | Discord

About

SpatialJS Clock App Demo

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages