feat(web): 3D tower scene + collapsible sidebar layout
Recast the cockpit shell: the phase stepper becomes a collapsible LEFT sidebar (brand + vertical stepper + theme), the center is the editorial content, and the right pane on Team Registration is a native three.js scene — the Meridian Tower in a procedural Turin/Po-valley landscape — that slides from night to day the moment the board connects. - TowerScene.tsx: the .dc.html scene ported to a native React component (no iframe), [email protected] via npm, day/night driven by a `night` prop (lerped), StrictMode-safe WebGL teardown. Code-split so three.js loads only when the scene renders. Sky textures in public/skyscraper/. - CockpitLayout: left sidebar + content + tower/rail right pane; tower stretches to the content height. Stepper is now a vertical, collapsible list. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4dd3681eef
commit
5975287d56
@@ -23,6 +23,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"three": "0.160.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "0.160.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/ui": "^4.1.8",
|
"@vitest/ui": "^4.1.8",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
|
|||||||
Generated
+41
@@ -32,6 +32,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
three:
|
||||||
|
specifier: 0.160.0
|
||||||
|
version: 0.160.0
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.14
|
specifier: ^5.0.14
|
||||||
version: 5.0.14(@types/[email protected])([email protected])
|
version: 5.0.14(@types/[email protected])([email protected])
|
||||||
@@ -57,6 +60,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3(@types/[email protected])
|
version: 19.2.3(@types/[email protected])
|
||||||
|
'@types/three':
|
||||||
|
specifier: 0.160.0
|
||||||
|
version: 0.160.0
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.2([email protected](@types/[email protected])([email protected]))
|
version: 6.0.2([email protected](@types/[email protected])([email protected]))
|
||||||
@@ -532,6 +538,15 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected]':
|
'@typescript-eslint/[email protected]':
|
||||||
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -914,6 +929,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
@@ -1172,6 +1190,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
@@ -1468,6 +1489,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2106,6 +2130,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
fflate: 0.6.11
|
||||||
|
meshoptimizer: 0.18.1
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
|
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -2518,6 +2553,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -2729,6 +2766,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
@@ -3012,6 +3051,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 886 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
@@ -0,0 +1,470 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||||
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
|
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||||
|
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||||
|
|
||||||
|
export function TowerScene({ night, className }: { night: boolean; className?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const nightRef = useRef<boolean>(night);
|
||||||
|
|
||||||
|
// keep the ref in sync with the prop so the animate loop can lerp toward it
|
||||||
|
useEffect(() => {
|
||||||
|
nightRef.current = night;
|
||||||
|
}, [night]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const W = () => container.clientWidth;
|
||||||
|
const H = () => container.clientHeight;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(W(), H());
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.0;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0xb7d2e8);
|
||||||
|
scene.fog = new THREE.FogExp2(0xb7d2e8, 0.0006);
|
||||||
|
|
||||||
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||||
|
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, W() / H(), 0.5, 3000);
|
||||||
|
camera.position.set(135, 78, 135);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.06;
|
||||||
|
controls.target.set(0, 42, 0);
|
||||||
|
controls.minDistance = 60;
|
||||||
|
controls.maxDistance = 1500;
|
||||||
|
controls.maxPolarAngle = Math.PI * 0.495;
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.5;
|
||||||
|
|
||||||
|
// ---- lights ----
|
||||||
|
const hemi = new THREE.HemisphereLight(0xcfe4f5, 0x36302a, 0.75);
|
||||||
|
scene.add(hemi);
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
||||||
|
scene.add(ambient);
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff3e0, 3.3);
|
||||||
|
sun.position.set(150, 200, 90);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
|
sun.shadow.camera.near = 10;
|
||||||
|
sun.shadow.camera.far = 600;
|
||||||
|
const sc = 200;
|
||||||
|
sun.shadow.camera.left = -sc;
|
||||||
|
sun.shadow.camera.right = sc;
|
||||||
|
sun.shadow.camera.top = sc;
|
||||||
|
sun.shadow.camera.bottom = -sc;
|
||||||
|
sun.shadow.bias = -0.0004;
|
||||||
|
scene.add(sun);
|
||||||
|
|
||||||
|
// ---- scene-wide collections (were `this.*`) ----
|
||||||
|
const winMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
const mainMats: THREE.Material[] = [];
|
||||||
|
const mainMeshes: THREE.Mesh[] = [];
|
||||||
|
const lampMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
const pointLights: THREE.PointLight[] = [];
|
||||||
|
const pools: THREE.Mesh[] = [];
|
||||||
|
const cars: { headMat: THREE.MeshStandardMaterial; tailMat: THREE.MeshStandardMaterial; poolMat: THREE.Material & { opacity: number } }[] = [];
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
const warm = { r: 255, g: 208, b: 132 };
|
||||||
|
const makeFacade = (cols: number, rows: number, litRatio: number) => {
|
||||||
|
const cell = 40, cw = cols * cell, ch = rows * cell;
|
||||||
|
const base = document.createElement('canvas'); base.width = cw; base.height = ch;
|
||||||
|
const bx = base.getContext('2d')!;
|
||||||
|
bx.fillStyle = '#191d24'; bx.fillRect(0, 0, cw, ch);
|
||||||
|
const lit = document.createElement('canvas'); lit.width = cw; lit.height = ch;
|
||||||
|
const lx = lit.getContext('2d')!; lx.fillStyle = '#000'; lx.fillRect(0, 0, cw, ch);
|
||||||
|
const mg = 5;
|
||||||
|
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||||
|
const px = c * cell + mg, py = r * cell + mg, pw = cell - 2 * mg, ph = cell - 2 * mg;
|
||||||
|
bx.fillStyle = `rgb(${118 + Math.random() * 22 | 0},${148 + Math.random() * 22 | 0},${176 + Math.random() * 26 | 0})`;
|
||||||
|
bx.fillRect(px, py, pw, ph);
|
||||||
|
if (Math.random() < litRatio) {
|
||||||
|
const b = 0.55 + Math.random() * 0.45;
|
||||||
|
lx.fillStyle = `rgba(${warm.r},${warm.g},${warm.b},${b.toFixed(3)})`;
|
||||||
|
lx.fillRect(px, py, pw, ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const map = new THREE.CanvasTexture(base); map.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const emap = new THREE.CanvasTexture(lit); emap.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return { map, emap };
|
||||||
|
};
|
||||||
|
|
||||||
|
const roofMat = () => new THREE.MeshStandardMaterial({ color: 0x14171c, metalness: 0.7, roughness: 0.5, transparent: true, opacity: 1 });
|
||||||
|
|
||||||
|
const makeBox = (w: number, h: number, d: number, cols: number, rows: number, litRatio: number, isMain: boolean) => {
|
||||||
|
const { map, emap } = makeFacade(cols, rows, litRatio);
|
||||||
|
const win = new THREE.MeshStandardMaterial({
|
||||||
|
map, emissiveMap: emap, emissive: 0xffffff, emissiveIntensity: 0,
|
||||||
|
metalness: 0.15, roughness: 0.12, envMapIntensity: 1.0, transparent: true, opacity: 1
|
||||||
|
});
|
||||||
|
const rf = roofMat();
|
||||||
|
const geo = new THREE.BoxGeometry(w, h, d);
|
||||||
|
const mesh = new THREE.Mesh(geo, [win, win, rf, rf, win, win]);
|
||||||
|
mesh.castShadow = true; mesh.receiveShadow = true;
|
||||||
|
winMats.push(win);
|
||||||
|
if (isMain) { mainMats.push(win, rf); mainMeshes.push(mesh); }
|
||||||
|
return { mesh, win, rf };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- main tower ----
|
||||||
|
const mainGroup = new THREE.Group();
|
||||||
|
const podium = makeBox(30, 8, 30, 12, 2, 0.55, true);
|
||||||
|
podium.mesh.position.y = 4; mainGroup.add(podium.mesh);
|
||||||
|
|
||||||
|
const tower = makeBox(22, 72, 22, 8, 18, 0.32, true);
|
||||||
|
tower.mesh.position.y = 44; mainGroup.add(tower.mesh);
|
||||||
|
|
||||||
|
const crown = new THREE.Mesh(new THREE.BoxGeometry(16, 6, 16),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x1b1f26, metalness: 0.85, roughness: 0.35, transparent: true, opacity: 1 }));
|
||||||
|
crown.position.y = 83; crown.castShadow = true; mainGroup.add(crown);
|
||||||
|
mainMats.push(crown.material as THREE.Material); mainMeshes.push(crown);
|
||||||
|
|
||||||
|
const mech = new THREE.Mesh(new THREE.BoxGeometry(9, 4, 9),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f1216, metalness: 0.8, roughness: 0.6, transparent: true, opacity: 1 }));
|
||||||
|
mech.position.y = 88; mech.castShadow = true; mainGroup.add(mech);
|
||||||
|
mainMats.push(mech.material as THREE.Material); mainMeshes.push(mech);
|
||||||
|
|
||||||
|
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 16, 12),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2a2f37, metalness: 0.9, roughness: 0.4, transparent: true, opacity: 1 }));
|
||||||
|
antenna.position.y = 98; mainGroup.add(antenna);
|
||||||
|
mainMats.push(antenna.material as THREE.Material); mainMeshes.push(antenna);
|
||||||
|
|
||||||
|
const beaconMat = new THREE.MeshStandardMaterial({ color: 0x330000, emissive: 0xff2a1a, emissiveIntensity: 0 });
|
||||||
|
const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 12), beaconMat);
|
||||||
|
beacon.position.y = 106.5; mainGroup.add(beacon);
|
||||||
|
scene.add(mainGroup);
|
||||||
|
|
||||||
|
// wireframe overlay of main building
|
||||||
|
const wireMat = new THREE.LineBasicMaterial({ color: 0x7fe0ff, transparent: true, opacity: 0 });
|
||||||
|
const wireGroup = new THREE.Group();
|
||||||
|
mainMeshes.forEach(mesh => {
|
||||||
|
const wf = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 1), wireMat);
|
||||||
|
wf.position.copy(mesh.position); wf.rotation.copy(mesh.rotation);
|
||||||
|
wireGroup.add(wf);
|
||||||
|
});
|
||||||
|
wireGroup.visible = false;
|
||||||
|
scene.add(wireGroup);
|
||||||
|
|
||||||
|
// ---- wireframe interior: per-floor plans ----
|
||||||
|
const floorMat = new THREE.LineBasicMaterial({ color: 0x3dffa0, transparent: true, opacity: 0 });
|
||||||
|
const gridMat = new THREE.LineBasicMaterial({ color: 0x27c47e, transparent: true, opacity: 0 });
|
||||||
|
const interiorGroup = new THREE.Group();
|
||||||
|
const wallPos: number[] = [], gridPos: number[] = [];
|
||||||
|
const seg3 = (arr: number[], x1: number, y1: number, z1: number, x2: number, y2: number, z2: number) => arr.push(x1, y1, z1, x2, y2, z2);
|
||||||
|
const extrude = (segs: number[][], y0: number, h: number) => {
|
||||||
|
for (const [a, b, c, d] of segs) {
|
||||||
|
seg3(wallPos, a, y0, b, c, y0, d);
|
||||||
|
seg3(wallPos, a, y0 + h, b, c, y0 + h, d);
|
||||||
|
seg3(wallPos, a, y0, b, a, y0 + h, b);
|
||||||
|
seg3(wallPos, c, y0, d, c, y0 + h, d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const gridPlate = (s: number, y0: number) => {
|
||||||
|
const step = 3, yy = y0 + 0.03;
|
||||||
|
for (let x = -s + step; x < s; x += step) seg3(gridPos, x, yy, -s, x, yy, s);
|
||||||
|
for (let z = -s + step; z < s; z += step) seg3(gridPos, -s, yy, z, s, yy, z);
|
||||||
|
};
|
||||||
|
const bath = (segs: number[][], cx: number, cz: number) => {
|
||||||
|
const b = 0.9;
|
||||||
|
segs.push([cx - b, cz - b, cx + b, cz - b], [cx + b, cz - b, cx + b, cz + b],
|
||||||
|
[cx + b, cz + b, cx - b, cz + b], [cx - b, cz + b, cx - b, cz - b], [cx, cz - b, cx, cz + b]);
|
||||||
|
};
|
||||||
|
const makePlan = (s: number, type: string) => {
|
||||||
|
const segs: number[][] = [], R = s - 0.7;
|
||||||
|
segs.push([-R, -R, R, -R], [R, -R, R, R], [R, R, -R, R], [-R, R, -R, -R]);
|
||||||
|
if (type === 'mech') {
|
||||||
|
const n = 6;
|
||||||
|
for (let i = 1; i < n; i++) { const p = -R + 2 * R * i / n; segs.push([p, -R, p, R], [-R, p, R, p]); }
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
const cfg = ({ lobby: { c: false, rooms: 1, bath: 0 }, open: { c: true, rooms: 2, bath: 0 },
|
||||||
|
office: { c: true, rooms: 6, bath: 2 }, hotel: { c: true, rooms: 8, bath: 99 },
|
||||||
|
pent: { c: false, rooms: 3, bath: 1 } } as Record<string, { c: boolean; rooms: number; bath: number }>)[type];
|
||||||
|
const rc = (k: number) => -R + 2 * R * (k + 0.5) / cfg.rooms;
|
||||||
|
if (cfg.c) {
|
||||||
|
const ch = 2.0;
|
||||||
|
segs.push([-R, -ch, R, -ch], [-R, ch, R, ch]);
|
||||||
|
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, -ch], [x, ch, x, R]); }
|
||||||
|
for (let k = 0; k < cfg.rooms; k++) {
|
||||||
|
const useBath = cfg.bath === 99 ? true : (cfg.bath === 2 ? (k === 0 || k === cfg.rooms - 1) : false);
|
||||||
|
if (useBath) bath(segs, rc(k), R - 1.4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, R]); }
|
||||||
|
if (cfg.bath) bath(segs, R - 2, R - 2);
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
};
|
||||||
|
const pattern = ['lobby', 'lobby', 'open', 'office', 'office', 'office', 'office', 'hotel', 'office', 'office',
|
||||||
|
'mech', 'office', 'office', 'office', 'office', 'hotel', 'office', 'open', 'pent', 'pent'];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const y0 = i * 4, s = (y0 < 8 ? 15 : 11) - 0.5;
|
||||||
|
extrude(makePlan(s, pattern[i]), y0, 4);
|
||||||
|
gridPlate(s, y0);
|
||||||
|
}
|
||||||
|
const wgeo = new THREE.BufferGeometry();
|
||||||
|
wgeo.setAttribute('position', new THREE.Float32BufferAttribute(wallPos, 3));
|
||||||
|
interiorGroup.add(new THREE.LineSegments(wgeo, floorMat));
|
||||||
|
const ggeo = new THREE.BufferGeometry();
|
||||||
|
ggeo.setAttribute('position', new THREE.Float32BufferAttribute(gridPos, 3));
|
||||||
|
interiorGroup.add(new THREE.LineSegments(ggeo, gridMat));
|
||||||
|
interiorGroup.visible = false;
|
||||||
|
scene.add(interiorGroup);
|
||||||
|
|
||||||
|
// ---- procedural terrain: Turin / Po valley ----
|
||||||
|
const fract = (v: number) => v - Math.floor(v);
|
||||||
|
const hash = (x: number, z: number) => fract(Math.sin(x * 127.1 + z * 311.7) * 43758.5453);
|
||||||
|
const vnoise = (x: number, z: number) => {
|
||||||
|
const xi = Math.floor(x), zi = Math.floor(z), xf = x - xi, zf = z - zi;
|
||||||
|
const u = xf * xf * (3 - 2 * xf), v = zf * zf * (3 - 2 * zf);
|
||||||
|
const a = hash(xi, zi), b = hash(xi + 1, zi), c = hash(xi, zi + 1), d = hash(xi + 1, zi + 1);
|
||||||
|
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
|
||||||
|
};
|
||||||
|
const fbm = (x: number, z: number) => { let f = 0, amp = 0.5, fr = 1; for (let i = 0; i < 5; i++) { f += amp * vnoise(x * fr, z * fr); fr *= 2; amp *= 0.5; } return f; };
|
||||||
|
const smooth = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
||||||
|
// height field (planar px,py -> world x=px, z=-py)
|
||||||
|
const HT = (px: number, py: number) => {
|
||||||
|
let h = (fbm(px * 0.0016 + 10, py * 0.0016) - 0.5) * 46;
|
||||||
|
const east = Math.max(0, (px - 300) / 1500);
|
||||||
|
h += east * east * 150 * fbm(px * 0.004, py * 0.004);
|
||||||
|
const north = Math.max(0, (py - 820) / 1200);
|
||||||
|
const ridge = 1 - Math.abs(fbm(px * 0.0022 + 5, py * 0.0022) * 2 - 1);
|
||||||
|
h += Math.pow(north, 1.5) * 600 * (0.35 + 0.65 * ridge);
|
||||||
|
h *= smooth(120, 300, Math.hypot(px, py));
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
const tGeo = new THREE.PlaneGeometry(4600, 4600, 260, 260);
|
||||||
|
const tp = tGeo.attributes.position, cols: number[] = [];
|
||||||
|
const cGrass = new THREE.Color(0x5c8a39), cDry = new THREE.Color(0x83904c),
|
||||||
|
cForest = new THREE.Color(0x3a5c27), cRock = new THREE.Color(0x6f665a), cSnow = new THREE.Color(0xeef2f6);
|
||||||
|
const tc = new THREE.Color();
|
||||||
|
for (let i = 0; i < tp.count; i++) {
|
||||||
|
const x = tp.getX(i), y = tp.getY(i), h = HT(x, y);
|
||||||
|
tp.setZ(i, h);
|
||||||
|
const nz = fbm(x * 0.02, y * 0.02);
|
||||||
|
if (h < 10) tc.copy(cGrass).lerp(cDry, nz * 0.5);
|
||||||
|
else if (h < 70) tc.copy(cGrass).lerp(cForest, smooth(10, 70, h));
|
||||||
|
else if (h < 300) tc.copy(cForest).lerp(cRock, smooth(70, 300, h));
|
||||||
|
else tc.copy(cRock).lerp(cSnow, smooth(300, 430, h));
|
||||||
|
tc.offsetHSL(0, 0, (nz - 0.5) * 0.06);
|
||||||
|
cols.push(tc.r, tc.g, tc.b);
|
||||||
|
}
|
||||||
|
tGeo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
|
||||||
|
tGeo.rotateX(-Math.PI / 2); tGeo.computeVertexNormals();
|
||||||
|
const terrain = new THREE.Mesh(tGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 }));
|
||||||
|
terrain.receiveShadow = true; scene.add(terrain);
|
||||||
|
|
||||||
|
// ---- Po river ----
|
||||||
|
const rpts: THREE.Vector3[] = [];
|
||||||
|
for (let k = 0; k <= 24; k++) { const py = -700 + k * 60, px = -260 + 230 * Math.sin(py * 0.004); rpts.push(new THREE.Vector3(px, 2, -py)); }
|
||||||
|
const rGeo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(rpts), 240, 27, 10, false);
|
||||||
|
const water = new THREE.Mesh(rGeo, new THREE.MeshStandardMaterial({ color: 0x2f6d97, roughness: 0.12, metalness: 0.35 }));
|
||||||
|
water.scale.y = 0.04; water.position.y = 1.3; scene.add(water);
|
||||||
|
|
||||||
|
// ---- surrounding town (Turin low blocks) ----
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const townPal = [0xb08159, 0xc39a6b, 0x9a6b4a, 0xc7b393, 0xa8875f, 0x8f5f43];
|
||||||
|
const NB = 220, townI = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1),
|
||||||
|
new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }), NB);
|
||||||
|
let bi = 0, ba = 0;
|
||||||
|
while (bi < NB && ba < NB * 8) {
|
||||||
|
ba++;
|
||||||
|
const ang = Math.random() * 7, r = 150 + Math.random() * 640, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (Math.hypot(px, py) < 140 || h > 45) continue;
|
||||||
|
const bw = 9 + Math.random() * 16, bh = 8 + Math.random() * 28, bd = 9 + Math.random() * 16;
|
||||||
|
dummy.position.set(px, h + bh / 2, -py); dummy.scale.set(bw, bh, bd); dummy.rotation.set(0, Math.random() * 7, 0);
|
||||||
|
dummy.updateMatrix(); townI.setMatrixAt(bi, dummy.matrix);
|
||||||
|
townI.setColorAt(bi, tc.setHex(townPal[bi % townPal.length]));
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
townI.count = bi; townI.instanceMatrix.needsUpdate = true; if (townI.instanceColor) townI.instanceColor.needsUpdate = true;
|
||||||
|
scene.add(townI);
|
||||||
|
|
||||||
|
// ---- trees on hills ----
|
||||||
|
const NT = 560;
|
||||||
|
const trunkI = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.6, 0.9, 6, 5),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 1 }), NT);
|
||||||
|
const foliI = new THREE.InstancedMesh(new THREE.ConeGeometry(3.2, 9, 7),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2f5223, roughness: 1 }), NT);
|
||||||
|
let fi = 0, fa = 0;
|
||||||
|
while (fi < NT && fa < NT * 8) {
|
||||||
|
fa++;
|
||||||
|
const ang = Math.random() * 7, r = 200 + Math.random() * 1050, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (Math.hypot(px, py) < 170 || h < 6 || h > 260) continue;
|
||||||
|
const s = 0.7 + Math.random() * 1.1, ry = Math.random() * 7;
|
||||||
|
dummy.rotation.set(0, ry, 0);
|
||||||
|
dummy.position.set(px, h + 3 * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); trunkI.setMatrixAt(fi, dummy.matrix);
|
||||||
|
dummy.position.set(px, h + (6 + 4.5) * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); foliI.setMatrixAt(fi, dummy.matrix);
|
||||||
|
fi++;
|
||||||
|
}
|
||||||
|
trunkI.count = foliI.count = fi;
|
||||||
|
trunkI.instanceMatrix.needsUpdate = true; foliI.instanceMatrix.needsUpdate = true;
|
||||||
|
scene.add(trunkI); scene.add(foliI);
|
||||||
|
|
||||||
|
// ---- grass tufts near base ----
|
||||||
|
const gcv = document.createElement('canvas'); gcv.width = gcv.height = 64;
|
||||||
|
const gx = gcv.getContext('2d')!;
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const bx = 8 + Math.random() * 48;
|
||||||
|
gx.strokeStyle = `rgb(${70 + Math.random() * 40 | 0},${130 + Math.random() * 50 | 0},${50 + Math.random() * 30 | 0})`;
|
||||||
|
gx.lineWidth = 1.5 + Math.random() * 1.5; gx.beginPath(); gx.moveTo(bx, 64);
|
||||||
|
gx.quadraticCurveTo(bx + (Math.random() - 0.5) * 20, 34, bx + (Math.random() - 0.5) * 26, 6 + Math.random() * 10); gx.stroke();
|
||||||
|
}
|
||||||
|
const gTex = new THREE.CanvasTexture(gcv); gTex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const NG = 2400;
|
||||||
|
const tuftI = new THREE.InstancedMesh(new THREE.PlaneGeometry(5, 5),
|
||||||
|
new THREE.MeshStandardMaterial({ map: gTex, alphaTest: 0.5, side: THREE.DoubleSide, roughness: 1, color: 0x7aa34e }), NG);
|
||||||
|
let gi = 0, ga = 0;
|
||||||
|
while (gi < NG && ga < NG * 6) {
|
||||||
|
ga++;
|
||||||
|
const ang = Math.random() * 7, r = 120 + Math.random() * 460, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (h > 26) continue;
|
||||||
|
dummy.position.set(px, h + 2.4, -py); dummy.scale.set(1, 1, 1); dummy.rotation.set(0, Math.random() * 7, 0);
|
||||||
|
dummy.updateMatrix(); tuftI.setMatrixAt(gi, dummy.matrix); gi++;
|
||||||
|
}
|
||||||
|
tuftI.count = gi; tuftI.instanceMatrix.needsUpdate = true; scene.add(tuftI);
|
||||||
|
const envGround: THREE.MeshStandardMaterial[] = [
|
||||||
|
terrain.material as THREE.MeshStandardMaterial,
|
||||||
|
townI.material as THREE.MeshStandardMaterial,
|
||||||
|
trunkI.material as THREE.MeshStandardMaterial,
|
||||||
|
foliI.material as THREE.MeshStandardMaterial,
|
||||||
|
tuftI.material as THREE.MeshStandardMaterial,
|
||||||
|
water.material as THREE.MeshStandardMaterial,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- image sky domes (day / night) ----
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
const skyDome = (url: string) => {
|
||||||
|
const tex = loader.load(url);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const geo = new THREE.SphereGeometry(2600, 48, 32);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, transparent: true, opacity: 1, depthWrite: false, fog: false });
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
scene.add(m); return m;
|
||||||
|
};
|
||||||
|
const skyDay = skyDome('/skyscraper/sky-day.png');
|
||||||
|
const skyNight = skyDome('/skyscraper/sky-night.png');
|
||||||
|
(skyNight.material as THREE.MeshBasicMaterial).opacity = 0;
|
||||||
|
// subtle cool moonlight fill at night
|
||||||
|
const moonLight = new THREE.DirectionalLight(0x9fb8e6, 0);
|
||||||
|
moonLight.position.set(-520, 430, -720);
|
||||||
|
scene.add(moonLight);
|
||||||
|
|
||||||
|
// ---- composer / bloom ----
|
||||||
|
const composer = new EffectComposer(renderer);
|
||||||
|
composer.addPass(new RenderPass(scene, camera));
|
||||||
|
const bloom = new UnrealBloomPass(new THREE.Vector2(W(), H()), 0.18, 0.7, 0.9);
|
||||||
|
composer.addPass(bloom);
|
||||||
|
|
||||||
|
// ---- resize ----
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = W(), h = H(); if (!w || !h) return;
|
||||||
|
camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h); composer.setSize(w, h);
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
// ---- animation ----
|
||||||
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
|
const cDay = new THREE.Color(0xb7d2e8), cNight = new THREE.Color(0x05070d);
|
||||||
|
const fDay = new THREE.Color(0xc6dcec), fNight = new THREE.Color(0x080b14);
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
|
||||||
|
const skyDayMat = skyDay.material as THREE.MeshBasicMaterial;
|
||||||
|
const skyNightMat = skyNight.material as THREE.MeshBasicMaterial;
|
||||||
|
|
||||||
|
const applyNight = (n: number) => {
|
||||||
|
(scene.background as THREE.Color).copy(cDay).lerp(cNight, n);
|
||||||
|
const fog = scene.fog as THREE.FogExp2;
|
||||||
|
fog.color.copy(fDay).lerp(fNight, n);
|
||||||
|
fog.density = lerp(0.0006, 0.0017, n);
|
||||||
|
hemi.intensity = lerp(0.75, 0.05, n);
|
||||||
|
ambient.intensity = lerp(0.25, 0.03, n);
|
||||||
|
sun.intensity = lerp(3.3, 0.0, n);
|
||||||
|
renderer.toneMappingExposure = lerp(1.0, 1.12, n);
|
||||||
|
const wi = lerp(0.0, 1.55, n), env = lerp(1.0, 0.28, n);
|
||||||
|
winMats.forEach(m => { m.emissiveIntensity = wi; m.envMapIntensity = env; });
|
||||||
|
lampMats.forEach(m => m.emissiveIntensity = lerp(0, 2.4, n));
|
||||||
|
pointLights.forEach(l => l.intensity = lerp(0, 900, n));
|
||||||
|
pools.forEach(p => (p.material as THREE.Material & { opacity: number }).opacity = lerp(0, 0.55, n));
|
||||||
|
cars.forEach(c => {
|
||||||
|
c.headMat.emissiveIntensity = lerp(0, 3, n);
|
||||||
|
c.tailMat.emissiveIntensity = lerp(0, 2.2, n);
|
||||||
|
c.poolMat.opacity = lerp(0, 0.5, n);
|
||||||
|
});
|
||||||
|
bloom.strength = lerp(0.18, 1.0, n);
|
||||||
|
bloom.threshold = lerp(0.9, 0.0, n);
|
||||||
|
skyDayMat.opacity = 1 - n;
|
||||||
|
skyNightMat.opacity = n;
|
||||||
|
moonLight.intensity = n * 0.7;
|
||||||
|
const genv = lerp(1.0, 0.12, n);
|
||||||
|
envGround.forEach(m => { m.envMapIntensity = genv; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyWire = (w: number) => {
|
||||||
|
const solid = 1 - w;
|
||||||
|
mainMats.forEach(m => { (m as THREE.Material & { opacity: number }).opacity = solid; m.transparent = true; });
|
||||||
|
mainMeshes.forEach(mesh => { mesh.visible = w < 0.995; });
|
||||||
|
wireGroup.visible = w > 0.005;
|
||||||
|
wireMat.opacity = w;
|
||||||
|
interiorGroup.visible = w > 0.005;
|
||||||
|
floorMat.opacity = w * 1.0;
|
||||||
|
gridMat.opacity = w * 0.72;
|
||||||
|
};
|
||||||
|
|
||||||
|
// day/night lerp state (init to correct mode so it starts right, then slides on prop change)
|
||||||
|
let nl = night ? 1 : 0;
|
||||||
|
let wf = 0; // mode = 'solid' -> wireframe stays 0
|
||||||
|
// control state (was this._ctrl); autoRotate ON, mode solid
|
||||||
|
const ctrl = { mode: 'solid' as const, autoRotate: true };
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
raf = requestAnimationFrame(animate);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
const t = clock.elapsedTime;
|
||||||
|
const k = Math.min(1, dt * 4);
|
||||||
|
nl += ((nightRef.current ? 1 : 0) - nl) * k;
|
||||||
|
wf += (((ctrl.mode as string) === 'wire' ? 1 : 0) - wf) * k;
|
||||||
|
applyNight(nl);
|
||||||
|
applyWire(wf);
|
||||||
|
beaconMat.emissiveIntensity = nl * (0.4 + 0.6 * Math.abs(Math.sin(t * 2.2)));
|
||||||
|
controls.autoRotate = ctrl.autoRotate;
|
||||||
|
controls.update();
|
||||||
|
composer.render();
|
||||||
|
};
|
||||||
|
applyNight(nl); applyWire(wf);
|
||||||
|
if (!disposed) animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
controls.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
pmrem.dispose();
|
||||||
|
if (renderer.domElement.parentNode === container) {
|
||||||
|
container.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Outlet, Link } from 'react-router-dom'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { Stepper } from './Stepper'
|
import { Stepper } from './Stepper'
|
||||||
import { CockpitRail } from './CockpitRail'
|
import { CockpitRail } from './CockpitRail'
|
||||||
|
|
||||||
function ThemeToggle() {
|
// Code-split three.js: the tower chunk only loads when the scene actually renders.
|
||||||
|
const TowerScene = lazy(() =>
|
||||||
|
import('@/components/TowerScene').then((m) => ({ default: m.TowerScene })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function ThemeToggle({ collapsed }: { collapsed: boolean }) {
|
||||||
const theme = useSession((s) => s.theme)
|
const theme = useSession((s) => s.theme)
|
||||||
const setTheme = useSession((s) => s.setTheme)
|
const setTheme = useSession((s) => s.setTheme)
|
||||||
const dark = theme === 'dark'
|
const dark = theme === 'dark'
|
||||||
@@ -12,50 +19,101 @@ function ThemeToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
||||||
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||||
className="rounded-[20px] border border-line px-3 py-1.5 font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink"
|
title={dark ? 'Light theme' : 'Dark theme'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink',
|
||||||
|
collapsed ? 'px-0 py-2' : 'px-3 py-1.5',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{dark ? '☀ LIGHT' : '☾ DARK'}
|
{collapsed ? (dark ? '☀' : '☾') : dark ? '☀ LIGHT' : '☾ DARK'}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
||||||
* Header + stepper + a two-pane grid (editorial content via <Outlet/> on the
|
* A collapsible left sidebar carries the brand + the phase stepper + theme; the
|
||||||
* left, the live instrument rail on the right). The rail stays mounted across
|
* center is the editorial content (<Outlet/>); the right pane is the live rail —
|
||||||
* phase navigation, so its live feed never resets.
|
* except on Team Registration, where it's the 3D tower scene that slides from
|
||||||
|
* night to day the moment the board connects.
|
||||||
*/
|
*/
|
||||||
export function CockpitLayout() {
|
export function CockpitLayout() {
|
||||||
const team = useSession((s) => s.team)
|
const team = useSession((s) => s.team)
|
||||||
|
const connected = useSession((s) => s.device.connected)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||||
|
const showTower = pathname === '/workshop'
|
||||||
|
// Night until the team is named AND the board is connected → then slide to day.
|
||||||
|
const night = !(team.name.trim().length > 0 && connected)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen">
|
||||||
<header className="flex items-center border-b border-line px-10 py-5 print:hidden">
|
{/* ── left sidebar ── */}
|
||||||
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
<aside
|
||||||
<span className="text-ink">APESS </span>
|
className={cn(
|
||||||
<span className="text-blue">2026</span>
|
'sticky top-0 flex h-screen shrink-0 flex-col border-r border-line bg-surface-soft transition-[width] duration-200 print:hidden',
|
||||||
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
collapsed ? 'w-[68px]' : 'w-[236px]',
|
||||||
</Link>
|
)}
|
||||||
<div className="ml-auto flex items-center gap-4">
|
>
|
||||||
<span className="font-mono text-[11px] text-[var(--muted)]">
|
<div className={cn('flex items-center border-b border-line py-5', collapsed ? 'justify-center px-2' : 'px-4')}>
|
||||||
{nodeName}
|
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
||||||
{team.name && <span> · {team.name}</span>}
|
{collapsed ? (
|
||||||
</span>
|
<span className="text-blue">26</span>
|
||||||
<ThemeToggle />
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-ink">APESS </span>
|
||||||
|
<span className="text-blue">2026</span>
|
||||||
|
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="print:hidden">
|
<div className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
<Stepper />
|
<Stepper collapsed={collapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto grid w-full max-w-[1400px] items-start gap-14 px-10 pb-[90px] pt-14 lg:grid-cols-[minmax(0,1fr)_464px] print:block print:p-0">
|
<div className="flex flex-col gap-2 border-t border-line px-3 py-3">
|
||||||
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
{!collapsed && (
|
||||||
<Outlet />
|
<span className="truncate px-1 font-mono text-[10px] text-[var(--muted)]">
|
||||||
</main>
|
{nodeName}
|
||||||
<div className="print:hidden">
|
{team.name && <span> · {team.name}</span>}
|
||||||
<CockpitRail />
|
</span>
|
||||||
|
)}
|
||||||
|
<ThemeToggle collapsed={collapsed} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-3 transition-colors hover:border-blue hover:text-blue-ink',
|
||||||
|
collapsed ? 'px-0 py-2' : 'px-3 py-1.5 text-left',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsed ? '»' : '« COLLAPSE'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── body: content + right pane (tower on registration, else rail) ── */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="grid w-full max-w-[1440px] items-start gap-12 px-10 pb-[90px] pt-10 lg:grid-cols-[minmax(0,1fr)_620px] print:block print:p-0">
|
||||||
|
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<div className={cn('print:hidden', showTower && 'self-stretch')}>
|
||||||
|
{showTower ? (
|
||||||
|
<aside className="h-full min-h-[520px] w-full overflow-hidden rounded-[18px] border border-line bg-[#05070d] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]">
|
||||||
|
<Suspense fallback={<div className="h-full w-full bg-[#05070d]" />}>
|
||||||
|
<TowerScene night={night} className="h-full w-full" />
|
||||||
|
</Suspense>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
|
<CockpitRail />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ const STEPS: Step[] = [
|
|||||||
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The five-phase stepper nav (replaces PhaseStrip). Forward-gated: you can only
|
/**
|
||||||
* jump to a step at or before the current one (advance via the Proceed buttons). */
|
* The five-phase stepper, as a vertical sidebar list. Forward-gated: you can only
|
||||||
export function Stepper() {
|
* jump to a step at or before the current one (advance via the Proceed buttons).
|
||||||
|
* `collapsed` shows just the phase number.
|
||||||
|
*/
|
||||||
|
export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const phases = useSession((s) => s.phases)
|
const phases = useSession((s) => s.phases)
|
||||||
@@ -30,7 +33,7 @@ export function Stepper() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="grid grid-cols-5 gap-2.5 border-b border-line px-10 py-4" data-testid="stepper">
|
<nav className="flex flex-col gap-1.5" data-testid="stepper">
|
||||||
{STEPS.map((s, i) => {
|
{STEPS.map((s, i) => {
|
||||||
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
||||||
const reachable = i <= activeIndex || phases[s.key]
|
const reachable = i <= activeIndex || phases[s.key]
|
||||||
@@ -39,36 +42,54 @@ export function Stepper() {
|
|||||||
key={s.key}
|
key={s.key}
|
||||||
type="button"
|
type="button"
|
||||||
data-state={state}
|
data-state={state}
|
||||||
|
data-phase={s.key}
|
||||||
disabled={!reachable}
|
disabled={!reachable}
|
||||||
|
title={collapsed ? `${s.eyebrow} · ${s.label}` : undefined}
|
||||||
onClick={() => reachable && navigate(s.to)}
|
onClick={() => reachable && navigate(s.to)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg px-3.5 py-[11px] text-left transition-colors',
|
'flex items-center gap-3 rounded-lg text-left transition-colors',
|
||||||
|
collapsed ? 'justify-center px-0 py-2.5' : 'px-3 py-2.5',
|
||||||
reachable ? 'cursor-pointer' : 'cursor-default',
|
reachable ? 'cursor-pointer' : 'cursor-default',
|
||||||
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
||||||
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
||||||
state === 'pending' && 'border border-line bg-surface',
|
state === 'pending' && 'border border-line bg-surface',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{/* number chip */}
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[10px] tracking-[0.16em]',
|
'grid h-6 w-6 shrink-0 place-items-center rounded-md font-mono text-[11px] font-semibold',
|
||||||
state === 'done' && 'text-green',
|
state === 'done' && 'text-green',
|
||||||
state === 'active' && 'text-blue-eyebrow',
|
state === 'active' && 'text-blue-ink',
|
||||||
state === 'pending' && 'text-faint',
|
state === 'pending' && 'text-[var(--muted-2)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.eyebrow}
|
{i + 1}
|
||||||
</div>
|
</span>
|
||||||
<div
|
{!collapsed && (
|
||||||
className={cn(
|
<span className="min-w-0">
|
||||||
'text-[15px]',
|
<span
|
||||||
state === 'done' && 'text-green font-medium',
|
className={cn(
|
||||||
state === 'active' && 'text-blue-ink font-semibold',
|
'block font-mono text-[9px] tracking-[0.16em]',
|
||||||
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
state === 'done' && 'text-green',
|
||||||
)}
|
state === 'active' && 'text-blue-eyebrow',
|
||||||
>
|
state === 'pending' && 'text-faint',
|
||||||
{s.label}
|
)}
|
||||||
</div>
|
>
|
||||||
|
{s.eyebrow}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block truncate text-[14px] leading-tight',
|
||||||
|
state === 'done' && 'text-green font-medium',
|
||||||
|
state === 'active' && 'text-blue-ink font-semibold',
|
||||||
|
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user