feat: nds render system
This commit is contained in:
45
.gitignore
vendored
45
.gitignore
vendored
@@ -1,32 +1,27 @@
|
|||||||
# Temporary
|
# temporary
|
||||||
_old
|
__old
|
||||||
|
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
# Misc
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
.fleet
|
||||||
*.ntvs*
|
.idea
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
# ESLint
|
# Local env files
|
||||||
.eslintcache
|
.env
|
||||||
|
.env.*
|
||||||
_old
|
!.env.example
|
||||||
|
|||||||
21
LICENSE.md
21
LICENSE.md
@@ -1,21 +0,0 @@
|
|||||||
Copyright (c) 2025 Pihkaal <hello@pihkaal.me>
|
|
||||||
Permission is hereby granted, free of charge, to any person
|
|
||||||
obtaining a copy of this software and associated documentation
|
|
||||||
files (the "Software"), to deal in the Software without
|
|
||||||
restriction, including without limitation the rights to use,
|
|
||||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
||||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
||||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
||||||
OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
96
README.md
96
README.md
@@ -1,39 +1,75 @@
|
|||||||
<h1 align="center">
|
# Nuxt Minimal Starter
|
||||||
<br>
|
|
||||||
<img src="https://i.imgur.com/8xRqYCS.png" alt="Pihkaal Profile Picture" width="200">
|
|
||||||
<br>
|
|
||||||
pihkaal.me
|
|
||||||
<br>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<h4 align="center">My <a href="https://pihkaal.me">personnal website</a>.</h4>
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
<p align="center">
|
## Setup
|
||||||
<a href="https://vitejs.dev">
|
|
||||||
<img src="https://img.shields.io/badge/vite-906dfe?style=for-the-badge&logo=vite&logoColor=white">
|
|
||||||
</a>
|
|
||||||
<a href="https://typescriptlang.org">
|
|
||||||
<img src="https://img.shields.io/badge/TypeScript-007acc?style=for-the-badge&logo=typescript&logoColor=white">
|
|
||||||
</a>
|
|
||||||
<a href="https://react.dev">
|
|
||||||
<img src="https://img.shields.io/badge/react-017fa5?style=for-the-badge&logo=react&logoColor=white">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center" id="links">
|
Make sure to install dependencies:
|
||||||
<a href="#description">Description</a> •
|
|
||||||
<a href="https://pihkaal.me">Visit it</a> •
|
|
||||||
<a href="#license">License</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br>
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
## Description
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
This is my personnal website, built with Vite, React and TypeScript. It's the recreation of my desktop, with [neovim](https://neovim.io/), [spotify-player](https://github.com/aome510/spotify-player) (that is synchronized with my spotify activity), and [cava](https://github.com/karlstav/cava).
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
<br>
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## Development Server
|
||||||
|
|
||||||
This project is <a href="https://opensource.org/licenses/MIT">MIT</a> licensed.
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
|
|||||||
20
app/app.vue
Normal file
20
app/app.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Background from "./components/screen/Background.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wrapper">
|
||||||
|
<Screen>
|
||||||
|
<Background />
|
||||||
|
<ScreenStats />
|
||||||
|
</Screen>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
app/components/screen/Background.vue
Normal file
6
app/components/screen/Background.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
useRender((ctx) => {
|
||||||
|
ctx.fillStyle = "green";
|
||||||
|
ctx.fillRect(0, 0, 100, 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
70
app/components/screen/Screen.vue
Normal file
70
app/components/screen/Screen.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const canvas = useTemplateRef("canvas");
|
||||||
|
|
||||||
|
const updateCallbacks = new Set<UpdateCallback>();
|
||||||
|
const renderCallbacks = new Set<RenderCallback>();
|
||||||
|
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
let lastRealFrameTime = 0;
|
||||||
|
|
||||||
|
const registerUpdateCallback = (callback: UpdateCallback) => {
|
||||||
|
updateCallbacks.add(callback);
|
||||||
|
return () => updateCallbacks.delete(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerRenderCallback = (callback: RenderCallback) => {
|
||||||
|
renderCallbacks.add(callback);
|
||||||
|
return () => renderCallbacks.delete(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFrame = (timestamp: number) => {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const deltaTime = timestamp - lastFrameTime;
|
||||||
|
lastFrameTime = timestamp;
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// update
|
||||||
|
for (const callback of updateCallbacks) {
|
||||||
|
callback(deltaTime, lastRealFrameTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// render
|
||||||
|
ctx.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||||
|
|
||||||
|
for (const callback of renderCallbacks) {
|
||||||
|
callback(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRealFrameTime = Date.now() - start;
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(renderFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!canvas.value) throw new Error("Missing canvas");
|
||||||
|
|
||||||
|
ctx = canvas.value.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Missing 2d context");
|
||||||
|
|
||||||
|
provide("registerUpdateCallback", registerUpdateCallback);
|
||||||
|
provide("registerRenderCallback", registerRenderCallback);
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(renderFrame);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas ref="canvas" :width="SCREEN_WIDTH" :height="SCREEN_HEIGHT" />
|
||||||
|
|
||||||
|
<slot v-if="canvas" />
|
||||||
|
</template>
|
||||||
68
app/components/screen/Stats.vue
Normal file
68
app/components/screen/Stats.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const props = defineProps({
|
||||||
|
x: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const SAMPLES = 60;
|
||||||
|
let average = { deltaTime: 0, realDeltaTime: 0 };
|
||||||
|
const lastFrames: (typeof average)[] = [];
|
||||||
|
|
||||||
|
useUpdate((deltaTime, realDeltaTime) => {
|
||||||
|
lastFrames.push({ deltaTime, realDeltaTime });
|
||||||
|
|
||||||
|
if (lastFrames.length > SAMPLES) {
|
||||||
|
lastFrames.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastFrames.length > 0) {
|
||||||
|
average = {
|
||||||
|
deltaTime:
|
||||||
|
lastFrames.reduce((acc, v) => acc + v.deltaTime, 0) /
|
||||||
|
lastFrames.length,
|
||||||
|
realDeltaTime:
|
||||||
|
lastFrames.reduce((acc, v) => acc + v.realDeltaTime, 0) /
|
||||||
|
lastFrames.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useRender((ctx) => {
|
||||||
|
const LINE_COUNT = 5;
|
||||||
|
const LINE_HEIGHT = 12;
|
||||||
|
|
||||||
|
ctx.fillStyle = "red";
|
||||||
|
ctx.fillRect(props.x - 2, props.y, 140, LINE_COUNT * LINE_HEIGHT + 3);
|
||||||
|
|
||||||
|
let textY = props.y;
|
||||||
|
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.fillText("[avg on 60 frames]", props.x, (textY += LINE_HEIGHT));
|
||||||
|
ctx.fillText(
|
||||||
|
`fps=${(1000 / average.deltaTime).toFixed()}`,
|
||||||
|
props.x,
|
||||||
|
(textY += LINE_HEIGHT),
|
||||||
|
);
|
||||||
|
ctx.fillText(
|
||||||
|
`frame_time=${average.deltaTime.toFixed(2)}ms`,
|
||||||
|
props.x,
|
||||||
|
(textY += LINE_HEIGHT),
|
||||||
|
);
|
||||||
|
ctx.fillText(
|
||||||
|
`real_fps=${(1000 / average.realDeltaTime).toFixed()}`,
|
||||||
|
props.x,
|
||||||
|
(textY += LINE_HEIGHT),
|
||||||
|
);
|
||||||
|
ctx.fillText(
|
||||||
|
`real_frame_time=${average.realDeltaTime.toFixed(2)}ms`,
|
||||||
|
props.x,
|
||||||
|
(textY += LINE_HEIGHT),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
18
app/composables/useRender.ts
Normal file
18
app/composables/useRender.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type RenderCallback = (ctx: CanvasRenderingContext2D) => void;
|
||||||
|
|
||||||
|
export const useRender = (callback: RenderCallback) => {
|
||||||
|
const registerRenderCallback = inject<
|
||||||
|
(callback: RenderCallback) => () => void
|
||||||
|
>("registerRenderCallback");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!registerRenderCallback) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing registerRenderCallback - useRender must be used within a Screen component",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = registerRenderCallback(callback);
|
||||||
|
onUnmounted(unregister);
|
||||||
|
});
|
||||||
|
};
|
||||||
18
app/composables/useUpdate.ts
Normal file
18
app/composables/useUpdate.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type UpdateCallback = (deltaTime: number, realFrameTime: number) => void;
|
||||||
|
|
||||||
|
export const useUpdate = (callback: UpdateCallback) => {
|
||||||
|
const registerUpdateCallback = inject<
|
||||||
|
(callback: UpdateCallback) => () => void
|
||||||
|
>("registerUpdateCallback");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!registerUpdateCallback) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing registerUpdateCallback - useUpdate must be used within a Screen component",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = registerUpdateCallback(callback);
|
||||||
|
onUnmounted(unregister);
|
||||||
|
});
|
||||||
|
};
|
||||||
2
app/utils/screen.ts
Normal file
2
app/utils/screen.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const SCREEN_WIDTH = 256;
|
||||||
|
export const SCREEN_HEIGHT = 192;
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import globals from "globals";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ["dist"] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ["**/*.{ts,tsx}"],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
"react-hooks": reactHooks,
|
|
||||||
"react-refresh": reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
"react-refresh/only-export-components": [
|
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
12
index.html
12
index.html
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>pihkaal.me</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
5
nuxt.config.ts
Normal file
5
nuxt.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true }
|
||||||
|
})
|
||||||
38
package.json
38
package.json
@@ -1,35 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "pihkaal.me",
|
"name": "pihkaal-me",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"build": "nuxt build",
|
||||||
"build": "tsc -b && vite build",
|
"dev": "nuxt dev",
|
||||||
"lint": "eslint --cache .",
|
"generate": "nuxt generate",
|
||||||
"preview": "vite preview",
|
"preview": "nuxt preview",
|
||||||
"format": "prettier --write --cache ."
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/drei": "^10.0.8",
|
"nuxt": "^4.2.1",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"vue": "^3.5.22",
|
||||||
"react": "^19.1.0",
|
"vue-router": "^4.6.3"
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"three": "^0.176.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.25.0",
|
|
||||||
"@types/react": "^19.1.2",
|
|
||||||
"@types/react-dom": "^19.1.2",
|
|
||||||
"@types/three": "^0.176.0",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
|
||||||
"eslint": "^9.25.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"typescript": "~5.8.3",
|
|
||||||
"typescript-eslint": "^8.30.1",
|
|
||||||
"vite": "^6.3.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8127
pnpm-lock.yaml
generated
8127
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- esbuild
|
||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
Binary file not shown.
Binary file not shown.
BIN
public/room.glb
BIN
public/room.glb
Binary file not shown.
10
src/App.css
10
src/App.css
@@ -1,10 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
125
src/App.tsx
125
src/App.tsx
@@ -1,125 +0,0 @@
|
|||||||
// TODO: handle moving the camera vs actual click
|
|
||||||
|
|
||||||
import { useRef, useState, type ComponentRef } from "react";
|
|
||||||
import {
|
|
||||||
Canvas,
|
|
||||||
useFrame,
|
|
||||||
useThree,
|
|
||||||
type ThreeEvent,
|
|
||||||
useLoader,
|
|
||||||
} from "@react-three/fiber";
|
|
||||||
import { OrbitControls } from "@react-three/drei";
|
|
||||||
import { GLTFLoader } from "three/examples/jsm/Addons.js";
|
|
||||||
import { Vector3 } from "three";
|
|
||||||
|
|
||||||
import "./App.css";
|
|
||||||
import { state, anim } from "./utils/animator";
|
|
||||||
import { useAnimator } from "./hooks/useAnimator";
|
|
||||||
|
|
||||||
const Room = () => {
|
|
||||||
const controls = useRef<ComponentRef<typeof OrbitControls>>(null);
|
|
||||||
const room = useLoader(GLTFLoader, "/room.glb");
|
|
||||||
|
|
||||||
const windowAnimator = useAnimator(
|
|
||||||
room.animations,
|
|
||||||
[
|
|
||||||
state(anim("Cube.010Action", false), anim("ShadeHandleAction", false)),
|
|
||||||
state(anim("LowerWindowAction", false)),
|
|
||||||
state(anim("LowerWindowAction", true)),
|
|
||||||
state(anim("Cube.010Action", true), anim("Cube.010Action", true)),
|
|
||||||
],
|
|
||||||
room.scene,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [focus, setFocus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const cameraPosition = useRef<Vector3 | null>(null);
|
|
||||||
const cameraTarget = useRef<Vector3>(new Vector3(0, 1, 0));
|
|
||||||
|
|
||||||
const pointerDownCameraPos = useRef(new Vector3());
|
|
||||||
|
|
||||||
const { camera } = useThree();
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (!controls.current) return;
|
|
||||||
|
|
||||||
if (cameraPosition.current) {
|
|
||||||
camera.position.lerp(cameraPosition.current, 0.09);
|
|
||||||
if (camera.position.distanceTo(cameraPosition.current) < 0.012) {
|
|
||||||
if (!focus) {
|
|
||||||
controls.current.enableZoom = true;
|
|
||||||
controls.current.enableRotate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraPosition.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.current.target.lerp(cameraTarget.current, 0.1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePointerUp = (e: ThreeEvent<MouseEvent>) => {
|
|
||||||
if (!controls.current) return;
|
|
||||||
if (pointerDownCameraPos.current.distanceTo(camera.position) >= 0.05)
|
|
||||||
return;
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (e.object.name === "WindowClickTarget") {
|
|
||||||
windowAnimator.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.object.name.includes("Poster") && e.object.name !== focus) {
|
|
||||||
const objectPos = new Vector3();
|
|
||||||
const objectDir = new Vector3();
|
|
||||||
e.object.getWorldPosition(objectPos);
|
|
||||||
e.object.getWorldDirection(objectDir);
|
|
||||||
|
|
||||||
cameraPosition.current = objectPos
|
|
||||||
.clone()
|
|
||||||
.add(objectDir.multiplyScalar(-0.65));
|
|
||||||
cameraTarget.current = objectPos;
|
|
||||||
|
|
||||||
controls.current.enableZoom = false;
|
|
||||||
controls.current.enableRotate = false;
|
|
||||||
|
|
||||||
setFocus(e.object.name);
|
|
||||||
} else if (focus) {
|
|
||||||
cameraPosition.current = new Vector3(3, 3, -3);
|
|
||||||
cameraTarget.current = new Vector3(0, 1, 0);
|
|
||||||
|
|
||||||
setFocus(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.current.update();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ambientLight intensity={Math.PI / 2} color={"#ffffff"} />
|
|
||||||
<directionalLight intensity={5} position={[5, 10, 5]} castShadow />
|
|
||||||
<OrbitControls
|
|
||||||
ref={controls}
|
|
||||||
target={[0, 1, 0]}
|
|
||||||
minPolarAngle={Math.PI / 5}
|
|
||||||
maxPolarAngle={Math.PI / 2}
|
|
||||||
enablePan={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<mesh
|
|
||||||
onPointerDown={() => pointerDownCameraPos.current.copy(camera.position)}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
>
|
|
||||||
<primitive object={room.scene} />
|
|
||||||
</mesh>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<Canvas camera={{ position: [3, 3, -3] }}>
|
|
||||||
<Room />
|
|
||||||
</Canvas>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { useAnimations } from "@react-three/drei";
|
|
||||||
import { LoopOnce, type AnimationClip } from "three";
|
|
||||||
import type { AnimationAction, Object3D } from "three";
|
|
||||||
import type { Animation, AnimatorState } from "../utils/animator";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
// NOTE: maybe root is not necessary
|
|
||||||
|
|
||||||
export const useAnimator = (
|
|
||||||
clips: AnimationClip[],
|
|
||||||
states: AnimatorState[],
|
|
||||||
root?: React.RefObject<Object3D | undefined | null> | Object3D,
|
|
||||||
) => {
|
|
||||||
const [stateIndex, setStateIndex] = useState(0);
|
|
||||||
const { mixer } = useAnimations(clips, root);
|
|
||||||
|
|
||||||
const getAction = (name: string): AnimationAction => {
|
|
||||||
const clip = clips.find((x) => x.name === name);
|
|
||||||
if (!clip) throw `Animation not found: '${name}'`;
|
|
||||||
return mixer.clipAction(clip);
|
|
||||||
};
|
|
||||||
|
|
||||||
const playAnimation = (animation: Animation): void => {
|
|
||||||
const action = getAction(animation.name);
|
|
||||||
|
|
||||||
action.clampWhenFinished = true;
|
|
||||||
action.setLoop(LoopOnce, 1);
|
|
||||||
action.timeScale = (animation.reverse ? -1 : 1) * 1.5;
|
|
||||||
if (animation.reverse) {
|
|
||||||
action.paused = false;
|
|
||||||
action.time = action.getClip().duration;
|
|
||||||
} else {
|
|
||||||
action.reset();
|
|
||||||
}
|
|
||||||
action.play();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
play() {
|
|
||||||
if (this.isPlaying()) return;
|
|
||||||
for (const animation of states[stateIndex]) {
|
|
||||||
playAnimation(animation);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStateIndex((stateIndex + 1) % states.length);
|
|
||||||
},
|
|
||||||
|
|
||||||
isPlaying(): boolean {
|
|
||||||
for (const state of states) {
|
|
||||||
for (const animation of state) {
|
|
||||||
const action = getAction(animation.name);
|
|
||||||
if (action.isRunning()) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { StrictMode } from "react";
|
|
||||||
import App from "./App";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { AnimationAction } from "three";
|
|
||||||
|
|
||||||
export type Animation = {
|
|
||||||
name: string;
|
|
||||||
reverse: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AnimatorState = Animation[];
|
|
||||||
|
|
||||||
export type Animator = {
|
|
||||||
actions: Map<string, AnimationAction>;
|
|
||||||
states: AnimatorState[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const state = (...animations: Animation[]): Animation[] => animations;
|
|
||||||
|
|
||||||
export const anim = (name: string, reverse: boolean): Animation => ({
|
|
||||||
name,
|
|
||||||
reverse,
|
|
||||||
});
|
|
||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
{
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{
|
||||||
{ "path": "./tsconfig.node.json" }
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react-swc";
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user