feat: nds render system

This commit is contained in:
2025-11-09 16:20:30 +01:00
parent ed8e35b1d7
commit 46debb815d
31 changed files with 6017 additions and 2898 deletions

45
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
useRender((ctx) => {
ctx.fillStyle = "green";
ctx.fillRect(0, 0, 100, 100);
});
</script>

View 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>

View 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>

View 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);
});
};

View 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
View File

@@ -0,0 +1,2 @@
export const SCREEN_WIDTH = 256;
export const SCREEN_HEIGHT = 192;

View File

@@ -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 },
],
},
},
);

View File

@@ -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
View File

@@ -0,0 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true }
})

View File

@@ -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"
} }
} }

8097
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- esbuild

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +0,0 @@
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#root {
width: 100%;
height: 100%;
}

View File

@@ -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>
);
}

View File

@@ -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;
},
};
};

View File

@@ -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;
}
}

View File

@@ -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>,
);

View File

@@ -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
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -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"]
}

View File

@@ -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"
}
] ]
} }

View File

@@ -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"]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});