SF映画っぽいエクスプローラ「Exp3D 1.4」
・ダウンロードされる方はこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Exp3D 1.4</title>
<style>
:root {
--cyan: #00ffff;
--magenta: #ff00ff;
--yellow: #ffee00;
--bg: #020205;
--panel: rgba(8, 12, 16, 0.95);
}
body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }
/* --- Layout --- */
#app { display: flex; width: 100vw; height: 100vh; position: relative; }
/* Sidebar */
#sidebar {
position: absolute; top: 0; left: 0; bottom: 0; width: 280px;
background: var(--panel); border-right: 1px solid var(--cyan);
display: flex; flex-direction: column; z-index: 50; backdrop-filter: blur(10px);
transition: transform 0.3s; transform: translateX(0);
}
#sidebar.closed { transform: translateX(-280px); }
#sb-tab {
position: absolute; top: 50%; right: -30px; width: 30px; height: 100px; margin-top: -50px;
background: var(--panel); border: 1px solid var(--cyan); border-left: none;
display: flex; align-items: center; justify-content: center; cursor: pointer;
writing-mode: vertical-rl; color: var(--cyan); font-weight:bold;
border-radius: 0 10px 10px 0;
}
#sb-header { padding: 15px; color: var(--cyan); border-bottom: 1px solid rgba(0,255,255,0.3); font-weight: bold; letter-spacing: 2px; }
#tree-container { flex: 1; overflow-y: auto; padding: 10px; }
.tree-node { display: flex; align-items: center; padding: 6px; cursor: pointer; color: #888; transition:0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
.tree-node:hover { color: #fff; background: rgba(0,255,255,0.1); }
.icon-tree { margin-right: 8px; display: inline-block; }
/* Main View */
#main { flex: 1; position: relative; overflow: hidden; }
#canvas-wrap { width: 100%; height: 100%; position: absolute; }
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#hud-canvas { width: 100%; height: 100%; display: block; }
/* Shelf */
#shelf {
position: absolute; bottom: 0; left: 0; right: 0; height: 160px;
background: linear-gradient(to top, rgba(0,20,30,0.98), transparent);
border-top: 2px solid var(--magenta); display: flex; align-items: center; padding: 0 50px; gap: 20px;
z-index: 40; transition: bottom 0.3s; overflow-x: auto;
}
#shelf.closed { bottom: -160px; }
#shelf-tab {
position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%);
background: var(--magenta); color: #000; padding: 2px 30px; font-weight: bold;
cursor: pointer; clip-path: polygon(15% 0, 85% 0, 100% 100%, 0% 100%); z-index: 41; transition: bottom 0.3s;
}
#shelf.closed + #shelf-tab { bottom: 0; }
.shelf-item {
width: 100px; height: 120px; background: rgba(0,0,0,0.6); border: 1px solid var(--magenta);
display: flex; flex-direction: column; align-items: center; justify-content: center;
cursor: grab; color: var(--magenta); flex-shrink: 0; position: relative;
}
/* Context Menu */
.ctx-menu {
position: fixed; display: none; flex-direction: column;
background: rgba(10, 15, 20, 0.98); border: 1px solid var(--cyan);
box-shadow: 0 0 20px rgba(0,255,255,0.3); min-width: 200px; z-index: 9999;
transform-origin: top; animation: menuSlide 0.15s ease-out; padding: 5px 0;
}
@keyframes menuSlide { from { transform: scaleY(0); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
.ctx-item { padding: 10px 15px; cursor: pointer; color: #ccc; display: flex; justify-content: space-between; border-bottom: 1px solid #333; }
.ctx-item:hover { background: var(--cyan); color: #000; }
.ctx-item:last-child { border-bottom: none; }
/* Preview */
#preview {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9);
display: flex; justify-content: center; align-items: center; z-index: 300;
opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
}
#preview.active { opacity: 1; pointer-events: auto; visibility: visible; }
#p-box { width: 90%; height: 90%; border: 1px solid var(--cyan); background: #000; display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2); transform: scale(0.9); transition: transform 0.3s; }
#preview.active #p-box { transform: scale(1); }
#p-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; align-items: center; }
#p-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; display: flex; justify-content: center; align-items: center; }
.p-btn { background: transparent; border: 1px solid var(--cyan); color: var(--cyan); padding: 5px 15px; margin-left: 10px; cursor: pointer; transition: 0.2s; }
.p-btn:hover { background: var(--cyan); color: #000; }
.hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: monospace; }
.scanlines {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
background-size: 100% 3px; z-index: 9999; opacity: 0.15;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="app">
<div id="sidebar">
<div id="sb-header">SYSTEM // NAV</div>
<div style="padding:10px;"><button id="btn-mount" class="p-btn" style="width:100%">MOUNT DRIVE</button></div>
<div id="tree-container"></div>
<div id="sb-tab">TREE</div>
</div>
<div id="main">
<div id="canvas-wrap"></div>
<div id="hud"><canvas id="hud-canvas"></canvas></div>
<div id="shelf">
<div style="color:var(--magenta); opacity:0.6;">[ DRAG FILES HERE ]</div>
</div>
<div id="shelf-tab">DOCK</div>
<div id="ctx-obj" class="ctx-menu">
<div class="ctx-item" id="cm-open">Open</div>
<div class="ctx-item" id="cm-copy">Copy</div>
<div class="ctx-item" id="cm-rename">Rename</div>
<div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
<div class="ctx-item" id="cm-prop">Properties</div>
</div>
<div id="ctx-bg" class="ctx-menu">
<div class="ctx-item" id="cm-new">New Folder</div>
<div class="ctx-item" id="cm-paste">Paste</div>
<div class="ctx-item" id="cm-up">Up Directory</div>
<div class="ctx-item" id="cm-refresh">Refresh</div>
</div>
</div>
</div>
<div id="preview">
<div id="p-box">
<div id="p-head">
<span id="p-title">FILE</span>
<div>
<button id="p-save" class="p-btn" style="border-color:#0f0; color:#0f0; display:none;">SAVE</button>
<button id="p-close" class="p-btn">CLOSE</button>
</div>
</div>
<div id="p-body"></div>
</div>
</div>
<div class="scanlines"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
const CONFIG = {
colFolder: 0xffee00, // Yellow
colFile: 0x00ffff, // Cyan
colCursor: 0xffee00,
grid: 0x002233
};
const state = {
rootHandle: null, currentHandle: null, currentPath: [],
objects: [], shelfData: [], dragging: null, hovered: null, selected: null,
clipboard: null,
targetRotation: Math.PI, currentRotation: Math.PI,
targetY: 0, currentY: 0,
isRotating: false, lastMouse: new THREE.Vector2(),
isTransitioning: false
};
// --- Sound ---
const sfx = {
ctx: null,
init: () => { if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
play: (type) => {
if (!sfx.ctx) return;
const t = sfx.ctx.currentTime;
const o = sfx.ctx.createOscillator();
const g = sfx.ctx.createGain();
o.connect(g); g.connect(sfx.ctx.destination);
if(type==='click'){o.frequency.setValueAtTime(600,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
else if(type==='hover'){o.frequency.setValueAtTime(300,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.05);o.start(t);o.stop(t+0.05);}
else if(type==='open'){o.type='triangle';o.frequency.setValueAtTime(200,t);o.frequency.linearRampToValueAtTime(600,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
else if(type==='back'){o.type='triangle';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(200,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
else if(type==='lock'){o.type='square';o.frequency.setValueAtTime(1000,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
}
};
// --- Three.js Setup ---
const container = document.getElementById('canvas-wrap');
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020205, 0.015);
const camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 1000);
camera.position.set(0, 0, 0.1);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
const dl = new THREE.DirectionalLight(0xffffff, 2);
dl.position.set(0, 50, 0);
scene.add(dl);
const gridGeo = new THREE.CylinderGeometry(22, 22, 100, 32, 20, true);
const gridMat = new THREE.MeshBasicMaterial({color: CONFIG.grid, wireframe: true, transparent:true, opacity:0.15, side:THREE.BackSide});
const gridCyl = new THREE.Mesh(gridGeo, gridMat);
scene.add(gridCyl);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(container.clientWidth, container.clientHeight), 1.5, 0.4, 0.85);
composer.addPass(bloom);
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.enablePan = false;
orbitControls.enableRotate = false;
orbitControls.enableZoom = false;
orbitControls.target.set(0,0,-1);
const filesGroup = new THREE.Group();
scene.add(filesGroup);
// --- Tactical Reticle ---
const reticleGroup = new THREE.Group();
scene.add(reticleGroup);
reticleGroup.visible = false;
const ring = new THREE.Mesh(new THREE.RingGeometry(1.6, 1.65, 64), new THREE.MeshBasicMaterial({ color: CONFIG.colCursor, transparent: true, opacity: 0.9, side: THREE.DoubleSide }));
reticleGroup.add(ring);
const outerRing = new THREE.Mesh(new THREE.RingGeometry(1.9, 2.0, 32, 1, 0, Math.PI * 2), new THREE.MeshBasicMaterial({ color: CONFIG.colCursor, transparent: true, opacity: 0.5, side: THREE.DoubleSide, wireframe: true }));
reticleGroup.add(outerRing);
const bGeo = new THREE.BufferGeometry();
const bs=2.2, bl=0.6;
bGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
-bs,bs,0, -bs+bl,bs,0, -bs,bs,0, -bs,bs-bl,0, bs,bs,0, bs-bl,bs,0, bs,bs,0, bs,bs-bl,0,
-bs,-bs,0, -bs+bl,-bs,0, -bs,-bs,0, -bs,-bs+bl,0, bs,-bs,0, bs-bl,-bs,0, bs,-bs,0, bs,-bs+bl,0
]), 3));
reticleGroup.add(new THREE.LineSegments(bGeo, new THREE.LineBasicMaterial({ color: CONFIG.colCursor })));
// --- HUD Init ---
const hudC = document.getElementById('hud-canvas');
const hudX = hudC.getContext('2d');
function resizeHUD() {
const dpr = window.devicePixelRatio || 1;
hudC.width = container.clientWidth * dpr;
hudC.height = container.clientHeight * dpr;
hudX.scale(dpr, dpr);
}
window.onresize = () => {
camera.aspect=container.clientWidth/container.clientHeight; camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
composer.setSize(container.clientWidth, container.clientHeight);
resizeHUD();
};
resizeHUD();
// --- Logic ---
document.getElementById('btn-mount').onclick = async () => {
sfx.init(); sfx.play('click');
try {
const h = await window.showDirectoryPicker();
state.rootHandle = h;
enterFolder(h, false);
updateTree(h);
} catch(e) {}
};
// --- Animated Transitions (Explode/Implode) ---
function animateTransition(dir, cb) {
if (state.isTransitioning) return;
state.isTransitioning = true;
sfx.play(dir==='in'?'open':'back');
const startT = Date.now();
const duration = 600;
// Store initial positions relative to group center
const initialPositions = state.objects.map(obj => obj.position.clone());
function loop1() {
const elapsed = Date.now() - startT;
const p = Math.min(elapsed / duration, 1);
// Ease In Expo
const ease = p === 0 ? 0 : Math.pow(2, 10 * p - 10);
if (dir === 'in') {
// Explode outwards towards camera/scatter
state.objects.forEach((obj, i) => {
const original = initialPositions[i];
// Move away from center (0,y,0) and towards camera
const dirVec = original.clone().setY(0).normalize();
const move = dirVec.multiplyScalar(ease * 20); // Push out
const camMove = new THREE.Vector3(0,0,1).multiplyScalar(ease * 10);
obj.position.copy(original).add(move).add(camMove);
obj.rotation.z += 0.1; // Spin a bit
obj.material.opacity = 0.8 * (1 - p);
});
} else {
// Implode current (shrink to center)
state.objects.forEach((obj, i) => {
obj.scale.setScalar(1 - p);
obj.material.opacity = 0.8 * (1 - p);
});
}
if (p < 1) requestAnimationFrame(loop1);
else {
cb().then(() => {
// Phase 2: Arrive
// Reset group pos/rot for new content
state.targetY=0; state.currentY=0;
filesGroup.position.y=0;
// Determine start state for new objects
if(dir === 'in') {
// Come from far away/center
state.objects.forEach(obj => {
obj.scale.setScalar(0.1);
obj.material.opacity = 0;
});
} else {
// Back: Come from exploded state to normal
state.objects.forEach((obj, i) => {
obj.userData.targetPos = obj.position.clone(); // Save final
// Scatter start
const rnd = new THREE.Vector3(Math.random()-0.5, Math.random()-0.5, Math.random()).multiplyScalar(20);
obj.position.add(rnd);
obj.material.opacity = 0;
});
}
const startT2 = Date.now();
function loop2() {
const p2 = Math.min((Date.now()-startT2)/duration, 1);
const ease2 = 1 - Math.pow(1 - p2, 3); // Cubic Out
if(dir === 'in') {
// Zoom in from small
const s = 0.1 + (1.0 - 0.1) * ease2;
state.objects.forEach(obj => {
obj.scale.setScalar(s);
obj.material.opacity = 0.8 * p2;
});
} else {
// Gather from scattered
state.objects.forEach(obj => {
if(obj.userData.targetPos) {
obj.position.lerp(obj.userData.targetPos, 0.1); // Simple lerp for gather effect
obj.material.opacity = 0.8 * p2;
}
});
}
if(p2 < 1) requestAnimationFrame(loop2);
else {
state.isTransitioning=false;
// Finalize positions/scales
state.objects.forEach(obj => {
obj.scale.setScalar(1);
obj.material.opacity = 0.8; // Correct opacity
if(obj.userData.targetPos) obj.position.copy(obj.userData.targetPos);
});
}
}
loop2();
});
}
}
loop1();
}
async function enterFolder(handle, animate=true) {
const load = async () => {
state.currentHandle = handle;
if(!state.currentPath.includes(handle)) state.currentPath.push(handle);
state.objects.forEach(o => filesGroup.remove(o));
state.objects = [];
reticleGroup.visible = false;
state.selected = null;
updatePopup(null);
const entries = [];
for await (const e of handle.values()) entries.push(e);
layoutCylinder(entries);
};
if(animate) animateTransition('in', load);
else await load();
}
async function goUp() {
if(state.currentPath.length>1) {
const p = state.currentPath[state.currentPath.length-2];
animateTransition('out', async () => {
state.currentPath.pop();
await enterFolder(p, false);
});
}
}
// --- Layout ---
function layoutCylinder(entries) {
const count = entries.length;
const radius = 18;
const spacing = 2.8;
const circumference = 2 * Math.PI * radius;
const maxCols = Math.floor(circumference / spacing);
const cols = Math.max(1, Math.min(count, maxCols));
const rowHeight = 3.8;
entries.forEach((e, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const angleStep = (Math.PI * 2) / Math.max(cols, 10);
const angle = col * angleStep + Math.PI;
const x = radius * Math.sin(angle);
const z = radius * Math.cos(angle);
const y = -(row * rowHeight) + (Math.floor(count/cols) * rowHeight) / 2;
createMonolith(e, x, y, z);
});
filesGroup.scale.set(1,1,1);
}
function createMonolith(handle, x, y, z) {
const isF = handle.kind === 'directory';
const col = isF ? CONFIG.colFolder : CONFIG.colFile;
const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
const mat = new THREE.MeshBasicMaterial({
color: col, transparent: true, opacity: 0.3, side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.lookAt(0, y, 0);
mesh.userData = { handle, isFolder:isF, isRoot:true, thumbTex:null, scrollOffset: 0 };
const edges = new THREE.EdgesGeometry(geo);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 }));
mesh.add(line);
// --- Marquee Text Texture ---
const cvs = document.createElement('canvas');
cvs.width = 512; cvs.height = 128; // Wider canvas
const ctx = cvs.getContext('2d');
// Icon
ctx.fillStyle = isF ? '#ffee00' : '#00ffff';
ctx.font = '60px Arial'; ctx.textAlign = 'center';
ctx.fillText(isF ? '📂' : '📄', 256, 60);
// Text (Draw twice for looping)
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 36px "Segoe UI"';
const text = handle.name + " "; // Add spacing
ctx.fillText(text + text, 256, 110); // Draw center
const defTex = new THREE.CanvasTexture(cvs);
// Setup for scrolling
defTex.wrapS = THREE.RepeatWrapping;
defTex.repeat.set(0.5, 1); // Show half width (one text instance)
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: defTex }));
sprite.scale.set(2.2, 1.5, 1);
sprite.position.z = 0.11;
sprite.position.y = -0.5; // Bottom area
mesh.add(sprite);
mesh.userData.textTex = defTex; // Save ref for animation
// Thumbnail
const ext = handle.name.split('.').pop().toLowerCase();
if (!isF && ['jpg','jpeg','png','webp','gif'].includes(ext)) {
handle.getFile().then(f => {
const url = URL.createObjectURL(f);
new THREE.TextureLoader().load(url, (tex) => {
mesh.material = new THREE.MeshBasicMaterial({ map: tex, color:0xffffff });
mesh.userData.thumbTex = tex;
});
});
} else if (!isF && ['mp4','webm'].includes(ext)) {
handle.getFile().then(f => {
const url = URL.createObjectURL(f);
const v = document.createElement('video');
v.src = url; v.muted = true; v.loop = true;
v.play().then(() => {
const vt = new THREE.VideoTexture(v);
mesh.material = new THREE.MeshBasicMaterial({ map: vt, color:0xffffff });
mesh.userData.thumbTex = vt;
});
});
}
filesGroup.add(mesh);
state.objects.push(mesh);
}
// --- Interaction ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let popupMesh = null;
function getRoot(o) { while(o){if(o.userData?.isRoot)return o; o=o.parent;} return null; }
function updateMouse(e) {
const r = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX-r.left)/r.width)*2-1;
mouse.y = -((e.clientY-r.top)/r.height)*2+1;
}
function updatePopup(target) {
if(popupMesh) { filesGroup.remove(popupMesh); popupMesh=null; }
if(!target) return;
const geo = new THREE.PlaneGeometry(4, 5.3);
const mat = new THREE.MeshBasicMaterial({
map: target.userData.thumbTex || null,
color: target.userData.thumbTex ? 0xffffff : (target.userData.isFolder?CONFIG.colFolder:CONFIG.colFile),
transparent: true, opacity: 0.95, side: THREE.DoubleSide
});
popupMesh = new THREE.Mesh(geo, mat);
popupMesh.position.copy(target.position);
const dir = new THREE.Vector3().subVectors(new THREE.Vector3(0,target.position.y,0), target.position).normalize();
popupMesh.position.add(dir.multiplyScalar(4));
popupMesh.lookAt(0, target.position.y, 0);
const border = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({color:0xffffff}));
popupMesh.add(border);
filesGroup.add(popupMesh);
}
window.addEventListener('pointerdown', e => {
sfx.init();
if(e.button!==0 || !e.target.closest('canvas')) return;
updateMouse(e);
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(state.objects, true);
document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
if(hits.length>0) {
const t = getRoot(hits[0].object);
state.selected = t;
state.dragging = { mesh:t, z:t.position.z };
orbitControls.enabled = false;
activateReticle(t);
updatePopup(t);
sfx.play('lock');
} else {
state.selected = null;
reticleGroup.visible = false;
updatePopup(null);
state.isRotating = true;
state.lastMouse.set(e.clientX, e.clientY);
document.body.style.cursor = 'grabbing';
}
});
window.addEventListener('pointermove', e => {
updateMouse(e);
if(state.isRotating) {
const deltaX = e.clientX - state.lastMouse.x;
const deltaY = e.clientY - state.lastMouse.y;
// CORRECTED Hand-Scroll Directions (Opposite to Mouse Drag)
// Drag Left -> Move Content Left -> Rotate Cylinder CCW -> Angle increases? No.
// Drag Left (deltaX < 0) -> We want content to move Left.
// To move content left on screen, we need to Rotate cylinder Clockwise (Y-).
state.targetRotation += deltaX * 0.005;
// Drag Up (deltaY < 0) -> Content moves Up (Y+)
state.targetY += deltaY * 0.05;
state.lastMouse.set(e.clientX, e.clientY);
} else if(state.dragging) {
raycaster.setFromCamera(mouse, camera);
const pl = new THREE.Plane(new THREE.Vector3(0,0,1), -state.dragging.z);
const pt = new THREE.Vector3();
raycaster.ray.intersectPlane(pl, pt);
} else {
checkHover();
}
});
window.addEventListener('pointerup', () => {
if(state.dragging) {
const m = state.dragging.mesh;
const sp = m.position.clone().project(camera);
if(sp.y < -0.7) moveToShelf(m);
state.dragging = null;
orbitControls.enabled = true;
}
state.isRotating = false;
document.body.style.cursor = 'default';
});
window.addEventListener('wheel', e => {
if(e.target.closest('canvas')) state.targetRotation += e.deltaY * 0.001;
});
function activateReticle(mesh) {
reticleGroup.visible = true;
}
function checkHover() {
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(state.objects, true);
if(hits.length>0) {
const t = getRoot(hits[0].object);
if(state.hovered !== t) sfx.play('hover');
state.hovered = t;
document.body.style.cursor = 'pointer';
} else {
state.hovered = null;
document.body.style.cursor = 'default';
}
}
function animate() {
requestAnimationFrame(animate);
orbitControls.update();
// Physics Smoothing
state.currentRotation += (state.targetRotation - state.currentRotation) * 0.1;
state.currentY += (state.targetY - state.currentY) * 0.1;
filesGroup.rotation.y = state.currentRotation;
filesGroup.position.y = state.currentY;
// Reticle Follow
if(reticleGroup.visible && (state.selected || state.hovered)) {
const t = state.selected || state.hovered;
const wp = new THREE.Vector3(); t.getWorldPosition(wp);
const wq = new THREE.Quaternion(); t.getWorldQuaternion(wq);
reticleGroup.position.copy(wp);
reticleGroup.quaternion.copy(wq);
reticleGroup.translateZ(0.2);
ring.rotation.z += 0.05;
const s = 1 + Math.sin(Date.now()*0.01)*0.05;
reticleGroup.scale.set(s,s,1);
} else {
reticleGroup.visible = false;
}
// Marquee Animation
state.objects.forEach(obj => {
if(obj.userData.textTex) {
obj.userData.textTex.offset.x += 0.002;
}
});
drawHUD();
composer.render();
}
animate();
// --- HUD ---
function drawHUD() {
const dpr = window.devicePixelRatio||1;
hudX.clearRect(0,0,hudC.width/dpr, hudC.height/dpr);
const t = state.hovered || state.selected;
if(!t || state.dragging) return;
const p = new THREE.Vector3(); t.getWorldPosition(p);
p.y += 2.0; p.project(camera);
if(p.z > 1) return;
const x = (p.x*0.5+0.5)*(hudC.width/dpr);
const y = (p.y*-0.5+0.5)*(hudC.height/dpr);
hudX.shadowBlur = 10; hudX.shadowColor = CONFIG.colCursor;
hudX.strokeStyle = '#ffee00'; hudX.lineWidth = 2;
hudX.beginPath(); hudX.moveTo(x,y); hudX.lineTo(x+40,y-40); hudX.lineTo(x+160,y-40); hudX.stroke();
hudX.fillStyle = '#fff'; hudX.font = "bold 20px 'Segoe UI'";
hudX.fillText(t.userData.handle.name, x+45, y-45);
hudX.shadowBlur = 0;
}
// --- Context Menus (FIXED) ---
const ctxObj = document.getElementById('ctx-obj');
const ctxBg = document.getElementById('ctx-bg');
// Stop prop logic
document.querySelectorAll('.ctx-item').forEach(el => {
el.addEventListener('pointerdown', e => e.stopPropagation());
el.addEventListener('click', e => e.stopPropagation());
});
document.querySelectorAll('.ctx-menu').forEach(el => el.addEventListener('pointerdown', e => e.stopPropagation()));
window.addEventListener('contextmenu', e => {
e.preventDefault(); if(!e.target.closest('canvas')) return;
updateMouse(e); raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(state.objects, true);
document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
sfx.play('click');
if(hits.length>0) {
const t = getRoot(hits[0].object);
state.selected = t; activateReticle(t); updatePopup(t);
showMenu(ctxObj, e.clientX, e.clientY);
} else {
state.selected = null; updatePopup(null);
showMenu(ctxBg, e.clientX, e.clientY);
}
});
function showMenu(el, x, y) {
el.style.display='flex';
if(x+200>window.innerWidth) x-=200; if(y+200>window.innerHeight) y-=200;
el.style.left=x+'px'; el.style.top=y+'px';
}
document.getElementById('cm-open').onclick = () => performOpen(state.selected);
document.getElementById('cm-copy').onclick = () => { if(state.selected) state.clipboard = state.selected.userData.handle; document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none'); };
document.getElementById('cm-delete').onclick = async () => {
if(state.selected && confirm('Delete?')) {
await state.currentHandle.removeEntry(state.selected.userData.handle.name, {recursive:true});
enterFolder(state.currentHandle, false);
} document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-rename').onclick = async () => {
if(state.selected) {
const n = prompt("Rename:", state.selected.userData.handle.name);
if(n && state.selected.userData.handle.move) {
await state.selected.userData.handle.move(n);
enterFolder(state.currentHandle, false);
}
} document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-prop').onclick = async () => {
if(state.selected) {
const h=state.selected.userData.handle;
alert(`Name: ${h.name}\nKind: ${h.kind}`);
} document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-new').onclick = async () => {
const n = prompt("New Folder Name:");
if(n) {
await state.currentHandle.getDirectoryHandle(n, {create:true});
enterFolder(state.currentHandle, false);
} document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-paste').onclick = async () => {
if(state.clipboard && state.clipboard.kind === 'file') {
const f = await state.clipboard.getFile();
const h = await state.currentHandle.getFileHandle('Copy_'+f.name, {create:true});
const w = await h.createWritable(); await w.write(f); await w.close();
enterFolder(state.currentHandle, false);
} document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-up').onclick = () => {
goUp(); document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
document.getElementById('cm-refresh').onclick = () => {
enterFolder(state.currentHandle, false); document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
};
window.addEventListener('dblclick', e => {
updateMouse(e); raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(state.objects, true);
if(hits.length>0) performOpen(getRoot(hits[0].object));
});
function performOpen(m) {
if(!m) return;
if(m.userData.isFolder) { enterFolder(m.userData.handle); updateTree(state.rootHandle); }
else openPreview(m.userData.handle);
document.querySelectorAll('.ctx-menu').forEach(x=>x.style.display='none');
}
// --- Sidebar/Shelf ---
document.getElementById('sb-tab').onclick=()=>document.getElementById('sidebar').classList.toggle('closed');
document.getElementById('shelf-tab').onclick=()=>document.getElementById('shelf').classList.toggle('closed');
function moveToShelf(m) {
filesGroup.remove(m); state.objects=state.objects.filter(o=>o!==m);
if(popupMesh) { filesGroup.remove(popupMesh); popupMesh=null; }
state.shelfData.push(m.userData.handle); renderShelf();
}
function renderShelf() {
const s = document.getElementById('shelf'); s.innerHTML='';
state.shelfData.forEach((h,i)=>{
const d=document.createElement('div'); d.className='shelf-item'; d.draggable=true;
d.innerHTML=`<div style="font-size:30px">📦</div><div style="font-size:10px">${h.name}</div>`;
d.ondragstart=e=>e.dataTransfer.setData('i',i);
s.appendChild(d);
});
}
container.ondragover=e=>e.preventDefault();
container.ondrop=e=>{
e.preventDefault(); const i=e.dataTransfer.getData('i');
if(i!==''){
const h=state.shelfData[i]; state.shelfData.splice(i,1); renderShelf();
createMonolith(h, 0, 0, 10);
}
};
// --- Preview ---
const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body');
document.getElementById('p-close').onclick=()=>pModal.classList.remove('active');
async function openPreview(handle) {
pModal.classList.add('active'); document.getElementById('p-title').innerText=handle.name;
pBody.innerHTML='Loading...';
try {
const f = await handle.getFile();
const u = URL.createObjectURL(f);
if(f.name.match(/\.(png|jpg|webp|gif)$/i)) pBody.innerHTML=`<img src="${u}" style="max-width:100%;max-height:100%">`;
else if(f.name.match(/\.(mp4|webm)$/i)) pBody.innerHTML=`<video src="${u}" controls autoplay style="max-width:100%"></video>`;
else if(f.name.match(/\.(fbx|obj|glb)$/i)) pBody.innerHTML='[3D Model]';
else pBody.innerText = await f.text();
} catch(e) { pBody.innerText="Error"; }
}
const treeC=document.getElementById('tree-container');
async function updateTree(r) { treeC.innerHTML=''; treeC.appendChild(mkNode(r,0)); for await(const e of r.values()) if(e.kind==='directory') treeC.appendChild(mkNode(e,1)); }
function mkNode(h,d) {
const e=document.createElement('div'); e.className='tree-node'; e.style.paddingLeft=(d*15)+'px';
e.innerHTML=`<span class="icon-tree">${h.kind==='directory'?'📂':'📄'}</span>${h.name}`;
e.onclick=()=>enterFolder(h);
return e;
}
</script>
</body>
</html>

コメント