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>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
SF映画っぽいエクスプローラ「Exp3D 1.4」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1